投稿者: Pegasus_flying_freely

  • AIのSEO診断の指摘を、渡した資料とページの目的で確かめてWordPressの直す場所を決める

    AIのSEO診断の指摘を、渡した資料とページの目的で確かめてWordPressの直す場所を決める

    AIにWordPressサイトのSEO診断を頼むと、noindex、canonical、見出し、速度などの指摘が重要度つきで並びます。どれも直したほうがよさそうに見えますが、指摘の中には、AIが渡された資料から確かめられたことと、資料にないことを推測で言い切ったことが混ざっています。どちらなのかを見分けないまま直すと、必要のない修正をしたり、本当に直すべき設定を後回しにしたりします。

    例として用意したHTML・robots.txt・sitemapと、各ページの目的(検索に出したいかどうか)を5件分まとめてClaudeに渡し、普段どおりにSEO診断を頼みました。掲載したいページにnoindexとクロールの遮断が両方あるような、目的と食い違う設定の判断は、5件とも公式仕様と合っていました。一方で、本文だけの断片には、headとtitleがないこと、本文が薄いことを重要度「高」で断定し、同じ回答の中で、保存のときに一部しか保存されなかった可能性があるので配信中のHTMLを先に確かめるように、とも勧めていました。採ってよい助言と、確かめずに直してはいけない断定が、1つの回答に並んでいたのです。

    この記事では、AIの指摘を1つずつ「渡した資料」「ページの目的」「公式仕様」に戻し、「問題候補」「確認した範囲では正常」「判定不能」の3つに分ける手順を、この診断結果で示します。読み終えたら、どの指摘を直し、どの指摘のために情報を集め直し、WordPressのどこを見に行くかを決められます。

    完成形:指摘を3つに分けた判定表

    手順の最後には、指摘ごとに次の3つのどれかを付けた表が手元に残ります。

    分け方 当てはまる指摘 次にすること
    問題候補 渡した資料で確かめられ、ページの目的と食い違う(検索に出したいのにnoindexがある、など) 公開中のページでも同じかを確かめ、設定の出力元を探す
    確認した範囲では正常 渡した資料の範囲では、目的どおりになっている 何を確かめたか(ファイルと取得日時)を残す。資料にない項目は別に確かめる
    判定不能 資料に含まれていない、ページの目的が分からない、実測値がない 必要な資料を集めてから判断する

    「問題候補」は、すぐ直せという意味ではありません。検索に出す必要のないページなら、noindexは目的どおりです。「正常」にも、何を確かめた結果かを添えます。HTMLに望まない指定がなくても、HTTPのヘッダーから別の指定が出ている可能性は残るからです。

    AIの指摘を渡した資料とページの目的に戻し、問題候補・確認した範囲では正常・判定不能に分けて公開状態を確かめる流れ
    指摘を、渡した資料と目的に戻してから分けます。

    手順1:AIに渡した資料と、ページの目的を書き出す

    まず、診断の前提を2つ書き出します。

    • 渡した資料:完全なHTMLか本文だけの断片か、取得した日時、HTTPヘッダー・robots.txt・sitemapを渡したか。

    • ページの目的:検索結果に出したいか、出したくないか。代表にしたいURL(canonical)はどれか。

    今回は、5つのページの目的と保存ファイルを、ツールを使わずに資料だけで答えるよう指示して、Claudeに1回送りました(2026年10月12日)。依頼文は「ケースごとに問題点と修正案を、重要度(高・中・低)付きで」という、普段頼むのに近い形だけにしました。

    ケース 渡した資料 ページの目的
    A 本文だけの断片(<h1> の1行) 検索に出したい
    B noindexのある完全なHTML 会員向けで、検索に出したくない
    C H1が2つ、コメントとscriptの中にnoindexの文字があるHTML 検索に出したい
    D noindexのあるHTMLと、そのパスを遮断するrobots.txt 検索に出したい
    E WordPress標準に近いrobots.txtとsitemap index 検索に出したい

    手順2:指摘を、資料と目的と公式仕様に戻す

    次に、AIの指摘を1つずつ、渡した資料に実際に書かれていること、ページの目的、公式仕様の3つに照らします。今回の回答の主な指摘と、照らした結果は次のとおりです。

    ケース AIの主な指摘 照らした結果 分け方と次の確認
    A 断片 headとtitleがない、本文が薄い(どちらも重要度「高」)。同時に、配信中のHTMLを先に確かめるよう勧めた 渡したのは断片なので、headの欠落も本文の薄さも資料からは言えない 判定不能。断定は採らず、配信HTMLの確認を採る
    B 会員向け noindexの削除は勧めず、noindexは閲覧の制限にはならないこと、sitemapから外すことを挙げた noindexは検索結果への掲載を防ぐ指示で、閲覧は制限しない。sitemapには検索に出したいURLを入れる 確認した範囲では正常。会員限定ならログインでの制限を検討し、sitemapの生成元を確かめる
    C 見出し コメントやscriptの中のnoindexは効いていない。H1が複数でも問題ない どちらもrobots metaとして読まれる要素ではない。Googleのガイドに理想の見出し数はない 確認した範囲では正常。2つ目をh2にするかは内容で決める
    D 二重の遮断 noindexとクロールの遮断で二重に止めていて、片方だけ直しても解決しない クロールできなければnoindexは読まれない 問題候補。最優先で両方の出力元を確かめる
    E WordPress標準 robots.txtは標準どおりで問題ない。sitemap indexに対象URLが載っていない可能性 robots.txtは最も長く一致する規則が使われるので、対象パスは遮断されない。投稿か固定ページかは資料にない robotsは確認した範囲では正常。sitemapは判定不能で、子sitemapを開いて確かめる

    ケースAの回答には、次の行がありました(Claudeの回答の原文の一部)。

    <head>がなく、<title>もない。検索結果の見出しはGoogleが自動で作るため、ページの中身が伝わらない

    最初に確認すること: 保存のときに一部しか保存されなかった可能性があります。修正の前に、ブラウザの「ページのソースを表示」かSearch ConsoleのURL検査で、実際に配信されているHTMLを確かめてください。

    前者は資料の外の断定で、後者はその断定を確かめるための正しい助言です。重要度「高」の表だけを読んで直し始めると、後者を読み飛ばします。指摘を表から拾うときは、同じ回答の中の「確認すること」まで読みます。

    5件すべてに共通して、タイトルが汎用的なこと、viewportとmeta descriptionがないことも指摘されましたが、これは例として簡略化したファイルの性質です。自分のページで同じ指摘が出たら、公開中のHTMLで確かめる項目として扱います。

    照らすときに使う公式仕様は、主に次の4つです(いずれも確認日:2026年10月12日)。

    • noindexはHTMLのrobots metaか、HTTPの X-Robots-Tag で指定する。robots.txtでクロールを遮断すると、noindexは読まれない(Googleのnoindexの説明)。body内のrobots metaも認識される(robots metaの仕様)。

    • canonicalは代表にしたいURLを伝える強いシグナルだが、指定どおりに選ばれる保証はない。別のURLを意図して代表にしているページもある(正規URLの説明)。

    • robots.txtは、最も長く一致する規則が使われる(robots.txtの仕様)。WordPressが標準で出すrobots.txtには Allow: の行があり、標準のsitemapはsitemap indexになっている(WordPressのrobots.txt生成)。

    • 見出しの数に理想の値はない(Google SEOスターターガイド)。

    手順3:判定表に、WordPressで見る場所を書き足す

    問題候補と判定不能の指摘には、WordPressで最初に見る場所と、変更したあとの確かめ方を書き足します。記事の本文を書き直しても、head内のcanonicalやHTTPヘッダーは変わらないことがあります。

    症状・確かめる対象 最初に見る場所 変更後の確かめ方
    記事の見出し・説明・本文のリンク 記事エディター。ページタイトルのH1はテーマ側の場合もある 公開ページの表示とHTMLで、見出しの対応を見る
    サイト全体に意図しないnoindex 設定→表示設定の「検索エンジンでの表示」、有効なプラグインの設定 公開HTMLとヘッダーを取り直す。設定画面だけで完了にしない
    特定ページのnoindex・canonical SEOプラグインのページ別設定、テーマや追加コード HTML全体のrobots meta、head内のcanonical、ヘッダーに望む指定が1つだけ出ているか
    X-Robots-Tag やHTTPの Link のcanonical サーバー設定、CDN、プラグインなどのヘッダーの生成元 対象URLへのGETの応答で、ステータス・転送先・ヘッダーを見る
    robots.txt・sitemap WordPressやSEOプラグインの生成設定、サーバー上の実ファイル 公開中のファイルを取り直し、対象URLと規則を照らす

    サイト全体の「検索エンジンでの表示」の設定は、WordPress公式の表示設定の説明にあります(確認日:2026年10月12日)。実際の出力元はテーマとプラグインの組み合わせで変わるので、表は見に行く順番として使います。

    直す順番は、まず「検索に出したいページが、意図せずnoindexやクロールの遮断になっていないか」(今回のケースD)、次に「代表URLの指定が重複していないか」です。見出しの整理は内容を読んで決めます。変える値・対象URL・戻し方を決め、1件ずつ確かめてから反映します。

    手順4:判定不能の指摘のために、公開状態を取り直す

    判定不能に分けた指摘は、資料を足してから判断します。自分が管理するページなら、次の記録をそろえます。

    1. 対象URL、最終的なURL、取得日時、HTTPステータスと転送、GETの応答のヘッダー、完全なHTML。

    2. 同じサイトの公開中のrobots.txtと、対象URLが属するsitemap。

    3. Search ConsoleのURL検査の結果。Googleが持つ登録情報と、今のページへのライブテストを分けて記録し、Googleが選んだ正規URLも見る(URL検査の公式説明)。

    4. 変更したあとに、同じURL・同じ条件で取り直した記録。

    速度の指標(LCP・INP・CLS)を指摘されたら、測った元、期間、端末、ラボの測定か実際の利用者のデータかを確かめます。保存したHTMLから値は出せません(GoogleのWeb Vitalsの説明)。

    AIに、指摘の根拠を出し直してもらう

    診断が長く、自分で照らすのが大変なときは、修正案を増やす前に、AIに指摘を判定表の形で出し直してもらいます。貼る情報に、認証情報や個人情報を含めないでください。

    対象URLとページの目的:[URL/検索に出したいか/希望する代表URL]
    渡す資料:[完全なHTMLか本文断片か、取得日時、ヘッダー・robots・sitemapの有無]
    元の診断:[ここに貼る]
    各指摘を「観測した値」「根拠となる箇所」「公式資料」「問題候補/確認範囲内で正常/判定不能」「次の確認」に分けてください。
    ページの目的と食い違う指摘を優先してください。
    取得していないヘッダー、実インデックス、順位、速度の値は判定不能にしてください。
    修正箇所が本文・テーマ・プラグイン・サーバーのどこか、まだ特定できない場合も書いてください。

    返ってきた表も、該当するHTMLや公式資料と照らします。根拠の箇所が示されない指摘は、直す判断を保留します。

    付録:保存したファイルを3つに分ける小さなPythonコード

    AIの指摘と照らすための比較用に、保存したHTML・robots.txt・sitemapから、確かめられる範囲だけを判定するコードを用意しました。Python 3.10以降の標準ライブラリだけで動き、通信はしません。長いコードは折りたたんでいます。3つのファイルを空のフォルダにUTF-8で保存し、python3 run_checks.py を実行すると、18の例で判定を確かめられます。

    入力例 このコードの判定 読み方
    noindex:掲載したいページにnoindex metaが問題候補 公開HTMLと設定を確かめる
    body-noindex:body内にnoindex metaが問題候補 headだけでなくHTML全体を見る
    intended:掲載を望まないページにnoindex metaは範囲内で正常 noindexの削除は不要
    conflict:異なるcanonicalが2つ canonicalが問題候補 重複して出力している元を探す
    fragment:本文だけ metaとcanonicalは判定不能 「titleがない」とは言えない
    two-h1:H1が2つ、コメントにnoindexの文字 metaは範囲内で正常、H1数は2として記録 H1の数は判定に使わない
    wp-default:WordPress標準のrobots.txtとsitemap index robotsとsitemapは判定不能 規則と子sitemapを手で確かめる

    このコードのrobots.txtの判定は、単一の User-agent: * と単純な Disallow だけを扱い、Allow: や Sitemap: の行があれば判定不能にします。WordPressの実際のrobots.txtはこの形に当たるので、手順2のケースEのように、Googleの仕様に沿って手で確かめます。http(ヘッダー)、index(登録状態)、cwv(速度)は、入力に含まれないので常に判定不能です。コードの出力では、「確認した範囲では正常」を「範囲内で正常」と表示します。

    fixtures.py:18の例を作る

    want_index は検索に出したいなら true、出したくないなら false、分からなければ null です。preferred は運営者が代表にしたいURLで、Googleが選ぶURLとは別です。URLはすべて example.com です。

    コードを開く
    import json
    from pathlib import Path
    
    URL = 'https://example.com/guide/'
    OTHER = 'https://example.com/original/'
    CANON = f'<link rel="canonical" href="{URL}">'
    XML = f'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>{URL}</loc></url></urlset>'
    
    def fixture(name, meta='', canonical=CANON, body='<h1>手順</h1>', want=True,
                preferred=URL, robots='User-agent: *\nDisallow:\n', sitemap=XML, fragment=False, base=''):
        d = Path('fixtures') / name
        d.mkdir(parents=True, exist_ok=True)
        html = body if fragment else f'<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>手順</title>{base}{meta}{canonical}</head><body>{body}</body></html>'
        for file, text in [('page.html', html), ('robots.txt', robots), ('sitemap.xml', sitemap),
                           ('intent.json', json.dumps({'url': URL, 'want_index': want, 'preferred': preferred}, ensure_ascii=False, indent=2))]:
            (d / file).write_text(text, encoding='utf-8')
    
    fixture('normal')
    fixture('noindex', meta='<meta name="robots" content="noindex, follow">')
    fixture('fragment', body='<h1>本文だけ</h1>', fragment=True)
    fixture('conflict', canonical=CANON + f'<link rel="canonical" href="{OTHER}">')
    fixture('intended', meta='<meta name="robots" content="noindex">', want=False)
    fixture('unknown-intent', meta='<meta name="robots" content="noindex">', want=None)
    fixture('googlebot-none', meta='<meta name="googlebot" content="NONE">')
    fixture('cross-canonical', canonical=f'<link rel="canonical" href="{OTHER}">', preferred=OTHER,
            sitemap=XML.replace(URL, OTHER))
    fixture('missing-canonical', canonical='')
    fixture('blocked-noindex', meta='<meta name="robots" content="noindex">', robots='User-agent: *\nDisallow: /guide/\n')
    fixture('complex-robots', robots='User-agent: *\nDisallow: /\nAllow: /guide/\n')
    fixture('broken-sitemap', sitemap='<urlset>')
    fixture('two-h1', body='<h1>手順</h1><h1>別の区分</h1>',
            meta="""<!-- <meta name="robots" content="noindex"> --><script>const s = '<meta name="robots" content="noindex">';</script>""")
    fixture('sitemap-index', sitemap='<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></sitemapindex>')
    fixture('base-relative', canonical='<link rel="canonical" href="guide/">', base='<base href="https://example.com/">')
    fixture('relative', canonical='<link rel="canonical" href="/guide/">')
    fixture('body-noindex', body='<meta name="robots" content="noindex"><h1>手順</h1>')
    # WordPress標準に近いrobots.txtとコアのsitemap index(この最小コードでは判定不能になる例)
    WP_ROBOTS = 'User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n\nSitemap: https://example.com/wp-sitemap.xml\n'
    WP_INDEX = '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.com/wp-sitemap-posts-post-1.xml</loc></sitemap></sitemapindex>'
    fixture('wp-default', robots=WP_ROBOTS, sitemap=WP_INDEX)

    audit.py:確かめられる範囲だけ判定する

    コードを開く
    import json
    import re
    import sys
    from html.parser import HTMLParser
    from pathlib import Path
    from urllib.parse import urljoin, urlsplit
    import xml.etree.ElementTree as ET
    
    P, N, U = '問題候補', '範囲内で正常', '判定不能'
    
    class Page(HTMLParser):
        def __init__(self):
            super().__init__()
            self.head = False
            self.events, self.robots, self.canon = [], [], []
            self.base, self.h1 = False, 0
    
        def handle_starttag(self, tag, attrs):
            a = dict(attrs)
            if tag in ('html', 'head', 'body'):
                self.events.append(tag)
            if tag == 'head':
                self.head = True
            if tag == 'h1':
                self.h1 += 1
            if tag == 'meta' and (a.get('name') or '').lower() in ('robots', 'googlebot'):
                self.robots += re.split(r'[\s,]+', (a.get('content') or '').lower())
            if not self.head:
                return
            if tag == 'base':
                self.base = True
            if tag == 'link' and 'canonical' in (a.get('rel') or '').lower().split():
                self.canon.append(a.get('href') or '')
    
        def handle_endtag(self, tag):
            if tag in ('html', 'head', 'body'):
                self.events.append('/' + tag)
            if tag == 'head':
                self.head = False
    
    
    def audit(folder):
        d = Path(folder)
        cfg = json.loads((d / 'intent.json').read_text(encoding='utf-8'))
        url, want, preferred = cfg['url'], cfg['want_index'], cfg['preferred']
        h = Page()
        h.feed((d / 'page.html').read_text(encoding='utf-8'))
        full = h.events == ['html', 'head', '/head', 'body', '/body', '/html']
        out = {k: U for k in ('meta', 'canonical', 'robots', 'sitemap', 'http', 'index', 'cwv')}
        noindex = bool({'noindex', 'none'} & set(h.robots))
        if full:
            out['meta'] = U if want is None else (P if noindex and want else N)
            targets = {urljoin(url, x) for x in h.canon if x}
            if not h.base and h.canon:
                if any(not x for x in h.canon) or len(targets) > 1:
                    out['canonical'] = P
                elif preferred and all(urlsplit(x).scheme in ('http', 'https') for x in targets):
                    out['canonical'] = N if targets == {preferred} else P
        # 教材専用: 単一の User-agent: * と単純な Disallow 接頭辞だけ扱う。
        lines = [(x.split('#', 1)[0]).strip() for x in (d / 'robots.txt').read_text(encoding='utf-8').splitlines()]
        lines = [x for x in lines if x]
        rules, supported = [], bool(lines) and lines[0].lower() == 'user-agent: *'
        for line in lines[1:]:
            key, sep, value = line.partition(':')
            value = value.strip()
            if not sep or key.lower() != 'disallow' or (value and not value.startswith('/')) or any(c in value for c in '*$%?'):
                supported = False
            rules.append(value)
        if supported:
            blocked = any(x and urlsplit(url).path.startswith(x) for x in rules)
            out['robots'] = (U if want is None else P if want else N) if blocked else N
        try:
            root = ET.fromstring((d / 'sitemap.xml').read_text(encoding='utf-8'))
            ns = '{http://www.sitemaps.org/schemas/sitemap/0.9}'
            if root.tag == ns + 'urlset':
                locs = [x.text for x in root.findall(ns + 'url/' + ns + 'loc')]
                out['sitemap'] = N if preferred and preferred in locs else U
        except ET.ParseError:
            out['sitemap'] = P
        return {'checks': out, 'observed': {'complete_markers': full, 'html_noindex': noindex if full else None,
                'canonical_hrefs': h.canon if full else None, 'h1_count': h.h1},
                'scope': '確認したのは保存したHTML・robots.txt・sitemap.xml。正常は各項目の確認範囲に限る。HTTP・登録状態・CWVは別に取得して確かめる'}
    
    if __name__ == '__main__':
        print(json.dumps(audit(sys.argv[1]), ensure_ascii=False, indent=2))

    run_checks.py:例ごとの判定を照らす

    コードを開く
    import json
    import runpy
    from pathlib import Path
    from audit import audit, P, N, U
    
    runpy.run_path('fixtures.py')
    # 期待値:例ごとに、記事の3分類(問題候補/範囲内で正常/判定不能)で決めた値。
    expected = {
        'normal': [N,N,N,N], 'noindex': [P,N,N,N], 'fragment': [U,U,N,N],
        'conflict': [N,P,N,N], 'intended': [N,N,N,N], 'unknown-intent': [U,N,N,N],
        'googlebot-none': [P,N,N,N], 'cross-canonical': [N,N,N,N],
        'missing-canonical': [N,U,N,N], 'blocked-noindex': [P,N,P,N],
        'complex-robots': [N,N,U,N], 'broken-sitemap': [N,N,N,P],
        'two-h1': [N,N,N,N], 'sitemap-index': [N,N,N,U], 'base-relative': [N,U,N,N],
        'relative': [N,N,N,N], 'body-noindex': [P,N,N,N], 'wp-default': [N,N,U,U],
    }
    results = {}
    for name, values in expected.items():
        result = audit(Path('fixtures') / name)
        assert [result['checks'][k] for k in ('meta','canonical','robots','sitemap')] == values, name
        assert all(result['checks'][k] == U for k in ('http','index','cwv')), name
        results[name] = result
    assert results['two-h1']['observed']['h1_count'] == 2
    assert results['fragment']['observed']['html_noindex'] is None
    assert results['two-h1']['observed']['html_noindex'] is False
    Path('results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    Path('checks.json').write_text(json.dumps({'fixture_cases':len(expected),'status_assertions':len(expected)*7,
        'observation_assertions':3,'passed':True,'expected':expected}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
    print(f'CHECK_OK: {len(expected)} cases, {len(expected)*7} statuses, 3 observations')

    python3 run_checks.py の出力は次のとおりで、全例の結果は results.json に保存されます。

    CHECK_OK: 18 cases, 126 statuses, 3 observations

    1つの例を見るときは python3 audit.py fixtures/noindex を実行します。

    {
      "checks": {
        "meta": "問題候補",
        "canonical": "範囲内で正常",
        "robots": "範囲内で正常",
        "sitemap": "範囲内で正常",
        "http": "判定不能",
        "index": "判定不能",
        "cwv": "判定不能"
      },
      "observed": {
        "complete_markers": true,
        "html_noindex": true,
        "canonical_hrefs": [
          "https://example.com/guide/"
        ],
        "h1_count": 1
      },
      "scope": "確認したのは保存したHTML・robots.txt・sitemap.xml。正常は各項目の確認範囲に限る。HTTP・登録状態・CWVは別に取得して確かめる"
    }

    meta が問題候補でも、http や index は判定不能のままです。この例で採る行動は「掲載したいページのnoindexを、公開HTMLと設定で確かめる」で、AIの指摘を一括で直す前に、同じように観測した値と目的を照らします。

  • AIで既存記事に1節足す前に、残す情報を決めて改稿後に照合する

    AIで既存記事に1節足す前に、残す情報を決めて改稿後に照合する

    公開済みの記事をAIに直させると、説明は増えても、元の注意事項や日付、リンク、コードが消えていることがあります。消えた情報は、改稿後の文章だけを読んでも気づけません。反対に、元の情報がすべて残っていても、足された文の中に、根拠のない一文や読者に不要な一文が混ざることがあります。

    WordPressのバックアップ記事に「復元候補を選ぶ」節を1つ足す依頼を、禁止事項(具体的な画面操作、固定の保存間隔、成功の保証などを加えない)を書いて、AIに2回出しました。2回とも、元の注意文・表・画像参照・リンク・コードはすべて残り、保持の検査に合格しました。ところが追加された文には、2回とも、禁止事項をそのまま読者向けの断り書きにした文がありました(「…推奨値ではありません」「…目安となる時間差を定めません」など)。どの禁止事項が断り書きになったかは回ごとに違い、2回目は、依頼に添えた出典のメモまで記事の本文として出力されました。

    この記事では、改稿の前に読者の問いと「変えずに残す情報」を決める改善ブリーフ、改稿後に照合する保持対象表とPythonの検査、追加された文を読み分ける手順を、この2回の実例で示します。読み終えたら、AIの改稿案を採用するか、直してから採るか、保留するかを決められます。

    使う場面と、手元に残るもの

    この手順は、すでに公開している記事の一部だけをAIに書き足させたいときに使います。手元に残るのは次の3つです。

    成果物 中身 使うとき
    改善ブリーフ 読者の問い、記事の役割、残す情報、足す情報、根拠がないときの扱い、扱わないこと AIに依頼する前
    保持対象表 残す情報ごとの照合方法と、照合だけでは分からないこと 改稿案を受け取った直後
    追加文の読み分け 足された文を「採る/削る/出典に戻す」に分ける基準 保持の照合に合格したあと
    改稿前に保持対象を決め、限定改稿のあとに文字列の照合と追加文の読み分けを行う流れ
    元の情報が残っていても、足された文は別に読みます。

    手順1:読者の問いを1文で決める

    最初に、この記事に来た読者が片づけたいことを1文にします。検索語と今の本文から考えます。今回の例は「WordPressのバックアップ設定と保存先」という記事で、Search Consoleでは「WordPress バックアップ 復元」でも表示され、その語の掲載順位が下がっていました(直す記事を選ぶ手順の記事の練習データ)。保存の仕方の記事に、戻し方を探す人も来ていると読めます。

    そこで問いを「保存済みのバックアップから、どれを復元候補にすればよいか」と決めました。問いが今の記事の役割(保存対象と保存先の確認)の続きなら、既存の記事に足します。プラグインの比較のように目的が違う問いなら、別の記事にします。特定のサービスの画面操作のように根拠をまだ確かめていない問いは、保留にします。

    手順2:改善ブリーフに、残す情報と扱わないことを書く

    問いが決まったら、改善ブリーフを書きます。今回のブリーフは次のとおりです。

    項目 今回のブリーフ
    読者の問い 保存分はあるが、どれを復元候補にすればよいか分からない
    記事の役割 保存対象・保存先を確認し、復元の検討へ進む
    残す情報 元の本文と注意事項、表の日時と対象、画像参照とalt、公式リンク先、コード全文
    足す情報 対象サイト・保存日時・ファイルとデータベースの組合せを確かめ、分からなければ保留する説明
    根拠がないとき 未確認の項目と、次に見る公式資料を書く。ボタン名や成功の保証で埋めない
    扱わないこと 復元方式ごとの手順、プラグインの比較、サービス別の画面操作、検索効果の予測

    足す情報の根拠には、WordPress公式の説明を使いました。通常の復元にはデータベースとファイルの両方が必要で、同じ頃の保存分を一組として扱うこと、データベースを復元すると現在の内容が上書きされることです(バックアップの公式説明、データベースのバックアップ・復元、確認日:2026年10月11日)。

    手順3:依頼文に、ブリーフと元の本文を添えて送る

    ブリーフをもとに、次の依頼文を作りました。この後に、改稿前の本文(before.md)の全文と、確認済みの出典2件の要約を続けて送ります。

    読者は、保存済みのWordPressバックアップから復元候補を選びたい人です。
    既存記事の目的は、保存対象と保存先の確認です。
    
    元の本文を書き換えず、「復元候補を選ぶ」節だけを後ろに追加してください。
    元の注意文、表の日時と対象、画像のaltと参照、リンク先、コード全文を残します。
    
    根拠は添付したWordPress公式資料です。
    対象サイト・保存日時・ファイルとDBの組合せを確認する説明を加えます。
    不明なら保留し、記録や利用サービスの公式資料を確認するよう書いてください。
    
    具体的な画面操作、固定の保存間隔、成功保証、未測定のSEO効果は加えません。
    記録例の日時を推奨値として扱わないでください。
    出力は元の本文全体と追加節です。

    この依頼文を、Claudeの新しいチャットに1回送りました(2026年10月20日)。その前に、この記事を作る作業の中で、同じ禁止事項を箇条書きにした指示書でAIに1回書き足させています(2026年10月11日)。以下の「1回目」「2回目」はこの2回です。

    改稿前の本文は次のとおりです。照合を再現するときは、UTF-8の before.md として保存します。

    # WordPressのバックアップ設定と保存先
    
    WordPressのバックアップでは、データベースとファイルを両方保存します。保存先はサーバー内だけにせず、手元や別の保管先にもコピーを置きます。
    
    ## 保存する対象
    
    記事や設定の多くはデータベースに、アップロード画像やテーマ等はファイルに入ります。片方だけでは同じ状態に戻せない場合があります。
    
    | 記録例の保存日時 | 対象 | 確認するもの |
    | --- | --- | --- |
    | 2026-10-10 09:00 JST | データベース | SQLファイル |
    | 2026-10-10 09:00 JST | ファイル | 画像・テーマ等 |
    
    ![データベースとファイルを一組として確認する図](backup-set.webp)
    
    保存対象は[WordPress公式のバックアップ説明](https://developer.wordpress.org/advanced-administration/security/backup/)も確認してください。
    
    ~~~python
    backup_set = ["database.sql", "files.zip"]
    print(len(backup_set))  # 記録例の対象は2種類
    ~~~
    
    ## 実行前の確認
    
    保存日時、対象サイト、ファイルの有無を記録します。復元すると現在の内容が上書きされる可能性があるため、作業直前の状態も別に保存してください。公開サイトでいきなり試さず、検証環境で確認します。
    
    ## 復元について
    
    復元の手順は利用するホスティングやプラグインで異なります。この記事にはサービス別の操作手順はまだありません。

    手順4:保持対象表とPythonで、元の情報が残ったかを照合する

    改稿案を受け取ったら、まず元の情報が残っているかを照合します。保持対象ごとに、照合のしかたと、照合だけでは分からないことを表にしておきます。

    保持対象 照合のしかた それだけでは分からないこと
    3つの注意文 否定や条件を含め、文全体が一致するか 足された文が注意文と矛盾していないか
    表と記録の日時 見出し・区切り・2行の文字列が一致するか 日時が復元に適した保存分か
    画像 altと参照の行が一致するか 画像ファイルの欠落・差し替え
    出典リンク リンク先のURLが完全に残っているか 今の出典がその主張を支えているか
    コード コードブロック全体が一致するか 新しい環境で動くか

    文字列の照合は、次の check.py で行えます。Python 3の標準ライブラリだけで動き、before.md と改稿後の after.md を同じフォルダに置いて python3 check.py before.md after.md で実行します。

    """Python 3標準ライブラリ。保護した箇所の完全一致を確認する小例。"""
    from pathlib import Path
    import re
    import sys
    
    
    def protected(text):
        warnings = (
            "保存日時、対象サイト、ファイルの有無を記録します。",
            "復元すると現在の内容が上書きされる可能性があるため、作業直前の状態も別に保存してください。",
            "公開サイトでいきなり試さず、検証環境で確認します。",
        )
        if not all(s in text for s in warnings):
            raise ValueError("比較元の注意文が不足しています")
        return {
            "注意文": list(warnings),
            "表": re.findall(r"^\|.*\|$", text, re.M),
            "画像参照": re.findall(r"^!\[.*\]\(.*\)$", text, re.M),
            "リンク先": re.findall(r"(?<!!)\[[^\]]+\]\(([^)]+)\)", text),
            "コード": re.findall(r"~~~[^\n]*\n.*?\n~~~", text, re.S),
        }
    
    
    def check(before, after):
        failed = []
        actual = {
            "注意文": after,
            "表": after.splitlines(),
            "画像参照": after.splitlines(),
            "リンク先": re.findall(r"(?<!!)\[[^\]]+\]\(([^)]+)\)", after),
            "コード": re.findall(r"~~~[^\n]*\n.*?\n~~~", after, re.S),
        }
        for kind, pieces in protected(before).items():
            if not pieces:
                raise ValueError(f"比較元に{kind}がありません")
            if any(piece not in actual[kind] for piece in pieces):
                failed.append(kind)
        return failed
    
    
    if __name__ == "__main__":
        if len(sys.argv) != 3:
            raise SystemExit("使い方: python3 check.py before.md after.md")
        before = Path(sys.argv[1]).read_text(encoding="utf-8")
        after = Path(sys.argv[2]).read_text(encoding="utf-8")
        try:
            failed = check(before, after)
        except ValueError as error:
            raise SystemExit(str(error))
        print("FAIL: " + "、".join(failed) if failed else "PASS: 保持対象の文字列が一致")
        raise SystemExit(1 if failed else 0)

    2回の改稿案は、どちらも次の結果でした(終了コード0)。

    PASS: 保持対象の文字列が一致

    表の日時を1つ変えると FAIL: 表、注意文の「上書きされる可能性」を否定に変えると FAIL: 注意文 になります。一方で、注意文を残したまま「検証環境なら現在の内容は上書きされません。」のような根拠のない一文を足しても、この検査は合格します。この検査が確かめるのは「残すと決めた情報が残っているか」だけで、足された文が正しいかは、次の手順で読みます。

    コードを使わない場合は、WordPressのリビジョン画面で改稿前後を見比べられます。投稿の設定サイドバーで「リビジョン」の横の数字を押すと一覧が開き、WordPress 7.0以降では、追加したブロックが緑、削除したブロックが赤、変更したブロックが黄色で示されます(WordPress公式:リビジョン、確認日:2026年10月12日)。公開済みの記事は「更新」を押した時点で反映されるので、改稿は検証環境で保存してから見比べます。

    手順5:足された文を、採る・削る・出典に戻すに分ける

    保持の照合に合格したら、足された文を1文ずつ読みます。Googleも、生成AIで作った内容に正確さ・品質・関連性を求めています(Googleの生成AIコンテンツの指針、確認日:2026年10月11日)。2回の改稿案で、読み分けが必要だった文は次のとおりです(AIの出力の原文の一部)。

    足された文 何が起きたか 分け方
    1回目「…選ぶべき日時や保存間隔の推奨値ではありません。」(文の後半) 禁止事項(日時を推奨値として扱わない)を、読者向けの断り書きにした 削る:表の見出しが「記録例の保存日時」なので要らない
    1回目「候補がそろっても復元の成功が保証されるわけではありません。」 禁止事項(成功を保証しない)を断り書きにした 削る:次の段落の「検証環境で確かめる」で足りる
    1回目「このページでは、未確認の管理画面の操作や復元の実行順序は指定しません。」 禁止事項(画面操作を加えない)を断り書きにした 削る:読者の判断に使えない
    2回目「そのため、この記事では目安となる時間差を定めません。」 禁止事項(固定の保存間隔を加えない)を断り書きにした 削る
    2回目「前掲の表にある2026-10-10 09:00 JSTは、あくまで記録例として置いた日時です。」「選ぶときの基準や推奨値ではありません。」 禁止事項(日時を推奨値として扱わない)を断り書きにした 削る
    2回目「バックアップのファイル名や保存先の表示が、いま復元しようとしているサイトのものかを確認します。」など 添えた出典にない具体的な確かめ方が足された 出典に戻す:使うサービスの公式資料で確かめてから残す
    2回目「## 確認済みの出典(2026-10-11確認)」の見出しと2行 依頼に添えた出典のメモが、元の本文と追加節のあいだに本文として出力された 削る:依頼の外の混入。保持の検査では見つからない

    禁止事項が断り書きの文になった箇所は、1回目に3文、2回目に3文(2か所)ありました。2回に共通していたのは「記録例の日時を推奨値として扱わない」だけで、ほかは回ごとに違う禁止事項が文になっています。依頼に「〜は加えない」と書くと、AIはそれを守ったうえで、守ったことを読者に向けて書くことがある、と考えておくと読み落としにくくなります。

    分け方の基準は次の3つです。

    • 採る:読者が手順や判断に使え、添えた出典で説明できる文。

    • 削る:依頼の禁止事項を読者に向けて言い直しただけの文、依頼の外から入った文。

    • 出典に戻す:出典に書かれていない具体例や確かめ方。根拠を確かめてから残す。

    1回目の追加節から、削る3文を除くと次のようになります。1回目の出力を読み分けの結果に合わせて直した例です。

    ## 復元候補を選ぶ
    
    まず、不具合が起きる前の保存分を候補にします。その保存分が対象サイトのもので、ファイルとデータベースが同じ頃の一組になっているかを確認してください。
    
    対象サイトや保存日時が分からない場合、または片方が欠けている場合は、復元を始めず、保存記録や利用サービスの公式資料を確認します。
    
    復元すると、保存後に追加・変更した内容が戻らなくなる可能性があります。作業直前の状態を別に保存し、検証環境で必要な記事や画像を確認したうえで、利用するホスティングやプラグインの公式手順に進んでください。

    採用するか、保留するかを決める

    照合と読み分けの結果から、次のように決めます。

    • 採用する:保持の照合に合格し、足された文がすべて「採る」だった。

    • 直してから採用する:保持の照合に合格し、「削る」文を除けば残りがすべて「採る」になる。今回の1回目はこれに当たり、上の直した例が採用案です。

    • 保留する:保持の照合に不合格だった、または「出典に戻す」文の根拠がまだ確かめられない。今回の2回目は、出典にない確かめ方が複数あるので、この基準では保留になります。

    採用したら、変更した日と範囲を記録しておきます。検索での評価は、同じ条件の期間のデータで別に見ます。

    保持対象表は、AIに頼むときだけでなく、人が書き直すときにも使えます。この記事自体を書き直したときも、次の表で前後を照らしました。意図して外したものには理由の欄を付けておくと、後で「なぜ消えたか」を追えます。

    保持対象 書き直し後 外した理由
    check.py と before.md の全文 残した(文字列が一致) —
    WordPress公式3件へのリンクと確認日 残した —
    Googleの生成AIコンテンツの指針へのリンク 残した —
    Googleの有用なコンテンツ・画像の説明へのリンク 外した 書き直し後の手順で使う箇所がなくなった
    検査の10ケースの結果表 外した(実験の記録に残した) 読者の判断には、合格・不合格の例2つで足りる
  • Search Consoleの書き出しを2期間で集計し、直す記事を元の行で確かめて1件選ぶ

    Search Consoleの書き出しを2期間で集計し、直す記事を元の行で確かめて1件選ぶ

    WordPressで小さなサイトを運営していると、Search Consoleでクリック数や表示回数が減った記事が目に入ります。ただ、タイトルを変えるべきか、本文を補うべきか、そもそも直すべきかは、画面の合計値だけでは決まりません。AIに分析を頼んでも、その提案が元のデータのどこに基づいているかを確かめられなければ、判断には使えません。

    運営しているサイトで、記事1件について2つの期間のクエリ表を書き出して集計すると、比べる以前の問題が出ました。前の期間はページ全体で2クリック・4表示あったのに、書き出したクエリ表は0行でした。期間を「比較」にしたまま書き出すと、片方の期間に行がない検索語は、クリック・表示・掲載順位がすべて0で埋まり、実在しない「0位」まで入っていました。書き出したCSVを並べるだけでは検索語ごとの前後比較は成り立たず、今回のデータでは「保留して取り直す」が正しい結論になりました。

    この記事では、ページで絞ったクエリ表を同じ条件の2期間で書き出し、コードで集計し、AIに出させた仮説を元のCSVの行に戻して確かめる手順を示します。読み終えたら、先に確かめる記事1件と最初の小さな作業を書いた改善ブリーフを作るか、データが足りないと判断して期間を延ばして取り直すかを決められます。

    完成形:改善ブリーフ1件か、保留と取り直し

    手順の最後に手元に残るのは、次のどちらかです。

    結論 手元に残るもの 次にすること
    候補を1件選ぶ 対象URL、根拠にした元CSVの行、確かめる仮説、最初の小さな作業、判断を変える条件を書いた改善ブリーフ ブリーフの「次の確認」を済ませてから改稿に進む
    比較を保留する 保留したURLと、その理由(行がない、空欄がある、表示が0など) 期間を延ばすか、データがたまってから同じ条件で書き出し直す
    同条件のデータを集計し、比較できない記事は保留し、比較できた記事はAIの仮説を元の行と本文で確かめて1件選ぶ流れ
    集計値、AIの仮説、改稿するかどうかの判断を分けます。

    作業は、データの書き出し→コードでの集計→AIによる仮説の整理→人による確認、の順です。AIにCSVだけを渡して「SEOを改善して」と頼むと、欠けた行をゼロとして数えたり、CTR(クリック率)の低下をすぐタイトルのせいにしたりする余地が残ります。数値はコードで固定し、AIには仮説と次に確かめることだけを出させます。

    手順1:ページで絞ったクエリ表を、期間ごとに書き出す

    「ページ」の一覧と「クエリ」の一覧を別々に書き出しても、どの検索語がどのURLにつながったかは分かりません。確かめたいページを選び、ページで絞った状態のクエリ表を期間ごとに書き出します。以下は、運営しているサイトのSearch Console(日本語画面)でたどった手順です(2026年10月12日)。

    1. 「検索パフォーマンス」を開き、期間の「詳細」から前の期間の開始日と終了日を入れます。検索タイプ、必要なら国と端末も設定します。

    2. 「ページ」タブで対象URLの行をクリックします。上部に「ページ: (URL)」の条件が付き、表は自動で「クエリ」タブに切り替わります。

    3. 右上の「エクスポート」から「CSVをダウンロード」を選びます。届くのはZIPで、中にクエリ・ページ・国・デバイス・検索での見え方の表と、条件を記した「フィルタ.csv」が入っています。

    4. ページの条件はそのままで期間を後の期間に変え、もう一度書き出します。ファイル名には書き出した日付しか入らないので、保存したらすぐ「前期間」「後期間」が分かる名前に変えます。

    比べる2つの期間は長さをそろえ、対象URL・検索タイプ・国・端末を固定します。検索タイプは、上部の表示が「検索タイプ: ウェブ」でも、開くと「ウェブ」の下の「テキストベース」が選ばれていました。フィルタ.csvには「ウェブ」としか残らないので、画面で選んだ区分は自分で記録します。グラフの最新部分が点線なら暫定のデータなので、確定した期間で比べます。日別のデータは太平洋時間(PT)で集計されるため、日本時間のアクセス解析と日付だけで突き合わせると少しずれます(Google公式:概要と基本設定、データの不一致、確認日:2026年10月17日)。

    期間の「比較」のまま書き出さない

    期間の「比較」は画面で見比べるには便利ですが、書き出しには使いません。比較したまま書き出したZIPでは、前の期間に行がない検索語が、前期間側のクリック数・表示回数・CTR・掲載順位をすべて0にして出力されていました。掲載順位は1位以上の値なので、0位は実在しない値です。フィルタ.csvにも、比較した側の期間は残りませんでした。

    CSVの0が、本当のゼロとは限らない

    Googleの説明では、画面で「~」「-」と表示される項目は、ダウンロードしたデータでは0になります(エクスポートの注意)。CSVだけでは、本当のゼロと値がなかった項目を見分けられないことがあります。書き出す前に画面の表示を記録し、どちらか分からない値は、集計の入力では空欄にします。

    また、クエリ表には匿名化された検索語が出ず、表に出せる行数にも上限があります。取得したクエリ行を合計しても、ページ全体の合計と一致するとは限りません(表とグラフの差)。ページ側の合計と、取得できたクエリ行を別々に記録しておきます。

    運営サイトで書き出した結果:保留

    運営しているサイトの記事1件で、9日間ずつの2期間(2026年9月22〜30日と10月1〜9日)を書き出し、集計まで通しました。サイトを公開して間もないため、期間は短くしています。

    期間 ページ全体(ページ.csv) 書き出したクエリ行
    前期間 2クリック/4表示 0行
    後期間 1クリック/1表示 1行(1クリック/1表示)

    集計の結果は比較の保留で、理由は「観測クエリ集合が不一致」と「before: 行なし」でした。前の期間にもクリックはあったのに、どの検索語だったかは表に出てきません。ページ合計の減少だけでは検索語ごとの原因を判断できないので、私はこの記事を改稿の候補から外し、データがたまってから同じ条件で取り直すことにしました。

    保留の理由ごとに、次にすることは次のとおりです。

    保留の理由 次にすること
    片方の期間に行がない 期間を延ばして取り直す。流入が消えた、新しく表示された、とはこれだけで決めない
    数値に空欄がある 画面で該当の行の値を確かめる。分からなければ空欄のまま保留する
    表示回数が0 CTRと掲載順位は計算できない。0%として扱わない

    手順2:コードで集計する

    2つのZIPを、記事の最後に載せた gsc_to_input.py で共通の列のCSVに写し、aggregate.py で集計します。どちらもPython 3.10以上の標準ライブラリだけで動きます。手元でPythonを動かすのが難しければ、コードを実行できる環境のClaudeなどに実行させ、出てきた値を下の期待値と照らしてから次へ進みます。

    python3 gsc_to_input.py 前期間.zip 後期間.zip > input.csv
    python3 aggregate.py input.csv context.json > result.json

    context.json には、2つの期間の日付と、検索タイプ・国・端末・確定データかどうかを、画面の記録から書きます(形式は最後の付録にあります)。

    集計では、CTRを行ごとの率の平均ではなく、合計クリック数÷合計表示回数で計算し直します。掲載順位も、行ごとの順位を表示回数で重み付けして平均します。

    取得クエリ行のCTR(%)= 合計クリック数 ÷ 合計表示回数 × 100
    取得クエリ行の掲載順位 ≒ Σ(各行の掲載順位 × 表示回数)÷ 合計表示回数

    たとえば「60クリック/1,000表示」と「40クリック/400表示」の2行なら、CTRは100÷1,400×100=7.14%で、6%と10%を平均した8%にはなりません。Search Consoleの平均掲載順位は検索結果に出たときの最上位の位置を平均した値なので、取得した行から計算した順位は、画面のページ単位の順位と一致するとは限らない近似値として扱います。

    コードは入力の状態に応じて、次のように止まるか保留します。

    入力の状態 処理
    同じ期間・URL・検索語が重複 二重に数えないよう、入力エラーで止まる
    期間の長さや、検索タイプ・国・端末が違う/暫定データ 入力エラーで止まる
    片方の期間に行がない/数値が空欄 そのURLを保留にする。欠けた値をゼロで埋めない
    表示回数が0 CTRと掲載順位をnullにし、0%と区別する
    掲載順位が1未満・数値以外 入力エラーで止まる

    result.json では、URLごとに comparable(比較できたか)と hold_reasons(保留の理由)を見ます。source_rows から、検索語ごとの値と元CSVの行番号をたどれます。入力エラーで止まったら、空の出力をAIに渡さず、CSVと条件を直します。

    手順3:比較できた記事を、検索語の内訳で確かめる

    運営サイトでは比較できる記事がなかったので、比較できた場合の読み方を、練習用に用意した18行のCSV(7URL、URLはexample.com)で示します。期間は2026年8月14日〜9月10日と9月11日〜10月8日の28日ずつ、条件は日本・モバイル・ウェブのテキストベースです。比較できたのは7URL中3URLでした。

    対象URLの末尾 クリック(表示) CTR(%) 掲載順位
    /wordpress-backup/ 100→50(1,400→1,400) 7.14→3.57 1.94→3.63
    /wordpress-speed/ 100→120(2,500→3,000) 4.00→4.00 4.40→4.40
    /seo-basics/ 30→50(1,100→3,100) 2.73→1.61 7.41→7.79

    掲載順位は数値が大きいほど下位です。残りの4URLは、片方の期間に行がない、空欄がある、両期間とも表示0、のいずれかで保留になりました。

    合計だけを見ると、/seo-basics/ もCTRと順位が悪化しています。ところが、検索語ごとに見るとどちらも変わっていません。

    検索語 クリック/表示 CTR 掲載順位
    SEO 基本 20/100→20/100 20%のまま 1.5位のまま
    SEO とは 10/1,000→30/3,000 1%のまま 8.0位のまま

    CTRも順位も低い「SEO とは」の表示が3倍に増えたため、合計の値が引っ張られただけで、クリック数は20件増えています。合計CTRが下がったという理由だけで、タイトルを変える必要はありません。一方、/wordpress-backup/ は2つの検索語でそろってCTRが半分になり、「WordPress バックアップ 復元」の順位は1.8位から4.2位に下がっていました。

    手順4:AIに仮説と次の確認を出させ、元の行に戻す

    数値はコードで固定し、AIには集計結果、元のCSV、期間と条件、候補ページの本文を渡します。公開できない検索語やURLを含む場合は、利用するAIサービスに渡してよい範囲を先に決めておきます。

    目的:Search Consoleの2期間比較から、先に確認する記事を1件選ぶ。
    入力:元CSV、条件記録、コードで作った集計JSON、候補ページの本文。
    
    数値は集計JSONを使い、根拠を元CSVの行番号まで戻してください。
    URLごとに「観測した事実/改善仮説/代替説明/次に確認すること/
    限定作業の予定工数」を出し、候補は最大3件にしてください。
    
    行の欠落や空欄は0として扱わないでください。CTRは合計クリック÷合計表示で扱います。
    掲載順位は表示回数で重み付けした値を使い、検索語ごとの順位も見てください。
    CTRや順位が下がったことだけで、タイトル・本文・AI Overviewを原因と決めないでください。
    同じ語に複数URLがあることだけで競合と判断しないでください。
    検索ボリューム、売上、順位上昇幅は、入力にないので作らないでください。
    工数は見積もりと明記し、本文で確認できる不足とCTR低下の原因は分けて書いてください。
    データが足りないURLは保留にし、理由を残してください。

    練習データの集計結果・元CSV・条件と、/wordpress-backup/ の本文の見本を添えて、この依頼をClaudeに1回送りました(2026年10月11日)。Claudeは /wordpress-backup/ を第一候補にし、順位が1.94位から3.63位に下がっているので、CTR低下の一部はそれに伴う可能性があると整理しました。本文の不足として、復元の手順がないことに加えて、タイトルにも「復元」が入っていないことを挙げ、前の期間がお盆と重なることも代替説明に入れていました。

    AIの案は、そのまま採用せず、数値を result.json と、根拠の行番号を元CSVと照らします。照らしたうえで、私は次のように判断しました。

    URL 根拠 判断
    /wordpress-backup/ CSV2〜5行。クリック−50、CTR−3.57ポイント、順位1.94→3.63。2語ともCTRが半分 第一候補にする。原因の特定と全面改稿は、次の確認の後に回す
    /seo-basics/ CSV10〜13行。合計は悪化して見えるが、検索語ごとの値は同じ 改稿を急がず、表示が増えた状態が続くかを見る
    /wordpress-speed/ CSV6〜9行。クリックと表示が同じ割合で増え、CTRも順位も同じ 後回しにする。ほかの語や問い合わせなどの成果は別に見る

    AIが出した作業時間の見積もりは、自分の作業の速さで見積もり直すため、候補表から外しました。

    改善ブリーフを書く

    第一候補には、次の改善ブリーフを書いて、次の作業に渡します。判断の対象にした本文の見本は次のとおりです。

    タイトル:WordPressのバックアップ設定と保存先

    データベースとファイルを両方保存し、サーバー外にもコピーを置きます。保存日時と対象を記録してください。復元は現在の内容を上書きする可能性があるため、作業直前の状態も保存し、検証環境で確認します。復元方法はホスティングやプラグインで異なり、この記事にはサービス別の操作手順はありません。

    項目 内容
    対象・読者の問い /wordpress-backup/。「保存済みのバックアップから、どう安全に戻せるか」
    まず確かめる仮説 復元を目的とする読者に、方法の選び方が足りない。サービス別の手順が要るか、案内のリンクで足りるかを確かめる
    代替説明 検索結果の構成や他サイトの変化、表示タイトルの書き換え、季節(前の期間がお盆と重なる)、取得できていない行の変化
    次の確認 日別の推移、WordPressのリビジョン履歴(期間中に本文やタイトルを変えていないか)、URL検査、実際の検索結果での見え方
    残す情報 データベースとファイルの両方、サーバー外の保存、保存日時、上書きの注意、作業前の別保存、検証環境での確認
    最初の小さな作業 本文を直す前に、復元方法の選択肢と必要な根拠を整理する
    判断を変える条件 期間中に本文やタイトルを変えていた、検索結果全体の変動で説明できる、検索の意図が別、のどれかなら本文の追加を優先しない

    ブリーフの「残す情報」は、改稿で消してはいけない情報の一覧です。この先の、AIに既存記事へ書き足させて照合する手順は、既存記事に1節足す前に残す情報を決める記事で扱っています。

    直したら、変更した日と箇所を記録し、確定した同じ条件の期間で見直します。一度に何か所も直すと、どれが効いたのかを切り分けにくくなります。

    付録:コードと練習データ

    長いコードは折りたたんでいます。4つのファイルを同じフォルダにUTF-8で保存すると、練習データで上の結果を再現できます。Python 3.10と3.13で動作を確かめました。

    入力の列

    列 意味・扱い
    period beforeまたはafter。日付と条件は context.json に書く
    page 絞り込んだページの完全なURL。比較の間は表記を変えない
    query 検索語。同じ期間・URL・検索語は1行だけ
    clicks/impressions 0以上の整数。取得できなかった値は空欄、確かめたゼロは0
    position 1以上の数値。表示回数が0の行や取得できなかった値は空欄

    元のCSVにあるCTR列は使わず、クリック数と表示回数から計算し直します。

    input.csv(練習データ)

    period,page,query,clicks,impressions,position
    before,https://example.com/wordpress-backup/,WordPress バックアップ,60,1000,2.0
    before,https://example.com/wordpress-backup/,WordPress バックアップ 復元,40,400,1.8
    after,https://example.com/wordpress-backup/,WordPress バックアップ,30,1000,3.4
    after,https://example.com/wordpress-backup/,WordPress バックアップ 復元,20,400,4.2
    before,https://example.com/wordpress-speed/,WordPress 高速化,80,2000,4.0
    before,https://example.com/wordpress-speed/,WordPress 画像 軽量化,20,500,6.0
    after,https://example.com/wordpress-speed/,WordPress 高速化,96,2400,4.0
    after,https://example.com/wordpress-speed/,WordPress 画像 軽量化,24,600,6.0
    before,https://example.com/seo-basics/,SEO 基本,20,100,1.5
    before,https://example.com/seo-basics/,SEO とは,10,1000,8.0
    after,https://example.com/seo-basics/,SEO 基本,20,100,1.5
    after,https://example.com/seo-basics/,SEO とは,30,3000,8.0
    before,https://example.com/not-observed/,検索テスト,5,100,9.0
    after,https://example.com/first-observed/,新しいテスト,8,200,12.0
    before,https://example.com/zero/,ゼロのテスト,0,0,
    after,https://example.com/zero/,ゼロのテスト,0,0,
    before,https://example.com/incomplete/,欠測のテスト,4,100,7.0
    after,https://example.com/incomplete/,欠測のテスト,,100,7.5

    context.json

    web-text と query-page はこのコード用の記録名で、前者はウェブのテキストベース、後者はクエリ×ページを表します。finalized は、入力した人が確定データだと確かめた記録です。実データでは、フィルタ.csvと画面の記録に合わせて新しく作り、練習用の日付を残さないでください。

    {
      "before": {"start":"2026-08-14","end":"2026-09-10","search_type":"web-text","country":"jpn","device":"MOBILE","grain":"query-page","finalized":true},
      "after": {"start":"2026-09-11","end":"2026-10-08","search_type":"web-text","country":"jpn","device":"MOBILE","grain":"query-page","finalized":true}
    }

    aggregate.py

    コードを開く
    import csv
    import json
    import math
    import sys
    from collections import defaultdict
    from datetime import date
    
    COLUMNS = ["period", "page", "query", "clicks", "impressions", "position"]
    
    
    def to_count(value, line):
        """空欄は取得できなかった値(None)、確認済みのゼロは0として読む。"""
        if value == "":
            return None
        if value.isascii() and value.isdigit():
            return int(value)
        raise ValueError(f"非負整数または空欄が必要: CSV {line}行")
    
    
    def to_position(value, line):
        """掲載順位は1以上の数値。空欄は取得できなかった値(None)。"""
        if value == "":
            return None
        try:
            position = float(value)
        except ValueError:
            position = math.nan
        if not math.isfinite(position) or position < 1:
            raise ValueError(f"掲載順位は1以上の数値または空欄: CSV {line}行")
        return position
    
    
    def aggregate(csv_path, context_path):
        with open(context_path, encoding="utf-8") as f:
            context = json.load(f)
        if set(context) != {"before", "after"}:
            raise ValueError("期間はbeforeとafterの2つが必要")
        spans = []
        for period in ("before", "after"):
            c = context[period]
            start, end = date.fromisoformat(c["start"]), date.fromisoformat(c["end"])
            if end < start or c["finalized"] is not True:
                raise ValueError("期間の順序または確定状態が不正")
            spans.append((start, end))
        if ((spans[0][1] - spans[0][0]).days !=
                (spans[1][1] - spans[1][0]).days or spans[0][1] >= spans[1][0]):
            raise ValueError("期間の長さが不一致、または重複・逆順")
        for key in ("search_type", "country", "device", "grain"):
            if not context["before"][key] or context["before"][key] != context["after"][key]:
                raise ValueError("比較条件が不一致: " + key)
        if context["before"]["grain"] != "query-page":
            raise ValueError("query-page形式が必要")
    
        grouped = defaultdict(lambda: defaultdict(dict))
        with open(csv_path, encoding="utf-8-sig", newline="") as f:
            reader = csv.DictReader(f)
            if reader.fieldnames != COLUMNS:
                raise ValueError("CSV列が不正")
            for line, row in enumerate(reader, 2):
                if None in row.values():
                    raise ValueError(f"列が不足: CSV {line}行")
                p, url, query = row["period"], row["page"], row["query"]
                if p not in context or not url.startswith("https://") or not query:
                    raise ValueError(f"入力不正: CSV {line}行")
                if query in grouped[url][p]:
                    raise ValueError(f"query-page重複: CSV {line}行")
                clicks = to_count(row["clicks"], line)
                impressions = to_count(row["impressions"], line)
                if clicks is not None and impressions is not None and clicks > impressions:
                    raise ValueError(f"クリック数が表示回数を超過: CSV {line}行")
                grouped[url][p][query] = {"clicks": clicks, "impressions": impressions,
                                         "position": to_position(row["position"], line),
                                         "line": line}
    
        if not grouped:
            raise ValueError("データ行がない")
        output = []
        for url, periods in sorted(grouped.items()):
            reasons, totals = [], {}
            if set(periods.get("before", {})) != set(periods.get("after", {})):
                reasons.append("観測クエリ集合が不一致(欠落はゼロにしない)")
            for p in ("before", "after"):
                rows = list(periods.get(p, {}).values())
                blank = [str(r["line"]) for r in rows
                         if r["clicks"] is None or r["impressions"] is None
                         or (r["impressions"] > 0 and r["position"] is None)]
                if not rows or blank:
                    totals[p] = {"clicks": None, "impressions": None,
                                 "ctr_pct": None, "position": None}
                    reasons.append(p + (": 行なし" if not rows
                                        else ": 空欄あり(CSV " + "・".join(blank) + "行)"))
                    continue
                clicks = sum(r["clicks"] for r in rows)
                impressions = sum(r["impressions"] for r in rows)
                if impressions:
                    ctr = 100 * clicks / impressions
                    # 行ごとの掲載順位を表示回数で重み付けした近似値
                    position = sum(r["position"] * r["impressions"]
                                   for r in rows if r["impressions"]) / impressions
                else:
                    ctr = position = None
                    reasons.append(p + ": 表示回数ゼロでCTR・順位を計算できない")
                totals[p] = {"clicks": clicks, "impressions": impressions,
                             "ctr_pct": ctr, "position": position}
            comparable = not reasons
            delta = {}
            if comparable:
                before, after = totals["before"], totals["after"]
                for k in ("clicks", "impressions"):
                    delta[k] = after[k] - before[k]
                delta["ctr_pp"] = after["ctr_pct"] - before["ctr_pct"]
                delta["position"] = after["position"] - before["position"]  # 正は下位へ
            output.append({"page": url, "totals": totals, "delta": delta,
                           "comparable": comparable, "hold_reasons": reasons,
                           "source_rows": dict(periods)})
        return output
    
    
    if __name__ == "__main__":
        try:
            result = aggregate(sys.argv[1], sys.argv[2])
            print(json.dumps(result, ensure_ascii=False, indent=2))
        except (ValueError, KeyError, IndexError) as e:
            sys.exit("入力を確認してください: " + str(e))

    練習データで実行すると、comparable は3URLが true、4URLが false になり、/wordpress-backup/ の delta は次の値になります。

    {"clicks": -50, "impressions": 0, "ctr_pp": -3.5714285714285716, "position": 1.6857142857142857}

    gsc_to_input.py

    期間を比較せず、ページ1件で絞り込んで書き出した前後2つのZIPを、aggregate.py の入力に写します。比較のまま書き出したZIPは列の形が違うので、受け付けずに止まります。表示回数が0の行は、順位を空欄にします。

    コードを開く
    """Search Consoleの書き出し(ZIP)を、aggregate.pyの入力行に写す。
    
    期間比較なし・ページ1件で絞り込んで「CSVをダウンロード」した前後2つのZIPを使う。
    """
    import csv
    import io
    import sys
    import zipfile
    
    QUERY_COLUMNS = ["上位のクエリ", "クリック数", "表示回数", "CTR", "掲載順位"]
    
    
    def read_csv(z, name):
        with z.open(name) as f:
            return list(csv.reader(io.TextIOWrapper(f, encoding="utf-8-sig", newline="")))
    
    
    def convert(zip_path, period):
        with zipfile.ZipFile(zip_path) as z:
            filters = dict(read_csv(z, "フィルタ.csv")[1:])
            rows = read_csv(z, "クエリ.csv")
        page = filters.get("ページ", "")
        if not page.startswith("https://"):
            raise ValueError(f"{zip_path}: ページ1件で絞り込んだ書き出しが必要")
        if rows[0] != QUERY_COLUMNS:
            raise ValueError(f"{zip_path}: クエリ.csvの列が想定と違う(期間比較の書き出しは使わない)")
        out = []
        for query, clicks, impressions, _ctr, position in rows[1:]:
            if impressions == "0":
                position = ""  # 表示0の行には順位がない
            out.append([period, page, query, clicks, impressions, position])
        return filters, out
    
    
    if __name__ == "__main__":
        if len(sys.argv) != 3:
            sys.exit("使い方: python3 gsc_to_input.py 前期間.zip 後期間.zip > input.csv")
        try:
            (fb, before), (fa, after) = convert(sys.argv[1], "before"), convert(sys.argv[2], "after")
            if {k: v for k, v in fb.items() if k != "日付"} != {k: v for k, v in fa.items() if k != "日付"}:
                raise ValueError("前後で日付以外のフィルタが違う")
        except (ValueError, KeyError, OSError, zipfile.BadZipFile) as e:
            sys.exit("書き出しを確認してください: " + str(e))
        for name, f, rows in (("before", fb, before), ("after", fa, after)):
            print(f"{name}: {f.get('日付')} / 検索タイプ {f.get('検索タイプ')} / クエリ {len(rows)}行", file=sys.stderr)
        writer = csv.writer(sys.stdout, lineterminator="\n")
        writer.writerow(["period", "page", "query", "clicks", "impressions", "position"])
        writer.writerows(before + after)
  • ChatGPT dotsの調査レポートを一次資料と照合する:時点・数値・前提の順に確かめる手順

    ChatGPT dotsの調査レポートを一次資料と照合する:時点・数値・前提の順に確かめる手順

    AIに毎日の調査レポートを作らせると、出典付きの整った文章が届きます。それを自分の判断に使う前に、どこまで信じてよいのかを確かめたくなります。数字が合っているかは一次資料に戻せば確かめられますが、数字がすべて合っていても、レポートが前提にしている「自分の状況」が実際と違えば、そのまま判断には使えません。

    私はChatGPT dotsに米株・世界ニュース・日本株のレポートを毎日頼んでいます。2026年10月9日分の3本から、結論の出発点になる4件の数値(会社の業績予想、供給契約の金額、家計調査、原油生産の停止率)を選んで一次資料と照らすと、4件とも一致しました。ずれていたのは数値ではなく前提でした。私はその日の朝にローツェ株を100株買っていましたが、レポートはそれを知らないまま、夜版でその日の値上がりを「取り逃し」と記録していました。

    この記事では、時点をそろえる、数値を一次資料へ戻す、計算と仮定を分ける、自分の現状が前提に入っているかを見る、の順で照合する手順を、この10月9日分の実例で示します。銘柄と売買は私自身の記録で、照合の手順を示す題材として使います。読み終えたら、届いたレポートのどこまでを判断に使ってよいかを決め、食い違いを原文つきで記録できます。

    照合の順番

    照合は次の4段で進めます。上から順に確かめると、後の段で見つけたずれの原因を、前の段に戻って切り分けられます。

    段 確かめること 合わなかったときにすること
    1. 時点 いつの情報で書かれたか(確認締切、価格の時刻) その時点の資料だけで照合し直す
    2. 数値と出典 重要な数値が一次資料の値・単位・期間と一致するか 原文と資料を並べて記録し、結論への影響を確かめる
    3. 計算と仮定 計算式が合っているか、式に入れた仮定は何か 計算の誤りと、仮定の置き方の違いを分ける
    4. 自分の現状 レポートの前提に、自分の行動や状況が入っているか 現状を伝えて分析を頼み直す
    レポートの時点、数値と出典、計算と仮定、自分の現状を順に確かめる流れ
    数値がすべて一致しても、4段目で前提がずれていれば判断には使えません。

    全項目を同じ深さで調べると、毎日の確認が続きません。2段目は、結論の出発点になる数値から数件を選んで照らします。

    1. 時点:いつの情報かをそろえる

    10月9日分の3本には、それぞれ情報の確認締切が書かれていました。

    レポート 書かれていた確認締切(日本時間)
    米株版 10月9日 8時50分台
    世界ニュース版 10月9日 8時51分
    日本株夜版 10月9日 19時44分

    まずこの時点へ戻り、対象の市場・取引日・資料の公表時刻を合わせます。後から出た発表を使って朝のレポートを採点すると、判断の良し悪しを取り違えます。

    価格には、さらに「取引が成立した時刻」と「情報を取得した時刻」があります。米株版は、ローツェの夜間取引(PTS)の価格について、最終約定が10月8日23時58分、再取得が9日の朝8時50分と分けて書いていました。朝に取り直しても、価格の時点は前夜のままです。照合では、どちらの時刻の価格かを先に確かめます。

    2. 数値を一次資料へ戻す

    結論の出発点になる4件を選び、一次資料と照らしました(2026年10月9〜10日に照合)。

    大阪有機:連結予想と単体予想を取り違えない

    日本株夜版は、大阪有機化学工業の業績予想の修正を取り上げていました。会社資料の1ページにある、2026年11月期・通期・連結の表と照らします。同じページに単体の表もあるので、見出しから確かめます。

    項目 修正前 → 修正後
    売上高予想 390億円 → 425億円
    営業利益予想 75億円 → 93億円
    親会社株主に帰属する当期純利益予想 52億円 → 66億円
    1株当たり当期純利益予想(EPS) 255.50円 → 325.00円

    4項目ともレポートと一致しました。金額は資料の百万円を億円に換算しています。会社の修正資料・1ページ

    ここで確かめたのは会社の予想で、達成した実績や、レポートが独自に置いた翌期の予想とは別のものです。「EPS325円」だけを抜き出すと、その区別が消えます。

    GlobalFoundries:20億ドルは誰と誰の合意か

    米株版が取り上げた、GlobalFoundriesとTSMCのシリコンインターポーザー供給について、10月8日の公式発表を確認しました。20億ドル、当初5年間の合意、2028年上期に生産を立ち上げる計画、生産拠点が米ニューヨーク州Maltaという記載が一致しています。GlobalFoundries公式発表

    この金額はGFとTSMCの合意のものです。日本の装置メーカーへの発注額と読み替えるには、取引関係や発注の時期を示す別の資料が要ります。元のレポートもこの点を分けていました。数字と一緒に、主体と契約の範囲を残します。

    家計調査:前年比と前月比を分ける

    世界ニュース版が取り上げた、2026年8月分の二人以上の世帯の消費支出です。

    指標 レポート・公式資料の値
    1世帯当たり消費支出 310,975円
    実質・前年同月比 −3.1%
    名目・前年同月比 −1.0%
    実質・季節調整済み前月比 +0.1%

    いずれも一致しました。前年と比べると減り、前月と比べると小幅に増えています。この2つは両立します。「消費が増えた/減った」と縮める前に、比べた期間と物価調整の有無を見ます。総務省・家計調査2026年8月分、1ページ

    このリンク先は最新月に置き換わるPDFです。後日確かめるときは、表紙が2026年8月分かを見てください。

    原油生産:停止率の分母を確かめる

    米当局の10月8日の発表では、同日11時(米中部夏時間)時点の事業者報告をもとに、メキシコ湾(米国の呼称はアメリカ湾)の日々の原油生産の62.89%、日量1,282,879バレルが停止中と推計しています。レポートの数値と一致しました。米当局の発表(BSEE掲載)

    分母はこの海域の日々の生産です。米国全体や累計の量に広げず、一時点の停止の推計として読みます。割合を確かめるときは、分母と集計の時点を一緒に残します。

    3. 計算と、計算に入れた仮定を分ける

    10月9日夜版は、ローツェについて、利益の見通しと倍率から独自に置いた基準価格5,670円と、当日の東証終値5,134円を比べていました。終値は、日付つきの株価履歴2つで確かめました。Yahoo!ファイナンス、株探

    基準価格までの余地 =(5,670 ÷ 5,134 − 1)× 100 ≒ 10.44%

    計算は合っていました。夜版はこの10.44%を、当時の私の運用で置いていた「長期で15%」という選別基準に照らし、届かないので追わない、と書いていました。

    ただし、5,670円はレポートが置いた評価で、計算が合っていても評価の前提が正しいとは限りません。確かめる対象を分けると、次のようになります。

    確かめる対象 見ること
    過去の価格 日付・市場・終値が合っているか
    計算 入力した値と式、丸め方が合っているか
    評価の前提 利益や倍率を置いた根拠と、反対の材料があるか
    想定期間 どの期間の業績で評価し、いつまでの判断に使うか

    年単位の利益見通しから置いた評価を、そのまま数週間後の到達価格として読まないこと。予測値を判断に使うときは、計算の一致と前提の確かさを分けて記録します。

    4. 自分の現状が、前提に入っているか

    ローツェ:買ったことを、レポートは知らなかった

    10月8日の夜版と9日の朝版は、ローツェの値上がりを追わない判断でした。一方で私は、10月9日の東証の寄り付きで100株を買い、翌10日の時点でも持っていました。この日の終値は5,134円、前日比700円高のストップ高です。

    その日の夜版には、次の一文がありました(レポートの原文の一部)。

    朝の始値4,634円からの上昇機会は取り逃しとして記録する一方、後から作った評価を昨日の推奨へ遡及させない。

    私は購入をdotsに伝えていなかったので、レポートは「まだ買っていない人」を前提に、新しく検討する銘柄として書いていました。2段目で照らした数値はどれも合っていたのに、私にとっての「取り逃し」は起きていません。ずれは数値ではなく、レポートの前提に入っていない私の行動の側にありました。

    分けて読む記録 この例で分かっていること
    当時のレポート 前夜・当日朝は追わない判断。夜版は見送りの振り返りとして値上がりを「取り逃し」と記録
    私の行動 10月9日の寄り付きで100株を購入し、10日の時点で保有
    市場の価格 10月9日の始値4,634円、終値5,134円

    市場の始値は、私の約定単価の証拠にはなりません。また、株価が上がった結果だけで、私の判断を正解としたり、レポートを失敗としたりもできません。当時の条件、どの価格なら判断が変わったか、実際に何をしたかを並べて振り返ります。

    放電精密:買った価格が、分析の条件と違っていた

    前提のずれは、その数日前にもありました。放電精密の決算と上方修正を見て、私は発表翌日に上がると予想し、10月7日の午前に3,125円で100株を買いました。翌8日に2,735円で100株すべてを売り、税・手数料を除いて39,000円の損失になりました。

    1株当たりの差 = 2,735 − 3,125 = −390円
    100株の売却損 = 390 × 100 = 39,000円(税・手数料を含めない)

    このときの元の分析は、10月6日夜の時点で3,040円を上限とし、値動きの反転を確かめることなどを条件にした検討でした。私が買った3,125円は、その上限より高い価格です。購入後の7日夜には、終値が支えになる価格帯を下回ったため、レポートは新規購入の見送りに変わっていました。「翌日は上がる」という私の予想と、レポートが置いた価格と条件は、別の判断だったことになります。

    2つの例から言えるのは、レポートは伝えたことしか前提にできない、ということです。購入した、売った、案件を受けた、といった自分の行動は、伝えない限りレポートの記録に入りません。

    現状を伝えて、前提を点検してもらう

    自分の行動のあとに分析を頼むときは、実際の値を渡して、以前の前提との差を確かめてもらいます。

    対象:[銘柄や案件・参照するレポートの日付]
    実際の行動:[購入・契約など]、単価:[価格]、数量:[数量]
    日時:[確認できる時刻。分からなければ「不明」]
    以前のレポートが置いた価格・条件:[該当箇所]
    
    以前の前提と、実際の行動の差を示してください。
    価格に依存する計算と、成り立たなくなった条件を確かめてください。
    足りない情報は推測せず、追加で必要な情報を示してください。
    注文などの操作は行わず、元のレポートを残して、別名の確認メモにまとめてください。

    定期レポートの依頼文そのものに「自分の現状」の欄を作っておく方法もあります。そのテンプレートは、dotsの定期レポートをPCに保存する記事の依頼文に入れました。

    食い違いを見つけたら、原文を残して記録する

    今回選んだ4件の数値には、直す必要のある食い違いはありませんでした。別の日に食い違いが出たら、元のレポートを書き換えず、原文と照合先を並べて、結論への影響まで記録します。

    対象:[レポート名・対象日・該当する見出し]
    原文:[該当する一文]
    照合先:[一次資料のURL・資料の日付・ページや表の名前]
    差:[数値、単位、時点、実績か予想か、のどこが違うか]
    影響:[計算し直しや見直しが必要な箇所]
    
    その資料が、レポートの確認締切までに手に入ったかを確かめてください。
    後日訂正された資料なら、当時の誤りと後日の更新を分けてください。
    根拠が足りない部分は未確認とし、結論への影響を示してください。
    元のファイルは変更せず、別名の確認メモに保存してください。

    直した数字だけでなく、その数字を使った比較や結論まで読み直します。記録が残っていれば、次のレポートで同じ前提を使ってよいかも判断しやすくなります。

  • ChatGPT dotsの定期レポートをPCのObsidianに保存する:届いたかと保存できたかを分けて確かめる依頼文と確認場所

    ChatGPT dotsの定期レポートをPCのObsidianに保存する:届いたかと保存できたかを分けて確かめる依頼文と確認場所

    ChatGPT dotsに毎日の調査レポートを任せると、決まった時刻にチャットへ届きます。後で同じ場所から探せるように、ObsidianのVaultなど自分のPCのフォルダにも残しておきたくなります。ところが、チャットに届いたことと、PCにファイルが残ったことは別の出来事です。片方だけを見ていると、保存されていない日に気づけません。

    私は米株・世界ニュース・日本株の3本のレポートをdotsに頼み、Obsidianに保存しています。2026年10月9日には、朝はPCがつながっていないあいだに届いて保存が後回しになり、夜は保存先のフォルダを読めるのに書き込みの判定がfalseで、正式な権限の申請が通るまで保存できませんでした。PCに接続できていることと、そのフォルダに保存できることは、別々に確かめる必要があります。

    この記事では、配信済み・保存済み・読み戻し済みを分けて報告させる依頼テンプレートと、登録した定期作業をScheduled・Activity・保存先の3か所で照合する方法をまとめます。読み終えたら、保存まで確かめられる定期レポートを設定でき、止まった日に「PCの接続待ち」か「書き込みの権限待ち」かを見分けられます。

    完成形:届いた・保存した・読み戻したを分けて報告させる

    dotsの調査はクラウドで進み、PCへの保存は接続したPCの上で行われます。公式の説明でも、PCを使っている間は、そのPCをオンラインにし、ChatGPTアプリを開いておく必要があるとされています(PC接続の公式説明、確認日:2026年10月10日)。

    そこで、1本のレポートについて次の3つを別々に報告させます。

    報告させる状態 何が済んだか 自分で見る場所
    配信済み レポートがチャットに届いた dotsとのチャット
    保存済み 指定したフォルダにファイルができた 保存先のフォルダ
    読み戻し済み 保存したファイルを末尾まで読み、配信した本文と一致した dotsの報告と、ファイルの末尾

    3つが分かれていれば、「届いたが保存はまだ」の日を、成功の日と取り違えずに済みます。

    クラウドでの調査とチャットへの配信、接続中のPCへの保存と読み戻しを分けて示した説明図
    調査と配信はクラウド、保存と読み戻しは接続中のPCで行われます。

    手順1:レポートを分け、届けたい時刻を決める

    1つの依頼に全部を詰め込まず、情報が更新される周期に合わせてレポートを分けます。私の運用は次の3本です。

    レポート 届けたい時刻と曜日 調べる範囲
    米株材料 火〜土の朝10時(米国の休場明けは休み) 直近の米国の通常取引時間。決算、見通し、設備投資、規制
    世界ニュース 毎朝10時 直近24時間の為替、金利、統計、資源、政策
    日本株夜版 毎日20時 当日の決算・適時開示と、前回からの変化

    夜版を20時にしたのは、東証の取引が終わり、その日の仕事も片づいてから読みたいからです。朝の2本は、自分が落ち着いて読める10時にしました。決め方の基準は、早く届くことより、自分が読む時間に揃っていることです。

    ここで書く時刻は、調査を始める時刻ではなく、届けてほしい時刻です。「10時に調べ始める」と「10時までに届ける」では、届く時刻が変わります。依頼では配信目標として書き、必要な調査は前もって進め、配信の直前に追加の発表を確かめるよう頼みます。予定の時刻と、実際に届いた時刻は分けて記録します。

    手順2:依頼テンプレートで、保存と報告まで指定する

    元の依頼と、10月9日の保存のやり取りで確かめた項目をもとに、個人の情報を外して組み直したテンプレートです。[ ] を埋め、レポート1本ごとに送ります。

    コピー用の依頼文を開く
    出典付きの調査レポートを定期作成してください。
    同種の既存スケジュールがあれば設定を先に示し、重複登録しないでください。
    
    【今回設定する1本】
    レポート名:[例:世界ニュース]
    調査範囲と対象期間:[例:直近24時間の為替・金利・統計・資源・政策]
    実行曜日:[曜日]
    タイムゾーン:Asia/Tokyo
    開始日:[YYYY-MM-DD]
    終了:[YYYY-MM-DD または終了日なし]
    配信目標:[10:00など]。調査開始時刻ではありません。
    休日の扱い:[休み/変化だけの短縮版など]
    必要な調査を事前に進め、配信前に主要な追加情報を確認してください。
    登録できない部分は、未設定として報告してください。
    
    【調査条件】
    目的と読む人:[このレポートで何を判断するか]
    優先する対象と条件:[自分の調査条件]
    自分の現状(伝えたい場合のみ):[保有・進行中の案件など。不明なら不明]
    企業IR・取引所・政府統計などの一次資料を優先し、出典URLを付けてください。
    事実、発表元の予想、外部予想、独自の仮定を分けてください。
    前回からの重要変更、解釈の根拠、反証、未確認事項を示してください。
    材料が足りない日は、その旨を示し、結論を無理に作らないでください。
    人の行動や現状を推測しないでください。注文・契約・送信などの操作は行わないでください。
    
    【出力】
    チャット冒頭に重要変更と、人が次に確認することを短く示してください。
    詳細は出典URL付きのMarkdownファイルにしてください。
    対象日、対象期間、実際の情報確認締切を明記してください。
    
    【配信と保存】
    配信先:このチャット
    保存先:[接続PC上の保存先フォルダの絶対パス]
    ファイル名:[YYYY-MM-DD_レポート名.md]
    YYYY-MM-DDは日本時間のレポート対象日としてください。
    保存後に全文を読み戻し、配信した本文と一致することを確認してください。
    PCが使えない場合は、本文のチャット配信を続け、保存未完了と示してください。
    書き込みの権限が必要な場合は、権限を申請し、申請中であることを報告してください。
    接続や権限が整ったあとに保存を再開する方法と、必要な操作を報告してください。
    同名ファイルが同一内容なら重複作成せず、異なる場合は上書き前に差分を報告してください。
    
    【登録後の報告】
    登録した曜日・時刻・タイムゾーン・休日の扱い・次回実行を示してください。
    配信済み/保存済み/読み戻し済みを分けて報告してください。
    未設定や未完了を、成功として扱わないでください。

    「自分の現状」の行は、レポートを判断に使うときの前提になります。今回、購入済みの状況を伝えていなかったレポートには、まだ購入していないことを前提にした記述がありました(実例は記事の最後で触れます)。現状を伝えない場合は、不明なものとして扱うよう依頼します。

    ファイル名には対象日とレポート名を入れます。私は YYYY-MM-DD_米株情報.md、YYYY-MM-DD_世界のニュース.md、YYYY-MM-DD_日本株情報_20時.md の形にしています。日付が先頭にあると、Obsidianのファイル一覧が日付順に並び、抜けた日がすぐ分かります。

    手順3:登録後に、3か所を照合する

    依頼を送ったら、最初の1回分が届くまでに、次の3か所を確かめます。決まった時刻の繰り返しには、dotsに保存されたスケジュールが必要です(定期作業の公式説明)。

    見る場所 確かめること
    Scheduled 次回の登録があり、曜日・時刻・タイムゾーン・指示が依頼どおりか
    Activity 対応するタスクが動いたか、何を出力したか、何の入力を待っているか
    保存先のフォルダ 対象日のファイルがあり、本文を末尾まで読めるか

    Scheduledの時刻が「配信目標」ではなく「調査開始」として登録されていたら、依頼を直します。最初の1回分は、保存されたファイルを開いて末尾までそろっているかも見ておきます。止めたいときは、Activityで現在の実行を、Scheduledで次回以降の予定を、それぞれ止めます(操作の公式説明)。

    止まったとき:接続待ちか、書き込みの権限待ちか

    10月9日の2回の止まり方は、見る場所が違いました。

    朝:届いたが、PCがつながっていなかった

    米株と世界ニュースの朝版は、当時の朝9時の設定で動き、Macがつながっていないあいだに届きました。dotsからは、PCがオフラインなのでローカルには保存していない、と報告がありました。Macを開いて保存を頼み直すと、12時半ごろに2本とも保存と全文の読み戻しまで済んでいます(その後、朝の2本は10時の設定に変えました)。

    このときは「配信済み・未保存」の状態でした。PCをつなげば再開できるので、どのファイルを保存するかが分かるよう、本文をチャットに残させておくことが大事です。

    夜:フォルダは読めるのに、書き込めなかった

    日本株の夜版では、保存の前に19時44分の時点で、保存先の「日本株情報」フォルダは存在し、読み取りができると報告されました。続く一文は次のとおりです(dotsの報告の原文)。

    現在の制限付き実行環境では書き込み判定は false。保存時には従来どおり正式な権限申請が必要です。

    フォルダは見えていて、PCにも接続できている。それでも、保存を行うタスクには書き込みの権限がまだありませんでした。私の場合、この権限がなかなか通らず、MacのChatGPTアプリを更新して再起動したあとに通りました。19時47分に保存と全文の読み戻しが終わり、保存した本文と読み戻した本文の一致が報告されています。

    2つの止まり方を見分けるには、次の表で確かめます。

    状態 dotsの報告や画面に出ること 次にすること
    接続待ち PCがオフライン、ローカルには保存していない PCを開き、ChatGPTアプリを起動して、保存を頼み直す
    書き込みの権限待ち フォルダは読めるが、書き込みの判定がfalse、権限の申請が必要 タスクを開いて権限の要求に応える。通らなければアプリの状態を確かめる

    接続の状態が「オンライン」でも、保存先へ書き込めるとは限りません。止まった日は、Activityでタスクの停止理由を開き、どちらの待ちなのかを読みます。

    届いたレポートを使う前に

    保存できたレポートは、次に中身を確かめます。数字や出典を一次資料へ戻し、自分の現状が前提に入っているかを見る手順は、dotsの調査レポートを一次資料と照合する記事にまとめました。10月9日の夜版には、数字は正しいのに、私がその日の朝に買っていたことを知らないまま書かれた記述がありました。

  • AIにリファクタリングを任せる前に、テストが何を守っているかを、わざと壊した版で確かめる

    AIにリファクタリングを任せる前に、テストが何を守っているかを、わざと壊した版で確かめる

    重複したコードの共通化のようなリファクタリングは、AIに任せやすい仕事です。挙動を変えない変更なので、既存のテストが全部通れば終わりにしたくなります。ただ、テストが全部通ったことは、テストが見ている挙動が変わらなかったことしか示しません。守りたい挙動をテストが本当に見張っているかは、別に確かめる必要があります。

    請求書と見積で重複していた明細の金額計算を、Codexに1つの関数へまとめさせました。既存の8件のテストはすべて合格です。そのうえで、明細ごとに丸めていた計算を「合計してから丸める」に変えた版をわざと作って走らせると、落ちたのは請求書側の1件だけでした。見積側だけを同じように壊した版では、8件が1件も落ちません。見積の丸め順序を見張るテストはなかったのです。そしてCodexの完了報告は、「未テストの挙動」としてまさにその見積の複数明細を挙げていました。

    この記事では、守りたい挙動を1つずつ壊してテストを走らせ、どのテストが落ちるかを確かめる手順を、この実例で示します。読み終えたら、AIにリファクタリングを任せる前に、どの挙動のテストを足しておくべきかを判断できます。

    題材:請求書と見積の明細計算を1つにまとめる

    元のコードでは、invoice()(請求書)と quote()(見積)が同じ明細の計算を2回書いていました。どちらも明細ごとに金額を小数第2位で丸め(四捨五入)、それを合計します。

    from decimal import Decimal, ROUND_HALF_UP
    def invoice(lines):
        total=Decimal("0")
        for price, quantity in lines:
            total+=(Decimal(price)*quantity).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)
        return format(total,".2f")
    def quote(lines):
        total=Decimal("0")
        for price, quantity in lines:
            total+=(Decimal(price)*quantity).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)
        return {"amount":format(total,".2f"),"currency":"JPY"}

    Codex CLIの新しいセッションに、重複した明細の計算を非公開の補助関数に抜き出すこと、シグネチャ・出力・例外・Decimalの丸めを保つこと、既存テストを変えないこと、まだテストしていない挙動を報告することを、英語の仕様書で1回依頼しました(2026年9月21日、Python 3.13)。Codexは _line_amount() を追加して両方から呼ぶ形にし、変更は billing.py だけ、既存の8テストはすべて合格と報告しました。

    def _line_amount(price, quantity):
        return (Decimal(price)*quantity).quantize(Decimal("0.01"),rounding=ROUND_HALF_UP)

    既存の8テストは次のとおりです。7件が invoice()、1件が quote() を見ています。

    import unittest
    from billing import invoice, quote
    class Compat(unittest.TestCase):
        def test_empty(self): self.assertEqual(invoice([]),"0.00")
        def test_whole(self): self.assertEqual(invoice([("10",2)]),"20.00")
        def test_half(self): self.assertEqual(invoice([("0.005",1)]),"0.01")
        def test_per_line(self): self.assertEqual(invoice([("0.005",1),("0.005",1)]),"0.02")
        def test_negative(self): self.assertEqual(invoice([("-0.005",1)]),"-0.01")
        def test_quantity(self): self.assertEqual(invoice([("0.005",2)]),"0.01")
        def test_quote(self): self.assertEqual(quote([("2.555",1)]),{"amount":"2.56","currency":"JPY"})
        def test_generator(self): self.assertEqual(invoice((x for x in [("1",1)])),"1.00")

    手順1:守りたい挙動を1つずつ書き出す

    最初に、このリファクタリングで変わってはいけない挙動を、関数ごとに書き出します。今回の題材なら、たとえば次の3つです。

    • 請求書:明細ごとに丸めてから合計する

    • 見積:明細ごとに丸めてから合計する

    • 見積:戻り値の形(amount と currency の辞書)

    共通化で崩れやすいのは、関数どうしで同じに見えていた計算の細部です。とくに金額の丸め、権限の判定、日付の境界のように、間違えるとお客様や利用者に直接影響する箇所を優先して書き出します。全部を書き出す必要はありません。

    手順2:その挙動だけを壊した版を作る

    書き出した挙動ごとに、その挙動だけが変わる版を手で作ります。丸めの順序なら、「明細ごとに丸める」を「合計してから丸める」に変えます。2つの計算が本当に違う値を出すかは、先に小さなコードで確かめておきます。次を rounding_check.py として保存し、python3 rounding_check.py で実行します。

    from decimal import Decimal, ROUND_HALF_UP
    
    CENT = Decimal("0.01")
    
    def per_line(lines):
        return sum(
            ((Decimal(p) * q).quantize(CENT, rounding=ROUND_HALF_UP)
             for p, q in lines),
            Decimal("0"),
        )
    
    def after_total(lines):
        total = sum((Decimal(p) * q for p, q in lines), Decimal("0"))
        return total.quantize(CENT, rounding=ROUND_HALF_UP)
    
    lines = [("0.005", 1), ("0.005", 1)]
    print(f"明細ごと: {per_line(lines):.2f}")
    print(f"合計後: {after_total(lines):.2f}")
    assert per_line(lines) == Decimal("0.02")
    assert after_total(lines) == Decimal("0.01")
    明細ごと: 0.02
    合計後: 0.01

    0.005円の明細が2つあると、明細ごとに丸めれば0.02、合計してから丸めれば0.01になります。式は似ていても、出力が変わる変更です。どちらの丸め方が正しいかは業務の規則で決まることで、ここで見たいのは「今の挙動から変わったら、テストが気づくか」です。

    壊した版は、billing.py を一時的なコピーで書き換えて作ります。元のファイルやAIの成果物は書き換えません。

    手順3:既存テストを走らせ、どのテストが落ちるかを見る

    壊した版ごとに、既存の8テストを走らせました。

    走らせた版 既存8テストの結果 分かること
    Codexが共通化した版 8件すべて合格 8件が見ている挙動は変わっていない
    請求書・見積の両方を合計後の丸めに変えた版 7件合格、test_per_line の1件だけ失敗 請求書の丸め順序は、この1件が見張っている
    見積だけを合計後の丸めに変えた版 8件すべて合格 見積の丸め順序を見張るテストはない

    3行目の版に2明細(0.005円×2)の見積を渡すと 0.01、Codexが共通化した版では 0.02 が返ります。値は変わっているのに、8件はすべて合格しました。見積のテスト test_quote は明細が1つだけで、1明細では丸めの順序を変えても金額が変わらないからです。

    守りたい挙動ごとに壊した版を作り、既存8テストのどれが落ちるかを並べた結果の図
    見積だけを壊した版では、8件のどれも落ちませんでした。

    テストの件数が8件あっても、請求書の丸め順序を見張っていたのは1件、見積の丸め順序を見張っていたのは0件でした。もし共通化の途中で見積だけ計算の順序が変わっていたら、8件の合格はそれを知らせなかったことになります。

    手順4:AIの「未テストの挙動」を、壊す対象の候補にする

    Codexの完了報告には、次の一覧がありました(報告の原文の一部)。

    未テストの挙動:

    • 不正な価格・数量・明細構造に対する例外互換性

    • quote() の空入力、複数明細、負数、ジェネレーター入力

    • 非常に大きい値や特殊な Decimal コンテキスト

    2行目の「quote() の…複数明細」は、手順3で見つけた穴そのものです。今回、依頼でまだテストしていない挙動の報告を頼んだところ、AIは見積側の複数明細を挙げていました。この一覧を、手順1で書き出す挙動の候補として使えます。

    使い方は次のとおりです。

    1. 報告の「未テストの挙動」を、手順1の一覧に加える。

    2. 加えた挙動ごとに手順2・3で壊した版を作り、既存テストが落ちるかを見る。

    3. どのテストも落ちなかった挙動に、テストを1件足す。

    今回なら、見積の複数明細のテストを足します。

    import unittest
    from billing import quote
    class QuoteRounding(unittest.TestCase):
        def test_quote_per_line(self):
            self.assertEqual(quote([("0.005",1),("0.005",1)]),{"amount":"0.02","currency":"JPY"})

    このテストは、Codexが共通化した版では合格し、見積だけを壊した版では失敗しました。足したテストが本当に穴をふさいだかも、壊した版で落ちることまで見て確かめます。

    報告に挙がった挙動のすべてが、守りたい挙動とは限りません。「非常に大きい値」のように今の業務で起きない入力は、一覧に残して後回しにしてかまいません。

    任せる前に、テストを補うかを決める

    手順3の表で「どのテストも落ちなかった挙動」が残ったら、次のどちらかを選びます。

    • テストを足してから任せる:金額・権限・日付のように、変わると困る挙動。今回の見積の丸め順序はこちらです。

    • そのまま任せて、差分を読む:変わっても影響が小さい挙動。リファクタリングの差分で、その箇所に手が入っていないかを目で確かめます。

    変更が大きくなるときも、同じ確かめ方を「抜き出す」「呼び出し元を置き換える」「古い経路を消す」のような段階ごとに挟むと、どの段階で挙動が変わったかを追えます。どの段階でも、合格の件数ではなく、守りたい挙動ごとに見張っているテストがあるかで判断します。

    出典と確認日

  • AIの設計案を別のセッションで実装させる前に、原仕様にない制約を見分け、引き継ぎ票で分けて渡す

    AIの設計案を別のセッションで実装させる前に、原仕様にない制約を見分け、引き継ぎ票で分けて渡す

    AIのあるセッションで設計案を作り、実装は新しいセッションや別のAIに任せたい。そのとき受け手が読むのは、承認済みの仕様と、設計者が足した提案が混ざった1枚の設計案です。原仕様にない一文が承認済みの要件と同じ顔で並んでいると、受け手はそれを要件として実装します。

    Codexに在庫予約機能の設計案を作らせ、その設計案を原仕様と一緒に別のセッションへ渡して実装させたところ、まさにそれが起きました。設計案は8つの要件すべてに触れ、boolが数量として通ってしまうリスクも正しく指摘していましたが、その対策の一文で、原仕様の「正のint」を「組み込みintだけ」に狭めていました。受け手には「設計案と原仕様が食い違ったら原仕様を優先」と添えましたが、実装はこの制約をそのまま取り込み、テストにはすべて合格しました。今回、原仕様が何も書いていない点への制約の追加は、この優先の一文では防げませんでした。

    この記事では、設計案を原仕様の要件ごとに照らして原仕様にない制約を見分ける手順と、原仕様・設計提案・未決事項を欄で分けて渡す引き継ぎ票、受け手に要件とテストの対応を返させる受領確認を、この実例で示します。読み終えたら、自分の設計案から、実装の前に渡せる引き継ぎ票を作れます。

    設計案の一文が、実装に入るまで

    題材は、Pythonの在庫予約関数 reserve(stock, requests) の追加です。原仕様(SPEC.md)は8つの要件で、数量については次のように書いています(R3)。

    R3: quantity must be a positive int; reject bool, zero, negative, float with ValueError.

    Codex CLIの新しいセッションに、原仕様・既存コード・15件のテストを渡し、ファイルを変えずに設計案だけを作らせました(2026年9月21日、1回)。設計案の「要件」と「変更案」には、次の2行がありました。

    quantity は bool を除く正の組み込み int のみ許可する。

    type(quantity) is int and quantity > 0 を検証する。

    同じ設計案の「具体的な失敗リスク」には、「isinstance(quantity, int) だけでは True を受理してしまう。」とあります。Pythonの bool は int のサブクラスなので、この指摘は正しく、R3の「boolを拒否」に沿っています。ただ、対策として書かれた type(quantity) is int は、bool以外のintのサブクラス(intを継承したクラスの値)まで拒否します。R3はそこまで書いていません。

    この設計案を PLAN.md として原仕様と一緒に置き、別の新しいセッションに実装させました(同じ日、1回)。依頼文には次の一文を入れています。

    Read PLAN.md as an advisory design. SPEC.md is authoritative if they differ.

    受け取った実装の数量の判定は、設計案のとおりでした。

    if type(quantity) is not int or quantity <= 0:

    この実装は、事前に渡した15件のテストと、実装後に足した5件のテストにすべて合格しました。完了報告は「数量の厳密な検証(bool、0、負数、浮動小数を拒否)」と書くだけで、原仕様より狭めたことには触れていません。

    原仕様だけを渡して実装させた別の実装と、intのサブクラスの値を渡して比べました。class Quantity(int) で作った Quantity(1) などを、在庫5の商品への1件の予約として、保存しておいた2つの実装に渡した結果です(Python 3.13)。

    渡した数量 原仕様だけで実装した版 設計案を添えて実装した版
    Quantity(1)(intのサブクラス) 受け入れる(在庫5→4) ValueError で拒否する
    1 受け入れる 受け入れる
    True 拒否する 拒否する
    1.0 拒否する 拒否する

    どちらの実装も同じ20件のテストにすべて合格しているので、テストの結果だけを見ると差はありません。原仕様だけで実装した版の点検の手順は、Codexの合格報告を受け入れる前の点検の記事で扱っています。

    原仕様はintのサブクラスの扱いを明記していないため、設計案の「組み込みintだけ」は、矛盾ではなく仕様の具体化として取り込まれたと解釈できます。今回確かめたのは、優先順位の一文だけでは追加制約の採用を防げなかったことです。そこで、原仕様にある要件と設計者の提案を欄で分け、追加制約を承認するかどうかも実装前に決める方法を提案します。

    設計案の一文が、原仕様の空白を通って実装に入った流れと、引き継ぎ票で原仕様と設計提案を分ける位置を示す図
    今回の実装では、原仕様にない制約の追加を、優先の一文だけでは止められませんでした。

    手順1:設計案を、原仕様の要件ごとに照らす

    渡す前に、設計案の要件や方針の一文ずつを、原仕様の要件番号と照らします。照らした結果には、次の3つの印のどれかを付けます。

    印 当てはまる記述 引き継ぎ票での扱い
    同じ 原仕様の要件を言い換えただけ 「原仕様」欄に要件番号で書く
    狭い・広い 原仕様の要件より条件を絞る、または緩める 「設計提案」欄に移し、承認するまで要件にしない
    原仕様にない 原仕様のどの要件にも当たらない方針 「設計提案」欄に移す。決める人がいなければ「未決事項」へ

    今回の設計案に印を付けると、たとえば次のようになります。

    設計案の記述 照らした原仕様 印と扱い
    入力を変更せず、常に新しい在庫辞書を返す R1 同じ:原仕様欄にR1と書く
    全リクエストを検証後に在庫を反映し、欠品・在庫不足・重複矛盾時は ValueError R4・R6 同じ:原仕様欄にR4・R6と書く
    bool を除く正の組み込み int のみ許可する R3(正のint、boolを拒否) 狭い:設計提案欄へ。intのサブクラスの扱いを未決事項にする
    エラーメッセージやエラー優先順位は仕様外。テストで文言へ依存させない なし 原仕様にない:設計提案欄へ(原仕様と矛盾しないので、承認すれば採用できる)

    設計案が8つの要件すべてに触れていても、この照合は省けません。網羅しているかと、頼んでいない条件を足していないかは、別の問いだからです。今回の「狭い」の一文は、正しいリスク指摘のすぐ隣にありました。指摘が的確なほど、その対策の一文も要件のように読めてしまいます。

    印を付けるときに迷ったら、2つの書き方で結果が割れる値を実際に渡してみます。次のコードを int_boundary.py として保存し、python3 int_boundary.py で実行すると、intのサブクラスも受け入れる判定(正のintで、boolを除く)と設計案の判定(組み込みintだけ)の違いが出ます。

    class Quantity(int):
        pass
    
    for value in [True, 1, Quantity(1)]:
        positive_int = isinstance(value, int) and not isinstance(value, bool) and value > 0
        builtin_only = type(value) is int and value > 0
        print(type(value).__name__, positive_int, builtin_only)
    bool False False
    int True True
    Quantity True False

    3行目の Quantity だけ結果が割れます。割れた値があれば、その記述は「同じ」ではなく「狭い・広い」です。

    手順2:引き継ぎ票に、原仕様・設計提案・未決事項を分けて書く

    照らした結果を、次の引き継ぎ票に書き写します。受け手が読む資料はこの票と、原仕様・テスト・対象のコードです。設計案は添えてもかまいませんが、判断の根拠は票の欄で示します。

    目的:誰のどの問題を解決するか
    対象:リポジトリ、基準コミット、作業ブランチ
    原仕様:承認済みの要件を番号つきで(R1〜)。設計案で言い換えた文ではなく原文を書く
    設計提案:原仕様より狭い・広い記述と、原仕様にない方針。それぞれ「承認済み」か「未承認」かを書く
    未決事項:決める人、決まるまで実装で触れない部分
    調査済み:読んだファイル、関数、確かめた挙動
    変更範囲:変更してよい場所、維持する公開API
    検査:実行コマンド、合格の条件、既に分かっている失敗
    禁止事項:依存の追加、データ移行、外部送信など
    復旧:変更を戻せる単位と、戻せない副作用
    受領確認:要件ごとに確かめるテストと、採用する設計提案を返してから実装する

    今回の例なら、設計提案と未決事項の欄は次のようになります。

    設計提案:
    - 数量は組み込みintだけを受け入れる(R3より狭い。未承認)
    - エラーの文言と優先順位はテストで固定しない(原仕様にない。承認済み)
    未決事項:
    - intのサブクラスの数量を受け入れるか(決める人:[呼び出し側の担当])
      決まるまで、数量判定の実装と、その判定を含む機能の取り込みを保留する。独立して進められる部分だけ実装する

    未決事項に「決まるまでの扱い」を書いておくのは、受け手が空白を自分で埋めないためです。今回の実装は、空白を設計案の一文で埋めていました。

    手順3:受け手に、要件とテストの対応を返させる

    実装を始める前に、受け手に次の2つを返してもらいます。

    実装の前に、次の2つを返してください。
    1. 原仕様の要件番号ごとに、それを確かめるテスト名(なければ「なし」)
    2. 引き継ぎ票の設計提案のうち、実装に取り入れるものと、その理由
    返した内容に不明点や矛盾があれば、実装を始めずに質問してください。

    1で「なし」が返ってきた要件は、テストが守っていない要件です。今回なら、R3に対して返るのは test_05〜test_08(bool・0・負数・float)で、intのサブクラスを確かめるテストはありません。そこが、引き継ぎで受け手の判断に任されている場所です。2で「未承認」の提案を取り入れると返ってきたら、実装の前に止めて決めます。

    返答が正しくても、実装がそのとおりになるとは限りません。実装を受け取ったら、差分と、未決事項に当たる値を渡した結果で確かめます。確かめ方は合格報告を受け入れる前の点検の記事の手順4と同じです。

    自分の案件で書き換える箇所と、渡す前の確認

    引き継ぎ票の各欄は、自分の案件で次のように書き換えます。

    • 原仕様:設計案の要約ではなく、承認済みの要件の原文を番号つきで貼る。

    • 設計提案:手順1で「狭い・広い」「原仕様にない」の印を付けた記述をすべて移し、承認の状態を書く。

    • 未決事項:決める人と、決まるまでの実装の扱いを書く。

    • 検査:実行するコマンドと、合格の条件(テスト件数、変更してよいファイル)を書く。

    渡す前に、次の3つを確かめます。

    1. 設計案の要件・方針の文に、すべて印が付いている。

    2. 「狭い・広い」「原仕様にない」の記述が、原仕様欄に混ざっていない。

    3. 未決事項のそれぞれに、決める人か、決まるまでの扱いが書いてある。

    3つがそろえば、受け手は設計案を「承認済みの要件」と「提案」に分けて読めます。今回の type(quantity) is int の一文も、設計提案の欄に「未承認」と書き、実装前に質問するよう受領確認で求めます。この引き継ぎ票で実装し直す検証は行っていないため、採用後も差分と境界値で確かめます。

    出典と確認日

  • Claudeへの返信指示を直したら、重大な約束違反と表現の不足を分けて採点し、採用するか決める

    Claudeへの返信指示を直したら、重大な約束違反と表現の不足を分けて採点し、採用するか決める

    問い合わせへの返信をClaudeに下書きさせていると、指示を少しずつ直したくなります。件名を付けてほしい、要点は箇条書きにしてほしい。直した版で何通か試し、読みやすくなっていれば採用したくなりますが、直したことで別のところが崩れていないかは、読みやすさからは分かりません。

    名入れ文具の店の返信指示で、元の版と、件名と箇条書きの2行を足した版を、同じ3件の問い合わせでClaudeに1回ずつ下書きさせました(2026年10月20日)。6つの項目で採点すると、一致した数は2つの版とも18項目中17項目で同じでした。ところが2行を足した版では、商品が届かないという問い合わせへの返信にだけ、店のルールにない「名入れ商品は通常の商品より発送までにお時間をいただく場合がございます」という説明が入っていました。Claudeは補足で「仮定です」と断っていましたが、その一文はお客様に送る本文の側にありました。

    一致数だけで比べていたら、この違反は見逃します。この記事では、重大な約束違反と表現の不足を分けた評価セットの作り方と、実際の2つの版の採点結果、ラベルから採否を出す短い採点スクリプトをまとめます。読み終えたら、自分の返信指示を直したときに、同じ評価セットで採点して、採用するか、もう一度直すかを決められます。

    評価セット:重大な条件と表現の条件を分けて先に決める

    評価セットは、試す問い合わせと、返信に期待することの組です。指示を直すたびに同じ組で採点すると、ある返信が良くなった代わりに別の返信が崩れた、という変化に気づけます。

    期待することは、間違えたときの影響で2つに分けます。お客様に誤った約束や事実を伝えてしまうものは「重大」、読みやすさや体裁に関わるものは「表現」です。

    ID 返信に期待すること 区分
    E1 返金・交換・作り直し・再送を約束しない。返金の依頼には、担当者が確認してから連絡すると伝える 重大
    E2 情報が足りなければ質問する。問い合わせにない事実(発送状況、到着日、原因など)を言い切らない 重大
    E3 1行目に件名がある 表現
    E4 箇条書きが2行以上ある 表現
    E5 担当窓口「サポート担当 support@example.com」を書いている 表現
    E6 結びのあいさつで締めている 表現

    どれを重大にするかは、汎用の点数表ではなく、自分の店で間違えたときに何が起きるかで決めます。たとえば緊急の連絡先を伝えないと困る業務なら、E5は重大に移します。

    採点は、お客様に送る返信の部分だけを対象にします。Claudeが返信の外に書いた前置きや「補足」は、お客様には届かないので数えません。

    2つの版の指示と、3件の問い合わせ

    元の版(v1)の指示は次のとおりです。

    名入れ文具のネットショップに届いた問い合わせへの返信を下書きしてください。次の店のルールに従ってください。
    - 返金・交換・作り直しの可否は、担当者が注文内容を確認してから決めます。返信では返金・交換・作り直しを約束せず、担当者が確認してから改めて連絡すると伝えてください。
    - 返信に必要な情報(注文番号、商品名、状況など)が足りないときは、推測せずにお客様へ質問してください。
    - 名入れは全角10文字までです。
    - 担当窓口として「サポート担当 support@example.com」を記載してください。
    - 最後に結びのあいさつを入れてください。

    直した版(v2)は、ここに次の2行を足しただけです。

    - 1行目に「件名:」で始まる件名を付けてください。
    - 要点は箇条書きで書いてください。

    問い合わせは、返金の依頼、情報の足りない問い合わせ、通常の質問の3件です。

    • Q1:注文番号1024で届いた名入れノートの名前が間違っていました。返金してもらえますか。

    • Q2:先週注文したものがまだ届きません。どうなっていますか。

    • Q3:名入れは何文字までできますか。

    指示の後に「問い合わせ:」と問い合わせを付け、ブラウザ版Claudeの新しいチャットに1通ずつ、2つの版で計6回送りました。

    採点の結果

    版 一致した数 重大な違反 一致しなかった項目
    v1 17/18 なし Q3の返信に箇条書きがない(E4、表現)
    v2 17/18 1件 Q2の返信に店のルールにない発送事情(E2、重大)

    返金の依頼(Q1)には、どちらの版も返金を約束せず、担当者が確認してから連絡すると伝え、注文時に指定した名前と届いた商品の印字を尋ねていました。v1は件名を指示していなくても3通とも件名を付けていて、2行を足したことで増えた一致は、Q3の箇条書きの1項目だけでした。

    減ったのは、v2のQ2です。注文番号や商品名を尋ねる部分は基準どおりでしたが、本文に次の一文がありました(表現は変えていません)。

    なお、名入れ商品は、ご注文後にお名前などを刻印・印刷してから発送するため、通常の商品より発送までにお時間をいただく場合がございます。

    同じ回答の最後には、お客様に送らない補足として、次のように書いていました。

    「通常より発送までにお時間をいただく場合がある」という一文は、名入れ商品の一般的な事情として入れた仮定です。実際の店舗の運用と異なる場合は削除または修正してください。

    店のルールには、発送にかかる時間の説明はありません。届かない理由がまだ分からない段階で、この一文が届くと、お客様は「名入れだから遅れている」と受け取ります。実際には発送漏れや配送の事故かもしれません。補足で仮定だと断っていても、補足はお客様に届かず、本文の一文だけが届きます。そこでE2の「問い合わせにない事実を言い切らない」に当たる重大な違反と数えました。

    一致数はv1とv2で同じ17/18でも、v2だけ未着の返信に重大な違反が1件あり、採否が分かれることを示した図
    一致した数だけで並べると2つの版は同じに見えますが、重大な違反の有無で採否が分かれました。

    各版1回ずつの結果なので、この一文が足した2行のせいで入ったのか、同じ指示でも入ったり入らなかったりするのかは、この結果からは決められません。それでも、採用するかの判断には十分です。v2をこのまま使えば、少なくとも1通に重大な違反があった版を採ることになるからです。

    決めきれなかった文は、理由と一緒に記録する

    ラベルは、実行担当のAI(Claude)が上の基準で付けたものです。そのなかで、基準だけでは決めきれない文が1つありました。どちらの版もQ2の返信で書いていた「お届けが遅れており」です。お客様の「まだ届きません」を言い直しただけとも読めますが、遅れていることを店が事実として認めたとも読めます。今回は言い直しと読んで一致に数えました。v1の回答は、返信の外の説明で「配送の遅れとも発送漏れとも断定せず」と書いていて、本文の言い回しとは食い違っています。

    こうした文は、どちらに数えたかと理由を記録に残しておき、次に指示を直すときの判断材料にします。採点を2人で分けて付けられるなら、この文で判断が割れるかどうかも、指示を直すべき箇所の手がかりになります。

    採点スクリプト

    ラベルを付けたあとの集計は、次のPythonでできます。重大な違反を一致数とは別に数え、採点の対象が欠けていたら止まります。Python 3.10以降の標準機能だけで動き、LABELSの1と0を自分の採点に書き換えて使います。

    # 返信指示の2つの版を、同じ評価セットで採点する。
    # ラベルは、出力1通ごとの一致(1)/不一致(0)。
    # この例は担当AIの判定。自分の返信に使うときは本文と基準を照合して確定する。
    CRITICAL = {"E1", "E2"}                       # 重大条件
    ITEMS = ["E1", "E2", "E3", "E4", "E5", "E6"]
    QUESTIONS = ["Q1", "Q2", "Q3"]
    
    LABELS = {
        "v1": {"Q1": [1, 1, 1, 1, 1, 1], "Q2": [1, 1, 1, 1, 1, 1], "Q3": [1, 1, 1, 0, 1, 1]},
        "v2": {"Q1": [1, 1, 1, 1, 1, 1], "Q2": [1, 0, 1, 1, 1, 1], "Q3": [1, 1, 1, 1, 1, 1]},
    }
    
    
    def score(labels):
        if sorted(labels) != QUESTIONS or any(len(v) != len(ITEMS) for v in labels.values()):
            raise ValueError("評価対象の不足または追加")
        matched, critical = 0, []
        for q in QUESTIONS:
            for item, ok in zip(ITEMS, labels[q]):
                matched += ok
                if item in CRITICAL and not ok:
                    critical.append(f"{q}-{item}")
        return matched, critical
    
    
    for version, labels in LABELS.items():
        matched, critical = score(labels)
        state = "不採用(重大違反あり)" if critical else "重大違反なし・表現の不足を確認"
        print(f"{version}: 一致{matched}/{len(QUESTIONS) * len(ITEMS)} 重大違反{critical} {state}")
    
    assert score(LABELS["v1"]) == (17, [])
    assert score(LABELS["v2"]) == (17, ["Q2-E2"])
    try:
        score({q: v for q, v in LABELS["v2"].items() if q != "Q2"})
    except ValueError:
        print("評価対象が欠ける場合: 停止")
    v1: 一致17/18 重大違反[] 重大違反なし・表現の不足を確認
    v2: 一致17/18 重大違反['Q2-E2'] 不採用(重大違反あり)
    評価対象が欠ける場合: 停止

    採点の対象が欠けたら止めるのは、うまくいかなかった問い合わせを評価セットから外すと、一致の割合が簡単に上がってしまうからです。分母を固定しておけば、範囲を変えたことと指示が良くなったことを取り違えません。

    指示を直すたびに回す手順

    1. 評価セットの問い合わせと、E1〜E6の基準を固定して保存する。

    2. 指示を1か所だけ直す。一度に複数を変えると、何が結果を動かしたかを追えません。

    3. 同じ問い合わせを、新しいチャットで直した版に送り、返信の全文を保存する。

    4. 返信ごとにラベルを付け、決めきれない文は理由と一緒に記録する。

    5. 重大な違反が1件でもあれば採用しない。重大な違反がなければ、表現の不足がお客様に送る前に直せる程度かを見て決める。

    指示を直すときに使った問い合わせとは別に、直すときには見ない確認用の問い合わせも数件用意しておくと、直した問い合わせにだけ合わせた指示になっていないかを確かめられます。

    今回のv2を直すなら、たとえば「店のルールにない発送や配送の事情は書かないでください」の1行を足し、同じ3件でもう一度採点します。そこで重大な違反が消え、Q3の箇条書きも残っていれば、採用を検討できます。

    参照した公式資料

  • Codexの「テストはすべて成功」を受け入れる前に、事後テストと差分で、テストが決めていない契約まで確かめる

    Codexの「テストはすべて成功」を受け入れる前に、事後テストと差分で、テストが決めていない契約まで確かめる

    Codexに関数の追加を頼むと、作業の最後に「テストはすべて成功しました」と返ってきます。採用するかどうかを決めるのは自分ですが、その一行が示しているのは「渡したテストに通った」ことだけです。テストに書いていない入力でどう振る舞うか、頼んでいないファイルを変えていないかは、報告からは分かりません。

    在庫予約の関数をCodexに1回実装させ、事前に渡した15件のテスト、実装後に足した5件のテスト、差分の3つで点検すると、すべて合格でした。それでも、仕様が何も言っていない入力が残っていました。数量として「intを継承したクラスの値」(intのサブクラス)を渡すと、この実装は受け入れます。同じ仕様・同じテストで別のセッションが書いた実装は、同じ値を拒否し、こちらも20件すべてに合格していました。合格の件数からは、この違いは見えません。

    この記事では、合格報告を4つに分けて読む手順を、この実装で順に示します。読み終えたら、Codexの合格報告を受けたときに、何を足して確かめ、どこを仕様として決めてから採用するかを判断できます。題材のコードとテストは全文を載せるので、同じ点検を手元で再現できます。

    合格報告を4つに分けて読む

    「テストはすべて成功」を、次の4つの問いに分けます。上の2つはテストで、3つ目は差分で、4つ目はコードを読んで確かめます。

    確かめること 見るもの 今回の実装
    渡した条件を満たしたか 事前に渡したテストの結果 15件すべて合格
    渡していない境界でも崩れないか 実装後に足したテストの結果 5件すべて合格
    頼んでいない場所を変えていないか git diff と git status Codexの変更は inventory.py だけ
    仕様が決めていない入力をどう扱うか 入力を判定している行と、そこへ値を渡した結果 intのサブクラスを受け入れる

    上の3行が合格なら、採用してよい実装の候補です。4行目は合否ではなく、採用の前に「その扱いでよいか」を決める項目です。今回の4行目は、実装の書き方しだいで逆の結果になりました。

    合格報告を、事前テスト、事後テスト、差分、テストが決めていない契約の4段に分けて確かめる図
    4段目は合否ではなく、採用の前に扱いを決める項目です。

    題材:在庫予約の関数と8つの条件

    既存の inventory.py には、在庫を参照する available() だけがあります。ここに、在庫の辞書と予約の一覧を受け取り、予約後の新しい在庫の辞書を返す reserve(stock, requests) を追加させました。条件は次の8つで、英語の仕様書(SPEC.md)として渡しています。

    1. 入力を書き換えず新しい辞書を返し、既存の available() を維持する。

    2. 在庫と重複判定をテナント(利用企業)ごとに分け、他テナントの在庫を使わない。

    3. 数量は正のintとし、bool・0・負数・floatは ValueError で拒否する。

    4. 全件を検証してから反映し、在庫がない・足りないときは ValueError にする。

    5. 同じバッチ内で、テナントとIDが同じで商品・数量も同じ予約は、一度だけ消費する。

    6. IDが同じで内容が違えば拒否し、別の呼び出しとは重複の状態を共有しない。

    7. IDが違う予約は合計して消費し、空の一覧でも新しい同値の辞書を返す。

    8. 外部依存・ネットワーク・永続化・並行実行・新しいCLI・シグネチャの変更は対象外。

    条件3は、Pythonの bool が int のサブクラスであることを踏まえて、True を数量として通さないための一文です。一方で、bool以外のintのサブクラスをどう扱うかは、8つの条件のどこにも書いていません。この空白が、手順4で問題になります。

    手順1:事前テストを固定してから実装を頼む

    空のGitリポジトリに、既存の関数だけを置いた inventory.py を作ります。

    def available(stock, tenant, sku):
        return stock.get((tenant, sku), 0)

    上の8条件を SPEC.md に書き、次のテストを test_contract.py として保存して、3つを同じコミットに入れます。在庫のキーは (tenant, sku)、予約は tenant・sku・quantity・request_id を持つ辞書です。

    import unittest
    from inventory import available, reserve
    def req(t="a", sku="pen", q=1, id="x"):
        return dict(tenant=t, sku=sku, quantity=q, request_id=id)
    class Contracts(unittest.TestCase):
        def setUp(self): self.s={("a","pen"):5,("b","pen"):8,("a","book"):3}
        def test_01_legacy(self): self.assertEqual(available(self.s,"a","pen"),5)
        def test_02_happy(self): self.assertEqual(reserve(self.s,[req(q=2)])[("a","pen")],3)
        def test_03_no_mutation(self):
            r=[req()]; before=r[0].copy(); out=reserve(self.s,r)
            self.assertEqual(self.s[("a","pen")],5); self.assertEqual(r,[before]); self.assertIsNot(out,self.s)
        def test_04_tenant(self):
            out=reserve(self.s,[req(),req(t="b")]); self.assertEqual((out[("a","pen")],out[("b","pen")]),(4,7))
        def test_05_bool(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(q=True)])
        def test_06_zero(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(q=0)])
        def test_07_negative(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(q=-1)])
        def test_08_float(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(q=1.0)])
        def test_09_atomic(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(),req(sku="missing",id="y")])
            self.assertEqual(self.s[("a","pen")],5)
        def test_10_shortage(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(q=6)])
        def test_11_same_duplicate(self): self.assertEqual(reserve(self.s,[req(),req()])[("a","pen")],4)
        def test_12_conflict(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(),req(q=2)])
            with self.assertRaises(ValueError): reserve(self.s,[req(),req(sku="book")])
        def test_13_cumulative(self):
            with self.assertRaises(ValueError): reserve(self.s,[req(q=3),req(q=3,id="y")])
        def test_14_empty(self):
            out=reserve(self.s,[]); self.assertEqual(out,self.s); self.assertIsNot(out,self.s)
        def test_15_no_global_cache(self): self.assertEqual(reserve(self.s,[req()]),reserve(self.s,[req()]))

    このコミットから作業用のコピーを作り、Codexに「SPEC.mdを実装し、既存テストは変えず、構文検査とテストを実行して報告する」と頼みます。実装前は reserve のimportで失敗しますが、それで正常です。

    Codex CLIの新しいセッションに、この依頼を1回送りました(2026年9月21日、Python 3.13)。Codexは inventory.py に reserve を追加し、「15件すべて成功」「変更ファイルは inventory.py のみ」と報告しました。報告を受け取ったあとに修正を頼み直した回数は0回です(Codexの内部で何回書き直したかとは別の数です)。

    点検に使う完成コード

    今回Codexが返した inventory.py の全文です。同じ結果を確かめる場合は、最初の available() だけのファイルを次の内容に置き換え、python3 -m unittest -v test_contract で事前テスト15件を実行します。自分でCodexに実装を頼む場合は、その返却コードを点検対象にします。

    def available(stock, tenant, sku):
        return stock.get((tenant, sku), 0)
    
    
    def reserve(stock, requests):
        """Return a stock snapshot with an atomic batch of reservations applied."""
        reservations = {}
        required = {}
    
        for request in requests:
            tenant = request["tenant"]
            sku = request["sku"]
            quantity = request["quantity"]
            request_id = request["request_id"]
    
            if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
                raise ValueError("quantity must be a positive integer")
    
            reservation_key = (tenant, request_id)
            reservation = (sku, quantity)
            if reservation_key in reservations:
                if reservations[reservation_key] != reservation:
                    raise ValueError("conflicting duplicate request")
                continue
    
            reservations[reservation_key] = reservation
            stock_key = (tenant, sku)
            if stock_key not in stock:
                raise ValueError("stock item does not exist")
    
            total = required.get(stock_key, 0) + quantity
            if total > stock[stock_key]:
                raise ValueError("insufficient stock")
            required[stock_key] = total
    
        result = stock.copy()
        for stock_key, quantity in required.items():
            result[stock_key] -= quantity
        return result

    手順2:実装後に境界テストを足す

    事前の15件は、Codexが実装中に読める場所に置きました。読めるテストに合わせた実装でも通るので、実装を受け取ってから、別の観点のテストを足します。今回足したのは次の5件です。test_after.py として同じフォルダに保存し、python3 -m unittest -v test_after で実行します。

    import unittest
    
    from inventory import reserve
    
    
    def req(t="a", sku="pen", q=1, id="x"):
        return dict(tenant=t, sku=sku, quantity=q, request_id=id)
    
    
    class AfterImplementation(unittest.TestCase):
        def test_same_id_in_other_tenant(self):
            stock = {("a", "pen"): 2, ("b", "pen"): 2}
            out = reserve(stock, [req(), req(t="b")])
            self.assertEqual(out, {("a", "pen"): 1, ("b", "pen"): 1})
    
        def test_resend_after_other_request(self):
            out = reserve({("a", "pen"): 5}, [req(), req(id="y"), req()])
            self.assertEqual(out, {("a", "pen"): 3})
    
        def test_cumulative_success(self):
            out = reserve({("a", "pen"): 5}, [req(q=2), req(q=3, id="y")])
            self.assertEqual(out, {("a", "pen"): 0})
    
        def test_other_stock_is_kept(self):
            out = reserve({("a", "pen"): 2, ("b", "book"): 9}, [req()])
            self.assertEqual(out, {("a", "pen"): 1, ("b", "book"): 9})
    
        def test_requests_unchanged_on_failure(self):
            requests = [req(), req(q=99, id="y")]
            saved = [dict(r) for r in requests]
            with self.assertRaises(ValueError):
                reserve({("a", "pen"): 2}, requests)
            self.assertEqual(requests, saved)
    
    
    if __name__ == "__main__":
        unittest.main(verbosity=2)

    別テナントの同じID、間に別の予約を挟んだ再送、在庫ちょうどまでの合計消費、予約と関係のない在庫、失敗したときの予約一覧。どれも事前の15件とは違う形の入力で、Codexの実装は5件すべてに合格しました。足すテストは、仕様の条件ごとに「事前テストとは違う並び・組み合わせ」を1つずつ考えると作りやすくなります。

    手順3:差分で変更範囲を確かめる

    テストの結果とは別に、何が変わったかを差分で確かめます。

    python3 -m compileall -q .
    python3 -m unittest discover -v
    git diff --check
    git diff --stat
    git status --short

    git diff --stat は変更をファイルごとに要約し、git status --short はまだGitに登録していない新しいファイルも表示します。今回の変更は inventory.py だけで、test_contract.py と SPEC.md はそのまま、依存の追加もありませんでした。テストのファイルが変わっていたら、合格の意味が変わるので、テストを弱めていないかを先に確かめます。

    手順4:テストが決めていない契約をコードで探す

    ここまでの3つは、渡した条件と頼んだ範囲の確認です。最後に、仕様が何も書いていない入力を探します。探し方は、入力を判定している行を読み、仕様の言葉と食い違いうる書き方を見つけて、その値を実際に渡すことです。

    今回の実装で数量を判定していたのは、次の1行でした。

    if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:

    isinstance(quantity, int) は、intのサブクラスの値も「int」と判定します。boolだけを先に除いているので、bool以外のサブクラスは通ります。

    同じ SPEC.md と同じ15件のテストで、設計案を添えて別のセッションのCodexに実装させた版もあります(その引き継ぎの記録は設計案を別のセッションに渡す記事で扱っています)。そちらの判定は次の1行でした。

    if type(quantity) is not int or quantity <= 0:

    type(quantity) is not int は、組み込みのint以外をすべて拒否します。2つの実装に、class Quantity(int) で作った Quantity(1) と、比べるための値を、在庫5の商品への1件の予約として渡しました(Python 3.13)。

    渡した数量 この記事の実装 別のセッションの実装
    Quantity(1)(intのサブクラス) 受け入れる(在庫5→4) ValueError で拒否する
    1 受け入れる 受け入れる
    True 拒否する 拒否する
    1.0 拒否する 拒否する

    2つの実装は、事前15件・事後5件のテストにどちらもすべて合格しています。テストに Quantity のような値がないので、合格の件数は同じでも、この1行の違いは結果に出ません。Codexの報告も、どちらも「数量の厳密な検証」と書くだけでした。

    どちらが正しいかは、仕様からは決まりません。呼び出し側が数量をintのサブクラスで渡す設計なら受け入れる実装が必要で、組み込みのintに限りたい理由があれば拒否する実装が必要です。決まっていないまま採用すると、その扱いは仕様ではなく、たまたま書かれた1行で決まります。

    自分の実装で同じ確認をするときは、次のように仕様の空白に当たる値を渡し、結果を表に残します。

    from inventory import reserve
    
    
    class Quantity(int):
        pass
    
    
    stock = {("a", "pen"): 5}
    for value in [Quantity(1), 1, True, 1.0]:
        request = {"tenant": "a", "sku": "pen", "quantity": value, "request_id": "x"}
        try:
            print(type(value).__name__, "受け入れ", reserve(stock, [request]))
        except ValueError as exc:
            print(type(value).__name__, "拒否", exc)

    今回の題材で空白になっていたのは数量の型でしたが、文字列の前後の空白、Noneや空文字、非常に大きな数など、仕様に書いていない入力はほかにも考えられます。全部を試すより、判定している行を読み、そこで使っている関数(isinstance・type・strip など)が仕様の言葉より広いか狭いかを見るほうが早く見つかります。

    採用するか、何を決めてから採用するか

    4つの点検の結果から、次のように判断します。

    • 1〜3のどれかが不合格:採用しない。落ちたテストか、想定外の差分を添えて直しを頼む。

    • 1〜3が合格で、4で扱いが決まった:採用する。決めた扱いを SPEC.md に一文足し、その値を渡すテストを test_contract.py に加えてから取り込む。

    • 1〜3が合格で、4の扱いを決める人がいない:取り込みを待ち、呼び出し側の担当に扱いを確かめる。

    今回なら、「数量はintのサブクラスも受け入れる」か「組み込みのintだけを受け入れる」かを決め、Quantity(1) を渡すテストを1件足せば、次に別の実装が来ても同じ扱いになります。テストに足した時点で、4つ目の問いは1つ目の問いに移ります。

    点検を続けるなら、合格の件数に加えて、受け取ってから修正を頼み直した回数と、4つの点検にかかった時間を記録しておくと、Codexに任せる範囲を決める材料になります。

    出典と確認日

    • OpenAI: Codex best practices。計画、編集後の検証、差分のレビューを含む進め方の案内。再確認日:2026-10-09。