Compare commits
38 Commits
43824075dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f27fb7a2c4 | |||
|
|
a08dc316be | ||
| dc18594d76 | |||
|
|
0b3a0ab129 | ||
| 5c904756ab | |||
| 07fca7fb81 | |||
| 00edd2e5dc | |||
| 478ac61975 | |||
| 5ff9b3d9f5 | |||
| cfbca6cc15 | |||
| dfbc52e531 | |||
| 337f2ad6e1 | |||
| 9fbd719048 | |||
| 5385f3bddd | |||
| 75cfcb9ad3 | |||
| bfade2b722 | |||
| 2b36feb81b | |||
| aa003e28cc | |||
| 87635e4208 | |||
| 4f78534e59 | |||
| d20675d02c | |||
| 2cadc30825 | |||
| c35250f06a | |||
| 3383878e12 | |||
| f953887b97 | |||
| 18b39ef6ea | |||
| e1f4d75dc2 | |||
| 6c97dfc913 | |||
| c068372abf | |||
| 587a32d239 | |||
| 626abd4173 | |||
| c6585c817f | |||
| 75acd3aa3e | |||
| 7b239dda8f | |||
| 9762ee97ab | |||
| 4416833cb3 | |||
| b574c97fcc | |||
| 7daa4cda68 |
163
.claude-plugin/marketplace.json
Normal file
163
.claude-plugin/marketplace.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-marketplace-schema.json",
|
||||
"name": "ourdigital-skills",
|
||||
"owner": {
|
||||
"name": "OurDigital",
|
||||
"email": "andrew.yim@ourdigital.org"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "OurDigital custom Claude skills, grouped into domain plugins (core, SEO, Jamie, D.intelligence, Notion, GTM, NotebookLM, utilities). Enable only the domains you need.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ourdigital-core",
|
||||
"description": "OurDigital core workflows — brand guide, Korean blog & English journal, research-to-blog, Notion-to-deck, visual design, ad copy, training material, back-office docs, skill creator, estimate engine.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/01-ourdigital-brand-guide",
|
||||
"./custom-skills/02-ourdigital-blog",
|
||||
"./custom-skills/03-ourdigital-journal",
|
||||
"./custom-skills/04-ourdigital-research",
|
||||
"./custom-skills/05-ourdigital-document",
|
||||
"./custom-skills/06-ourdigital-designer",
|
||||
"./custom-skills/07-ourdigital-ad-manager",
|
||||
"./custom-skills/08-ourdigital-trainer",
|
||||
"./custom-skills/09-ourdigital-backoffice",
|
||||
"./custom-skills/10-ourdigital-skill-creator",
|
||||
"./custom-skills/96-ourdigital-estimate-engine"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-seo",
|
||||
"description": "Full SEO suite — technical/on-page audits, Core Web Vitals, Search Console, schema validate/generate, local, keyword, SERP, rank tracking, links, content, e-commerce, KPIs, international, AI visibility, knowledge graph, gateway pages, competitor intel, crawl budget, migration, reporting, presales.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/11-seo-comprehensive-audit",
|
||||
"./custom-skills/12-seo-technical-audit",
|
||||
"./custom-skills/13-seo-on-page-audit",
|
||||
"./custom-skills/14-seo-core-web-vitals",
|
||||
"./custom-skills/15-seo-search-console",
|
||||
"./custom-skills/16-seo-schema-validator",
|
||||
"./custom-skills/17-seo-schema-generator",
|
||||
"./custom-skills/18-seo-local-audit",
|
||||
"./custom-skills/19-seo-keyword-strategy",
|
||||
"./custom-skills/20-seo-serp-analysis",
|
||||
"./custom-skills/21-seo-position-tracking",
|
||||
"./custom-skills/22-seo-link-building",
|
||||
"./custom-skills/23-seo-content-strategy",
|
||||
"./custom-skills/24-seo-ecommerce",
|
||||
"./custom-skills/25-seo-kpi-framework",
|
||||
"./custom-skills/26-seo-international",
|
||||
"./custom-skills/27-seo-ai-visibility",
|
||||
"./custom-skills/28-seo-knowledge-graph",
|
||||
"./custom-skills/29-seo-gateway-architect",
|
||||
"./custom-skills/30-seo-gateway-builder",
|
||||
"./custom-skills/31-seo-competitor-intel",
|
||||
"./custom-skills/32-seo-crawl-budget",
|
||||
"./custom-skills/33-seo-migration-planner",
|
||||
"./custom-skills/34-seo-reporting-dashboard",
|
||||
"./custom-skills/35-seo-signal-validation",
|
||||
"./custom-skills/95-ourdigital-presales-seo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-notion",
|
||||
"description": "Notion workspace tools — organize/clean up a workspace and write/export content into Notion.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/31-notion-organizer",
|
||||
"./custom-skills/32-notion-writer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-jamie",
|
||||
"description": "Jamie Plastic Surgery Clinic brand suite — branded content, brand audit, KakaoTalk Kanana FAQ, YouTube SEO + subtitle QA, Instagram management, journal editor, multi-channel marketing.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/40-jamie-brand-editor",
|
||||
"./custom-skills/41-jamie-brand-audit",
|
||||
"./custom-skills/42-jamie-faq-entry",
|
||||
"./custom-skills/43-jamie-youtube-manager",
|
||||
"./custom-skills/44-jamie-youtube-subtitle-checker",
|
||||
"./custom-skills/45-jamie-instagram-manager",
|
||||
"./custom-skills/46-jamie-journal-editor",
|
||||
"./custom-skills/47-jamie-marketing-editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-notebooklm",
|
||||
"description": "NotebookLM automation — Q&A with citations, notebook/source/artifact management, studio content generation (podcasts, videos, quizzes), and research/source discovery. Requires the notebooklm-py CLI.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/50-notebooklm-agent",
|
||||
"./custom-skills/51-notebooklm-automation",
|
||||
"./custom-skills/52-notebooklm-studio",
|
||||
"./custom-skills/53-notebooklm-research"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-gtm",
|
||||
"description": "Google Tag Manager tooling — container audit/gap analysis, tag/trigger/variable editor (API + ES5 Custom HTML + dataLayer), and QA/validation.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/60-gtm-audit",
|
||||
"./custom-skills/61-gtm-editor",
|
||||
"./custom-skills/62-gtm-validator"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-dintel",
|
||||
"description": "D.intelligence Agent Corps — brand guardian/editor, document secretary, quotation manager, service architect, marketing manager, back-office manager, account manager, and cross-skill update meta-agent.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/70-dintel-brand-guardian",
|
||||
"./custom-skills/71-dintel-brand-editor",
|
||||
"./custom-skills/72-dintel-doc-secretary",
|
||||
"./custom-skills/73-dintel-quotation-mgr",
|
||||
"./custom-skills/74-dintel-service-architect",
|
||||
"./custom-skills/75-dintel-marketing-mgr",
|
||||
"./custom-skills/76-dintel-backoffice-mgr",
|
||||
"./custom-skills/77-dintel-account-mgr",
|
||||
"./custom-skills/79-dintel-skill-update"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-utils",
|
||||
"description": "Utility skills — Claude settings/token optimizer, Google Drive organizer, reference-documentation curator suite, and TUI wizard design template.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/80-claude-settings-optimizer",
|
||||
"./custom-skills/82-our-gdrive-organizer",
|
||||
"./custom-skills/90-reference-curator",
|
||||
"./custom-skills/92-tui-design-template"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mac-optimizer",
|
||||
"description": "macOS system health toolkit — read-only audits, cleanup, and security checks. Commands: mac-doctor, mac-packages, mac-environment, mac-security, mac-cleanup, mac-resources (+ mac-optimizer skill).",
|
||||
"source": "./custom-skills/81-mac-optimizer",
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "multi-agent-guide",
|
||||
"description": "Multi-agent collaboration framework — agent hierarchies, ownership rules, guardrails, handoff protocols, and CI/CD integration for Claude, Gemini, Codex, and human agents. Commands: quick-setup, setup-agents (+ multi-agent-guide skill).",
|
||||
"source": "./custom-skills/91-multi-agent-guide",
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "dintel-bootstrap",
|
||||
"description": "Install and verify D.intelligence custom MCP agents (DTM, D.DA, OurSEO) in settings.json; bootstrap a new machine or diagnose a broken install (dintel-bootstrap skill).",
|
||||
"source": "./custom-skills/94-dintel-bootstrap",
|
||||
"strict": false
|
||||
}
|
||||
]
|
||||
}
|
||||
31
.claude/commands/dintel-campaign-designer.md
Normal file
31
.claude/commands/dintel-campaign-designer.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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
|
||||
36
.claude/commands/jamie-copy-trimmer.md
Normal file
36
.claude/commands/jamie-copy-trimmer.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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
|
||||
|
||||
```bash
|
||||
cd ~/Projects/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
|
||||
# Test connection
|
||||
python notion_writer.py --test
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -99,3 +99,7 @@ build/
|
||||
# Temporary files
|
||||
output/
|
||||
keyword_analysis_*.json
|
||||
|
||||
# graphify: keep graph.json/html/report, drop regenerable cache + dated backups
|
||||
graphify-out/cache/
|
||||
graphify-out/????-??-??/
|
||||
|
||||
4
.graphifyignore
Normal file
4
.graphifyignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# 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,6 +77,7 @@ This is a Claude Skills collection repository containing:
|
||||
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
||||
| 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", "제이미 마케팅", "광고 카피" |
|
||||
| 48 | jamie-copy-trimmer | Trim/sharpen Korean aesthetic-medical copy against cliché & compliance corpus | "카피 다듬어", "카피 트리밍", "심의 안전하게", "copy trim" |
|
||||
|
||||
### NotebookLM Tools (50-59)
|
||||
|
||||
@@ -109,6 +110,7 @@ This is a Claude Skills collection repository containing:
|
||||
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
||||
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
||||
| 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", "스킬 업데이트" |
|
||||
|
||||
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
||||
@@ -253,6 +255,7 @@ our-claude-skills/
|
||||
│ ├── 45-jamie-instagram-manager/
|
||||
│ ├── 46-jamie-journal-editor/
|
||||
│ ├── 47-jamie-marketing-editor/
|
||||
│ ├── 48-jamie-copy-trimmer/
|
||||
│ │
|
||||
│ ├── 50-notebooklm-agent/
|
||||
│ ├── 51-notebooklm-automation/
|
||||
@@ -271,6 +274,7 @@ our-claude-skills/
|
||||
│ ├── 75-dintel-marketing-mgr/
|
||||
│ ├── 76-dintel-backoffice-mgr/
|
||||
│ ├── 77-dintel-account-mgr/
|
||||
│ ├── 78-dintel-campaign-designer/
|
||||
│ ├── 79-dintel-skill-update/
|
||||
│ │
|
||||
│ ├── 80-claude-settings-optimizer/
|
||||
|
||||
20
README.md
20
README.md
@@ -12,6 +12,11 @@ cd our-claude-skills/custom-skills/_ourdigital-shared
|
||||
./install.sh
|
||||
```
|
||||
|
||||
This symlinks the global slash commands into `~/.claude/commands/`, sets up the
|
||||
Python virtual environment, and configures credentials. It does **not** register
|
||||
skills natively — to load a skill as a Claude Code skill, also symlink it into
|
||||
`~/.claude/skills/` (see [Usage → Claude Code](#claude-code)).
|
||||
|
||||
## Custom Skills Overview
|
||||
|
||||
### OurDigital Core (01-10)
|
||||
@@ -215,16 +220,23 @@ The `_ourdigital-shared/` directory provides:
|
||||
|
||||
### Claude Code
|
||||
|
||||
Skills are auto-detected via symlinks in `~/.claude/skills/`:
|
||||
The Quick Install symlinks the **slash commands** into `~/.claude/commands/`. To
|
||||
also load a skill natively, symlink its **root** directory (which holds the
|
||||
loadable `SKILL.md`) into `~/.claude/skills/`, using the clean name without the
|
||||
`NN-` prefix:
|
||||
|
||||
```bash
|
||||
# Install skill symlink
|
||||
ln -sf /path/to/skill/desktop ~/.claude/skills/skill-name
|
||||
# From the repo root — symlink a skill into Claude Code
|
||||
ln -sf "$PWD/custom-skills/16-seo-schema-validator" ~/.claude/skills/seo-schema-validator
|
||||
```
|
||||
|
||||
> Legacy skills that don't yet have a root `SKILL.md` expose it under
|
||||
> `code/SKILL.md` instead — symlink `.../<skill>/code` for those.
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Copy the `desktop/SKILL.md` file to your Claude Desktop skills folder.
|
||||
Import the skill's `desktop/` folder (containing `SKILL.md` + `skill.yaml`) via
|
||||
your Claude Desktop skills settings.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"_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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
{
|
||||
"_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,6 +607,16 @@ def _address_street(node):
|
||||
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):
|
||||
"""Collect @id definitions vs pure references by walking the whole document.
|
||||
|
||||
@@ -645,21 +655,26 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
|
||||
break # one placeholder defect per node is enough signal
|
||||
|
||||
# ---- 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)
|
||||
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():
|
||||
key = (normalize_name(first_text(node.get("name"))), _address_locality(node))
|
||||
by_name[key].append((entry, node))
|
||||
for (name, locality), group in by_name.items():
|
||||
loc = f" ({locality})" if locality else ""
|
||||
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"Business '{name}'{loc} 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"Business '{name}'{loc} has conflicting streetAddress values across "
|
||||
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
||||
|
||||
# ---- @id duplicates + dangling references (P1) ----
|
||||
@@ -719,10 +734,147 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run(entries, rules, inventory, strict, no_recommended):
|
||||
def run(entries, rules, inventory, strict, no_recommended, verify_refs=False):
|
||||
defects = DefectLog()
|
||||
if inventory is not None:
|
||||
layer0_coverage(entries, inventory, defects)
|
||||
@@ -743,6 +895,7 @@ def run(entries, rules, inventory, strict, no_recommended):
|
||||
node_index.append((entry, node))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -822,6 +975,9 @@ def main(argv=None):
|
||||
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")
|
||||
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)
|
||||
|
||||
if not args.dataset and not args.live:
|
||||
@@ -838,7 +994,8 @@ def main(argv=None):
|
||||
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)
|
||||
args.strict, args.no_recommended,
|
||||
verify_refs=args.verify_refs)
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,854 @@
|
||||
#!/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
|
||||
|
||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv`
|
||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
|
||||
- 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)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts
|
||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
@@ -139,6 +139,19 @@ python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
|
||||
| `---` | Divider |
|
||||
| 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
|
||||
|
||||
Integrate with Jamie YouTube Manager to log video info:
|
||||
|
||||
@@ -189,6 +189,52 @@ 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.
|
||||
|
||||
### 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
|
||||
@@ -398,9 +444,10 @@ python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
|
||||
|
||||
---
|
||||
|
||||
*Version 1.2.0 | Claude Code | 2026-04-27*
|
||||
*Version 1.3.0 | Claude Code | 2026-06-27*
|
||||
|
||||
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.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.
|
||||
|
||||
@@ -16,8 +16,11 @@ from notion_client import Client
|
||||
from notion_client.errors import APIErrorCode, APIResponseError
|
||||
|
||||
|
||||
def make_client(api_key: str) -> Client:
|
||||
"""Build a sync Notion client on the SDK default API version (2025-09-03+)."""
|
||||
def make_client(api_key: str, notion_version: str = None) -> Client:
|
||||
"""Build a sync Notion client. Pass notion_version to override the SDK
|
||||
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)
|
||||
|
||||
|
||||
@@ -210,7 +213,10 @@ def explain_api_error(exc: APIResponseError, context: str = "") -> str:
|
||||
"https://www.notion.so/my-integrations."
|
||||
)
|
||||
if code == APIErrorCode.ValidationError:
|
||||
return f"Validation error{suffix}: {exc.body.get('message', str(exc))}"
|
||||
msg = 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:
|
||||
return f"Rate limited{suffix}. Back off and retry."
|
||||
return f"Notion API error [{code}]{suffix}: {exc}"
|
||||
@@ -253,3 +259,33 @@ def find_existing_page(
|
||||
)
|
||||
results = response.get("results") or []
|
||||
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}},
|
||||
)
|
||||
|
||||
135
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
135
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/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,6 +17,11 @@ from notion_client import Client
|
||||
from notion_client.errors import APIResponseError
|
||||
|
||||
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_dotenv(Path(__file__).parent / '.env')
|
||||
@@ -56,6 +61,7 @@ def format_id_with_dashes(raw_id: str) -> str:
|
||||
|
||||
|
||||
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:
|
||||
@@ -210,6 +216,13 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
||||
blocks.append(create_column_list_block(column_blocks))
|
||||
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]):
|
||||
header_cells = _split_table_row(line)
|
||||
i += 2
|
||||
@@ -507,6 +520,55 @@ 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]:
|
||||
"""Build a Notion `table` block with header + body rows.
|
||||
|
||||
@@ -746,21 +808,6 @@ def update_page_properties(notion: Client, page_id: str, properties: Dict) -> bo
|
||||
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():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Push markdown content to Notion pages or databases',
|
||||
@@ -796,9 +843,28 @@ Examples:
|
||||
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
||||
help='List accessible pages and/or databases (default: all)')
|
||||
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()
|
||||
|
||||
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:
|
||||
print("Error: NOTION_API_KEY not set in environment.")
|
||||
print("Preferred: fetch from 1Password at runtime —")
|
||||
@@ -885,23 +951,64 @@ Examples:
|
||||
sys.exit(1)
|
||||
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
|
||||
if args.page:
|
||||
if not content:
|
||||
print("Error: No content provided. Use --file or --stdin")
|
||||
sys.exit(1)
|
||||
|
||||
page_id = extract_notion_id(args.page)
|
||||
if not page_id:
|
||||
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
||||
sys.exit(1)
|
||||
formatted_id = format_id_with_dashes(page_id)
|
||||
|
||||
mode = 'replace' if args.replace else 'append'
|
||||
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...")
|
||||
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
|
||||
|
||||
if write_to_page(notion, page_id, content, mode):
|
||||
print(f"✅ Successfully wrote content to page")
|
||||
formatted_id = format_id_with_dashes(page_id)
|
||||
# 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('-', '')}")
|
||||
else:
|
||||
print("❌ Failed to write content")
|
||||
@@ -960,6 +1067,8 @@ Examples:
|
||||
|
||||
content_blocks = markdown_to_notion_blocks(content) if content else None
|
||||
|
||||
existing = None
|
||||
|
||||
# Upsert path: look for existing row by the named property
|
||||
if args.upsert_by:
|
||||
if args.upsert_by not in schema_props:
|
||||
@@ -980,19 +1089,52 @@ Examples:
|
||||
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
||||
sys.exit(1)
|
||||
|
||||
if existing:
|
||||
page_id = existing['id']
|
||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||
if not update_page_properties(notion, page_id, properties):
|
||||
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:
|
||||
page_id = existing['id']
|
||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||
if not update_page_properties(notion, page_id, properties):
|
||||
sys.exit(1)
|
||||
if content_blocks:
|
||||
if not clear_page_content(notion, page_id):
|
||||
sys.exit(1)
|
||||
if content_blocks:
|
||||
if not clear_page_content(notion, page_id):
|
||||
sys.exit(1)
|
||||
if not append_to_page(notion, page_id, content_blocks):
|
||||
sys.exit(1)
|
||||
print(f"✅ Successfully updated database row")
|
||||
print(f" https://notion.so/{page_id.replace('-', '')}")
|
||||
return
|
||||
if not append_to_page(notion, page_id, content_blocks):
|
||||
sys.exit(1)
|
||||
print(f"✅ Successfully updated database row")
|
||||
print(f" https://notion.so/{page_id.replace('-', '')}")
|
||||
return
|
||||
|
||||
print(f"Creating database row...")
|
||||
row_id = create_database_row(notion, data_source_id, properties, content_blocks)
|
||||
|
||||
154
custom-skills/32-notion-writer/code/scripts/ntn_files.py
Normal file
154
custom-skills/32-notion-writer/code/scripts/ntn_files.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/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()
|
||||
101
custom-skills/32-notion-writer/code/scripts/test_md_translate.py
Normal file
101
custom-skills/32-notion-writer/code/scripts/test_md_translate.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/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()
|
||||
152
custom-skills/32-notion-writer/code/scripts/test_ntn_files.py
Normal file
152
custom-skills/32-notion-writer/code/scripts/test_ntn_files.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/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,6 +413,28 @@ def test_no_literal_markers_leak():
|
||||
_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():
|
||||
tests = [
|
||||
test_rich_text_plain,
|
||||
@@ -447,6 +469,9 @@ def run_all():
|
||||
test_rich_text_relative_link_becomes_plain,
|
||||
test_rich_text_absolute_link_preserved,
|
||||
test_no_literal_markers_leak,
|
||||
test_image_remote_external,
|
||||
test_image_local_external_shape_preupload,
|
||||
test_image_only_when_standalone,
|
||||
]
|
||||
for t in tests:
|
||||
print(f"\n{t.__name__}")
|
||||
|
||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv`
|
||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
|
||||
- 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)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts
|
||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
|
||||
142
custom-skills/35-seo-signal-validation/DESIGN.md
Normal file
142
custom-skills/35-seo-signal-validation/DESIGN.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 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).
|
||||
729
custom-skills/35-seo-signal-validation/PLAN.md
Normal file
729
custom-skills/35-seo-signal-validation/PLAN.md
Normal file
@@ -0,0 +1,729 @@
|
||||
# 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.**
|
||||
141
custom-skills/35-seo-signal-validation/SKILL.md
Normal file
141
custom-skills/35-seo-signal-validation/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
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).
|
||||
25
custom-skills/35-seo-signal-validation/code/CLAUDE.md
Normal file
25
custom-skills/35-seo-signal-validation/code/CLAUDE.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,4 @@
|
||||
query clicks impressions position
|
||||
호텔 9 371 18.1
|
||||
grand josun busan 49 1500 3.4
|
||||
조선호텔 150 5000 1.3
|
||||
|
@@ -0,0 +1,4 @@
|
||||
query clicks impressions position
|
||||
호텔 5 572 11.6
|
||||
grand josun busan 250 4000 1.2
|
||||
조선호텔 300 6000 1.1
|
||||
|
@@ -0,0 +1,160 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1 @@
|
||||
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/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()
|
||||
100
custom-skills/48-jamie-copy-trimmer/SKILL.md
Normal file
100
custom-skills/48-jamie-copy-trimmer/SKILL.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.*
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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.*
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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.*
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
canon_compliance: v1.3
|
||||
agent-id: "70"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 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.
|
||||
autonomy: auto
|
||||
---
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.2.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "70"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 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.
|
||||
autonomy: auto
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ version: 1.2.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "71"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: auto
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ version: 1.2.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "71"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: auto
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "72"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: draft-and-wait
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "72"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: draft-and-wait
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "73"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Quotation Manager for D.intelligence. Generates professional quotations
|
||||
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
|
||||
canon_compliance: v1.3
|
||||
agent-id: "73"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Quotation Manager for D.intelligence. Generates professional quotations
|
||||
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
|
||||
canon_compliance: v1.3
|
||||
agent-id: "74"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Service Architect for D.intelligence. Designs service scope and recommends
|
||||
optimal module combinations through structured inquiry.
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "74"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Service Architect for D.intelligence. Designs service scope and recommends
|
||||
optimal module combinations through structured inquiry.
|
||||
|
||||
@@ -12,7 +12,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "75"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: draft-and-wait
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "75"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: draft-and-wait
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "76"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
||||
NDA, employment contracts, billing, HR operations, and compliance.
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "76"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
||||
NDA, employment contracts, billing, HR operations, and compliance.
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "77"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
||||
management — project monitoring, meeting prep, status reports, issue escalation.
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "77"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
description: |
|
||||
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
||||
management — project monitoring, meeting prep, status reports, issue escalation.
|
||||
|
||||
58
custom-skills/78-dintel-campaign-designer/README.md
Normal file
58
custom-skills/78-dintel-campaign-designer/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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
|
||||
```
|
||||
147
custom-skills/78-dintel-campaign-designer/SKILL.md
Normal file
147
custom-skills/78-dintel-campaign-designer/SKILL.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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.
|
||||
31
custom-skills/78-dintel-campaign-designer/code/CLAUDE.md
Normal file
31
custom-skills/78-dintel-campaign-designer/code/CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
147
custom-skills/78-dintel-campaign-designer/desktop/SKILL.md
Normal file
147
custom-skills/78-dintel-campaign-designer/desktop/SKILL.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 결정 로그 — 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 (브리프)** 진행
|
||||
@@ -0,0 +1,32 @@
|
||||
# 캠페인 브리프 — 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 (기획서화)** 진행
|
||||
@@ -0,0 +1,22 @@
|
||||
# 기획서 아웃라인 — 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
|
||||
canon_compliance: v1.3
|
||||
agent-id: "79"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: triggered
|
||||
---
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ version: 1.1.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
agent-id: "79"
|
||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
||||
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||
autonomy: triggered
|
||||
---
|
||||
|
||||
|
||||
@@ -15,31 +15,5 @@
|
||||
"packages",
|
||||
"disk-space"
|
||||
],
|
||||
"license": "MIT",
|
||||
"commands": [
|
||||
{
|
||||
"name": "mac-doctor",
|
||||
"description": "Full macOS system health check — runs all 5 audit modules and presents unified findings"
|
||||
},
|
||||
{
|
||||
"name": "mac-packages",
|
||||
"description": "Audit package managers (Homebrew, npm, pip, pyenv) for outdated packages and issues"
|
||||
},
|
||||
{
|
||||
"name": "mac-environment",
|
||||
"description": "Audit shell environment — PATH, symlinks, shell configs, and startup time"
|
||||
},
|
||||
{
|
||||
"name": "mac-security",
|
||||
"description": "Security posture assessment — SIP, Gatekeeper, Firewall, FileVault, SSH, ports"
|
||||
},
|
||||
{
|
||||
"name": "mac-cleanup",
|
||||
"description": "Scan and clean caches, logs, and clutter — shows sizes first, cleans only with consent"
|
||||
},
|
||||
{
|
||||
"name": "mac-resources",
|
||||
"description": "Monitor CPU, memory, disk, battery, and identify resource-hungry processes"
|
||||
}
|
||||
]
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -18,15 +18,5 @@
|
||||
"gemini",
|
||||
"codex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"commands": [
|
||||
{
|
||||
"name": "multi-agent-setup",
|
||||
"description": "Quick setup for multi-agent collaboration"
|
||||
},
|
||||
{
|
||||
"name": "setup-agents",
|
||||
"description": "Full interactive multi-agent setup"
|
||||
}
|
||||
]
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
139
custom-skills/97-ourdigital-okf/DESIGN.md
Normal file
139
custom-skills/97-ourdigital-okf/DESIGN.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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 |
|
||||
51
custom-skills/97-ourdigital-okf/README.md
Normal file
51
custom-skills/97-ourdigital-okf/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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>
|
||||
65
custom-skills/97-ourdigital-okf/SKILL.md
Normal file
65
custom-skills/97-ourdigital-okf/SKILL.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
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/`.
|
||||
11
custom-skills/97-ourdigital-okf/code/CLAUDE.md
Normal file
11
custom-skills/97-ourdigital-okf/code/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
149
custom-skills/97-ourdigital-okf/code/SKILL.md
Normal file
149
custom-skills/97-ourdigital-okf/code/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
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.
|
||||
18
custom-skills/97-ourdigital-okf/code/assets/concept.md
Normal file
18
custom-skills/97-ourdigital-okf/code/assets/concept.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
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)
|
||||
8
custom-skills/97-ourdigital-okf/code/assets/index.md
Normal file
8
custom-skills/97-ourdigital-okf/code/assets/index.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 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
|
||||
5
custom-skills/97-ourdigital-okf/code/assets/log.md
Normal file
5
custom-skills/97-ourdigital-okf/code/assets/log.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Update Log
|
||||
|
||||
## 2026-06-16
|
||||
* **Initialization**: Created the bundle structure.
|
||||
* **Creation**: Added the [orders table](/tables/orders.md).
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
129
custom-skills/97-ourdigital-okf/code/scripts/okf_common.py
Normal file
129
custom-skills/97-ourdigital-okf/code/scripts/okf_common.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""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)
|
||||
89
custom-skills/97-ourdigital-okf/code/scripts/okf_validate.py
Normal file
89
custom-skills/97-ourdigital-okf/code/scripts/okf_validate.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/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())
|
||||
115
custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
Normal file
115
custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,2 @@
|
||||
# ourdigital-okf scripts use the Python standard library only.
|
||||
# No third-party runtime dependencies. Tests use stdlib `unittest`.
|
||||
10
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/datasets/sales.md
vendored
Normal file
10
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/datasets/sales.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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).
|
||||
8
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/index.md
vendored
Normal file
8
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/index.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 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.
|
||||
16
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/customers.md
vendored
Normal file
16
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/customers.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
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).
|
||||
17
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/orders.md
vendored
Normal file
17
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/orders.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,62 @@
|
||||
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()
|
||||
@@ -0,0 +1,64 @@
|
||||
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()
|
||||
@@ -0,0 +1,36 @@
|
||||
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()
|
||||
56
custom-skills/97-ourdigital-okf/desktop/SKILL.md
Normal file
56
custom-skills/97-ourdigital-okf/desktop/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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.
|
||||
10
custom-skills/97-ourdigital-okf/desktop/skill.yaml
Normal file
10
custom-skills/97-ourdigital-okf/desktop/skill.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# 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
|
||||
17
custom-skills/97-ourdigital-okf/docs/CHANGELOG.md
Normal file
17
custom-skills/97-ourdigital-okf/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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`.
|
||||
1058
custom-skills/97-ourdigital-okf/docs/IMPLEMENTATION-PLAN.md
Normal file
1058
custom-skills/97-ourdigital-okf/docs/IMPLEMENTATION-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
18
custom-skills/97-ourdigital-okf/install.sh
Executable file
18
custom-skills/97-ourdigital-okf/install.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/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
|
||||
|
||||
Shared Python package and utilities used by all `dintel-` Claude Skills (agents 70-77, 88).
|
||||
Shared Python package and utilities used by all `dintel-` Claude Skills (agents 70-78, 88).
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# D.intelligence Agent Corps — User Guide
|
||||
|
||||
> **Version**: 1.0.0
|
||||
> **Updated**: 2026-03-09
|
||||
> **Version**: 1.1.0
|
||||
> **Updated**: 2026-07-01
|
||||
> **Author**: D.intelligence Engineering
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
D.intelligence Agent Corps는 9개의 AI 에이전트로 구성된 스킬 번들입니다.
|
||||
D.intelligence Agent Corps는 10개의 AI 에이전트로 구성된 스킬 번들입니다.
|
||||
모든 에이전트는 D.intelligence의 브랜드 가이드, 서비스 아키텍처, 가격 정책을 공유하며,
|
||||
일관된 품질의 결과물을 생성합니다.
|
||||
|
||||
@@ -24,6 +24,7 @@ D.intelligence Agent Corps는 9개의 AI 에이전트로 구성된 스킬 번들
|
||||
│ #75 Marketing Mgr ←── 콘텐츠 파이프라인 │
|
||||
│ #76 Back Office Mgr ←── 계약/인보이스/HR │
|
||||
│ #77 Account Mgr ←── 고객 관계 관리 │
|
||||
│ #78 Campaign Designer ←── 캠페인 3-게이트 기획 │
|
||||
│ #88 Skill Update ←── 메타 에이전트 (일괄 변경)│
|
||||
│ │
|
||||
│ _dintel-shared/ ←── 공유 인프라 │
|
||||
@@ -49,7 +50,7 @@ cd our-claude-skills/custom-skills/_dintel-shared/
|
||||
|-------|--------|---------|
|
||||
| **Auto** | #70 | Runs automatically when D.intelligence content is detected |
|
||||
| **Auto + Ask** | #71 | Auto-activates, asks before making changes |
|
||||
| **Draft & Wait** | #72, #73, #75, #76 | Generates draft, then STOPS for Andrew's review |
|
||||
| **Draft & Wait** | #72, #73, #75, #76, #78 | Generates draft, then STOPS for Andrew's review |
|
||||
| **Inquiry-driven** | #74 | Asks structured questions before recommending |
|
||||
| **Mixed** | #77 | Monitors autonomously, asks before client-facing actions |
|
||||
| **Triggered** | #88 | Only runs when explicitly invoked by Andrew |
|
||||
@@ -357,6 +358,36 @@ 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 — 스킬 업데이트 (메타 에이전트)
|
||||
|
||||
**What it does**: 공유 참조(브랜드, 가격, 서비스 모듈 등)가 변경될 때, 영향 받는 모든 스킬을 일괄 업데이트합니다.
|
||||
@@ -518,3 +549,4 @@ _dintel-shared/
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 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 |
|
||||
|
||||
1335
docs/superpowers/plans/2026-06-27-notion-writer-cli-enhancements.md
Normal file
1335
docs/superpowers/plans/2026-06-27-notion-writer-cli-enhancements.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
# 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.
|
||||
1
graphify-out/.graphify_labels.json
Normal file
1
graphify-out/.graphify_labels.json
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user