Compare commits

...

43 Commits

Author SHA1 Message Date
f27fb7a2c4 Add jamie-copy-trimmer (48) and dintel-campaign-designer (78) skills
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Import two skills authored in Claude Cowork: jamie-copy-trimmer as a
root-only Jamie skill, and campaign-gate-process as dintel-campaign-designer
with full D.intelligence Agent Corps packaging (code/desktop/shared,
agent-id 78, Draft & Wait autonomy) since it now sits inside the 70-79
block. Bumps the corps roster count to 9 agents + 1 meta-agent across all
sibling skills and registers #78 in the shared USER-GUIDE/README. Also adds
.claude/commands wrappers and ~/.claude/skills symlinks so both are usable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 12:59:19 +09:00
Andrew Yim
a08dc316be Merge pull request #14 from ourdigital/fix/notion-writer-skill-paths
fix(notion-writer): correct stale 02→32 skill paths
2026-06-27 11:42:19 +09:00
dc18594d76 fix(notion-writer): correct stale 02→32 skill paths after dir renumber
The 02-notion-writer dir was renumbered to 32-notion-writer, but the
skill's SKILL.md (both code + desktop variants) still pointed venv/cd
paths at the nonexistent 02 dir, and the slash-command file had a
~/Projects (should be ~/Project) typo. Fixes invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:41:39 +09:00
Andrew Yim
0b3a0ab129 Merge pull request #13 from ourdigital/notion-writer-cli-enhancements
feat(notion-writer): file uploads + native markdown engine (v1.3.0)
2026-06-27 11:27:11 +09:00
5c904756ab fix(ntn-files): propagate NOTION_API_KEY into ntn subprocesses
Force ntn to authenticate as the same integration that writes the page by
injecting NOTION_API_TOKEN=$NOTION_API_KEY into every ntn subprocess env.
Notion scopes file uploads to the creating integration, so a mismatch caused
"Could not find file_upload with ID …" when attaching uploaded images.

Added test_upload_passes_token_env (9 total in test_ntn_files.py) and updated
code/CLAUDE.md to reflect that a separate ntn login is no longer required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
07fca7fb81 fix(notion-writer): fence-aware image/translate, NtnUploadError boundary, dead-code removal
FIX 1: _content_has_local_images and extract_local_images now skip lines inside
  ``` fences so a local-image ref inside a code block is not treated as real.
FIX 2: md_translate.translate() tracks fence state; callout/columns/mention
  transforms are suppressed for lines inside ``` ... ``` blocks (pass verbatim).
FIX 3: main() catches NtnUploadError and prints a clean error + e.stderr before
  sys.exit(1); body moved to _main_body(parser, args).
FIX 4: ntn_files.upload() raises NtnUploadError on empty stdout instead of
  IndexError, giving a clean message combined with FIX 3.
FIX 5: Removed unused write_to_page() (both page paths are inlined).
FIX 6: create_page_markdown() omits the 'markdown' key when value is falsy,
  avoiding empty-markdown sends on DB rows created without --file/--stdin.
FIX 7: CLAUDE.md documents --allow-deleting-content scope (markdown engine only).
TDD: Added test_fence_passthrough_no_transform (md_translate, suite now 8) and
  test_local_image_inside_fence_ignored (engine_routing, suite now 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
00edd2e5dc docs(notion-writer): document engines + file uploads (v1.3.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
478ac61975 fix(notion-writer): fail loudly on markdown image append + upsert prop update
Mirror the blocks path's error handling in the markdown engine:
- page + DB markdown two-phase image append now check append_to_page's
  return and exit(1) on failure instead of printing success silently
- markdown DB upsert guards update_page_properties return
- restore blocks-engine page progress print + replace-failure print
- move unused parent computation into the create branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
5ff9b3d9f5 feat(notion-writer): --engine routing + two-phase image append
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
cfbca6cc15 feat(notion-writer): markdown endpoint helpers + version-aware client
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
dfbc52e531 feat(notion-writer): dialect->Notion enhanced markdown translator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
337f2ad6e1 feat(notion-writer): materialize local images via upload walk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
9fbd719048 feat(notion-writer): parse standalone images to image blocks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
5385f3bddd feat(notion-writer): ntn_files upload + preflight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
75cfcb9ad3 docs(notion-writer): fix Task 2 upload tests to mock builtins.open
upload() opens the file before subprocess.run; the two upload tests must
mock open() so the nonexistent /tmp paths don't raise FileNotFoundError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
bfade2b722 docs(notion-writer): fix plan — circular import, columns loop, error hint
Pre-flight review fixes: lazy import in md_translate to break the
notion_writer<->md_translate cycle; clean columns state machine; markdown
deletion hint in explain_api_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:12:48 +09:00
2b36feb81b docs(notion-writer): implementation plan for CLI enhancements
9 TDD tasks: venv baseline, ntn_files (upload+preflight), image parsing,
materialize walk, md_translate dialect translator, compat markdown
helpers + version-aware client, CLI engine routing + two-phase image
append, docs (v1.3.0), live smoke test. 55 unit tests across 4 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:12:48 +09:00
aa003e28cc docs(notion-writer): design spec for CLI file uploads + markdown engine
Tier 1: local ![](path) images uploaded via `ntn files create` and
embedded as file_upload image blocks (parser stays pure; upload happens
in an isolated post-parse walk).

Tier 2: opt-in `--engine markdown` writes through Notion's native
enhanced-markdown endpoints (POST /pages, PATCH .../markdown) with a
dialect translator (callouts/columns/toggles/mentions) so one source
doc works in both engines. Default engine stays `blocks` (no regression).

Adds ntn_files.py + md_translate.py, version-aware client (2026-03-11
for markdown writes only), new test suites, venv setup, docs → v1.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:12:48 +09:00
87635e4208 fix(skill): absent claim term -> INCONCLUSIVE hint; note surge-tuning (final-review minors #3,#4)
- gsc_signal_delta.py: extract `found` local var; add first branch in verdict_hint
  chain so a term absent from both GSC windows yields INCONCLUSIVE (not ARTIFACT).
  Existing ARTIFACT / CONFIRMED-PARTIAL / PARTIAL branches unchanged (elif chain).
- test_gsc_signal_delta.py: add test_absent_claim_term_inconclusive asserting
  found=False and "INCONCLUSIVE" in verdict_hint for a term in neither fixture.
- code/CLAUDE.md: one-line surge-tuning note — verdict_hint/in_top_movers are
  calibrated for upward claims; for drops, inspect top_decliners directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuT3W81t88QQFaxY2ruWv2
2026-06-26 10:38:14 +09:00
4f78534e59 test(skill): genesis 호텔 smoke fixtures + end-to-end ARTIFACT check 2026-06-26 10:29:35 +09:00
d20675d02c feat(skill): register seo-signal-validation in marketplace; reconcile spec layout 2026-06-26 10:25:46 +09:00
2cadc30825 feat(skill): gsc_signal_delta helper + tests + code notes 2026-06-26 10:21:36 +09:00
c35250f06a feat(skill): seo-signal-validation SKILL.md decision half (L3-L4, verdict, output) 2026-06-26 10:16:11 +09:00
3383878e12 feat(skill): seo-signal-validation SKILL.md measurement half (L1-L2) 2026-06-26 10:10:47 +09:00
f953887b97 docs(skill): add 35-seo-signal-validation implementation plan
5 bite-sized tasks: SKILL.md measurement half (L1-L2) + decision half
(L3-L4/verdict), gsc_signal_delta.py helper with TDD tests, marketplace
registration + spec-layout reconcile, and a genesis-case smoke test that
asserts the JHR "호텔" claim resolves to ARTIFACT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuT3W81t88QQFaxY2ruWv2
2026-06-26 10:05:57 +09:00
18b39ef6ea docs(skill): add 35-seo-signal-validation design spec
New conductor skill that adjudicates whether a claimed SERP/Knowledge-Graph
movement for a (term, entity) pair is real, misattributed, an artifact, or
unprovable. Stateless, on-demand; delegates measurement to seo-serp-analysis,
seo-position-tracking, and seo-knowledge-graph. Genesis: JHR "호텔 16→3"
SEMrush claim refuted via GSC/GA4/live-SERP cross-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuT3W81t88QQFaxY2ruWv2
2026-06-26 09:58:50 +09:00
e1f4d75dc2 graphify: index full doc layer via Gemini 3.1 Pro
Indexed 869 skill docs + 6 images (excl. 73 redundant .claude/commands mirrors)
using Gemini 3.1 Pro (~1.3M in / 301k out tokens, ~$6.23 - no Claude tokens):
+1552 nodes, +1308 edges, 61 doc<->code cross-links, 500 new doc communities
labeled by skill. Every skill's docs/specs are now queryable; prior graph was
code-only. Extraction offloaded to Gemini via graphify's OpenAI-compatible backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:20:45 +09:00
6c97dfc913 graphify: index ourdigital-okf skill into knowledge graph
OKF-scoped --update of custom-skills/97-ourdigital-okf (7 code + 19 docs):
+131 nodes (62 AST + 75 semantic), +207 edges, 8 OKF communities, 6 hyperedges
capturing the produce/validate/visualize design and zero-dep utility trio.
Prior graph was code-only; the OKF skill is now queryable. ~172k extraction tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:33:44 +09:00
c068372abf Add graphify knowledge graph outputs and pending changes
Includes graphify-out/ (interactive graph.html, GRAPH_REPORT.md, graph.json
from Gemini-backed extraction) plus .graphifyignore/.gitignore scope config,
alongside other pending working-tree changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:41:30 +09:00
587a32d239 feat(okf): add install.sh; changelog; e2e produce->validate->visualize verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
626abd4173 feat(okf): add SKILL.md variants (top/code/desktop), CLAUDE.md, README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
c6585c817f docs(okf): add distilled spec, frontmatter reference, and templates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
75acd3aa3e feat(okf): add minimal okf_viz Cytoscape generator with tests
Full suite green (20 tests). Smoke-tested on Google crypto_bitcoin bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
7b239dda8f feat(okf): add okf_validate; harden YAML parser for block lists + folded scalars
Verified conformant against Google reference bundles (crypto_bitcoin, ga4, stackoverflow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
9762ee97ab feat(okf): add okf_common frontmatter/link parser with tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
4416833cb3 feat(okf): scaffold ourdigital-okf skill skeleton + mini fixture bundle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:28 +09:00
b574c97fcc chore(marketplace): add ourdigital-skills local marketplace + fix README install docs (#1) 2026-06-16 14:08:27 +09:00
7daa4cda68 SEO schema validator skill update 2026-06-08 13:21:09 +09:00
Andrew Yim
8ffb6bec6b docs(ourdigital): align journal + research skill style guides to v3.3/v2.1 (#12)
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Merge-align the remaining OurDigital skill references to the authoritative
guides, preserving each skill's structure:

- 03-journal: brand identity, three-channel context, journal role/length
  (English, 1,000-2,000 words), authority order, OurDigital-vs-Clinic boundary,
  shared writing principles adapted for English. English voice preserved — the
  Korean-blog-only rules (평서체, 전문용어 병기) were deliberately NOT imposed.
- 04-research: per-channel voice/tone aligned to Writing Style Guide v2.1;
  fixed an incorrect 경어체 (~입니다) rule for the Korean blog → 평서체; SEO
  numbers corrected (title ~40자/≤60, meta ≤155, English slug); added brand
  identity + authority order + Clinic boundary.

05-document brand_config.json (corporate doc colors) and the claude-ai-export
snapshot were intentionally left out of scope.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:27:54 +09:00
Andrew Yim
9b914b9dd4 fix(estimate-engine): quote SKILL.md description to fix verify-skills YAML error (#11)
The unquoted description contained "Costing methods: effort …" — the mid-string
": " made YAML treat it as a nested mapping ("mapping values are not allowed
here"), failing verify-skills since 2026-05-27. Single-quote the value (it has
inner double quotes but no apostrophes), preserving the exact text.

verify_skills.py: 76/76 skills valid.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:21:26 +09:00
Andrew Yim
aab98f405d docs(ourdigital): align brand/blog/designer skill references to v3.3/v2.1 (#10)
Merge-align the core trio's reference material to the authoritative OurDigital
guides (Blog Project Instruction v3.3, Writing Style Guide v2.1, Visual Style
Guide v2.1), preserving each skill's structure:

- 01-brand-guide: brand-foundation + writing-style — identity statement, mission,
  three channels, OurDigital-vs-Clinic boundary, four writing principles,
  평서체/전문용어 병기 rules, authority order.
- 02-blog: blog-style-guide — channel identity, writing characteristics,
  post structure (char counts), SEO (title/meta/English slug), Ghost metadata,
  self-edit checklist.
- 06-designer: visual_metaphors (both copies kept identical) — bright editorial
  minimalism, Preferred/Avoid metaphor dictionary, 5 palettes, signature motifs,
  Korean-via-restraint, human-trace rule.

Source of truth: knowledge-base/ourdigital in our-ai-editor (PR #12).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:16:07 +09:00
6f69a6a484 Route estimate outputs to central archive (ourdigital-space/estimates)
All estimates now archive to ~/Workspaces/ourdigital-space/estimates/
<YYYY-MM-DD>_<prospect|account>_<service>/ (single 견적 archive). Engine
SKILL.md documents the output-home convention (engine stays --out-dir-agnostic).
presales-seo Stage 5 writes the estimate to $EST (archive); Stage 6 deck reads
from $EST but stays in the engagement bundle.

(Workspace side, not in this repo: SHR estimate moved to the archive, pointer
left in the engagement, ourdigital-space/CLAUDE.md documents estimates/.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:22:33 +09:00
9f7f9e7221 education: clarify ga4_gtm_intermediate (=기본구성/숨고), add marketing-analytics course
The 숨고 GA4/GTM 중급 기본견적 is identical to the existing ga4_gtm_intermediate
course (17 lessons, ₩1,570,000) — labeled it as the 기본구성 standard + aliases
rather than duplicating. Added the genuinely distinct GA4/GTM 마케팅 애널리틱스
course (22 lessons incl. 메타/구글 솔루션 + 워크숍 + 트리트먼트) → list ₩4,070,000.

Validated: intermediate ₩1,570,000, marketing_analytics ₩4,070,000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:15:56 +09:00
116 changed files with 19749 additions and 547 deletions

View 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
}
]
}

View 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

View 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

View File

@@ -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
View File

@@ -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
View 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-*/

View File

@@ -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/

View File

@@ -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

View File

@@ -1,6 +1,8 @@
# OurDigital Brand Foundation
Complete brand identity reference for OurDigital Clinic.
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
Complete brand identity reference for OurDigital.
## Brand Identity
@@ -8,25 +10,34 @@ Complete brand identity reference for OurDigital Clinic.
| Element | Content |
|---------|---------|
| **Brand Name** | OurDigital Clinic |
| **Tagline** | 우리 디지털 클리닉 \| Your Digital Health Partner |
| **Mission** | 디지털 마케팅 클리닉 for SMBs, 자영업자, 프리랜서, 비영리단체 |
| **Brand Name** | OurDigital |
| **Identity Statement** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| **Mission** | 기술이 사람과 문화에 미치는 영향을 관찰하고 기록한다. 생각의 씨앗을 남기고 성찰한다. 검증되지 않은 도전과 실험의 결과를 기록한다. |
| **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 |
| **Promise** | 진단-처방-측정 가능한 성장 |
### Core Values
### Brand Keywords
| 가치 | English | 클리닉 메타포 | 설명 |
|------|---------|--------------|------|
| 마케팅 과학 | Marketing Science | 근거 중심 의학 | 검증된 방법론과 프레임워크 |
| 실행 지향 | In-Action | 실행 가능한 처방 | 분석에서 끝나지 않는 실행력 |
| 지속 성장 | Sustainable Growth | 체질 개선 | 일회성이 아닌 지속 가능한 성장 |
| 핵심 가치 | 설명 |
|----------|------|
| **관찰** | 현상을 있는 그대로 포착하되, 표면 아래를 본다 — 무엇이 일어나고 있는가 |
| **분석** | 현상의 원인과 맥락을 탐구한다 — 왜 이런 일이 일어나는가 |
| **성찰** | 심층적 의미와 인간적 함의를 도출한다 — 이것이 우리에게 무엇을 의미하는가 |
| **실험** | 검증되지 않은 시도를 두려워하지 않는다 |
| **기록** | 생각의 궤적을 남겨 미래의 자산으로 삼는다 |
| **균형** | 낙관과 비관, 이론과 실무 사이에서 중심을 잡는다 |
### Brand Philosophy
**"Precision + Empathy + Evidence"**
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications.
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications. 철학적 깊이와 실용적 가치의 균형이 OurDigital의 정체성이다.
### Brand Boundary (중요)
- **블로그 전체 브랜드**: OurDigital
- **블로그 설명**: 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트
- **OurDigital Clinic**: 진단형 콘텐츠, SEO/데이터/콘텐츠 감사, 컨설팅 상품, 문제 해결형 시리즈에서만 사용 가능한 서비스 메타포. 블로그 전체 브랜드명이 아니다.
## Positioning Statement
@@ -34,8 +45,16 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
> OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스,
> unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론
*(Note: OurDigital Clinic은 컨설팅/서비스 맥락에서의 포지셔닝. 블로그 전체 포지셔닝과 구분한다.)*
## Target Audience
**블로그 독자 (blog.ourdigital.org)**
- **1순위**: 디지털 마케팅/기술 분야 종사자 — 실무 전략을 수립하고 실행하는 전문가
- **2순위**: 기술과 사회의 관계에 관심 있는 지식인 — 디지털 전환이 사회에 미치는 영향을 고민하는 독자
- **3순위**: 자기 성찰과 비판적 사고를 중시하는 독자
**컨설팅 서비스 (OurDigital Clinic)**
- **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체
- **Secondary**: 스타트업 창업자, 브랜드 매니저
@@ -54,24 +73,30 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
| Level | Element | Description |
|-------|---------|-------------|
| Level 1 | Master Brand | OurDigital Clinic |
| Level 1 | Master Brand | OurDigital |
| Level 2 | Channel Identity | Blog, Journal, OurStory |
| Level 3 | Service Identity | 4개 핵심 서비스 |
| Level 3 | Service Identity | 4개 핵심 서비스 (OurDigital Clinic 메타포 적용 가능) |
### Channel Personality & Tone
| Channel | Domain | Personality | Tone | Content Type |
|---------|--------|-------------|------|--------------|
| Main Hub | ourdigital.org | Professional & Confident | Data-driven, Solution-oriented | 서비스, 케이스, 리드 |
| Blog | blog.ourdigital.org | Analytical & Personal | Educational, Thought-provoking | 가이드, 분석, 인사이트 |
| Journal | journal.ourdigital.org | Conversational & Poetic | Reflective, Cultural Observer | 에세이, 문화, 관찰 |
| OurStory | ourstory.day | Intimate & Reflective | Authentic, Personal Journey | 개인 서사, 경험 |
| D.intelligence | dintelligence.co.kr | Professional | B2B | Corporate Partnership |
| Channel | Domain | Language | Character | Length |
|---------|--------|----------|-----------|--------|
| Main Hub | ourdigital.org | Korean | Professional & Confident, Data-driven | 서비스, 케이스, 리드 |
| **Blog** | blog.ourdigital.org | **Korean** | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | **1,500-3,000자** |
| **Journal** | journal.ourdigital.org | **English** | 산업 트렌드, 기술-인간 교차점, reflective essay | **1,000-2,000 words** |
| **OurStory** | ourstory.day | **Korean** | 개인 에세이, 삶의 성찰, 일상의 관찰 | **800-1,500자** |
| D.intelligence | dintelligence.co.kr | Korean/English | Professional | B2B Corporate Partnership |
**채널 라우팅 규칙:**
- 기술 분석 + 철학적 사유 → `blog.ourdigital.org`
- 순수 개인 에세이, 감정적 성찰 → `ourstory.day`
- 영문 심층 에세이, 산업 관점 → `journal.ourdigital.org`
- 진단형 콘텐츠, 컨설팅 제안 → `OurDigital Clinic` 메타포 사용 가능
### Content Flow Strategy
```
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site / OurDigital Clinic)
```
### Publishing Cadence
@@ -80,7 +105,7 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|---------|-----------|--------|
| ourdigital.org | 필요시 업데이트 | 서비스별 상세 |
| blog.ourdigital.org | 주 1-2회 | 1,500-3,000자 |
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000 |
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000 words |
| ourstory.day | 월 1-2회 | 800-1,500자 |
## Service Portfolio
@@ -112,13 +137,16 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
| Use Case | Message |
|----------|---------|
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
| Blog Identity | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| Consulting Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
| Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
| Process | 진단 → 처방 → 측정 |
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
## CTA Library
*(OurDigital Clinic 컨설팅 맥락에서 사용)*
| Context | CTA Text |
|---------|----------|
| General | 무료 상담 신청하기 |

View File

@@ -1,5 +1,7 @@
# OurDigital Writing Style Guide
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
Comprehensive writing guidelines for OurDigital content across all channels.
## Overview
@@ -8,7 +10,7 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|-------|-------|
| **Author** | Andrew Yim |
| **Primary Blog** | blog.ourdigital.org |
| **Tagline** | 사람, 디지털 그리고 문화 (People, Digital, and Culture) |
| **Brand Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| **Platform** | Ghost CMS |
| **History** | 2004-2025 (20+ years of content) |
@@ -16,54 +18,73 @@ Comprehensive writing guidelines for OurDigital content across all channels.
## Part 1: 한국어 스타일가이드
### Writer 역할 정의
25년차 디지털 마케팅 에세이 작가. 기술과 인간의 관계를 관찰하는 실무자이자 사유자. 분석적이면서 개인적이고, 단정 대신 질문을 두고, 결론 대신 가능성을 제시하며, 독자를 가르치지 않고 함께 생각하는 동료로 대한다.
### 문체 규칙 (Korean)
- 대화체 `~다`, `~이다`, `~한다`를 사용한다.
- 경어체 `~합니다`, `~입니다`는 사용하지 않는다.
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다.
- 문단 전환 시 호흡을 바꾼다: 분석 → 서사 → 질문.
- **전문용어는 첫 등장 시 영문을 병기한다.** 예: `검색엔진 최적화(SEO)`.
### 핵심 글쓰기 특성
#### 1. 철학-기술 융합
#### 1. 철학-기술 융합
기술 분석과 실존적 질문을 자연스럽게 결합한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 의와 분리되지 않는다.
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 의와 분리되지 않는다.
**예시**: Llama 4와 DeepSeek 비교 글에서도 거버넌스 우려와 사회적 영향을 포함한다.
```
나쁜 예: 구글의 알고리즘은 200개 이상의 신호를 분석한다.
좋은 예: 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
```
#### 2. 역설(Paradox)을 주요 수사 장치로 활용
#### 2. 핵심 긴장 / 관점 전환 / 역설 / 열린 질문
논증을 긴장과 모순 구조로 전개한다:
- 인간이 컴퓨터를 모방하면서 컴퓨터가 인간을 모방하는 역설
- 기술이 해방하면서 동시에 의미를 축소하는 역설
- 디지털 네이티브가 기성세대가 가르칠 수 없는 유창함을 보유하는 역설
모든 글에 억지 역설을 넣지는 않는다. 대신 **핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상**을 자연스럽게 포함한다. 공식화를 경계한다.
#### 3. 수사적 질문 활용
유용한 패턴:
- `~하면서 동시에 ~하다`
- `~를 위해 오히려 ~해야 한다`
- `가장 ~한 것이 사실은 가장 ~하다`
- `우리가 놓치고 있는 것은 무엇인가?`
선언적 권위보다 질문을 통한 참여를 선호한다:
수사적 질문은 선언적 권위보다 독자와의 지적 동반자 관계를 형성한다.
> "이 디지털 세대에게 무엇을 가르쳐야 하는가?"
> "그 무한한 자유 속에서 우리는 무엇을 소중히 여기게 될 것인가?"
#### 3. 우울한 낙관주의 (Melancholic Optimism)
이는 독자와 지적 동반자 관계를 형성하며, 교훈적 지시를 피한다.
디지털 세계의 불안, 피로, 한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 더 나은 실천, 작은 회복의 여지를 찾는다.
#### 4. 우울한 낙관주의 (Melancholic Optimism)
```
예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
```
불안과 상실을 인정하되 절망하지 않는다. 기술의 불가피성을 수용하면서도 대체되는 것에 대한 진정한 우려를 표현한다.
#### 4. 분석적이면서 개인적
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
### 문장 구조 패턴
| 요소 | 패턴 |
|------|------|
| 문장 길이 | 여러 절을 포함한 긴 복합문 - 논의되는 상호연결된 개념을 반영 |
| 문장 길이 | 짧은 문장과 긴 복합문을 교차 배치 — 리듬을 만든다 |
| 단락 구조 | 관찰 → 분석 → 철학적 함의로 점진적 심화 |
| 근거 제시 | 역사적 사례, 문화간 참조, 기술 명세를 함께 엮음 |
| 결론부 | 종종 열린 결말로, 답을 제시하기보다 질문을 던짐 |
### 독자 조율
**교양 있는 일반 독자**를 대상으로 한다 — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다.
**교양 있는 일반 독자** — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다. 독자를 가르치려 하지 않고 결론을 강요하지 않는다. 생각의 재료를 제공한다.
기술 글에도 요약본과 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
기술 글에도 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
### 고유 특성
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영 (단, 전문용어는 첫 등장 시 영문 병기)
2. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식
3. **인식론적 겸손**특히 세대간 격차에서 이해의 한계를 인정
3. **인식론적 겸손** — 이해의 한계를 인정하며, 겸손한 추정 표현을 적절히 활용 (`~일지도 모른다`, `~인 듯하다`, `어쩌면 ~`)
4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸
---
@@ -124,20 +145,28 @@ Technical articles include executive summaries and metaphorical anchoring ("Data
### Do's
- Use paradox to structure arguments
- Ask rhetorical questions to engage readers
- Connect technical content to human implications
- Acknowledge uncertainty and epistemic limits
- Blend Korean and English naturally for technical terms
- Reference historical context and generational shifts
- 핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상을 포함한다
- 수사적 질문으로 독자와 지적 동반자 관계를 형성한다
- 기술 콘텐츠를 인간적 함의와 연결한다
- 불확실성과 이해의 한계를 인정한다
- 전문용어 첫 등장 시 영문을 병기한다 (`검색엔진 최적화(SEO)`)
- 역사적 맥락과 세대적 변화를 참조한다
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다
- 1인칭 경험과 관찰을 분석에 자연스럽게 엮는다
### Don'ts
### Don'ts (피해야 할 것)
- Avoid purely declarative, authoritative tone
- Don't separate technical analysis from cultural impact
- Avoid simplistic or overly optimistic technology narratives
- Don't provide prescriptive conclusions without exploration
- Avoid ignoring regulatory and governance concerns
| 피해야 할 것 | 이유 |
|-------------|------|
| `~합니다`, `~입니다` | 경어체 — 대화체(`~다`, `~한다`) 사용 |
| `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다` | 상투적 도입부 |
| `첫째, 둘째, 셋째` 기계적 나열 | 교과서적 구조 |
| `따라서 반드시 ~해야 한다` | 확정적 결론 |
| 근거 없는 통계나 데이터 | 데이터 정직성 위반 |
| 기술에 대한 무조건적 찬양 또는 혐오 | 균형 원칙 위반 |
| 과도한 감탄부호, 물결표, 이모지 | 톤 일관성 파괴 |
| 특정 업체나 제품의 노골적 홍보성 문장 | 브랜드 신뢰 훼손 |
| SEO 키워드 과잉 최적화 | SEO가 글을 지배해서는 안 된다 |
---

View File

@@ -1,5 +1,7 @@
# OurDigital Blog Style Guide
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
Detailed writing guidelines for blog.ourdigital.org.
## Channel Identity
@@ -7,62 +9,74 @@ Detailed writing guidelines for blog.ourdigital.org.
| Field | Value |
|-------|-------|
| **Domain** | blog.ourdigital.org |
| **Tagline** | 사람, 디지털 그리고 문화 |
| **Language** | Korean (전문용어 영문 병기) |
| **Tone** | Analytical & Personal, Educational |
| **Target** | 교양 있는 일반 독자 - 기술의 문화적 영향에 호기심 있는 독자 |
| **Brand** | OurDigital |
| **Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| **Core Theme** | 사람, 디지털 그리고 문화 |
| **Language** | Korean (전문용어 첫 등장 시 영문 병기) |
| **CMS** | Ghost — 초안은 Markdown으로 작성 |
| **Tone** | Analytical & Personal (비판적이되 공정, 겸손하되 자신감 있음) |
| **Target** | 디지털 마케팅/기술 실무자 (1순위), 기술-사회 관계에 관심 있는 지식인 (2순위), 비판적 사고를 중시하는 일반 독자 (3순위) |
**브랜드 경계**: `OurDigital`이 블로그 전체 정체성이다. `OurDigital Clinic`은 진단형 콘텐츠·컨설팅 상품에서만 사용하는 서비스 메타포다.
**우선순위**: 지침 충돌 시 Blog_Project_Instruction_v3.3 → 이 스타일 가이드 → 사용자 일반 스타일 순으로 따른다.
## Writing Characteristics
### 1. 철학-기술 융합체
모든 초안은 아래 네 원칙을 기준으로 자기 점검한다.
기술 분석과 실존적 질문을 자연스럽게 결합한다.
### 1. 철학-기술 융합
**Good Example:**
> AI가 우리의 업무를 대체할 수 있다는 사실은 분명하다. 그러나 더 중요한 질문은 "AI가 대체할 수 없는 것은 무엇인가?"이다.
**Bad Example:**
> AI는 업무 효율성을 높여준다. 다양한 분야에서 활용되고 있다.
### 2. 역설(Paradox) 활용
논증을 긴장과 모순 구조로 전개한다.
**Paradox Patterns:**
- "~하면서 동시에 ~하다"
- "~인 것 같지만 실은 ~이다"
- "~를 얻었지만 ~를 잃었다"
### 3. 수사적 질문
선언적 권위보다 질문을 통한 참여를 선호한다.
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다.
**Good:**
> 우리는 정말 데이터를 이해하고 있는 것일까?
> 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
**Bad:**
> 데이터를 이해하는 것이 중요하다.
> 구글의 알고리즘은 200개 이상의 신호를 분석한다.
### 4. 우울한 낙관주의
### 2. 긴장과 역설
불안과 상실을 인정하되 절망하지 않는다.
억지 역설을 억지로 넣지 않는다. 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
**Useful Patterns:**
- `~하면서 동시에 ~하다`
- `~를 위해 오히려 ~해야 한다`
- `가장 ~한 것이 사실은 가장 ~하다`
- `우리가 놓치고 있는 것은 무엇인가?`
> 예: 데이터를 가장 잘 활용하는 방법은, 때때로 데이터를 내려놓는 것이다.
### 3. 우울한 낙관주의
디지털 세계의 불안·피로·한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 작은 회복의 여지를 찾는다.
**Tone Spectrum:**
```
비관 ←――――――――――――――――――→ 낙관
우울한 낙관주의
(여기에 위치)
우울한 낙관주의
(여기에 위치)
```
> 예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
### 4. 분석적이면서 개인적
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
> 예: 지난 3년간 50개 이상의 사이트 마이그레이션을 지켜봤다. 숫자로 보면 성공률은 70% 정도다. 하지만 '성공'의 정의가 사이트마다 달랐다는 게 진짜 이야기다.
## 문장 구조
| Element | Pattern |
|---------|---------|
| 문장 길이 | 긴 복합문 허용 - 상호연결된 개념 반영 |
| 문장 길이 | 단문 기본, 짧은 문장과 긴 문장을 교차해 리듬을 만든다 |
| 단락 구조 | 관찰 → 분석 → 철학적 함의 |
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조 |
| 단락 전환 | 분석 → 서사 → 질문으로 호흡을 바꾼다 |
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조; 출처 없는 통계는 사용하지 않는다 |
| 결론 | 열린 결말, 답보다 질문 |
| 문체 | `~다`, `~이다`, `~한다` 평서체; `~합니다`, `~입니다` 경어체 사용 금지 |
## 언어 규칙
@@ -83,32 +97,61 @@ Detailed writing guidelines for blog.ourdigital.org.
## 포스트 구조
### 도입부 (10-15%)
### 도입부 (200-300자)
1. **Hook**: 독자의 관심을 끄는 질문/통계/역설
2. **Context**: 주제의 배경 설명
3. **Preview**: 글에서 다룰 내용 암시
- 장면·질문·역설·데이터 중 하나로 시작한다.
- 핵심 질문을 제시하고, 독자가 왜 이 글을 읽어야 하는지 암시한다.
- 상투적 표현 금지: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
### 본론 (70-80%)
### 본론 (3-5개 섹션, 각 300-500자)
3-5개의 핵심 포인트, 각각:
1. **주장**: 명확한 포인트 제시
2. **근거**: 데이터, 사례, 전문가 의견
3. **함의**: 이것이 의미하는 바
- 소제목(`##`, `###`)을 사용한다.
- 분석·서사·데이터·개인 관찰을 교차시킨다.
- 최소 1회 관점 전환 또는 핵심 긴장을 포함한다.
- 전문용어는 첫 등장 시 영문을 병기한다. 예: `검색엔진 최적화(SEO)`
### 결론 (10-15%)
### 결론 (200-300자)
1. **Summary**: 핵심 내용 요약
2. **Reflection**: 더 넓은 맥락에서의 의미
3. **Open Question**: 독자가 생각할 질문
- 인사이트를 정리하되 단정하지 않는다.
- 열린 질문 또는 다음 사유의 출발점으로 마무리한다.
- 독자가 이어서 생각할 여운을 남긴다.
### CTA (선택, 1-2문장)
관련 글, 뉴스레터, 댓글, 실무 문의 등 필요한 경우만 사용한다.
### 길이 기준
| Type | 길이 | 용도 |
|------|-----:|------|
| Short-form | 1,000-1,500자 | 단일 인사이트, 짧은 단상 |
| Standard | 2,000-2,500자 | 기본 블로그 포스트 |
| Long-form | 2,500-3,000자 | 심층 분석, 리서치 기반 글 |
| Research Essay | 3,000자 이상 | 사용자 명시 요청 시만 |
기본값: 별도 지정이 없으면 **2,000자 ±300자**.
## SEO Guidelines
Ghost CMS 메타데이터는 아래 형식으로 완성한다.
```yaml
title: "글 제목"
slug: "url-friendly-english-slug"
meta_title: "SEO 타이틀, 60자 이내"
meta_description: "SEO 디스크립션, 155자 이내"
tags:
- primary: "메인 카테고리"
- secondary: ["태그1", "태그2"]
featured: false
excerpt: "카드/프리뷰용 발췌문, 2-3문장"
```
### Title (제목)
- 60자 이내
- 핵심 키워드 포함
- 호기심 유발 또는 가치 제안
- **60자 이내**, 핵심 키워드 자연스럽게 포함
- 호기심·긴장·관점 전환을 만든다 (40자 내외 기준)
- 키워드 밀도만 의식한 SEO 과잉 최적화 금지
**Patterns:**
- "[주제]의 역설: ~하면서 ~하는 시대"
@@ -117,15 +160,13 @@ Detailed writing guidelines for blog.ourdigital.org.
### Meta Description
- 155자 이내
- 글의 핵심 가치 요약
- 클릭 유도 문구
- **155자 이내**, 글의 핵심 질문과 독자 효용을 함께 담는다
- 클릭을 유도하되 과장하지 않는다
### URL Slug
- 영문 소문자
- 하이픈으로 구분
- 3-5 단어
- **영문** 소문자, 하이픈으로 구분, 3-5 단어
- 한국어 제목이라도 slug는 영문으로 작성한다
## Content Calendar
@@ -138,12 +179,34 @@ Detailed writing guidelines for blog.ourdigital.org.
## Quality Checklist
Before publishing:
### Must Have
- [ ] 제목이 60자 이내인가?
- [ ] 메타 설명이 155자 이내인가?
- [ ] 전문용어 영문 병기되었는가?
- [ ] 수사적 질문이 포함되었는가?
- [ ] 기술 내용에 인간적 함의가 있는가?
- [ ] 결론이 열린 질문으로 끝나는가?
- [ ] 1,500-3,000자 범위인가?
- [ ] 핵심 긴장·역설·관점 전환·열린 질문 중 하나 이상이 있다.
- [ ] 도입부 300자 이내에 hook과 핵심 질문이 있다.
- [ ] 전문용어 첫 등장 시 영문 병기했다.
- [ ] 기술 내용에 인간적 함의가 있다.
- [ ] 독자를 가르치려 하지 않고 함께 생각하는 태도를 유지했다.
- [ ] 결론이 열린 질문 또는 여운으로 마무리된다.
- [ ] SEO 메타데이터(title ≤60자, meta ≤155자, 영문 slug)가 완성되었다.
- [ ] 기본 길이 2,000자 ±300자를 준수했다 (또는 이탈 이유가 있다).
### Must Avoid
- [ ] 상투적 도입: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
- [ ] 경어체: `~합니다`, `~입니다`
- [ ] 교과서적 나열: `첫째`, `둘째`, `셋째`의 기계적 반복
- [ ] 확정적 결론: `따라서 반드시 ~해야 한다`
- [ ] 출처 없는 통계나 데이터
- [ ] 기술에 대한 무조건적 찬양 또는 혐오
- [ ] 과도한 감탄부호, 물결표, 이모지
- [ ] 특정 업체·제품의 노골적 홍보성 문장
### 자기 편집 (Self-Edit) 의무
초안 완성 후 아래 형식으로 가장 약한 항목 1개를 반드시 지목한다. "모두 통과"는 허용하지 않는다.
```
[자기 편집] 가장 약한 부분: [항목명]
이유: [1문장]
개선 방향: [1문장]
```

View File

@@ -1,7 +1,42 @@
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 + Writing_Style_Guide_v2.1 (2026-06-05); journal = English channel, voice preserved -->
# OurDigital Journal Style Guide
Writing guidelines for journal.ourdigital.org - English essays and articles.
## Brand Identity
OurDigital is a personal digital research notebook that **observes and records how technology shapes people and culture** ("사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트"). It moves between three registers:
- **Observation** — what is happening
- **Analysis** — why it is happening
- **Reflection** — what it means for us
`journal.ourdigital.org` is the **English essay channel** within the OurDigital family. It carries the same philosophical core as the Korean blog but in a distinct voice: conversational, poetic, reflective.
### Brand Boundary
`OurDigital` is the brand. `OurDigital Clinic` is a service metaphor reserved for diagnostic content, audits, or consulting products — it is **not** the overall blog or journal brand.
## Channel Context
| Channel | Language | Character | Length |
|---------|----------|-----------|--------|
| `blog.ourdigital.org` | Korean | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | 1,5003,000자 |
| **`journal.ourdigital.org`** | **English** | **Industry trends, techhuman intersection, reflective essay** | **1,0002,000 words** |
| `ourstory.day` | Korean | 개인 에세이, 삶의 성찰, 일상의 관찰 | 8001,500자 |
| `Medium` | English | Technology, marketing, AI for broad audiences | 8001,500 words |
## Instruction Authority Order
When instructions conflict, follow this order:
| Priority | Source |
|----------|--------|
| **1** | OurDigital_Blog_Project_Instruction_v3.3 (channel routing + brand rules) |
| **2** | This style guide (journal-specific voice and structure) |
| **3** | Writing_Style_Guide_v2.1 (shared brand principles, adapted for English) |
## Channel Identity
| Field | Value |
@@ -10,6 +45,8 @@ Writing guidelines for journal.ourdigital.org - English essays and articles.
| **Language** | English |
| **Tone** | Conversational & Poetic, Reflective |
| **Target** | Informed generalists with intellectual curiosity |
| **Default length** | 1,0002,000 words |
| **Content focus** | Industry trends, techhuman intersection, reflective essays |
## Voice Characteristics
@@ -20,11 +57,16 @@ Seamlessly blend technical analysis with existential questioning. Technology is
**Example:**
> The dashboard promises clarity—every metric tracked, every trend visualized. Yet as I stared at the perfectly organized data, I wondered: does seeing everything mean understanding anything?
### Paradox as Primary Device
### Tension and Paradox
Structure arguments around tensions and contradictions that illuminate rather than confuse.
Structure arguments around core tensions that illuminate rather than confuse. Not every essay needs a paradox — forced ones feel mechanical. Instead, ensure at least one of the following is naturally present:
**Paradox Patterns:**
- A genuine tension or contradiction
- A perspective shift that reframes the subject
- A rhetorical question that opens rather than closes
- An ending that leaves productive uncertainty
**Tension patterns:**
- "The more we measure, the less we understand"
- "In optimizing for efficiency, we optimize away meaning"
- "The tools that connect us also isolate us"
@@ -39,6 +81,10 @@ Favor interrogative engagement. Questions create intellectual partnership with r
**Avoid:**
> Data-driven decision-making is important for businesses.
### Analytical and Personal
Bring data, evidence, and argument — then weave in first-person experience and observation. This is an essay, not a paper, but it is not impressionism without evidence either. The personal grounds the analytical; the analytical elevates the personal.
### Melancholic Optimism
Acknowledge loss and anxiety without despair. Accept technological inevitability while mourning what's displaced.
@@ -157,10 +203,14 @@ Connect Korean and Western perspectives, offering unique viewpoints.
Before publishing:
- [ ] Does the opening draw readers in?
- [ ] Are there rhetorical questions?
- [ ] Does technical content connect to human experience?
- [ ] Is there at least one paradox or tension?
- [ ] Does the closing leave an open question?
- [ ] Is the tone melancholic but not despairing?
- [ ] Does the opening draw readers in within the first paragraph?
- [ ] Does technical content connect to human experience (philosophy-tech fusion)?
- [ ] Is at least one of the following naturally present: tension, paradox, perspective shift, or open question?
- [ ] Are rhetorical questions used to create intellectual partnership (not overused)?
- [ ] Is analysis grounded in personal observation or experience?
- [ ] Does the closing leave an open question or productive uncertainty?
- [ ] Is the tone melancholic but not despairing, hopeful but not naive?
- [ ] Are sentences varied in length and rhythm?
- [ ] Does the essay avoid lecturing — does it treat readers as fellow thinkers?
- [ ] Is the essay within 1,0002,000 words?
- [ ] **Self-edit**: identify the single weakest element ("all pass" is not allowed).

View File

@@ -1,38 +1,64 @@
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
# OurDigital Blog Style Guide
## Brand Identity & Authority Order
**Brand**: OurDigital — 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트.
**OurDigital vs. OurDigital Clinic**: `OurDigital`은 블로그 전체 정체성. `OurDigital Clinic`은 진단형 콘텐츠, SEO/데이터 감사, 컨설팅 상품에서만 사용하는 서비스 메타포. 블로그 본문에서 전체 브랜드명으로 사용하지 않는다.
**Authority order** (conflicts: higher wins):
1. `OurDigital_Blog_Project_Instruction_v3.3` — blog.ourdigital.org 최종 권위
2. Skills Bundle `SKILL.md` files — workflow detail
3. Project reference files (this guide, Visual Style Guide, etc.)
4. `userStyle` — non-blog conversation only
---
## Channel-Specific Voice & Tone
### blog.ourdigital.org (Korean)
**Voice**: 전문적이면서 친근한 선배 마케터
**Tone**: 실용적, 데이터 기반, 인사이트 중심
**Platform**: Ghost CMS
**Voice**: 분석적이면서 개인적인 관찰자 — 호기심 어린 실무자이자 사유자. 가르치는 선배가 아니라 함께 생각하는 동료.
**Tone**: 차분하고 성찰적; 에세이는 사려 깊게, 분석은 객관적으로, 비평은 날카롭되 공정하게.
Writing patterns:
- 제목: 핵심 키워드 포함, 30자 이내
- 도입부: 독자의 고민/질문으로 시작
- 본문: 번호 매기기보다 소제목 활용
- 전문용어: 한글(영문) 형식 - 예: 검색엔진최적화(SEO)
- 문장: ~입니다/~니다 경어체
- 단락: 3-4문장, 모바일 가독성 고려
- 제목: SEO 키워드 자연스럽게 포함, **40자 내외 기준 (≤60자)**
- 도입부: 개인 관찰·장면·질문·역설·데이터 중 하나로 시작; 독자 고민 직접 나열 지양
- 본문: 소제목 활용; 분석-서사-질문 교차; 최소 1회 관점 전환 또는 핵심 긴장 포함
- 전문용어: **첫 등장 시 영문 병기** 예: `검색엔진 최적화(SEO)`
- 문장: **`~다`, `~이다`, `~한다` 평서체** (경어체 `~합니다/~니다` 사용 금지)
- 단락: 3-4문장, 모바일 가독성 고려; 문장 길이에 변화를 줌
- 마무리: 열린 질문 또는 다음 사유의 출발점; 단정적 결론 지양
Example opening:
Writing principles (Writing Style Guide v2.1 §3-4):
- **철학-기술 융합**: 기술 주제 이면의 인간적 함의를 탐구한다.
- **긴장과 역설**: 억지로 넣지 않되, 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
- **분석적이면서 개인적**: 데이터/논거를 제시하되 1인칭 경험·관찰을 자연스럽게 엮는다. 논문이 아니라 에세이.
Example opening (올바른 톤):
```
"구글 상위 노출, 왜 이렇게 어려울까요?
많은 마케터들이 SEO에 시간을 투자하지만
결과가 보이지 않아 좌절합니다.
오늘은 실제로 효과를 본 전략 3가지를 공유합니다."
검색엔진 최적화(SEO)를 가장 잘하는 방법이 뭐냐고 물으면,
나는 종종 엉뚱한 대답을 한다. "SEO를 잊어버리세요."
알고리즘을 쫓는 사람은 항상 알고리즘에 뒤처진다는 역설.
구글이 원하는 건 구글을 위한 콘텐츠가 아니라, 사람을 위한 콘텐츠다.
```
### journal.ourdigital.org (English)
**Voice**: Thoughtful industry analyst
**Tone**: Insightful, evidence-based, forward-looking
**Voice**: Thoughtful industry analyst — reflective, personal, essayistic
**Tone**: Insightful, evidence-based, forward-looking; confident but not arrogant
Writing patterns:
- Headlines: Clear value proposition, under 60 chars
- Opening: Hook with industry trend or data point
- Body: Structured arguments with supporting evidence
- Opening: Hook with personal observation, industry trend, or data point
- Body: Structured arguments with supporting evidence; analysis and personal observation interwoven
- Terminology: Define jargon on first use
- Style: Active voice, varied sentence length
- Style: Active voice, varied sentence length; short sentences as default
- Paragraphs: 2-4 sentences for scannability
- Closing: Open question or reflection — avoid definitive wrap-ups
Example opening:
```
@@ -43,17 +69,19 @@ and what it means for your strategy."
```
### ourstory.day (Korean)
**Voice**: 성찰하는 동료, 이야기꾼
**Voice**: 성찰하는 동료, 이야기꾼 — 개인 에세이, 삶의 성찰, 일상의 관찰
**Tone**: 개인적, 진솔한, 영감을 주는
Writing patterns:
- 제목: 감성적, 질문형 또는 은유적
- 도입부: 개인 경험이나 장면 묘사로 시작
- 본문: 이야기 흐름, 대화체 허용
- 문장: ~해요/~네요 부드러운 경어체 가능
- 단락: 자유로운 길이, 호흡에 따라
- 마무리: 열린 질문 또는 여운
> **Channel boundary**: 순수 개인 에세이, 감정적 성찰은 `ourstory.day`. 기술 분석+철학적 사유는 `blog.ourdigital.org`. 둘을 혼동하지 않는다.
Example opening:
```
"새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다.
@@ -63,6 +91,7 @@ Example opening:
```
### Medium (English)
**Voice**: Knowledgeable peer sharing discoveries
**Tone**: Conversational, practical, slightly informal
@@ -82,33 +111,40 @@ Last month, I ran an experiment that changed how I think
about content strategy entirely. Let me walk you through it."
```
---
## Universal Guidelines
### SEO Considerations
### SEO & Metadata
- Primary keyword in title and first 100 words
- Secondary keywords naturally distributed
- Meta description: 150-160 chars, action-oriented
- URL slug: Short, keyword-rich, no dates
- **Meta description: 155 chars**, action-oriented, captures the article's question and reader value
- **URL slug: English, keyword-rich, no dates** — even for Korean-title posts
- Alt text for all images
### Formatting Rules
- Use `##` for main sections, `###` for subsections
- Code blocks with language specification
- Blockquotes for key insights or quotes
- Bold for emphasis (sparingly)
- Lists only when truly listing items
- Lists only when truly listing items; avoid in essay-form posts
### Citation Style
- Inline links preferred over footnotes
- Source attribution: "According to [Source Name](URL)..."
- Data citations: Include date of data
- Internal links: Reference related OurDigital posts
---
## Word Count Guidelines
| Channel | Target | Min | Max |
|---------|--------|-----|-----|
| blog.ourdigital.org | 1,500 | 1,000 | 2,500 |
| journal.ourdigital.org | 1,800 | 1,200 | 3,000 |
| ourstory.day | 1,000 | 500 | 2,000 |
| Medium | 1,500 | 800 | 2,500 |
| blog.ourdigital.org | 2,000 | 1,000 | 3,000 |
| journal.ourdigital.org | 1,500 words | 1,000 words | 2,000 words |
| ourstory.day | 1,000 | 800 | 1,500 |
| Medium | 1,500 words | 800 words | 2,500 words |

View File

@@ -1,101 +1,104 @@
# Visual Metaphor Dictionary
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
Quick reference for translating abstract concepts into visual elements.
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
## Technology & Digital
| Concept | Primary Metaphor | Alternative Visuals |
|---------|-----------------|-------------------|
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
| AI | Crystalline growth | Mirror reflections, fractal patterns |
| Data | Water flow, particles | Bird murmurations, sand grains |
| Network | Root systems | Neural pathways, spider silk, web |
| Code | Musical notation | DNA strands, city blueprints |
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
| Security | Locks dissolving | Fortresses becoming permeable |
| Automation | Clockwork organic | Self-assembling structures |
| Virtual | Layers of reality | Parallel dimensions, glass planes |
| Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-------------------|--------------|-------|
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
## Social & Cultural
| Concept | Primary Metaphor | Alternative Visuals |
|---------|-----------------|-------------------|
| Identity | Layered masks | Fingerprints merging, mirrors |
| Community | Overlapping circles | Shared spaces, woven threads |
| Isolation | Islands in fog | Glass barriers, empty chairs |
| Communication | Bridge structures | Echo patterns, light beams |
| Conflict | Opposing forces | Tectonic plates, storm systems |
| Harmony | Resonance patterns | Orchestra arrangements, balance |
| Culture | Textile patterns | Layered sediments, palimpsest |
| Tradition | Tree rings | Ancient stones, inherited objects |
| Change | Metamorphosis | Phase transitions, seasonal cycles |
| Power | Pyramids inverting | Current flows, gravity wells |
| Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-------------------|--------------|-------|
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
## Philosophical & Abstract
| Concept | Primary Metaphor | Alternative Visuals |
|---------|-----------------|-------------------|
| Time | Spirals, loops | Sediment layers, clock dissolution |
| Knowledge | Light sources | Growing trees, opening books |
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
| Truth | Clear water | Prisms splitting light, unveiled |
| Illusion | Distorted mirrors | Smoke shapes, double images |
| Choice | Diverging paths | Doors opening, quantum splits |
| Balance | Tensegrity | Scales reimagined, equilibrium |
| Paradox | Möbius strips | Impossible objects, Escher-like |
| Existence | Breath patterns | Pulse rhythms, presence/absence |
| Consciousness | Nested awareness | Recursive mirrors, awakening |
| Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-------------------|--------------|-------|
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
## Emotional States
| Emotion | Visual Translation | Color Association |
|---------|-------------------|------------------|
| Anxiety | Fragmented grids | Desaturated, glitch |
| Hope | Light breaking through | Warm gradients |
| Melancholy | Soft dissolution | Muted blues, grays |
| Joy | Expansion patterns | Bright, ascending |
| Fear | Contracting spaces | Sharp contrasts |
| Peace | Still water | Soft neutrals |
| Confusion | Tangled lines | Overlapping hues |
| Clarity | Clean geometry | Pure, minimal |
| Emotion | Visual Translation | Color Guidance |
|---------|-------------------|----------------|
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
| Hope | Light breaking through window, seedling | Warm gradients on light background |
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
## Transformation & Process
## Signature Motifs (Brand Consistency)
| Process | Visual Narrative | Symbolic Elements |
|---------|------------------|------------------|
| Growth | Seeds → trees | Fibonacci spirals |
| Decay | Entropy patterns | Rust, dissolution |
| Evolution | Branching forms | Darwin's tree reimagined |
| Revolution | Circles breaking | Shattered patterns |
| Innovation | Spark → flame | Lightning, fusion |
| Tradition | Continuous thread | Inherited patterns |
| Disruption | Broken grids | Glitch aesthetics |
| Integration | Merging streams | Confluence points |
| Motif | Description |
|-------|-------------|
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
## Korean-Western Fusion Elements
## Recommended Color Palettes
| Korean Element | Western Parallel | Fusion Approach |
|---------------|-----------------|-----------------|
| 여백 (Empty space) | Negative space | Active emptiness |
| 오방색 (Five colors) | Color theory | Symbolic palette |
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
| 한글 geometry | Typography | Structural letters |
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
| Palette | Background | Accent | Mood |
|---------|-----------|--------|------|
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
**Dark color limit:** max 15% of canvas; never full dark background by default.
## Korean Design Elements
| Korean Element | Western Parallel | Application |
|----------------|-----------------|-------------|
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
| 한지 texture | Paper grain | Subtle background texture only |
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
## Usage Notes
1. **Layer metaphors**: Combine 2-3 for depth
2. **Avoid clichés**: No obvious tech symbols
3. **Cultural sensitivity**: Universal over specific
4. **Abstraction levels**: Match essay tone
5. **Emotional resonance**: Feel over literal
1. **Layer metaphors**: Combine 23 elements; one clear focal object + one human-scale anchor
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
4. **30%+ negative space**: default; minimum 20% only when composition needs density
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
## Quick Selection Guide
For **technology essays**: organic-digital hybrids
For **social commentary**: human elements in systems
For **philosophy pieces**: space and light
For **cultural topics**: layered traditions
For **future themes**: transformation states
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
For **social commentary**: network patterns with small human traces
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
For **practical guides**: clean editorial diagrams with warm accents
For **personal reflections**: soft everyday scenes with metaphorical detail

View File

@@ -1,101 +1,104 @@
# Visual Metaphor Dictionary
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
Quick reference for translating abstract concepts into visual elements.
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
## Technology & Digital
| Concept | Primary Metaphor | Alternative Visuals |
|---------|-----------------|-------------------|
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
| AI | Crystalline growth | Mirror reflections, fractal patterns |
| Data | Water flow, particles | Bird murmurations, sand grains |
| Network | Root systems | Neural pathways, spider silk, web |
| Code | Musical notation | DNA strands, city blueprints |
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
| Security | Locks dissolving | Fortresses becoming permeable |
| Automation | Clockwork organic | Self-assembling structures |
| Virtual | Layers of reality | Parallel dimensions, glass planes |
| Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-------------------|--------------|-------|
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
## Social & Cultural
| Concept | Primary Metaphor | Alternative Visuals |
|---------|-----------------|-------------------|
| Identity | Layered masks | Fingerprints merging, mirrors |
| Community | Overlapping circles | Shared spaces, woven threads |
| Isolation | Islands in fog | Glass barriers, empty chairs |
| Communication | Bridge structures | Echo patterns, light beams |
| Conflict | Opposing forces | Tectonic plates, storm systems |
| Harmony | Resonance patterns | Orchestra arrangements, balance |
| Culture | Textile patterns | Layered sediments, palimpsest |
| Tradition | Tree rings | Ancient stones, inherited objects |
| Change | Metamorphosis | Phase transitions, seasonal cycles |
| Power | Pyramids inverting | Current flows, gravity wells |
| Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-------------------|--------------|-------|
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
## Philosophical & Abstract
| Concept | Primary Metaphor | Alternative Visuals |
|---------|-----------------|-------------------|
| Time | Spirals, loops | Sediment layers, clock dissolution |
| Knowledge | Light sources | Growing trees, opening books |
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
| Truth | Clear water | Prisms splitting light, unveiled |
| Illusion | Distorted mirrors | Smoke shapes, double images |
| Choice | Diverging paths | Doors opening, quantum splits |
| Balance | Tensegrity | Scales reimagined, equilibrium |
| Paradox | Möbius strips | Impossible objects, Escher-like |
| Existence | Breath patterns | Pulse rhythms, presence/absence |
| Consciousness | Nested awareness | Recursive mirrors, awakening |
| Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-------------------|--------------|-------|
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
## Emotional States
| Emotion | Visual Translation | Color Association |
|---------|-------------------|------------------|
| Anxiety | Fragmented grids | Desaturated, glitch |
| Hope | Light breaking through | Warm gradients |
| Melancholy | Soft dissolution | Muted blues, grays |
| Joy | Expansion patterns | Bright, ascending |
| Fear | Contracting spaces | Sharp contrasts |
| Peace | Still water | Soft neutrals |
| Confusion | Tangled lines | Overlapping hues |
| Clarity | Clean geometry | Pure, minimal |
| Emotion | Visual Translation | Color Guidance |
|---------|-------------------|----------------|
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
| Hope | Light breaking through window, seedling | Warm gradients on light background |
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
## Transformation & Process
## Signature Motifs (Brand Consistency)
| Process | Visual Narrative | Symbolic Elements |
|---------|------------------|------------------|
| Growth | Seeds → trees | Fibonacci spirals |
| Decay | Entropy patterns | Rust, dissolution |
| Evolution | Branching forms | Darwin's tree reimagined |
| Revolution | Circles breaking | Shattered patterns |
| Innovation | Spark → flame | Lightning, fusion |
| Tradition | Continuous thread | Inherited patterns |
| Disruption | Broken grids | Glitch aesthetics |
| Integration | Merging streams | Confluence points |
| Motif | Description |
|-------|-------------|
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
## Korean-Western Fusion Elements
## Recommended Color Palettes
| Korean Element | Western Parallel | Fusion Approach |
|---------------|-----------------|-----------------|
| 여백 (Empty space) | Negative space | Active emptiness |
| 오방색 (Five colors) | Color theory | Symbolic palette |
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
| 한글 geometry | Typography | Structural letters |
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
| Palette | Background | Accent | Mood |
|---------|-----------|--------|------|
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
**Dark color limit:** max 15% of canvas; never full dark background by default.
## Korean Design Elements
| Korean Element | Western Parallel | Application |
|----------------|-----------------|-------------|
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
| 한지 texture | Paper grain | Subtle background texture only |
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
## Usage Notes
1. **Layer metaphors**: Combine 2-3 for depth
2. **Avoid clichés**: No obvious tech symbols
3. **Cultural sensitivity**: Universal over specific
4. **Abstraction levels**: Match essay tone
5. **Emotional resonance**: Feel over literal
1. **Layer metaphors**: Combine 23 elements; one clear focal object + one human-scale anchor
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
4. **30%+ negative space**: default; minimum 20% only when composition needs density
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
## Quick Selection Guide
For **technology essays**: organic-digital hybrids
For **social commentary**: human elements in systems
For **philosophy pieces**: space and light
For **cultural topics**: layered traditions
For **future themes**: transformation states
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
For **social commentary**: network patterns with small human traces
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
For **practical guides**: clean editorial diagrams with warm accents
For **personal reflections**: soft everyday scenes with metaphorical detail

File diff suppressed because it is too large Load Diff

View File

@@ -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]
}
}

View File

@@ -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
]
}
}

View File

@@ -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}

View File

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

View File

@@ -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 (`![alt](./file.png)`) 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:

View File

@@ -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 (`![alt](./image.png)`) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (`![](https://...)`) 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 ![chart](./chart.png)
# 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 (`![](local)` → 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.

View File

@@ -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}},
)

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

View File

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

View 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

View File

@@ -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![remote](https://ex.com/a.png)\n\n"
"![local](./b.png)\n\nbody")
without, imgs = notion_writer.extract_local_images(content)
assert imgs == [("local", "./b.png")]
assert "![local](./b.png)" not in without
assert "![remote](https://ex.com/a.png)" in without # remote stays
def test_content_has_local_images():
assert notion_writer._content_has_local_images("![x](./y.png)") is True
assert notion_writer._content_has_local_images("![x](https://e/y.png)") 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![x](./y.png)\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 "![x](./y.png)" 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()

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

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

View File

@@ -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("![a chart](https://ex.com/c.png)")
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("![local](./pics/x.png)")
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 ![x](y.png) 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__}")

View File

@@ -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
```

View 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).

View 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, ~180220 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 L1L2)
**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 13.
- 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 12).
- 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, L1L4, short-circuit) → Tasks 1 (L1L2) + 2 (L3L4); 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.**

View 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).

View 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).

View File

@@ -0,0 +1,4 @@
query clicks impressions position
호텔 9 371 18.1
grand josun busan 49 1500 3.4
조선호텔 150 5000 1.3
1 query clicks impressions position
2 호텔 9 371 18.1
3 grand josun busan 49 1500 3.4
4 조선호텔 150 5000 1.3

View File

@@ -0,0 +1,4 @@
query clicks impressions position
호텔 5 572 11.6
grand josun busan 250 4000 1.2
조선호텔 300 6000 1.1
1 query clicks impressions position
2 호텔 5 572 11.6
3 grand josun busan 250 4000 1.2
4 조선호텔 300 6000 1.1

View File

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

View File

@@ -0,0 +1 @@
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.

View File

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

View 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 **23 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`.

View File

@@ -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.*

View 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.*

View File

@@ -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.*

View File

@@ -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

View File

@@ -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 (15 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
```

View File

@@ -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.

View File

@@ -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?

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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).

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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
```

View 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.

View 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.

View 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.

View File

@@ -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 13 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.

View File

@@ -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 (브리프)** 진행

View File

@@ -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 (기획서화)** 진행

View File

@@ -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: 의료광고 심의, 주의의무, 위험요인, 부가세/고지 표기, 그리고 모든 [확인] 항목 -->
- [확인] …

View File

@@ -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
---

View File

@@ -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
---

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -55,17 +55,18 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso
→ write `03_presales-opportunity-brief.md`.
## Stage 5 — Estimate (견적) — REVIEW GATE
Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`); this skill only maps findings → scope, then calls the engine:
- `python scripts/findings_to_scope.py --findings <out>/data/findings.json --out <out>/data/scope.json --seq <N> [--tier auto|smb|basic|treatment] [--billing 0.70]`
- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope <out>/data/scope.json --out-dir <out>`
Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`). Estimates are archived **centrally in OurDigital's space**, not in the engagement bundle:
- `EST=~/Workspaces/ourdigital-space/estimates/<YYYY-MM-DD>_<account_code>_seo` — create it (central 견적 archive).
- `python scripts/findings_to_scope.py --findings <out>/data/findings.json --out "$EST/data/scope.json" --seq <N> [--tier auto|smb|basic|treatment] [--billing 0.70]`
- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope "$EST/data/scope.json" --out-dir "$EST"`
- Engine (effort method, `seo` catalog): auto-tier (smb/basic/treatment) by portfolio + premium-vertical floor; On-page hours scale by `subbrands_total` (cap ×2.0); 제안가 = 합계 절사. Reproduces Basic ₩10.5M / Treatment ₩25.0M; SMB ~₩3M; 25-property chain ~₩29.5M.
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json` (effort shape → consumed by `build_deck.py`). Present the 견적; get sign-off.
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json` in `$EST` (the central quote archive; effort shape → consumed by `build_deck.py`). Present the 견적; get sign-off.
- SEO findings→scope/tier mapping lives here (`findings_to_service.md`); **rates/hours/tiers live in the engine** (`rate_card.yaml` + `catalog/seo.yaml`) — edit pricing there, not here.
## Stage 6 — Deliverables — REVIEW GATE before send
- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS),
then `bash scripts/render_pdf.sh <brief>.html` → PDF. Verify Korean renders (Read the PDF).
- **Sales deck**: `python scripts/build_deck.py --findings <out>/data/findings.json --estimate <out>/data/estimate.json --out <out>/sales-deck.pptx`
- **Sales deck**: `python scripts/build_deck.py --findings <out>/data/findings.json --estimate "$EST/data/estimate.json" --out <out>/sales-deck.pptx` (deck stays in the engagement bundle; reads the archived estimate.json from `$EST`)
- Sanitize the client-facing pieces: no internal pricing strategy beyond the 견적; tasteful competitor benchmark only.
## Stage 7 — Archive (standard)

View File

@@ -1,6 +1,6 @@
---
name: ourdigital-estimate-engine
description: Method-aware estimate/견적 engine for OurDigital / D.intelligence professional services (SEO, GA4/GTM, education/coaching, digital ads, branding, …). Generates a Korean 견적 (md/xlsx/json) from a generic scope.json using the real company rate card. Use for "OurDigital 견적", "estimate", "quote", "proposal pricing", "cost estimate", "견적서 생성", or when another skill needs to price a service. Costing methods: effort (role×billing×hours), coaching (lesson×hours), procurement (+15%).
description: 'Method-aware estimate/견적 engine for OurDigital / D.intelligence professional services (SEO, GA4/GTM, education/coaching, digital ads, branding, …). Generates a Korean 견적 (md/xlsx/json) from a generic scope.json using the real company rate card. Use for "OurDigital 견적", "estimate", "quote", "proposal pricing", "cost estimate", "견적서 생성", or when another skill needs to price a service. Costing methods: effort (role×billing×hours), coaching (lesson×hours), procurement (+15%).'
---
# OurDigital Estimate Engine
@@ -54,6 +54,13 @@ Example consumer: `ourdigital-presales-seo` (`findings_to_scope.py` → engine
real quote data. All current catalogs are real.
- Rates change in `rate_card.yaml` only (single source). Validate against a known real quote.
## Output location
Estimates are archived centrally in OurDigital's space:
`~/Workspaces/ourdigital-space/estimates/<YYYY-MM-DD>_<prospect|account>_<service>/`
(e.g. `…/estimates/2026-05-27_shr_seo/`). The engine stays `--out-dir`-agnostic — the caller
passes that path. Client engagements keep their audit docs/deck in their own workspace but the
견적 lives here (single quote archive).
## Conventions
Korean-first output · 부가세 별도 · 유효기간 14d · 현금 · `OD-YYYY-NNN`. Don't invent rates —
stub and flag instead. Legal entity (주)디인텔리전스 / info@ourdigital.org.

View File

@@ -6,8 +6,10 @@ method: coaching
courses:
# Real quote: GA4/GTM 중급 과정 1:1 코칭 → ₩1,570,000 (7 대면×100k + 9 화상×80k + 1 실습×150k).
# = the "중급 기본구성" standard course (identical to 숨고 GA4/GTM 중급 기본견적; sent to 코코네·45번가 등).
ga4_gtm_intermediate:
title: "GA4/GTM 중급 과정 1:1 코칭"
title: "GA4/GTM 중급 과정 1:1 코칭 (기본구성)"
aliases: ["기본구성", "숨고 GA4/GTM 중급 기본견적"]
lessons:
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
@@ -27,5 +29,33 @@ courses:
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
# Real menu: GA4/GTM 마케팅 애널리틱스 (중급 기본구성 + 메타/구글 마케팅 솔루션 + 워크숍/트리트먼트).
# Full list price = ₩4,070,000. (참고: 디하이브 외 발송 견적은 선택/협상으로 ₩2,360,000에 체결된 사례 있음.)
ga4_gtm_marketing_analytics:
title: "GA4/GTM 마케팅 애널리틱스 과정 1:1 코칭"
lessons:
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "기본 리포트 설정", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "맞춤 리포트 구성", type: 대면, hours: 1}
- {subject: "Google Analytics", title: "획득 보고서의 이해", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "참여도 보고서 해석", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "수익창출 보고서 관리", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "주요 이벤트와 전환", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "탐색 분석 활용", type: 화상, hours: 1}
- {subject: "Google Tag Manager", title: "이벤트 태깅 관리 준비", type: 화상, hours: 1}
- {subject: "Google Tag Manager", title: "GTM 설정 분석과 검수", type: 대면, hours: 1}
- {subject: "Google Tag Manager", title: "이벤트 태깅 실습", type: 대면, hours: 1}
- {subject: "Google Tag Manager", title: "마케팅 태그 설정", type: 대면, hours: 1}
- {subject: "Google Tag Manager", title: "e-Commerce 태그 설정", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "잠재 고객 설정과 활용", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
- {subject: "Digital Marketing Strategy", title: "메타 마케팅 솔루션 활용", type: 대면, hours: 1}
- {subject: "Digital Marketing Strategy", title: "Facebook Insight·Pixel·전환 최적화", type: 대면, hours: 1}
- {subject: "Digital Marketing Strategy", title: "인스타그램 인사이트·비즈니스 API", type: 대면, hours: 1}
- {subject: "Digital Marketing Strategy", title: "구글 마케팅 솔루션(Google Ads·YouTube Analytics)", type: 워크숍, hours: 4}
- {subject: "Digital Marketing Strategy", title: "광고 데이터 통합 자동화(아웃코드·Kakao·네이버 API)", type: 트리트먼트, hours: 2}
# Add more courses (workshops, SEO/Content Marketing coaching, etc.) as real lesson plans arrive.
# For an ad-hoc plan, pass scope.lessons directly instead of a named course.

View File

@@ -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 |

View 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>

View 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/`.

View 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.

View 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.

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

View 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

View File

@@ -0,0 +1,5 @@
# Update Log
## 2026-06-16
* **Initialization**: Created the bundle structure.
* **Creation**: Added the [orders table](/tables/orders.md).

View File

@@ -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.

View File

@@ -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.

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

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

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

View File

@@ -0,0 +1,2 @@
# ourdigital-okf scripts use the Python standard library only.
# No third-party runtime dependencies. Tests use stdlib `unittest`.

View 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).

View 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.

View 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).

View 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).

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More