0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMに書かせた原稿の「差し込み漏れ」は、人のレビューでは止まらなかった

0
Posted at

環境・前提条件

  • TypeScript: 5.4.5
  • Node.js: 20.12.2 / 24.x(検証は両方で実施)
  • Vitest: 4.1.10
  • Cloudflare Workers + D1
  • LLMで下書きを生成し、人がレビューして公開する社内システム

この記事の要点

  • 人のレビューは内容の妥当性を見る関門であって、テンプレートの残骸を探す検査ではありません。
  • 承認済みの原稿2本に [相談予約URL] が残ったまま、公開待ちの状態になっていました。
  • 検査を足すときは誤検知が公開停止に直結するため、実原稿全件で誤爆しないことを先に固定します。

なぜ人のレビューで差し込み漏れが素通りするのか?

人は文章を読むとき意味を追うため、テンプレートの残骸は「あとで直すもの」として視界から外れるからです。レビューは内容の正しさを判断する工程で、機械的な残骸検査ではありません。

実際に起きたことを書きます。LLMが生成した下書きをレビュー担当が承認し、公開待ちのキューに入っていました。その中の2本にこれが残っていました。

詳細・お問合せ:[相談予約URL]
電話: GBPと同一の電話番号

前者はURLの差し込み欄、後者は「他媒体と同じ値を入れる」という執筆者向けの指示文です。どちらもレビューを通っています。レビュー担当は内容が正しいかを見ており、実際、内容は正しかった。外部の審査が下りた瞬間に、この文面のまま公開される状態でした。

「投稿できるか」(文字数・画像の有無)は既に検査していましたが、「投稿していいか」を見る場所がありませんでした。

どこに検査を置くのが正しいか?

生成時ではなく、公開処理の直前に置きます。生成と公開の間に人の編集が挟まるため、生成時点で通っても公開時点で壊れている可能性があるからです。

// 承認済みかを確認した直後、公開処理に入る前
const reason = placeholderReason(draft.body)
if (reason) {
  await markFailed(draft.id, reason)   // 理由を必ず残す。黙って止めない
  continue
}
await publish(draft)

止めるだけでは運用が回りません。何が引っかかったのかを本文から抜き出して理由に入れることで、担当者が原稿を開かずに直す箇所を特定できます。

export function placeholderReason(text: string): string | null {
  const found = findPlaceholders(text)
  if (found.length === 0) return null
  return `原稿に差し込み漏れが残っています(${found.join('')})。`
       + `実際の値に置き換えてから公開してください。`
}

検査のパターンをどこまで広げてよいか?

狭く取ります。 この検査の誤検知は「公開されない」に直結するため、取りこぼしより誤爆のほうが損害が大きいからです。

実際に使っているパターンは6種類だけです。

const PATTERNS: { re: RegExp; label: string }[] = [
  // [相談予約URL] のような角括弧の指示。
  // Markdownリンク [表示文字](https://…) を巻き込まないよう、直後の ( を除外する。
  { re: /\[[^\]\n]{0,40}(?:URL|url|URL)[^\]\n]{0,40}\](?!\()/g, label: 'URLの差し込み欄' },
  { re: /\{\{[^}\n]{0,60}\}\}/g,                                   label: 'テンプレート変数' },
  { re: /\[(?:ここに|挿入|差し込み|要入力|未定)[^\]\n]{0,40}\]/g,   label: '差し込み指示' },
  { re: /(?:GBP|GBP)と同一[^\n]{0,20}/g,                         label: '他媒体を参照したままの記述' },
  { re: /\b(?:TODO|FIXME|XXX)\b/g,                                 label: '作業メモ' },
  { re: /【(?:画像指示|動画指示)】/g,                                label: '画像・動画の指示文' },
]

最初に書いた版は (?!\() が無く、本文中のMarkdownリンクを巻き込みかけました。実際の挙動はこうなります。

const re = /\[[^\]\n]{0,40}(?:URL|url)[^\]\n]{0,40}\](?!\()/g
const md = '[相談予約URLはこちら](https://example.com/reserve) と [相談予約URL] が並ぶ'
md.match(new RegExp(re.source, re.flags))
// => [ '[相談予約URL]' ]   ← 実在のリンクは拾わず、差し込み欄だけを拾う

角括弧の直後が ( なら、それは書き終えたリンクです。差し込み欄はリンクになっていません。この1文字の否定先読みが、誤爆と検知を分けています。

g フラグ付きの正規表現を使い回してよいか?

match() なら安全ですが、test()exec() では使い回してはいけません。 g フラグ付きの正規表現オブジェクトは lastIndex を保持し、test() はそこから探索を再開するためです。

Node.js 24 で実測しました。

const s = '詳細は [相談予約URL] へ。もう一つ [予約URL] もある。'
const re = /\[[^\]\n]{0,40}URL[^\]\n]{0,40}\](?!\()/g

re.test(s)  // true   lastIndex: 13
re.test(s)  // true   lastIndex: 28
re.test(s)  // false  lastIndex: 0   ← 同じ文字列なのに3回目で false

3回目が false になるのは、2回目の探索で末尾まで進んだ lastIndex から再開して、もう見つからないからです。そこで lastIndex が 0 に戻るため、4回目はまた true になります。同じ入力に対する結果が呼び出し回数で変わるわけで、検査に使えば「たまに通る」という最悪の壊れ方をします。

一方 String.prototype.match() は挙動が違います。

s.match(re)  // [ '[相談予約URL]', '[予約URL]' ]
s.match(re)  // [ '[相談予約URL]', '[予約URL]' ]   ← 何度呼んでも同じ
re.lastIndex // 0

g フラグ付きの match() は、探索の開始時に lastIndex を 0 にリセットし、終了時にも 0 に戻します(MDN: RegExp.prototype[Symbol.match])。したがって使い回しても壊れません。

紛らわしいのは、test() で踏んだ経験があると match() でも防御的に正規表現を作り直したくなることです。作り直しても害はありませんが、「match()lastIndex を持ち回るから」という理由をコメントに書くと、それ自体が誤った知識として残ります。実際、私たちのコードにはその誤ったコメントが残っていました。

検査を追加するとき、何を先に固定するか?

既存の原稿全件で誤爆しないことを、テストで先に固定します。 検知できることより、誤検知しないことのほうが先に壊れるからです。

やったことは単純で、検査を入れる前の実原稿11本と、修正後の11本をそれぞれJSONに固めて、両方をテストの入力にしました。

// 修正前の全11本。id 1 と 6 に差し込み漏れが残っていた実物。
const before = load('drafts-before-fix.json')
// 修正後の全11本。誤爆すると次の公開が止まるので、実物で確かめる。
const current = load('drafts-current.json')

it('差し込み漏れのあった2本を実際に止める', () => {
  for (const id of [1, 6]) {
    const d = before.find(x => x.id === id)
    expect(placeholderReason(d!.body), `draft ${id} を素通りさせた`).not.toBeNull()
  }
})

it('残る9本は誤検知しない', () => {
  const clean = before.filter(d => ![1, 6].includes(d.id))
  expect(clean).toHaveLength(9)
  for (const d of clean) expect(placeholderReason(d.body)).toBeNull()
})

止めるべき2本をIDで名指しし、残り9本の件数まで固定しているのが要点です。「1本以上検知する」で済ませると、パターンを広げすぎて他の原稿まで巻き込んだときに、テストが緑のまま公開が止まります。件数を書いておけば、巻き込んだ瞬間に toHaveLength(9) が落ちます。

最後に、テストではなく実際の公開処理を1回動かして、止まることを確認しました。差し込み漏れを含む原稿を1本用意し、公開処理を起動して、状態が failed になり理由が記録されることを見ています。テストが通ることと、本番の経路で止まることは別の話です。

追記: この記事自身が、この関門に弾かれました

公開しようとしたところ、15件の差し込み漏れを検知して配信が止まりました。 すべて、上に載せているコード例の中の文字列です。

URLの差し込み欄「[相談予約URL]」
他媒体を参照したままの記述「GBPと同一の電話番号」
作業メモ「TODO」

検査は本文を素のテキストとして見ており、コード領域という概念を持っていませんでした。 この記事に固有の問題ではありません。コード例を含む技術記事は、書いた時点ですべて公開できない状態でした。

バッククォートで囲まれた文字列は「置き換えを待っている値」ではなく「読者に見せている文字列」です。意味が違うので、検査の対象から外すのが正しい。

function maskCodeRegions(text: string): string {
  // 改行は残す。行単位のパターンが行をまたいで繋がるのを防ぐため。
  return text.replace(/```[\s\S]*?```|`[^`\n]*`/g, m => m.replace(/[^\n]/g, ' '))
}

検査を緩める変更なので、本文側の検知が生きていることを同じテストファイルで固定しました。緩めた側だけを検証すると、次に穴が空いても気づけません。閉じられていないフェンスではマスクが発生せず、後続の本文がそのまま検査される(安全側に倒れる)ことも確認しています。

前節の主張がそのまま返ってきた形です。誤検知が公開停止に直結する検査は、入れたあとも誤爆し続ける。 想定していた誤爆はMarkdownリンクでしたが、実際に踏んだのはコードブロックでした。

まとめ

  • レビューは内容の妥当性を見る関門であり、テンプレートの残骸は構造的に見落とされる
  • 検査は生成時ではなく公開直前に置く。生成と公開の間に人の編集が挟まるため
  • 誤検知が公開停止に直結する検査は、既存データ全件で誤爆しないことを先に固定してから入れる
  • g フラグ付き正規表現は test() / exec() で使い回すと結果が呼び出し回数に依存する。match() は安全
  • 技術記事を扱う媒体では、コード領域を検査対象から外す。例示と実物は意味が違う

参考


執筆・運用:株式会社HY(神奈川県横浜市西区/相続・空き家・終活の不動産相談窓口)
本記事は、自社が実際に運用しているコンテンツ生成・公開システムの実装記録です。
会社情報:https://hyconsulting2021.jp/

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?