/report.html \
+ --locale en
+```
+
+Use `--locale ko` for a Korean review surface. Preserve measured questions and
+full worker answers in their original language; localize report chrome,
+analysis notes, and recommendations.
+
+The renderer creates a self-contained HTML file with:
+
+- Named rate and valid-answer denominator.
+- Question-by-question exposure.
+- Competitor mention totals.
+- Cited-domain ledger.
+- Run-level evidence excerpts and URLs.
+- Evidence-linked recommended actions.
+- Methodology and limitations.
+- Embedded machine-readable audit JSON.
+
+## Bundled resources
+
+Use the bundled files as follows:
+
+- `scripts/render_report.py` is the CLI entry point.
+- `scripts/aggregate.py`, `scripts/audit_fields.py`,
+ `scripts/audit_parse.py`, and `scripts/audit_types.py` parse and aggregate
+ the audit contract.
+- `scripts/report_common.py`, `scripts/report_evidence.py`,
+ `scripts/report_locale.py`, and `scripts/report_questions.py` render the
+ report sections.
+- `schemas/audit.schema.json` defines the machine-readable audit contract.
+- `references/data-contract.md` documents question, run, citation, and insight
+ fields.
+- `references/methodology.md` documents measurement boundaries and
+ interpretation.
+- `templates/report.css` provides the self-contained responsive and print
+ styles.
+- `examples/searchd-ai-audit.json` is a complete renderer input example.
+- `tests/test_render_report.py` and `pyrightconfig.json` provide regression and
+ type-check coverage for maintainers.
+- `DESIGN.md` records the report design and accessibility decisions.
+- `README.md`, `LICENSE`, and `.gitignore` provide package usage, licensing,
+ and generated-file exclusions.
+
+Open the report in a real browser and inspect desktop and mobile widths before
+delivering it.
+
+## Completion checklist
+
+- Every question has a completed or failed run record.
+- Every percentage shows its numerator and denominator.
+- Failed runs are disclosed and excluded from the named-rate denominator.
+- Competitor and cited-domain totals reconcile with run records.
+- Every insight cites evidence IDs.
+- The report states the actual measurement method.
+- `report.html` opens without external CSS, JavaScript, fonts, or images.
+- The user receives the report path and a concise summary of the strongest
+ findings.
diff --git a/skills/searchd-aeo-audit/examples/searchd-ai-audit.json b/skills/searchd-aeo-audit/examples/searchd-ai-audit.json
new file mode 100644
index 0000000000..66cb814011
--- /dev/null
+++ b/skills/searchd-aeo-audit/examples/searchd-ai-audit.json
@@ -0,0 +1,484 @@
+{
+ "schema_version": "searchd-aeo-audit-v1",
+ "audit": {
+ "brand": "SearchD",
+ "domain": "searchd.ai",
+ "market": "United States",
+ "language": "English",
+ "measured_at": "2026-09-10T19:59:10Z",
+ "measurement_method": "구매 질문별 중립 실행 1회, 독립 librarian 웹 검색 에이전트 10개",
+ "scope_note": "에이전트가 측정한 웹 조사 결과입니다. 소비자 앱 노출 수나 ChatGPT, Claude, Gemini, Perplexity, Google AI Overviews를 직접 측정한 결과가 아닙니다."
+ },
+ "questions": [
+ {
+ "id": "q01",
+ "text": "What are the best AEO agencies for Asian consumer brands entering the US market?",
+ "intent": "commercial"
+ },
+ {
+ "id": "q02",
+ "text": "Which companies help Asian brands become visible in ChatGPT and other AI answers for US buyers?",
+ "intent": "market_fit"
+ },
+ {
+ "id": "q03",
+ "text": "What services can measure whether AI assistants recommend my brand and show which competitors appear instead?",
+ "intent": "commercial"
+ },
+ {
+ "id": "q04",
+ "text": "Which AI visibility services show the source pages behind an answer, not just a visibility score?",
+ "intent": "comparison"
+ },
+ {
+ "id": "q05",
+ "text": "Who can help a non-English consumer brand build the English-language evidence needed for AI search in the US?",
+ "intent": "market_fit"
+ },
+ {
+ "id": "q06",
+ "text": "What are the best generative engine optimization agencies for international brands expanding into America?",
+ "intent": "commercial"
+ },
+ {
+ "id": "q07",
+ "text": "Which agencies audit why competitors appear in AI answers instead of my brand?",
+ "intent": "commercial"
+ },
+ {
+ "id": "q08",
+ "text": "What are the best tools for tracking brand share of voice across ChatGPT, Claude, Gemini, and Perplexity?",
+ "intent": "comparison"
+ },
+ {
+ "id": "q09",
+ "text": "Which companies offer a free AI visibility report that includes competitors and cited sources?",
+ "intent": "commercial"
+ },
+ {
+ "id": "q10",
+ "text": "Which AEO agencies publish a measurable baseline, disclose the questions tested, and avoid guaranteeing citations?",
+ "intent": "trust"
+ }
+ ],
+ "runs": [
+ {
+ "id": "r01",
+ "question_id": "q01",
+ "agent": "aeo-q01 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.6-luna:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "Foundation Marketing",
+ "Pattern"
+ ],
+ "citations": [
+ {
+ "url": "https://foundationinc.co/lab/generative-engine-optimization",
+ "title": "What Is Generative Engine Optimization and How To Do It?",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://www.pattern.com/",
+ "title": "Pattern: Global Ecommerce Accelerator",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "There is no reliable independent ranking of AEO agencies specifically for Asian consumer brands entering the United States. Based on current public evidence, Foundation Marketing is the strongest explicit GEO strategy candidate, while Pattern is relevant when US marketplace entry and ecommerce execution are also required. Neither source independently proves results for an Asian consumer brand.",
+ "answer_excerpt": "아시아 소비재 브랜드에 특화된 중립 순위는 발견되지 않았습니다. GEO 전문성에서는 Foundation Marketing, 미국 이커머스 진출에서는 Pattern이 제시됐습니다.",
+ "analyst_note": "SearchD의 핵심 구매 의도에 해당합니다. 전문 업체를 찾지 못해 일반 GEO 대행사와 이커머스 운영사를 조합한 답변이 나왔습니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r02",
+ "question_id": "q02",
+ "agent": "aeo-q02 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.6-luna:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [],
+ "citations": [
+ {
+ "url": "https://openai.com/",
+ "title": "OpenAI",
+ "relationship": "other"
+ },
+ {
+ "url": "https://gemini.google.com/",
+ "title": "Google Gemini",
+ "relationship": "other"
+ },
+ {
+ "url": "https://www.perplexity.ai/",
+ "title": "Perplexity",
+ "relationship": "other"
+ },
+ {
+ "url": "https://copilot.microsoft.com/",
+ "title": "Microsoft Copilot",
+ "relationship": "other"
+ }
+ ],
+ "answer_text": "The public search did not establish a reliable company that specifically helps Asian brands become visible in AI answers for US buyers. It primarily surfaced the AI platforms OpenAI, Google Gemini, Perplexity, and Microsoft Copilot rather than a marketing or AEO provider.",
+ "answer_excerpt": "아시아 브랜드의 미국 AI 답변 노출을 지원하는 검증된 업체 대신 AI 플랫폼 운영사들만 검색됐습니다.",
+ "analyst_note": "구매자의 요구가 어떤 서비스 업체와도 연결되지 않았습니다. SearchD가 놓친 질문인 동시에 아직 특정 업체가 소유하지 못한 카테고리 질문입니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r03",
+ "question_id": "q03",
+ "agent": "aeo-q03 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.6-luna:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "BrandRank.AI",
+ "OtterlyAI"
+ ],
+ "citations": [
+ {
+ "url": "https://www.brandrank.ai/",
+ "title": "BrandRank.AI",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://otterly.ai/",
+ "title": "OtterlyAI AI Search Monitoring",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "BrandRank.AI and OtterlyAI publicly describe services that monitor AI-answer prompts, measure whether a brand is recommended or mentioned, and identify competitors named instead. Their public claims should not be treated as independent accuracy tests, and buyers should verify metric definitions and US targeting.",
+ "answer_excerpt": "BrandRank.AI와 OtterlyAI가 추천 여부, 브랜드 언급, 경쟁사 노출을 추적하는 서비스로 확인됐습니다.",
+ "analyst_note": "이 질문은 SearchD의 조사·실행 서비스보다 소프트웨어 모니터링 제품에 유리합니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r04",
+ "question_id": "q04",
+ "agent": "aeo-q04 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.6-luna:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "OtterlyAI"
+ ],
+ "citations": [
+ {
+ "url": "https://otterly.ai/features",
+ "title": "OtterlyAI Generative Engine Optimization Features",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "In this search run, OtterlyAI was the only service explicitly verified from public product material as showing the source pages and domains behind AI answers rather than only a visibility score. Other services may provide this capability but were not verified in this pass.",
+ "answer_excerpt": "이번 실행에서는 OtterlyAI만 AI 답변의 근거 페이지와 도메인을 보여주는 서비스로 확인됐습니다.",
+ "analyst_note": "SearchD도 출처 단위 리포트를 제공한다고 설명하지만 이번 단일 검색에서는 모니터링 제품만 발견됐습니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r05",
+ "question_id": "q05",
+ "agent": "aeo-q05 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.4:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "Smartling",
+ "Translated",
+ "NoGood",
+ "Terakeet"
+ ],
+ "citations": [
+ {
+ "url": "https://www.smartling.com/search?q=transcreation",
+ "title": "Smartling Transcreation Search",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://translated.com/welcome",
+ "title": "Translated Professional Language Solutions",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://nogood.io/answer-engine-optimization-aeo/",
+ "title": "NoGood AEO Services",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://terakeet.com/generative-ai-optimization/",
+ "title": "Terakeet Generative AI Optimization",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "The clearest publicly evidenced options split into two groups: Smartling and Translated for English localization and brand-voice adaptation, and NoGood and Terakeet for AEO or generative-AI visibility work. Public marketing pages do not establish performance for a specific brand or product set.",
+ "answer_excerpt": "언어 현지화는 Smartling과 Translated, AI 검색 최적화는 NoGood과 Terakeet으로 문제가 분리됐습니다.",
+ "analyst_note": "SearchD의 통합된 해외 진출·영어권 근거 구축 제안은 발견되지 않았습니다. 답변은 언어와 AEO 업체를 따로 조합했습니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r06",
+ "question_id": "q06",
+ "agent": "aeo-q06 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.4:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "TransPerfect Digital",
+ "Avenue Z",
+ "iPullRank",
+ "NoGood"
+ ],
+ "citations": [
+ {
+ "url": "https://www.transperfect.com/blog/international-seo-what-it-takes-win-ai-era",
+ "title": "International SEO: What It Takes to Win in the AI Era",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://avenuez.com/blog/best-aeo-agencies-dtc-ecommerce-brands-2026/",
+ "title": "Best AEO Agencies for DTC and Ecommerce Brands",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://ipullrank.com/services/geo",
+ "title": "iPullRank Generative Engine Optimization Services",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://nogood.io/answer-engine-optimization-aeo/",
+ "title": "NoGood AEO Services",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "The best-supported candidates in this run were TransPerfect Digital for international-expansion requirements, followed by Avenue Z, iPullRank, and NoGood for US AI-search and GEO work. Evidence was mostly vendor-owned and does not form an objective market ranking.",
+ "answer_excerpt": "해외 진출에서는 TransPerfect Digital이 먼저 제시됐고, 미국 AI 검색 업무에서는 Avenue Z, iPullRank, NoGood이 뒤를 이었습니다.",
+ "analyst_note": "범위가 넓은 해외 진출 질문이라 기존 현지화 업체와 미국 대행사 브랜드에 유리했습니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r07",
+ "question_id": "q07",
+ "agent": "aeo-q07 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.4:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "Avenue Z",
+ "Terakeet",
+ "iPullRank",
+ "Omniscient Digital"
+ ],
+ "citations": [
+ {
+ "url": "https://avenuez.com/what-we-do/ai-optimization/",
+ "title": "Avenue Z AI Search Optimization",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://terakeet.com/generative-ai-optimization/",
+ "title": "Terakeet Generative AI Optimization",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://ipullrank.com/services/geo",
+ "title": "iPullRank GEO Services",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://beomniscient.com/services",
+ "title": "Omniscient Digital Services",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "Avenue Z and Terakeet were the clearest public matches for auditing AI visibility gaps and competitor presence. iPullRank and Omniscient Digital also market adjacent AI-search assessment work, but were less explicit about diagnosing why a competitor appears instead of the target brand.",
+ "answer_excerpt": "경쟁사 중심 AI 노출 감사에서는 Avenue Z와 Terakeet이 가장 직접적인 후보였고, iPullRank와 Omniscient Digital이 인접 후보로 제시됐습니다.",
+ "analyst_note": "다른 회사가 선택된 이유를 설명한다는 SearchD의 약속과 직접 겹치는 질문입니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r08",
+ "question_id": "q08",
+ "agent": "aeo-q08 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.4:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "Profound",
+ "Peec AI",
+ "Brandlight"
+ ],
+ "citations": [
+ {
+ "url": "https://www.tryprofound.com/",
+ "title": "Profound AI Answer Engine Optimization",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://docs.peec.ai/intro-to-peec-ai",
+ "title": "Peec AI Documentation",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://www.peec.ai/chat-gpt-visibility-tracker",
+ "title": "Peec AI ChatGPT Visibility Tracker",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://www.brandlight.ai/",
+ "title": "Brandlight AI Visibility Platform",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "Profound had the strongest explicit public evidence for tracking ChatGPT, Claude, Gemini, and Perplexity together. Peec AI also documents comparative visibility metrics across major assistants, while Brandlight describes broader enterprise AI visibility but was less explicit about Claude in the pages reviewed.",
+ "answer_excerpt": "여러 엔진을 함께 측정하는 도구로 Profound가 가장 명확했고, Peec AI와 Brandlight가 뒤를 이었습니다.",
+ "analyst_note": "소프트웨어 도구를 찾는 질문이므로 SearchD 핵심 패널에서 서비스 구매 의도 질문과 같은 비중으로 계산하면 안 됩니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r09",
+ "question_id": "q09",
+ "agent": "aeo-q09 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.5:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "Profound"
+ ],
+ "citations": [
+ {
+ "url": "https://www.tryprofound.com/aeo-report",
+ "title": "Profound Free AEO Report",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://www.tryprofound.com/",
+ "title": "Profound",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "In this search run, Profound was the only company directly verified as offering a free AEO visibility report that covers competition and source citations. This does not establish that Profound is the only provider; it was the only one substantiated from accessible public evidence in this pass.",
+ "answer_excerpt": "이번 실행에서는 Profound만 경쟁사와 인용 출처가 포함된 무료 리포트를 제공하는 업체로 확인됐습니다.",
+ "analyst_note": "SearchD의 무료 리포트 제안과 매우 유사하지만 이번 단일 검색은 더 큰 모니터링 플랫폼을 반환했습니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ },
+ {
+ "id": "r10",
+ "question_id": "q10",
+ "agent": "aeo-q10 / neutral librarian web search",
+ "agent_version": "librarian / openai/gpt-5.5:low",
+ "searched_at": "2026-09-10T19:59:10Z",
+ "search_queries": [],
+ "status": "completed",
+ "brand_mentioned": false,
+ "brand_position": null,
+ "competitors": [
+ "First Page Sage",
+ "Omniscient Digital",
+ "iPullRank"
+ ],
+ "citations": [
+ {
+ "url": "https://www.bing.com/search?format=rss&cc=us&setlang=en&q=%22answer%20engine%20optimization%20agency%22",
+ "title": "Bing results for answer engine optimization agency",
+ "relationship": "other"
+ },
+ {
+ "url": "https://firstpagesage.com/seo-blog/generative-engine-optimization-services/",
+ "title": "First Page Sage Generative Engine Optimization Services",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://www.omniscientdigital.com/services/ai-search-optimization",
+ "title": "Omniscient Digital AI Search Optimization",
+ "relationship": "competitor_owned"
+ },
+ {
+ "url": "https://ipullrank.com/services/generative-ai-search-optimization",
+ "title": "iPullRank Generative AI Search Optimization",
+ "relationship": "competitor_owned"
+ }
+ ],
+ "answer_text": "No US-market AEO or GEO agency was found in this run that publicly satisfied all three criteria at once: publishing a measurable baseline, disclosing the exact questions tested, and avoiding citation guarantees. First Page Sage, Omniscient Digital, and iPullRank were checked but did not provide all three forms of evidence on the pages reviewed.",
+ "answer_excerpt": "측정 가능한 기준선 공개, 테스트 질문 공개, 인용 보장 금지라는 세 조건을 모두 공개적으로 충족한 대행사는 발견되지 않았습니다.",
+ "analyst_note": "SearchD는 사이트에 세 조건을 모두 공개했지만 중립 검색 워커가 발견하지 못했습니다. 이번 실행에서 발견성 격차를 가장 직접적으로 보여주는 근거입니다. 워커의 실제 검색 쿼리는 수집되지 않았습니다."
+ }
+ ],
+ "insights": [
+ {
+ "priority": 1,
+ "observation": "SearchD의 공개 투명성 기준과 정확히 일치하는 질문을 포함해 완료된 중립 웹 조사 답변 10개 중 SearchD를 언급한 답변은 0개였습니다.",
+ "action": "공개 기준선을 0으로 유지하고 9월 10일에 추가된 페이지가 인덱싱될 시간을 둔 뒤 동일 패널을 재실행합니다. 재측정 전에는 새 페이지를 성과로 설명하지 않습니다.",
+ "evidence_ids": [
+ "r01",
+ "r02",
+ "r03",
+ "r04",
+ "r05",
+ "r06",
+ "r07",
+ "r08",
+ "r09",
+ "r10"
+ ],
+ "remeasure": "인덱싱 기간 후 동일한 중립 워커 방식으로 q01-q10을 반복하고 이 고정 기준선과만 비교합니다."
+ },
+ {
+ "priority": 2,
+ "observation": "해외 진출 질문에서는 한 전문 업체 대신 일반 GEO 대행사, 이커머스 운영사 또는 별도의 현지화·AEO 업체가 반환됐습니다.",
+ "action": "SearchD를 아시아 소비재 브랜드를 위한 AEO와 영어권 근거 구축 통합 서비스로 설명하는 독립적인 미국 대상 출처를 확보합니다.",
+ "evidence_ids": [
+ "r01",
+ "r02",
+ "r05",
+ "r06"
+ ],
+ "remeasure": "독립 출처 한 곳 이상에서 해당 카테고리 표현을 사용한 뒤 q01, q02, q05, q06을 반복합니다."
+ },
+ {
+ "priority": 3,
+ "observation": "도구 중심 질문은 Profound, OtterlyAI, Peec AI, BrandRank.AI, Brandlight가 지속적으로 차지했습니다.",
+ "action": "인접 도구에서의 미노출이 상업적 Named Rate를 왜곡하지 않도록 SearchD 표준 패널에서 소프트웨어 모니터링 질문과 서비스 구매 의도 질문을 분리합니다.",
+ "evidence_ids": [
+ "r03",
+ "r04",
+ "r08",
+ "r09"
+ ],
+ "remeasure": "도구 구매 의도와 대행사 구매 의도의 Named Rate를 별도 세그먼트로 보고합니다."
+ },
+ {
+ "priority": 4,
+ "observation": "인용된 페이지 대부분은 추천된 업체가 직접 소유한 페이지였고 독립 비교 출처는 드물었습니다.",
+ "action": "현재 포지셔닝을 반복하는 자사 주장 추가보다 제3자 근거와 카테고리 비교 콘텐츠 확보를 우선합니다.",
+ "evidence_ids": [
+ "r01",
+ "r03",
+ "r04",
+ "r05",
+ "r06",
+ "r07",
+ "r08",
+ "r09"
+ ],
+ "remeasure": "향후 답변이 searchd.ai뿐 아니라 SearchD를 언급하는 독립 출처를 인용하는지 추적합니다."
+ }
+ ]
+}
diff --git a/skills/searchd-aeo-audit/pyrightconfig.json b/skills/searchd-aeo-audit/pyrightconfig.json
new file mode 100644
index 0000000000..99a74322bd
--- /dev/null
+++ b/skills/searchd-aeo-audit/pyrightconfig.json
@@ -0,0 +1,15 @@
+{
+ "include": ["scripts", "tests"],
+ "typeCheckingMode": "basic",
+ "executionEnvironments": [
+ {
+ "root": "scripts",
+ "extraPaths": ["scripts"]
+ },
+ {
+ "root": "tests",
+ "extraPaths": ["scripts"]
+ }
+ ],
+ "reportImplicitRelativeImport": "none"
+}
diff --git a/skills/searchd-aeo-audit/references/data-contract.md b/skills/searchd-aeo-audit/references/data-contract.md
new file mode 100644
index 0000000000..320869220f
--- /dev/null
+++ b/skills/searchd-aeo-audit/references/data-contract.md
@@ -0,0 +1,53 @@
+# Audit data contract
+
+`audit.json` must conform to `schemas/audit.schema.json`.
+
+## Top-level fields
+
+- `schema_version`: always `searchd-aeo-audit-v1`.
+- `audit`: target and measurement metadata.
+- `questions`: ordered question definitions.
+- `runs`: one completed or failed record per research worker.
+- `insights`: evidence-linked recommendations.
+
+## Run rules
+
+- `status` is `completed` or `failed`.
+- `agent_version`, `searched_at`, and `search_queries` preserve execution
+ provenance. Use an empty query list when the host cannot expose its search
+ queries; do not reconstruct them.
+- Completed runs may be named or not named.
+- `answer_text` preserves the complete worker answer; `answer_excerpt` is the
+ short report preview.
+- Failed runs must have empty answer fields and are excluded from calculated
+ rates.
+- `brand_position` is a positive one-based integer only when the answer
+ contains an ordered list and the target brand appears in it.
+- `relationship` is one of:
+ - `first_party`
+ - `competitor_owned`
+ - `editorial`
+ - `directory`
+ - `community`
+ - `other`
+
+## Calculation rules
+
+```text
+named rate = completed runs naming the brand / all completed runs
+question coverage = questions with at least one named completed run /
+ questions with at least one completed run
+competitor mentions = completed runs naming each competitor
+cited-domain answers = completed runs citing each normalized domain
+```
+
+Co-mentions are not mutually exclusive. Do not render competitor totals as a
+pie chart or call them market share.
+
+## Evidence rules
+
+- Preserve exact source URLs after validating that each uses `http` or `https`
+ and includes a host.
+- Excerpts should be short enough to review but must not alter their meaning.
+- Analyst notes are commentary and must not be presented as source text.
+- Every insight references at least one run ID in `evidence_ids`.
diff --git a/skills/searchd-aeo-audit/references/methodology.md b/skills/searchd-aeo-audit/references/methodology.md
new file mode 100644
index 0000000000..4bc1780a00
--- /dev/null
+++ b/skills/searchd-aeo-audit/references/methodology.md
@@ -0,0 +1,42 @@
+# SearchD AEO audit methodology
+
+## What the audit measures
+
+The audit measures whether independent web-research workers naturally name a
+target company while answering a fixed set of buyer questions.
+
+It records:
+
+- Target-brand mentions.
+- Ordered recommendation position when available.
+- Co-mentioned brands.
+- Sources used in the answer.
+- Which questions produce or omit the target brand.
+
+## What the audit does not measure
+
+- Consumer-app impressions or clicks.
+- Market share.
+- A model's training-data contents.
+- Internal crawling, indexing, retrieval, or ranking state.
+- Guaranteed responses from ChatGPT, Claude, Gemini, Perplexity, or Google AI
+ Overviews.
+
+## Contamination control
+
+Question workers do not receive the target brand or website context. They
+receive only the question, market, language, and neutral research instruction.
+Brand matching happens after the answer is complete.
+
+## Reproducibility
+
+Every report records the question set, measurement time, agent label, search
+method, valid and failed runs, answer excerpts, and URLs. Repeating the audit
+can still produce different results because public search results and agent
+behavior change.
+
+## Interpretation
+
+Treat one run per question as directional discovery. Use repeated runs with a
+fixed question panel before claiming a trend or a small difference between
+competitors.
diff --git a/skills/searchd-aeo-audit/schemas/audit.schema.json b/skills/searchd-aeo-audit/schemas/audit.schema.json
new file mode 100644
index 0000000000..1c9cb02fa5
--- /dev/null
+++ b/skills/searchd-aeo-audit/schemas/audit.schema.json
@@ -0,0 +1,160 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://searchd.ai/schemas/searchd-aeo-audit-v1.json",
+ "title": "SearchD AEO audit",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema_version", "audit", "questions", "runs", "insights"],
+ "properties": {
+ "schema_version": {
+ "const": "searchd-aeo-audit-v1"
+ },
+ "audit": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "brand",
+ "domain",
+ "market",
+ "language",
+ "measured_at",
+ "measurement_method",
+ "scope_note"
+ ],
+ "properties": {
+ "brand": { "type": "string", "minLength": 1 },
+ "domain": { "type": "string", "minLength": 1 },
+ "market": { "type": "string", "minLength": 1 },
+ "language": { "type": "string", "minLength": 1 },
+ "measured_at": { "type": "string", "format": "date-time" },
+ "measurement_method": { "type": "string", "minLength": 1 },
+ "scope_note": { "type": "string", "minLength": 1 }
+ }
+ },
+ "questions": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 25,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "text", "intent"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^q[0-9]{2}$" },
+ "text": { "type": "string", "minLength": 10 },
+ "intent": {
+ "enum": [
+ "commercial",
+ "comparison",
+ "informational",
+ "trust",
+ "market_fit"
+ ]
+ }
+ }
+ }
+ },
+ "runs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "id",
+ "question_id",
+ "agent",
+ "agent_version",
+ "searched_at",
+ "search_queries",
+ "status",
+ "brand_mentioned",
+ "brand_position",
+ "competitors",
+ "citations",
+ "answer_text",
+ "answer_excerpt",
+ "analyst_note"
+ ],
+ "properties": {
+ "id": { "type": "string", "pattern": "^r[0-9]{2,}$" },
+ "question_id": { "type": "string", "pattern": "^q[0-9]{2}$" },
+ "agent": { "type": "string", "minLength": 1 },
+ "agent_version": { "type": "string", "minLength": 1 },
+ "searched_at": { "type": "string", "format": "date-time" },
+ "search_queries": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 }
+ },
+ "status": { "enum": ["completed", "failed"] },
+ "brand_mentioned": { "type": "boolean" },
+ "brand_position": {
+ "oneOf": [
+ { "type": "integer", "minimum": 1 },
+ { "type": "null" }
+ ]
+ },
+ "competitors": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "citations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["url", "title", "relationship"],
+ "properties": {
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https?://[^\\s]+$"
+ },
+ "title": { "type": "string", "minLength": 1 },
+ "relationship": {
+ "enum": [
+ "first_party",
+ "competitor_owned",
+ "editorial",
+ "directory",
+ "community",
+ "other"
+ ]
+ }
+ }
+ }
+ },
+ "answer_text": { "type": "string" },
+ "answer_excerpt": { "type": "string" },
+ "analyst_note": { "type": "string" }
+ }
+ }
+ },
+ "insights": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "priority",
+ "observation",
+ "action",
+ "evidence_ids",
+ "remeasure"
+ ],
+ "properties": {
+ "priority": { "type": "integer", "minimum": 1 },
+ "observation": { "type": "string", "minLength": 1 },
+ "action": { "type": "string", "minLength": 1 },
+ "evidence_ids": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "type": "string", "pattern": "^r[0-9]{2,}$" },
+ "uniqueItems": true
+ },
+ "remeasure": { "type": "string", "minLength": 1 }
+ }
+ }
+ }
+ }
+}
diff --git a/skills/searchd-aeo-audit/scripts/aggregate.py b/skills/searchd-aeo-audit/scripts/aggregate.py
new file mode 100644
index 0000000000..f83f95770b
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/aggregate.py
@@ -0,0 +1,160 @@
+from __future__ import annotations
+
+from collections import Counter
+from dataclasses import dataclass
+from urllib.parse import urlsplit
+
+from audit_types import Audit, Question, Relationship, Run, RunStatus
+
+
+@dataclass(frozen=True, slots=True)
+class QuestionSummary:
+ question: Question
+ valid_runs: int
+ named_runs: int
+ failed_runs: int
+ median_position: int | None
+ competitors: tuple[str, ...]
+ evidence_ids: tuple[str, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class CompetitorSummary:
+ name: str
+ answer_count: int
+
+
+@dataclass(frozen=True, slots=True)
+class DomainSummary:
+ domain: str
+ answer_count: int
+ relationship: Relationship
+
+
+@dataclass(frozen=True, slots=True)
+class AuditSummary:
+ valid_runs: int
+ named_runs: int
+ failed_runs: int
+ named_rate: float
+ valid_questions: int
+ named_questions: int
+ question_coverage: float
+ questions: tuple[QuestionSummary, ...]
+ competitors: tuple[CompetitorSummary, ...]
+ domains: tuple[DomainSummary, ...]
+
+
+def normalize_domain(url: str) -> str:
+ hostname = urlsplit(url).hostname or ""
+ normalized = hostname.lower()
+ if normalized.startswith("www."):
+ return normalized[4:]
+ return normalized
+
+
+def _median_position(runs: tuple[Run, ...]) -> int | None:
+ positions = sorted(
+ run.brand_position
+ for run in runs
+ if run.brand_position is not None
+ )
+ if not positions:
+ return None
+ return positions[(len(positions) - 1) // 2]
+
+
+def _question_summary(question: Question, runs: tuple[Run, ...]) -> QuestionSummary:
+ completed = tuple(run for run in runs if run.status is RunStatus.COMPLETED)
+ failed = tuple(run for run in runs if run.status is RunStatus.FAILED)
+ named = tuple(run for run in completed if run.brand_mentioned)
+ competitors = sorted(
+ {
+ competitor
+ for run in completed
+ for competitor in run.competitors
+ }
+ )
+ return QuestionSummary(
+ question=question,
+ valid_runs=len(completed),
+ named_runs=len(named),
+ failed_runs=len(failed),
+ median_position=_median_position(named),
+ competitors=tuple(competitors),
+ evidence_ids=tuple(run.id for run in runs),
+ )
+
+
+def aggregate(audit: Audit) -> AuditSummary:
+ completed = tuple(
+ run for run in audit.runs if run.status is RunStatus.COMPLETED
+ )
+ failed = tuple(run for run in audit.runs if run.status is RunStatus.FAILED)
+ named = tuple(run for run in completed if run.brand_mentioned)
+
+ question_summaries = tuple(
+ _question_summary(
+ question,
+ tuple(run for run in audit.runs if run.question_id == question.id),
+ )
+ for question in audit.questions
+ )
+ valid_questions = sum(
+ 1 for summary in question_summaries if summary.valid_runs > 0
+ )
+ named_questions = sum(
+ 1 for summary in question_summaries if summary.named_runs > 0
+ )
+
+ competitor_counts = Counter(
+ competitor
+ for run in completed
+ for competitor in set(run.competitors)
+ )
+ competitors = tuple(
+ CompetitorSummary(name=name, answer_count=count)
+ for name, count in sorted(
+ competitor_counts.items(),
+ key=lambda item: (-item[1], item[0].lower()),
+ )
+ )
+
+ domain_counts: Counter[str] = Counter()
+ relationships: dict[str, Relationship] = {}
+ for run in completed:
+ domains_in_run: set[str] = set()
+ for citation in run.citations:
+ domain = normalize_domain(citation.url)
+ if not domain:
+ continue
+ domains_in_run.add(domain)
+ relationships.setdefault(domain, citation.relationship)
+ domain_counts.update(domains_in_run)
+
+ domains = tuple(
+ DomainSummary(
+ domain=domain,
+ answer_count=count,
+ relationship=relationships[domain],
+ )
+ for domain, count in sorted(
+ domain_counts.items(),
+ key=lambda item: (-item[1], item[0]),
+ )
+ )
+
+ return AuditSummary(
+ valid_runs=len(completed),
+ named_runs=len(named),
+ failed_runs=len(failed),
+ named_rate=len(named) / len(completed) if completed else 0.0,
+ valid_questions=valid_questions,
+ named_questions=named_questions,
+ question_coverage=(
+ named_questions / valid_questions if valid_questions else 0.0
+ ),
+ questions=question_summaries,
+ competitors=competitors,
+ domains=domains,
+ )
diff --git a/skills/searchd-aeo-audit/scripts/audit_fields.py b/skills/searchd-aeo-audit/scripts/audit_fields.py
new file mode 100644
index 0000000000..afb92f01d8
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/audit_fields.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from typing import TypeVar
+from urllib.parse import urlsplit
+
+from audit_types import AuditInputError, JsonObject, JsonValue, StringEnum
+
+EnumValue = TypeVar("EnumValue", bound=StringEnum)
+
+
+def as_object(value: JsonValue, path: str) -> JsonObject:
+ match value:
+ case dict() as mapping:
+ return mapping
+ case _:
+ raise AuditInputError(path=path, detail="expected an object")
+
+
+def as_array(value: JsonValue, path: str) -> list[JsonValue]:
+ match value:
+ case list() as items:
+ return items
+ case _:
+ raise AuditInputError(path=path, detail="expected an array")
+
+
+def required(mapping: JsonObject, key: str, path: str) -> JsonValue:
+ if key not in mapping:
+ raise AuditInputError(path=f"{path}.{key}", detail="field is required")
+ return mapping[key]
+
+
+def string(mapping: JsonObject, key: str, path: str) -> str:
+ value = required(mapping, key, path)
+ match value:
+ case str() as text if text:
+ return text
+ case _:
+ raise AuditInputError(
+ path=f"{path}.{key}",
+ detail="expected a non-empty string",
+ )
+
+
+def optional_string(mapping: JsonObject, key: str, path: str) -> str:
+ value = required(mapping, key, path)
+ match value:
+ case str() as text:
+ return text
+ case _:
+ raise AuditInputError(path=f"{path}.{key}", detail="expected a string")
+
+
+def web_url(mapping: JsonObject, key: str, path: str) -> str:
+ value = string(mapping, key, path)
+ try:
+ parsed = urlsplit(value)
+ _ = parsed.port
+ except ValueError as error:
+ raise AuditInputError(
+ path=f"{path}.{key}",
+ detail="expected an http or https URL with a host",
+ ) from error
+
+ is_http = parsed.scheme in {"http", "https"}
+ has_host = parsed.hostname is not None
+ has_credentials = parsed.username is not None or parsed.password is not None
+ has_whitespace = any(character.isspace() for character in value)
+ if not is_http or not has_host or has_credentials or has_whitespace:
+ raise AuditInputError(
+ path=f"{path}.{key}",
+ detail="expected an http or https URL with a host",
+ )
+ return value
+
+
+def positive_integer(mapping: JsonObject, key: str, path: str) -> int:
+ value = required(mapping, key, path)
+ match value:
+ case bool():
+ raise AuditInputError(path=f"{path}.{key}", detail="expected an integer")
+ case int() as number if number >= 1:
+ return number
+ case _:
+ raise AuditInputError(
+ path=f"{path}.{key}",
+ detail="expected a positive integer",
+ )
+
+
+def boolean(mapping: JsonObject, key: str, path: str) -> bool:
+ value = required(mapping, key, path)
+ match value:
+ case bool() as flag:
+ return flag
+ case _:
+ raise AuditInputError(path=f"{path}.{key}", detail="expected a boolean")
+
+
+def optional_position(mapping: JsonObject, path: str) -> int | None:
+ value = required(mapping, "brand_position", path)
+ match value:
+ case None:
+ return None
+ case bool():
+ raise AuditInputError(
+ path=f"{path}.brand_position",
+ detail="expected a positive integer or null",
+ )
+ case int() as position if position >= 1:
+ return position
+ case _:
+ raise AuditInputError(
+ path=f"{path}.brand_position",
+ detail="expected a positive integer or null",
+ )
+
+
+def enum_value(
+ enum_type: type[EnumValue],
+ mapping: JsonObject,
+ key: str,
+ path: str,
+) -> EnumValue:
+ value = string(mapping, key, path)
+ try:
+ return enum_type(value)
+ except ValueError as error:
+ allowed = ", ".join(item.value for item in enum_type)
+ raise AuditInputError(
+ path=f"{path}.{key}",
+ detail=f"expected one of: {allowed}",
+ ) from error
+
+
+def string_tuple(mapping: JsonObject, key: str, path: str) -> tuple[str, ...]:
+ items = as_array(required(mapping, key, path), f"{path}.{key}")
+ values: list[str] = []
+ for index, item in enumerate(items):
+ match item:
+ case str() as text if text:
+ values.append(text)
+ case _:
+ raise AuditInputError(
+ path=f"{path}.{key}[{index}]",
+ detail="expected a non-empty string",
+ )
+ return tuple(values)
diff --git a/skills/searchd-aeo-audit/scripts/audit_parse.py b/skills/searchd-aeo-audit/scripts/audit_parse.py
new file mode 100644
index 0000000000..32a7240671
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/audit_parse.py
@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+import json
+
+from audit_fields import (
+ as_array,
+ as_object,
+ boolean,
+ enum_value,
+ optional_position,
+ optional_string,
+ positive_integer,
+ required,
+ string,
+ string_tuple,
+ web_url,
+)
+from audit_types import (
+ SCHEMA_VERSION,
+ Audit,
+ AuditInputError,
+ AuditMetadata,
+ Citation,
+ Insight,
+ Intent,
+ JsonObject,
+ JsonValue,
+ Question,
+ Relationship,
+ Run,
+ RunStatus,
+ assert_unreachable,
+)
+
+
+def _parse_metadata(root: JsonObject) -> AuditMetadata:
+ path = "$.audit"
+ mapping = as_object(required(root, "audit", "$"), path)
+ return AuditMetadata(
+ brand=string(mapping, "brand", path),
+ domain=string(mapping, "domain", path),
+ market=string(mapping, "market", path),
+ language=string(mapping, "language", path),
+ measured_at=string(mapping, "measured_at", path),
+ measurement_method=string(mapping, "measurement_method", path),
+ scope_note=string(mapping, "scope_note", path),
+ )
+
+
+def _parse_questions(root: JsonObject) -> tuple[Question, ...]:
+ raw_questions = as_array(required(root, "questions", "$"), "$.questions")
+ questions: list[Question] = []
+ for index, raw_question in enumerate(raw_questions):
+ path = f"$.questions[{index}]"
+ mapping = as_object(raw_question, path)
+ questions.append(
+ Question(
+ id=string(mapping, "id", path),
+ text=string(mapping, "text", path),
+ intent=enum_value(Intent, mapping, "intent", path),
+ )
+ )
+ if not questions:
+ raise AuditInputError(path="$.questions", detail="at least one is required")
+ return tuple(questions)
+
+
+def _parse_citations(mapping: JsonObject, path: str) -> tuple[Citation, ...]:
+ raw_citations = as_array(
+ required(mapping, "citations", path),
+ f"{path}.citations",
+ )
+ citations: list[Citation] = []
+ for index, raw_citation in enumerate(raw_citations):
+ citation_path = f"{path}.citations[{index}]"
+ citation = as_object(raw_citation, citation_path)
+ citations.append(
+ Citation(
+ url=web_url(citation, "url", citation_path),
+ title=string(citation, "title", citation_path),
+ relationship=enum_value(
+ Relationship,
+ citation,
+ "relationship",
+ citation_path,
+ ),
+ )
+ )
+ return tuple(citations)
+
+
+def _parse_runs(root: JsonObject) -> tuple[Run, ...]:
+ raw_runs = as_array(required(root, "runs", "$"), "$.runs")
+ runs: list[Run] = []
+ for index, raw_run in enumerate(raw_runs):
+ path = f"$.runs[{index}]"
+ mapping = as_object(raw_run, path)
+ runs.append(
+ Run(
+ id=string(mapping, "id", path),
+ question_id=string(mapping, "question_id", path),
+ agent=string(mapping, "agent", path),
+ agent_version=string(mapping, "agent_version", path),
+ searched_at=string(mapping, "searched_at", path),
+ search_queries=string_tuple(mapping, "search_queries", path),
+ status=enum_value(RunStatus, mapping, "status", path),
+ brand_mentioned=boolean(mapping, "brand_mentioned", path),
+ brand_position=optional_position(mapping, path),
+ competitors=string_tuple(mapping, "competitors", path),
+ citations=_parse_citations(mapping, path),
+ answer_text=optional_string(mapping, "answer_text", path),
+ answer_excerpt=optional_string(mapping, "answer_excerpt", path),
+ analyst_note=optional_string(mapping, "analyst_note", path),
+ )
+ )
+ return tuple(runs)
+
+
+def _parse_insights(root: JsonObject) -> tuple[Insight, ...]:
+ raw_insights = as_array(required(root, "insights", "$"), "$.insights")
+ insights: list[Insight] = []
+ for index, raw_insight in enumerate(raw_insights):
+ path = f"$.insights[{index}]"
+ mapping = as_object(raw_insight, path)
+ insights.append(
+ Insight(
+ priority=positive_integer(mapping, "priority", path),
+ observation=string(mapping, "observation", path),
+ action=string(mapping, "action", path),
+ evidence_ids=string_tuple(mapping, "evidence_ids", path),
+ remeasure=string(mapping, "remeasure", path),
+ )
+ )
+ return tuple(insights)
+
+
+def _validate_references(audit: Audit) -> None:
+ question_ids = {question.id for question in audit.questions}
+ run_ids = {run.id for run in audit.runs}
+
+ for run in audit.runs:
+ if run.question_id not in question_ids:
+ raise AuditInputError(
+ path=f"$.runs[{run.id}].question_id",
+ detail=f"unknown question {run.question_id}",
+ )
+ match run.status:
+ case RunStatus.COMPLETED:
+ continue
+ case RunStatus.FAILED:
+ if run.answer_excerpt or run.answer_text:
+ raise AuditInputError(
+ path=f"$.runs[{run.id}]",
+ detail="failed runs must have empty answer fields",
+ )
+ case unreachable:
+ assert_unreachable(unreachable)
+
+ for insight in audit.insights:
+ missing = set(insight.evidence_ids) - run_ids
+ if missing:
+ missing_ids = ", ".join(sorted(missing))
+ raise AuditInputError(
+ path=f"$.insights[{insight.priority}].evidence_ids",
+ detail=f"unknown runs: {missing_ids}",
+ )
+
+
+def parse_audit(text: str) -> Audit:
+ try:
+ raw: JsonValue = json.loads(text)
+ except json.JSONDecodeError as error:
+ raise AuditInputError(path="$", detail=error.msg) from error
+
+ root = as_object(raw, "$")
+ version = string(root, "schema_version", "$")
+ if version != SCHEMA_VERSION:
+ raise AuditInputError(
+ path="$.schema_version",
+ detail=f"expected {SCHEMA_VERSION}",
+ )
+
+ audit = Audit(
+ metadata=_parse_metadata(root),
+ questions=_parse_questions(root),
+ runs=_parse_runs(root),
+ insights=_parse_insights(root),
+ raw_json=text,
+ )
+ _validate_references(audit)
+ return audit
diff --git a/skills/searchd-aeo-audit/scripts/audit_types.py b/skills/searchd-aeo-audit/scripts/audit_types.py
new file mode 100644
index 0000000000..1f3fcb9018
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/audit_types.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Final, NoReturn
+
+SCHEMA_VERSION: Final = "searchd-aeo-audit-v1"
+
+JsonScalar = str | int | float | bool | None
+JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
+JsonObject = dict[str, JsonValue]
+
+
+@dataclass(frozen=True, slots=True)
+class AuditInputError(Exception):
+ path: str
+ detail: str
+
+ def __str__(self) -> str:
+ return f"{self.path}: {self.detail}"
+
+
+class StringEnum(str, Enum):
+ pass
+
+
+class Intent(StringEnum):
+ COMMERCIAL = "commercial"
+ COMPARISON = "comparison"
+ INFORMATIONAL = "informational"
+ TRUST = "trust"
+ MARKET_FIT = "market_fit"
+
+
+class RunStatus(StringEnum):
+ COMPLETED = "completed"
+ FAILED = "failed"
+
+
+class Relationship(StringEnum):
+ FIRST_PARTY = "first_party"
+ COMPETITOR_OWNED = "competitor_owned"
+ EDITORIAL = "editorial"
+ DIRECTORY = "directory"
+ COMMUNITY = "community"
+ OTHER = "other"
+
+
+@dataclass(frozen=True, slots=True)
+class AuditMetadata:
+ brand: str
+ domain: str
+ market: str
+ language: str
+ measured_at: str
+ measurement_method: str
+ scope_note: str
+
+
+@dataclass(frozen=True, slots=True)
+class Question:
+ id: str
+ text: str
+ intent: Intent
+
+
+@dataclass(frozen=True, slots=True)
+class Citation:
+ url: str
+ title: str
+ relationship: Relationship
+
+
+@dataclass(frozen=True, slots=True)
+class Run:
+ id: str
+ question_id: str
+ agent: str
+ agent_version: str
+ searched_at: str
+ search_queries: tuple[str, ...]
+ status: RunStatus
+ brand_mentioned: bool
+ brand_position: int | None
+ competitors: tuple[str, ...]
+ citations: tuple[Citation, ...]
+ answer_text: str
+ answer_excerpt: str
+ analyst_note: str
+
+
+@dataclass(frozen=True, slots=True)
+class Insight:
+ priority: int
+ observation: str
+ action: str
+ evidence_ids: tuple[str, ...]
+ remeasure: str
+
+
+@dataclass(frozen=True, slots=True)
+class Audit:
+ metadata: AuditMetadata
+ questions: tuple[Question, ...]
+ runs: tuple[Run, ...]
+ insights: tuple[Insight, ...]
+ raw_json: str
+
+
+def assert_unreachable(value: NoReturn) -> NoReturn:
+ raise AssertionError(f"Unhandled value: {value!r}")
diff --git a/skills/searchd-aeo-audit/scripts/render_report.py b/skills/searchd-aeo-audit/scripts/render_report.py
new file mode 100644
index 0000000000..d62aee9dd2
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/render_report.py
@@ -0,0 +1,117 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = []
+# ///
+# ─── How to run ───
+# python3 render_report.py --input audit.json --output report.html
+
+from __future__ import annotations
+
+import argparse
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+from aggregate import aggregate
+from audit_parse import parse_audit
+from audit_types import Audit, AuditInputError
+from report_common import (
+ CSS_PATH,
+ REPORT_TEMPLATE,
+ boundary,
+ escape,
+ executive,
+ footer,
+ masthead,
+ metadata,
+)
+from report_evidence import actions_exhibit, evidence_exhibit, methodology_exhibit
+from report_locale import ReportLocale, localized
+from report_questions import landscape_exhibit, questions_exhibit
+
+
+@dataclass(frozen=True, slots=True)
+class CliArguments:
+ input_path: Path
+ output_path: Path
+ locale: ReportLocale
+
+
+def render(audit: Audit, locale: ReportLocale) -> str:
+ summary = aggregate(audit)
+ body = "".join(
+ (
+ masthead(audit, locale),
+ executive(summary, locale),
+ metadata(audit, summary, locale),
+ boundary(audit, locale),
+ questions_exhibit(summary, locale),
+ landscape_exhibit(summary, locale),
+ evidence_exhibit(audit, locale),
+ actions_exhibit(audit, locale),
+ methodology_exhibit(audit, summary, locale),
+ footer(locale),
+ )
+ )
+ audit_json = audit.raw_json.replace("<", "\\u003c")
+ return REPORT_TEMPLATE.substitute(
+ lang=locale.value,
+ title=escape(
+ localized(
+ locale,
+ f"{audit.metadata.brand} AEO audit",
+ f"{audit.metadata.brand} AEO 노출 감사",
+ )
+ ),
+ description=escape(
+ localized(
+ locale,
+ f"Agent-measured AEO exposure audit for {audit.metadata.brand}.",
+ f"{audit.metadata.brand}의 에이전트 측정 AEO 노출 감사입니다.",
+ )
+ ),
+ css=CSS_PATH.read_text(encoding="utf-8"),
+ body=body,
+ audit_json=audit_json,
+ )
+
+
+def _parse_arguments() -> CliArguments:
+ parser = argparse.ArgumentParser(
+ description="Render a self-contained SearchD AEO audit report.",
+ )
+ parser.add_argument("--input", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument(
+ "--locale",
+ choices=[locale.value for locale in ReportLocale],
+ default=ReportLocale.ENGLISH.value,
+ )
+ parsed = parser.parse_args()
+ return CliArguments(
+ input_path=parsed.input,
+ output_path=parsed.output,
+ locale=ReportLocale(parsed.locale),
+ )
+
+
+def main() -> int:
+ arguments = _parse_arguments()
+ try:
+ audit = parse_audit(arguments.input_path.read_text(encoding="utf-8"))
+ output = render(audit, arguments.locale)
+ arguments.output_path.parent.mkdir(parents=True, exist_ok=True)
+ arguments.output_path.write_text(output, encoding="utf-8")
+ except AuditInputError as error:
+ print(f"Invalid audit input: {error}", file=sys.stderr)
+ return 2
+ except OSError as error:
+ print(f"File operation failed: {error}", file=sys.stderr)
+ return 1
+
+ print(arguments.output_path)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/skills/searchd-aeo-audit/scripts/report_common.py b/skills/searchd-aeo-audit/scripts/report_common.py
new file mode 100644
index 0000000000..8340a62614
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/report_common.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import html
+from pathlib import Path
+from string import Template
+from typing import Final
+
+from aggregate import AuditSummary
+from audit_types import Audit, Relationship, assert_unreachable
+from report_locale import ReportLocale, localized
+
+SKILL_ROOT: Final = Path(__file__).resolve().parents[1]
+CSS_PATH: Final = SKILL_ROOT / "templates" / "report.css"
+
+REPORT_TEMPLATE: Final = Template(
+ """
+
+
+
+
+
+$title
+
+
+
+
+$body
+
+
+
+
+"""
+)
+
+
+def escape(value: str) -> str:
+ return html.escape(value, quote=True)
+
+
+def percent(rate: float) -> str:
+ return f"{round(rate * 100)}%"
+
+
+def powered_by(locale: ReportLocale) -> str:
+ label = localized(
+ locale,
+ "Open-source SearchD AEO Audit Skill",
+ "오픈소스 SearchD AEO Audit Skill",
+ )
+ return f"""
+
+
+
+
+
+
+
+
+
+ {label}
+
+"""
+
+
+def relationship_label(
+ relationship: Relationship,
+ locale: ReportLocale,
+) -> str:
+ match relationship:
+ case Relationship.FIRST_PARTY:
+ return localized(locale, "First party", "자사")
+ case Relationship.COMPETITOR_OWNED:
+ return localized(locale, "Competitor-owned", "경쟁사 소유")
+ case Relationship.EDITORIAL:
+ return localized(locale, "Independent editorial", "독립 편집 매체")
+ case Relationship.DIRECTORY:
+ return localized(locale, "Directory or review", "디렉터리·리뷰")
+ case Relationship.COMMUNITY:
+ return localized(locale, "Community", "커뮤니티")
+ case Relationship.OTHER:
+ return localized(locale, "Other", "기타")
+ case unreachable:
+ assert_unreachable(unreachable)
+
+
+def masthead(audit: Audit, locale: ReportLocale) -> str:
+ metadata = audit.metadata
+ overline = localized(
+ locale,
+ "Answer-engine exposure audit",
+ "답변 엔진 노출 감사",
+ )
+ title = localized(
+ locale,
+ f"{metadata.brand} visibility across buyer questions",
+ f"{metadata.brand} 구매 질문 노출 현황",
+ )
+ return f"""
+
+"""
+
+
+def metadata(
+ audit: Audit,
+ summary: AuditSummary,
+ locale: ReportLocale,
+) -> str:
+ report = audit.metadata
+ return f"""
+
+
{localized(locale, "Domain", "도메인")}
+ {escape(report.domain)}
+
{localized(locale, "Market", "대상 시장")}
+ {escape(report.market)}
+
{localized(locale, "Language", "측정 언어")}
+ {escape(report.language)}
+
{localized(locale, "Measured", "측정 시각")}
+ {escape(report.measured_at)}
+
{localized(locale, "Method", "측정 방식")}
+ {escape(report.measurement_method)}
+
{localized(locale, "Questions", "질문")}
+ {len(audit.questions)}
+
{localized(locale, "Valid answers", "유효 답변")}
+ {summary.valid_runs}
+
{localized(locale, "Report schema", "리포트 스키마")}
+ searchd-aeo-audit-v1
+
+"""
+
+
+def executive(summary: AuditSummary, locale: ReportLocale) -> str:
+ run_word = "run" if summary.failed_runs == 1 else "runs"
+ sentence = localized(
+ locale,
+ (
+ f"{summary.named_runs} of {summary.valid_runs} valid measured "
+ f"answers named the target brand; {summary.failed_runs} failed "
+ f"{run_word} excluded."
+ ),
+ (
+ f"유효 답변 {summary.valid_runs}개 중 {summary.named_runs}개가 "
+ f"대상 브랜드를 언급했습니다. 실패한 실행 "
+ f"{summary.failed_runs}건은 제외했습니다."
+ ),
+ )
+ return f"""
+
+
+
+ {localized(locale, "Executive finding", "핵심 결과")}
+
+ {percent(summary.named_rate)}
+ {sentence}
+
+
+
{localized(locale, "Question coverage", "노출된 질문")}
+ {summary.named_questions} / {summary.valid_questions}
+
{localized(locale, "Competitors observed", "관찰된 경쟁사")}
+ {len(summary.competitors)}
+
{localized(locale, "Cited domains", "인용 도메인")}
+ {len(summary.domains)}
+
+
+"""
+
+
+def boundary(audit: Audit, locale: ReportLocale) -> str:
+ return f"""
+
+"""
+
+
+def footer(locale: ReportLocale) -> str:
+ text = localized(
+ locale,
+ (
+ "Generated by the open SearchD AEO Audit Skill. Review the embedded "
+ "audit data and source URLs before treating any finding as a decision."
+ ),
+ (
+ "공개 SearchD AEO Audit Skill로 생성했습니다. 결론을 의사결정에 "
+ "사용하기 전에 포함된 감사 데이터와 출처 URL을 확인하세요."
+ ),
+ )
+ return f"""
+
+"""
diff --git a/skills/searchd-aeo-audit/scripts/report_evidence.py b/skills/searchd-aeo-audit/scripts/report_evidence.py
new file mode 100644
index 0000000000..703f6978f9
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/report_evidence.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+from aggregate import AuditSummary
+from audit_types import Audit, Run, RunStatus, assert_unreachable
+from report_common import escape
+from report_locale import ReportLocale, localized
+
+
+def _run_status(run: Run, locale: ReportLocale) -> tuple[str, str]:
+ match run.status:
+ case RunStatus.FAILED:
+ return localized(locale, "Failed", "실패"), "status-failed"
+ case RunStatus.COMPLETED:
+ if run.brand_mentioned:
+ return localized(locale, "Named", "노출"), "status-named"
+ return localized(locale, "Not named", "노출 안 됨"), "status-missing"
+ case unreachable:
+ assert_unreachable(unreachable)
+
+
+def _source_list(run: Run, locale: ReportLocale) -> str:
+ if not run.citations:
+ return f"{localized(locale, 'No source URLs were returned.', '반환된 출처 URL이 없습니다.')}
"
+ items = "".join(
+ f"""
+
+ {escape(citation.title)}
+ {escape(citation.url)}
+
+"""
+ for citation in run.citations
+ )
+ return f'{items} '
+
+
+def _evidence_record(
+ run: Run,
+ audit: Audit,
+ locale: ReportLocale,
+) -> str:
+ question = next(
+ question for question in audit.questions if question.id == run.question_id
+ )
+ label, class_name = _run_status(run, locale)
+ excerpt = (
+ f"{escape(run.answer_excerpt)} "
+ if run.answer_excerpt
+ else f"{localized(locale, 'No answer excerpt was available.', '표시할 답변 요약이 없습니다.')}
"
+ )
+ competitors = ", ".join(run.competitors) or localized(
+ locale,
+ "None observed",
+ "관찰되지 않음",
+ )
+ note = (
+ f''
+ f"{localized(locale, 'Analyst note:', '분석 메모:')} "
+ f"{escape(run.analyst_note)}
"
+ if run.analyst_note
+ else ""
+ )
+ queries = ", ".join(run.search_queries) or localized(
+ locale,
+ "Not captured",
+ "수집되지 않음",
+ )
+ full_answer = (
+ f"""
+
+ {localized(locale, "Full worker answer", "워커 전체 답변 원문")}
+ {escape(run.answer_text)}
+
+"""
+ if run.answer_text
+ else f"{localized(locale, 'No completed worker answer was available.', '완료된 워커 답변이 없습니다.')}
"
+ )
+ return f"""
+
+
+
+ {escape(run.agent)} · {escape(run.agent_version)} ·
+ {escape(run.searched_at)} · question {escape(run.question_id)}
+
+
+ {localized(locale, "Search queries:", "검색 쿼리:")} {escape(queries)}
+
+ {excerpt}
+ {full_answer}
+ {localized(locale, "Returned sources", "반환된 출처")}
+ {_source_list(run, locale)}
+ {localized(locale, "Co-mentioned:", "함께 언급됨:")}
+ {escape(competitors)}
+ {note}
+
+"""
+
+
+def evidence_exhibit(audit: Audit, locale: ReportLocale) -> str:
+ records = "".join(
+ _evidence_record(run, audit, locale)
+ for run in audit.runs
+ )
+ return f"""
+
+
+ {localized(locale, "Exhibit 03", "근거 03")}
+
+ {localized(locale, "Run-level evidence", "실행별 근거")}
+
+
+
+ {localized(
+ locale,
+ "Source text and analyst commentary are separated. URLs are shown in full so aggregate findings can be checked.",
+ "출처 내용과 분석자 의견을 구분했습니다. 집계 결과를 확인할 수 있도록 URL 전체를 표시합니다.",
+ )}
+
+ {records}
+
+"""
+
+
+def actions_exhibit(audit: Audit, locale: ReportLocale) -> str:
+ def evidence_links(evidence_ids: tuple[str, ...]) -> str:
+ return ", ".join(
+ f''
+ f"{escape(evidence_id)} "
+ for evidence_id in evidence_ids
+ )
+
+ actions = "".join(
+ f"""
+
+ {insight.priority:02}
+
+
{escape(insight.action)}
+
{localized(locale, "Observed:", "관찰:")}
+ {escape(insight.observation)}
+
{localized(locale, "Evidence:", "근거:")}
+ {evidence_links(insight.evidence_ids)}
+
{localized(locale, "Re-measure:", "재측정:")}
+ {escape(insight.remeasure)}
+
+
+"""
+ for insight in sorted(audit.insights, key=lambda item: item.priority)
+ )
+ if not actions:
+ actions = f"{localized(locale, 'No recommendations were recorded.', '기록된 권고가 없습니다.')}
"
+ return f"""
+
+
+ {localized(locale, "Exhibit 04", "근거 04")}
+
+ {localized(locale, "Observed patterns and actions", "관찰 결과와 권고")}
+
+
+ {actions}
+
+"""
+
+
+def methodology_exhibit(
+ audit: Audit,
+ summary: AuditSummary,
+ locale: ReportLocale,
+) -> str:
+ measurement = localized(
+ locale,
+ (
+ f"{summary.valid_questions} measured questions produced "
+ f"{summary.valid_runs} valid answers. {summary.failed_runs} failed "
+ "runs were reported separately and excluded from rates."
+ ),
+ (
+ f"측정 질문 {summary.valid_questions}개에서 유효 답변 "
+ f"{summary.valid_runs}개를 얻었습니다. 실패한 실행 "
+ f"{summary.failed_runs}건은 별도로 표시하고 비율에서 제외했습니다."
+ ),
+ )
+ return f"""
+
+
+ {localized(locale, "Exhibit 05", "근거 05")}
+
+ {localized(locale, "Methodology and limitations", "측정 방법과 한계")}
+
+
+
+
+ {localized(locale, "Measurement.", "측정.")}
+ {measurement}
+
+
+ {localized(locale, "Brand matching.", "브랜드 판정.")}
+ {localized(
+ locale,
+ "A run counts as named only when the company name, domain, or a confirmed alias appears in the completed answer.",
+ "완료된 답변에 회사명, 도메인 또는 확인된 별칭이 등장한 경우에만 브랜드 노출로 계산했습니다.",
+ )}
+
+
+ {localized(locale, "Sources.", "출처.")}
+ {localized(
+ locale,
+ "Citation counts represent completed answers containing each normalized domain, not the number of repeated links.",
+ "인용 수는 각 정규화 도메인이 포함된 완료 답변의 수이며 반복 링크 수가 아닙니다.",
+ )}
+
+
+ {localized(locale, "Boundary.", "한계.")}
+ {localized(
+ locale,
+ "Results characterize this controlled agent test. They are not consumer reach, traffic, model market share, or proof of any model's internal retrieval state.",
+ "이 결과는 통제된 에이전트 테스트를 설명합니다. 소비자 도달, 트래픽, 모델 시장 점유율 또는 모델 내부 검색 상태의 증거가 아닙니다.",
+ )}
+
+
+
+"""
diff --git a/skills/searchd-aeo-audit/scripts/report_locale.py b/skills/searchd-aeo-audit/scripts/report_locale.py
new file mode 100644
index 0000000000..8c96c34088
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/report_locale.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from enum import Enum
+
+from audit_types import Intent, assert_unreachable
+
+
+class ReportLocale(str, Enum):
+ ENGLISH = "en"
+ KOREAN = "ko"
+
+
+def localized(locale: ReportLocale, english: str, korean: str) -> str:
+ match locale:
+ case ReportLocale.ENGLISH:
+ return english
+ case ReportLocale.KOREAN:
+ return korean
+ case unreachable:
+ assert_unreachable(unreachable)
+
+
+def intent_label(intent: Intent, locale: ReportLocale) -> str:
+ match intent:
+ case Intent.COMMERCIAL:
+ return localized(locale, "commercial", "구매")
+ case Intent.COMPARISON:
+ return localized(locale, "comparison", "비교")
+ case Intent.INFORMATIONAL:
+ return localized(locale, "informational", "정보")
+ case Intent.TRUST:
+ return localized(locale, "trust", "신뢰")
+ case Intent.MARKET_FIT:
+ return localized(locale, "market fit", "시장 적합성")
+ case unreachable:
+ assert_unreachable(unreachable)
diff --git a/skills/searchd-aeo-audit/scripts/report_questions.py b/skills/searchd-aeo-audit/scripts/report_questions.py
new file mode 100644
index 0000000000..6d56a0c747
--- /dev/null
+++ b/skills/searchd-aeo-audit/scripts/report_questions.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+from aggregate import AuditSummary, QuestionSummary
+from report_common import escape, relationship_label
+from report_locale import ReportLocale, intent_label, localized
+
+
+def _question_status(
+ summary: QuestionSummary,
+ locale: ReportLocale,
+) -> tuple[str, str]:
+ if summary.valid_runs == 0:
+ return localized(locale, "Not measured", "미측정"), "status-unmeasured"
+ if summary.named_runs == 0:
+ return (
+ localized(
+ locale,
+ f"Not named 0/{summary.valid_runs}",
+ f"노출 안 됨 0/{summary.valid_runs}",
+ ),
+ "status-missing",
+ )
+ if summary.named_runs == summary.valid_runs:
+ return (
+ localized(
+ locale,
+ f"Named {summary.named_runs}/{summary.valid_runs}",
+ f"노출 {summary.named_runs}/{summary.valid_runs}",
+ ),
+ "status-named",
+ )
+ return (
+ localized(
+ locale,
+ f"Occasional {summary.named_runs}/{summary.valid_runs}",
+ f"일부 노출 {summary.named_runs}/{summary.valid_runs}",
+ ),
+ "status-named",
+ )
+
+
+def _question_rows(summary: AuditSummary, locale: ReportLocale) -> str:
+ rows: list[str] = []
+ for item in summary.questions:
+ label, class_name = _question_status(item, locale)
+ position = str(item.median_position) if item.median_position else "—"
+ competitors = ", ".join(item.competitors) or localized(
+ locale,
+ "None observed",
+ "관찰되지 않음",
+ )
+ evidence = ", ".join(
+ f'{escape(run_id)} '
+ for run_id in item.evidence_ids
+ )
+ width = (
+ round(item.named_runs / item.valid_runs * 100)
+ if item.valid_runs
+ else 0
+ )
+ rows.append(
+ f"""
+
+ {escape(item.question.text)}
+ {intent_label(item.question.intent, locale)}
+
+ {label}
+
+
+ {position}
+ {escape(competitors)}
+ {evidence}
+
+"""
+ )
+ return "".join(rows)
+
+
+def questions_exhibit(summary: AuditSummary, locale: ReportLocale) -> str:
+ description = localized(
+ locale,
+ (
+ "Coverage is directional: each completed answer is one controlled "
+ "agent observation, not a consumer impression."
+ ),
+ (
+ "이 노출 결과는 방향성 관찰입니다. 완료된 답변 한 개는 통제된 "
+ "에이전트 관찰 한 건이며 소비자 노출 수가 아닙니다."
+ ),
+ )
+ return f"""
+
+
+
+ {localized(locale, "Exhibit 01", "근거 01")}
+
+
+ {localized(locale, "Question-by-question exposure", "질문별 노출")}
+
+
+ {description}
+
+ {localized(locale, "Scroll to inspect all columns →", "옆으로 스크롤해 전체 열 보기 →")}
+
+
+
+"""
+
+
+def _ranked_items(items: tuple[tuple[str, str], ...]) -> str:
+ return "".join(
+ f"""
+
+ {index:02}
+ {escape(label)}
+ {escape(value)}
+
+"""
+ for index, (label, value) in enumerate(items, start=1)
+ )
+
+
+def _answer_count(count: int, locale: ReportLocale) -> str:
+ unit = "answer" if count == 1 else "answers"
+ return localized(locale, f"{count} {unit}", f"답변 {count}개")
+
+
+def landscape_exhibit(summary: AuditSummary, locale: ReportLocale) -> str:
+ competitors = tuple(
+ (item.name, _answer_count(item.answer_count, locale))
+ for item in summary.competitors
+ ) or (
+ (
+ localized(locale, "No competitors observed", "관찰된 경쟁사 없음"),
+ localized(locale, "0 answers", "답변 0개"),
+ ),
+ )
+ domains = tuple(
+ (
+ item.domain,
+ (
+ f"{_answer_count(item.answer_count, locale)} · "
+ f"{relationship_label(item.relationship, locale)}"
+ ),
+ )
+ for item in summary.domains
+ ) or (
+ (
+ localized(locale, "No cited domains observed", "관찰된 인용 도메인 없음"),
+ localized(locale, "0 answers", "답변 0개"),
+ ),
+ )
+ return f"""
+
+
+ {localized(locale, "Exhibit 02", "근거 02")}
+
+ {localized(locale, "Competitive and citation landscape", "경쟁사 및 인용 출처")}
+
+
+
+ {localized(
+ locale,
+ "Mention totals may exceed the answer count because one answer can name several organizations.",
+ "한 답변에 여러 조직이 등장할 수 있으므로 언급 합계는 전체 답변 수보다 클 수 있습니다.",
+ )}
+
+
+
+
+ {localized(locale, "Brands named beside or instead of the target", "대상 브랜드와 함께 또는 대신 등장한 브랜드")}
+
+
{_ranked_items(competitors)}
+
+
+
{localized(locale, "Domains used as answer evidence", "답변 근거로 사용된 도메인")}
+
{_ranked_items(domains)}
+
+
+
+"""
diff --git a/skills/searchd-aeo-audit/templates/report.css b/skills/searchd-aeo-audit/templates/report.css
new file mode 100644
index 0000000000..301adc3297
--- /dev/null
+++ b/skills/searchd-aeo-audit/templates/report.css
@@ -0,0 +1,561 @@
+:root {
+ --paper: #f3f0e8;
+ --sheet: #faf8f2;
+ --ink: #191a18;
+ --muted: #56574f;
+ --rule: #c9c5b8;
+ --signal: #a13f32;
+ --signal-soft: #ead7d1;
+ --positive: #2f6250;
+ --neutral: #e7e3d8;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+ --space-24: 6rem;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ background: var(--paper);
+ color: var(--ink);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 16px;
+ line-height: 1.65;
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+}
+
+a {
+ color: var(--signal);
+ text-decoration-color: color-mix(in srgb, var(--signal), transparent 55%);
+ text-underline-offset: 0.18em;
+ transition: color 150ms ease-out;
+}
+
+a:hover {
+ color: var(--ink);
+}
+
+a:focus-visible,
+summary:focus-visible {
+ outline: 3px solid var(--signal);
+ outline-offset: 3px;
+}
+
+.report {
+ width: min(1180px, calc(100% - 48px));
+ margin: 32px auto;
+ padding: clamp(32px, 6vw, 80px);
+ background: var(--sheet);
+ border: 1px solid var(--rule);
+}
+
+.masthead {
+ padding-bottom: var(--space-16);
+ border-bottom: 1px solid var(--ink);
+}
+
+.brand-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+}
+
+.powered-by {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--muted);
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.powered-by:hover {
+ color: var(--ink);
+}
+
+.powered-by svg {
+ flex: none;
+}
+
+.overline,
+.exhibit-number,
+.status,
+dt {
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+.metric-value {
+ font-family: Georgia, "Times New Roman", serif;
+ font-weight: 500;
+}
+
+h1 {
+ max-width: 820px;
+ margin: var(--space-4) 0 var(--space-6);
+ font-size: clamp(2rem, 5vw, 3.5rem);
+ line-height: 1.05;
+ letter-spacing: -0.025em;
+}
+
+h2 {
+ margin: 0;
+ font-size: clamp(1.5rem, 3vw, 2.25rem);
+ line-height: 1.15;
+ letter-spacing: -0.018em;
+}
+
+h3 {
+ margin: 0;
+ font-size: 1.125rem;
+ line-height: 1.35;
+}
+
+p {
+ max-width: 760px;
+}
+
+.scope {
+ margin: 0;
+ color: var(--muted);
+ font-size: 1.125rem;
+}
+
+.metadata {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--space-6);
+ margin: 0 0 var(--space-16);
+ padding: var(--space-8) 0;
+ border-top: 1px solid var(--rule);
+ border-bottom: 1px solid var(--rule);
+}
+
+.metadata div {
+ min-width: 0;
+}
+
+dt {
+ color: var(--muted);
+}
+
+dd {
+ margin: var(--space-2) 0 0;
+ overflow-wrap: anywhere;
+}
+
+.executive {
+ display: grid;
+ grid-template-columns: minmax(0, 7fr) minmax(260px, 5fr);
+ gap: var(--space-12);
+ padding: var(--space-16) 0;
+}
+
+.metric-value {
+ display: block;
+ font-size: clamp(3.5rem, 9vw, 6.5rem);
+ line-height: 0.85;
+ color: var(--signal);
+ font-variant-numeric: tabular-nums;
+}
+
+.metric-label {
+ display: block;
+ max-width: 34rem;
+ margin-top: var(--space-6);
+ font-size: 1.125rem;
+}
+
+.supporting {
+ display: grid;
+ align-content: start;
+ gap: var(--space-6);
+ margin: 0;
+}
+
+.supporting dd {
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 1.75rem;
+ line-height: 1.1;
+}
+
+.boundary {
+ margin-bottom: var(--space-24);
+ padding: var(--space-6) var(--space-8);
+ background: var(--signal-soft);
+ border-left: 6px solid var(--signal);
+}
+
+.boundary p {
+ margin-bottom: 0;
+}
+
+.exhibit {
+ position: relative;
+ padding: var(--space-12) 0 var(--space-16);
+ border-top: 1px solid var(--ink);
+}
+
+.exhibit-heading {
+ display: grid;
+ grid-template-columns: 72px minmax(0, 1fr);
+ gap: var(--space-4);
+ align-items: baseline;
+ margin-bottom: var(--space-8);
+}
+
+.exhibit-number {
+ color: var(--signal);
+}
+
+.table-scroll {
+ width: 100%;
+ overflow-x: auto;
+ border: 1px solid var(--rule);
+ background: color-mix(in srgb, var(--sheet), white 35%);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-variant-numeric: tabular-nums;
+}
+
+th,
+td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--rule);
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ font-size: 0.75rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.status {
+ white-space: nowrap;
+}
+
+.status-named {
+ color: var(--positive);
+}
+
+.status-missing,
+.status-failed {
+ color: var(--signal);
+}
+
+.status-unmeasured {
+ color: var(--muted);
+}
+
+.question {
+ min-width: 280px;
+}
+
+.count {
+ white-space: nowrap;
+}
+
+.bar {
+ display: block;
+ width: 96px;
+ height: 5px;
+ margin-top: var(--space-2);
+ overflow: hidden;
+ background: var(--neutral);
+}
+
+.bar > span {
+ display: block;
+ height: 100%;
+ background: var(--signal);
+}
+
+.ledger {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-8);
+}
+
+.ranked-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-top: 1px solid var(--rule);
+}
+
+.ranked-list li {
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr) auto;
+ gap: var(--space-3);
+ padding: var(--space-3) 0;
+ border-bottom: 1px solid var(--rule);
+}
+
+.rank {
+ color: var(--muted);
+ font-family: "SFMono-Regular", Consolas, monospace;
+}
+
+.evidence-list {
+ display: grid;
+ gap: var(--space-6);
+}
+
+.evidence {
+ padding: var(--space-6);
+ border: 1px solid var(--rule);
+ break-inside: avoid;
+}
+
+.evidence-header {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+.evidence-id,
+.run-meta,
+.source-url {
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.8125rem;
+}
+
+blockquote {
+ margin: var(--space-6) 0;
+ padding-left: var(--space-6);
+ border-left: 3px solid var(--rule);
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 1.125rem;
+}
+
+.source-list {
+ padding-left: 1.25rem;
+}
+
+.evidence details {
+ margin: var(--space-4) 0 var(--space-6);
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--rule);
+}
+
+.evidence summary {
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.evidence details p {
+ max-width: none;
+ white-space: pre-wrap;
+}
+
+.source-url {
+ display: block;
+ overflow-wrap: anywhere;
+ color: var(--muted);
+}
+
+.analyst-note {
+ padding: var(--space-3) var(--space-4);
+ background: var(--neutral);
+ color: var(--muted);
+}
+
+.actions {
+ display: grid;
+ gap: var(--space-6);
+}
+
+.action {
+ display: grid;
+ grid-template-columns: 64px minmax(0, 1fr);
+ gap: var(--space-6);
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--rule);
+}
+
+.action-priority {
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 2rem;
+ color: var(--signal);
+}
+
+.action p {
+ margin: var(--space-2) 0;
+}
+
+.methodology {
+ columns: 2;
+ column-gap: var(--space-12);
+}
+
+.methodology p {
+ break-inside: avoid;
+}
+
+.report-footer {
+ padding-top: var(--space-8);
+ border-top: 1px solid var(--ink);
+ color: var(--muted);
+ font-size: 0.875rem;
+}
+
+html[lang="ko"] :where(
+ h1,
+ h2,
+ h3,
+ p,
+ dd,
+ blockquote,
+ .metric-label,
+ .scope,
+ .analyst-note,
+ .action
+) {
+ overflow-wrap: break-word;
+ word-break: keep-all;
+}
+
+html[lang="ko"] th:nth-child(2),
+html[lang="ko"] td:nth-child(2) {
+ min-width: 5rem;
+ white-space: nowrap;
+ word-break: keep-all;
+}
+
+@media (max-width: 899px) {
+ .report {
+ width: 100%;
+ margin: 0;
+ padding: 32px 20px;
+ border: 0;
+ }
+
+ .executive,
+ .ledger {
+ grid-template-columns: 1fr;
+ }
+
+ .metadata {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .exhibit-heading {
+ grid-template-columns: 1fr;
+ gap: var(--space-2);
+ }
+
+ .action {
+ grid-template-columns: 48px minmax(0, 1fr);
+ }
+
+ .ranked-list li {
+ grid-template-columns: 32px minmax(0, 1fr);
+ }
+
+ .ranked-list li strong {
+ grid-column: 2;
+ color: var(--muted);
+ font-size: 0.875rem;
+ }
+
+ .methodology {
+ columns: 1;
+ }
+
+ .scroll-cue {
+ display: block;
+ }
+}
+
+@media (max-width: 599px) {
+ .metadata {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (min-width: 900px) {
+ .scroll-cue {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ a {
+ transition: none;
+ }
+}
+
+@media print {
+ @page {
+ size: A4;
+ margin: 18mm;
+ }
+
+ :root {
+ --paper: #ffffff;
+ --sheet: #ffffff;
+ }
+
+ .report {
+ width: auto;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ }
+
+ a {
+ color: var(--ink);
+ text-decoration: none;
+ }
+
+ .table-scroll {
+ overflow: visible;
+ }
+
+ thead {
+ display: table-header-group;
+ }
+
+ .evidence,
+ .action,
+ .boundary {
+ break-inside: avoid;
+ }
+
+ .evidence details > * {
+ display: block;
+ }
+}
diff --git a/skills/searchd-aeo-audit/tests/test_render_report.py b/skills/searchd-aeo-audit/tests/test_render_report.py
new file mode 100644
index 0000000000..3251c0d02d
--- /dev/null
+++ b/skills/searchd-aeo-audit/tests/test_render_report.py
@@ -0,0 +1,312 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+SKILL_ROOT = Path(__file__).resolve().parents[1]
+RENDERER = SKILL_ROOT / "scripts" / "render_report.py"
+
+
+@pytest.fixture
+def audit_payload() -> dict[str, object]:
+ return {
+ "schema_version": "searchd-aeo-audit-v1",
+ "audit": {
+ "brand": "SearchD",
+ "domain": "searchd.ai",
+ "market": "United States",
+ "language": "English",
+ "measured_at": "2026-09-10T12:00:00Z",
+ "measurement_method": "Independent web-search agents",
+ "scope_note": (
+ "Agent-measured research, not consumer application impression data."
+ ),
+ },
+ "questions": [
+ {
+ "id": "q01",
+ "text": "Which agencies help Asian brands appear in AI answers in the US?",
+ "intent": "commercial",
+ },
+ {
+ "id": "q02",
+ "text": "How can a Japanese brand improve ChatGPT visibility in America?",
+ "intent": "informational",
+ },
+ ],
+ "runs": [
+ {
+ "id": "r01",
+ "question_id": "q01",
+ "agent": "web-research-agent-a",
+ "agent_version": "research-agent/1",
+ "searched_at": "2026-09-10T12:01:00Z",
+ "search_queries": [
+ "AEO agencies Asian brands US"
+ ],
+ "status": "completed",
+ "brand_mentioned": True,
+ "brand_position": 2,
+ "competitors": ["Profound", "Scrunch AI"],
+ "citations": [
+ {
+ "url": "https://www.searchd.ai/",
+ "title": "SearchD",
+ "relationship": "first_party",
+ },
+ {
+ "url": "https://www.g2.com/categories/generative-engine-optimization",
+ "title": "G2",
+ "relationship": "directory",
+ },
+ ],
+ "answer_text": (
+ "SearchD and two competitors were named in the full worker answer."
+ ),
+ "answer_excerpt": "SearchD focuses on Asian brands entering the US market.",
+ "analyst_note": "The mention was supported by first-party positioning.",
+ },
+ {
+ "id": "r02",
+ "question_id": "q01",
+ "agent": "web-research-agent-b",
+ "agent_version": "research-agent/1",
+ "searched_at": "2026-09-10T12:02:00Z",
+ "search_queries": [
+ "generative engine optimization agencies"
+ ],
+ "status": "completed",
+ "brand_mentioned": False,
+ "brand_position": None,
+ "competitors": ["Profound"],
+ "citations": [
+ {
+ "url": "https://www.g2.com/categories/generative-engine-optimization?ref=test",
+ "title": "G2",
+ "relationship": "directory",
+ }
+ ],
+ "answer_text": (
+ "Profound was named and SearchD did not appear in the full answer."
+ ),
+ "answer_excerpt": "Profound is frequently discussed for enterprise AI visibility.",
+ "analyst_note": "",
+ },
+ {
+ "id": "r03",
+ "question_id": "q02",
+ "agent": "web-research-agent-a",
+ "agent_version": "research-agent/1",
+ "searched_at": "2026-09-10T12:03:00Z",
+ "search_queries": [],
+ "status": "failed",
+ "brand_mentioned": False,
+ "brand_position": None,
+ "competitors": [],
+ "citations": [],
+ "answer_text": "",
+ "answer_excerpt": "",
+ "analyst_note": "Search timed out.",
+ },
+ ],
+ "insights": [
+ {
+ "priority": 1,
+ "observation": "SearchD appears only when the cross-border context is explicit.",
+ "action": "Publish an evidence-led page for Asian brands entering the US.",
+ "evidence_ids": ["r01", "r02"],
+ "remeasure": "Repeat q01 after independent coverage is published.",
+ }
+ ],
+ }
+
+
+def run_renderer(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+ locale: str = "en",
+) -> tuple[subprocess.CompletedProcess[str], Path]:
+ input_path = tmp_path / "audit.json"
+ output_path = tmp_path / "report.html"
+ input_path.write_text(json.dumps(audit_payload), encoding="utf-8")
+
+ command = [
+ sys.executable,
+ str(RENDERER),
+ "--input",
+ str(input_path),
+ "--output",
+ str(output_path),
+ "--locale",
+ locale,
+ ]
+ result = subprocess.run(
+ command,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ return result, output_path
+
+
+def test_renderer_builds_auditable_html_when_runs_include_failure(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+) -> None:
+ # Given
+ expected_named_rate = "50%"
+
+ # When
+ result, output_path = run_renderer(tmp_path, audit_payload)
+
+ # Then
+ assert result.returncode == 0, result.stderr
+ html = output_path.read_text(encoding="utf-8")
+ assert expected_named_rate in html
+ assert "1 of 2 valid measured answers" in html
+ assert "1 failed run excluded" in html
+ assert "Profound" in html
+ assert "g2.com" in html
+ assert 'href="https://www.g2.com/categories/generative-engine-optimization?ref=test"' in html
+ assert 'href="#evidence-r01"' in html
+ assert "Full worker answer" in html
+ assert 'id="audit-data"' in html
+ assert "Agent-measured research" in html
+
+
+def test_renderer_normalizes_duplicate_citation_domains(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+) -> None:
+ # Given
+ expected_domain_total = "2 answers"
+
+ # When
+ result, output_path = run_renderer(tmp_path, audit_payload)
+
+ # Then
+ assert result.returncode == 0, result.stderr
+ html = output_path.read_text(encoding="utf-8")
+ g2_row_start = html.index("g2.com")
+ g2_row = html[g2_row_start : g2_row_start + 500]
+ assert expected_domain_total in g2_row
+ assert "1 answers" not in html
+
+
+def test_renderer_rejects_run_with_unknown_question(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+) -> None:
+ # Given
+ runs = audit_payload["runs"]
+ assert isinstance(runs, list)
+ first_run = runs[0]
+ assert isinstance(first_run, dict)
+ first_run["question_id"] = "missing-question"
+
+ # When
+ result, output_path = run_renderer(tmp_path, audit_payload)
+
+ # Then
+ assert result.returncode == 2
+ assert "missing-question" in result.stderr
+ assert not output_path.exists()
+
+
+@pytest.mark.parametrize(
+ "unsafe_url",
+ [
+ "javascript:alert(document.domain)",
+ "data:text/html,",
+ "file:///etc/passwd",
+ "https:///missing-host",
+ ],
+)
+def test_renderer_rejects_unsafe_citation_url(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+ unsafe_url: str,
+) -> None:
+ # Given
+ runs = audit_payload["runs"]
+ assert isinstance(runs, list)
+ first_run = runs[0]
+ assert isinstance(first_run, dict)
+ citations = first_run["citations"]
+ assert isinstance(citations, list)
+ first_citation = citations[0]
+ assert isinstance(first_citation, dict)
+ first_citation["url"] = unsafe_url
+
+ # When
+ result, output_path = run_renderer(tmp_path, audit_payload)
+
+ # Then
+ assert result.returncode == 2
+ assert "$.runs[0].citations[0].url" in result.stderr
+ assert "http or https URL with a host" in result.stderr
+ assert not output_path.exists()
+
+
+def test_renderer_localizes_report_chrome_without_translating_evidence(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+) -> None:
+ # Given
+ exact_measured_question = (
+ "Which agencies help Asian brands appear in AI answers in the US?"
+ )
+
+ # When
+ result, output_path = run_renderer(tmp_path, audit_payload, locale="ko")
+
+ # Then
+ assert result.returncode == 0, result.stderr
+ html = output_path.read_text(encoding="utf-8")
+ assert '' in html
+ assert "핵심 결과" in html
+ assert "질문별 노출" in html
+ assert "경쟁사 및 인용 출처" in html
+ assert "실행별 근거" in html
+ assert "관찰 결과와 권고" in html
+ assert exact_measured_question in html
+
+
+def test_renderer_attributes_open_source_without_commercial_link(
+ tmp_path: Path,
+ audit_payload: dict[str, object],
+) -> None:
+ # Given
+ expected_named_rate = "50%"
+
+ # When
+ result, output_path = run_renderer(tmp_path, audit_payload)
+
+ # Then
+ assert result.returncode == 0, result.stderr
+ html = output_path.read_text(encoding="utf-8")
+ assert 'class="powered-by"' in html
+ assert 'href="https://github.com/runbear-io/searchd-skills"' in html
+ assert 'data-attribution="source"' in html
+ assert " None:
+ # When
+ result = subprocess.run(
+ [sys.executable, str(RENDERER), "--help"],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ # Then
+ assert result.returncode == 0, result.stderr
+ assert "--agency-cta" not in result.stdout