テストは全部通っていた。でも公開15ページの文章は全部同じだった — 生成した文章の品質を、テストではなくSQLで測る
住所を入れると、その地点の災害リスクや将来人口をまとめたレポートが出る、という
サービスを個人で運用しています。文章はテンプレートで組み立てています
(LLM は使っていません。取得できた数値からしか文を作らない方針にしています)。
ある日、別の調査のついでに本番データベースへ SQL を投げて、固まりました。
select count(distinct
assessment_result->'score'->'normalized'->'futureProjection'->1->>'summary')
from sample_reports;
-- 1
公開している15ページの「10年後の見立て」が、全部同じ1文でした。
長期居住では、交通・商業維持が効いてくる。
東京駅でも梅田でも博多でも札幌でも、同じ文が出ていました。5年後の見立ても、
15件中12件が同じ文でした。
テストは全部通っていました。型エラーもありません。例外も出ません。
「動いている」の定義に、出力の中身が入っていなかったのです。
この記事は、そこから原因を突き止めて直すまでの記録です。
文章を生成するコードを書いている人には、たぶん同じ穴があります。
1. コードを読んでも「壊れている」と分からなかった
まず実装を読みました。該当する分岐はこうなっていました。
summary:
futureChange !== null && futureChange < 0 && priceGapRatio !== null
? `10年視点では、将来人口 ${fmt(futureChange)} と価格差 ${fmt(priceGapRatio)} が…`
: future.score < 55 || asset.score < 55
? "長期居住では、人口構成と資産性が論点になる。"
: "長期居住では、交通・商業維持が効いてくる。" // ← 全15件がここ
コードとしては正しく見えます。具体的な文もちゃんと用意されています。
条件が「2つの値が揃っていること」を要求しているのが問題でした。
片方が欠けた瞬間、いちばん下の定型文まで滑り落ちます。そして
priceGapRatio(価格差)は取得できないことが多いデータでした。
「エラーではないが、意図した分岐に一度も入っていない」という状態は、
コードを読むだけでは気づけません。どの分岐に何件落ちているかは、
実データを数えないと分からない。
2. 「定型文かどうか」を、実データから定義する
直すには「何を定型文とみなすか」を決める必要があります。
感覚で決めたくなかったので、本番の15件から全部の文を取り出して数えました。
select kind, line, count(*) as n_samples
from ( /* futureProjection, blindSpotAnalysis, comparisonGuidance を union all */ ) t
group by kind, line
order by kind, n_samples desc;
結果が気持ちいいくらいはっきり分かれました。
全15件で同一だった文(=どのエリアでも成り立つ文)
長期居住では、交通・商業維持が効いてくる。 15件
災害時、帰宅導線の条件差が出やすい。 15件
最寄駅だけを見て、代替駅や路線の逃げ道を見落としやすい。 15件
エリアごとに違った文
空き家率 11.6% が高めなら、日用品や生活機能の残り方に差が出やすい。
数年単位では、将来人口 -2.9% と空き家率 15.8% が、商業や交通維持の差として出やすい。
具体的な文には例外なく「単位付きの数値」が入っていて、
定型文には入っていませんでした。 判定基準はこれで決まりです。
/**
* 「この行は取得した数値に裏打ちされているか」を見る。
*
* **「5年」「10年」「数年」は根拠ではない**ので単位から除く。ここを入れると
* 「5年では前提条件の差がじわっと出る。」が具体的な行として通ってしまう。
*/
export function hasDataAnchor(line: string): boolean {
return /[0-9][0-9.,]*\s*(?:%|%|㎡|平米|坪|km|m|分|円|万円|度|人|件|棟|駅)/u.test(line);
}
年 を単位から外すのが地味に重要です。「5年では前提条件の差が…」は
数字を含んでいるのに、中身は空っぽです。見出しの期間は根拠ではありません。
この判定は、本番に実際に入っていた文字列をそのままテストに並べて固定しました。
test("本番で全エリア同一だった文は定型文と判定される", () => {
for (const line of GENERIC) assert.equal(hasDataAnchor(line), false, line);
});
test("本番でエリアごとに違った文は具体的な文と判定される", () => {
for (const line of SPECIFIC) assert.equal(hasDataAnchor(line), true, line);
});
分類の根拠が実データなので、後から読んだ自分が判定を変えるときも迷いません。
3. 「定型文を弾く」実装は、セクションを空にする
判定ができたので、定型文をフィルタで落とそうとしました。
既存のコードにも、まさにそのための呼び出しが書いてありました。
const comparisonAxes = curateEditorialLines([
...(guidance?.priorities ?? []).filter((item) => !isGenericComparisonAxis(item)),
], 3);
これをやると比較軸のセクションが空になります。 比較軸の候補は
災害時導線
駅の代替性
地形と地盤条件
のような短い名詞句で、全部数値を持っていません。 つまり全部「定型文」判定です。
弾いたら候補がゼロになります。
空欄より定型文のほうがまだましなので、弾くのをやめて順位を下げる形にしました。
/**
* `deprioritize` に一致した行は **捨てずに最後へ回す**。
* 以前は呼び出し側で `.filter(!isGeneric...)` と捨てていたが、定型文しか候補が
* 無いケース (比較軸の「災害時導線」など) ではセクションが空になる。
*/
function curateEditorialLines(lines, limit, consumed = [], themes = [], options = {}) {
const primary = [];
const fallback = [];
for (const raw of lines) {
if (typeof raw !== "string") continue;
const line = raw.trim();
if (!line) continue; // 空白のみの行を落とす
if (seen.has(line)) continue; // 重複を落とす
seen.add(line);
(options.deprioritize?.(line) ? fallback : primary).push(line);
}
return [...primary, ...fallback].slice(0, limit);
}
具体的な行が必ず上に来て、枠が余ったときだけ定型文が入ります。
どの入力でもセクションが空にならないことが構造的に保証されます。
「良いものを選ぶ」機能を作るときは、選別に失敗したときの出力を先に決めてください。
フィルタは黙って空を返します。ランキングは何かを返します。
4. .filter(Boolean) は空白だけの文字列を通す
デバッグ中、生成物を覗いたらこういう値が出てきました。
{ title: "5年後の見立て", lead: " ", items: [" "] }
{ title: "10年後の見立て", lead: "\n", items: ["\n"] }
テンプレートの補間が全部空になった結果、空白だけの文字列が
「内容あり」として下流に流れていました。そして呼び出し側はこう書いていました。
items={[
...(perspective.futureLife ?? []),
perspective.fiveYearOutlook?.lead ?? "",
perspective.tenYearOutlook?.lead ?? "",
].filter(Boolean)}
" " は truthy なので .filter(Boolean) を通ります。
空の箇条書きが並ぶはずでした。
助かったのは、描画側が独自に trim していたからでした。
const normalizedItems = items.map((item) => item.trim()).filter(Boolean);
PDF 側も別の場所で value.replace(/\s+/g, " ").trim() をしていました。
たまたま2箇所で守られていただけで、設計としてそうなっていたわけではありません。
空文字チェックは Boolean ではなく line.trim() で書く。
そして生成する側で落とす(描画側の善意に頼らない)。今回は
curateEditorialLines の中で落とすようにしました。
5. 12件のテストが「導入時から一度も通っていなかった」
原因を追っていて、関連するテストファイルが12件落ちていることに気づきました。
自分の変更で壊したのかと思い、git worktree で過去のコミットを遡って走らせました。
git worktree add --detach /tmp/wt <commit>
cd /tmp/wt && node --test lib/reporting/__tests__/editorial-perspective.test.ts
結果、テストを追加したコミットの時点から12件落ちていました。
回帰ではなく、書きかけの仕様がテストだけコミットされた状態でした。
実装側を見ると、こういう一行スタブが11個並んでいました。
function syntheticStickyIssue(input: any): string | null { return null; }
function syntheticBlindSpot(input: any): string | null { return null; }
function isGenericBlindSpot(line: string): boolean { return false; }
function buildSyntheticFutureLifeLines(input: any): string[] { return []; }
function buildDailyReadings(input: any): string[] { return []; }
function buildLivingAnchors(input: any): string[] { return []; }
// ...
引数は全部 any。isGenericBlindSpot が常に false を返すので、
定型文の選別はそもそも動いていませんでした。「定型文が全エリアで同じ」の
直接の原因はこれです。
失敗したままのテストを放置すると、本物の回帰が埋もれる
ここが判断の分かれ目でした。12件をどうするか。
-
通す:
personaSummary.includes("【タイパ:高効率】")のような、
逐語の文言を要求するアサーションが並んでいます。通すには
実装されていない仕様の文言を発明することになります。目的が逆です - 消す: 「何を出したいか」という情報が失われます
- skip にする: 選びました
/*
* **この中の test.skip は「実装されていない仕様」を書いたものです。**
*
* 12件は、テストを追加したコミットの時点から一度も通っていない。回帰ではなく、
* 書きかけの仕様がテストだけコミットされた状態だった。
*
* 12件を赤のまま置いておくと、**本物の回帰が赤の中に埋もれて気づけない**ので
* skip にした。消さないのは、期待している出力の仕様メモとして読む価値があるため。
*
* 実装するときは skip を外して1件ずつ通す。逐語の文言に合わせる必要はなく、
* 「何を出したいか」を読み取って実装とテストの両方を直すこと。
*/
そのうえで、いま保証できることを別ファイルに書き直しました。
-
data-anchor.test.ts— 定型文と具体文の判定(本番の実文字列で固定) -
future-projection.test.ts— 値が1つでもあれば数値付きの文になること
/** エリアごとに違う文になることを、値を変えて直接確かめる。 */
test("値が違えば文も違う (全エリア同一文にならない)", () => {
const lines = [-2.9, -5.2, -9.0, 1.2, 4.8].map((v) => summaries({ futureChange: v }));
assert.equal(new Set(lines.map((x) => x.five)).size, 5);
assert.equal(new Set(lines.map((x) => x.ten)).size, 5);
});
「入力を変えたら出力も変わる」を直接アサートする。 文言の一致ではなく
「異なること」を検査するので、コピーを書き換えても壊れません。
今回の不具合の再発防止として、これが一番効くテストでした。
最終的に 450件中 437 pass / 1 fail(この件とは無関係の既存の失敗)/ 12 skip。
落ちているのが1件だけになったので、次に何か壊れたら気づけます。
6. 直しても公開ページは変わらなかった
いちばん大事な落とし穴です。
コードを直してデプロイして、それで終わりだと思っていました。違いました。
公開している15ページは、生成結果を丸ごとデータベースに保存しています
(ISR でキャッシュするのでそのほうが速い)。
-- sample_reports.assessment_result に score ごと保存されている
select assessment_result->'score'->'normalized'->'futureProjection' from sample_reports;
つまり コードを直しても、公開済みのページの文章は1文字も変わりません。
再生成のスクリプトを走らせるまで、あの「全部同じ10年後の見立て」が出続けます。
有料レポートも購入時のスナップショットを保存しているので同じ性質です。
購入済みのレポートを後から良くすることはできません。
生成結果を保存する設計を採るときは、
- 「コードの修正」と「保存済みデータの更新」が別の作業になることを最初に決めておく
- 再生成のコストを把握しておく(外部APIの課金、生成時間)
- 保存したデータに生成に使ったコードのバージョンを入れておく
(どのレコードが古いロジック由来か分からないと、再生成の判断ができない)
3つ目は、別の機能ではやっていました(キャッシュキーにパイプラインの版番号を
入れて、結果が変わる修正をしたら版を上げる運用)。同じことを
こちらでもやっておくべきでした。
まとめ
-
どの分岐に何件落ちているかは、実データを数えないと分からない。
条件が「値が2つ揃うこと」を要求していて、一度も入っていない分岐があった -
「良い文/悪い文」の基準は実データから定義する。 今回は
「単位付きの数値があるか」。期間の数字(5年/10年)は根拠に数えない -
選別に失敗したときの出力を先に決める。 フィルタは空を返す。
ランキングなら何かを返す。空欄より定型文のほうがまし -
.filter(Boolean)は空白だけの文字列を通す。trim()で判定し、
描画側ではなく生成側で落とす -
失敗したままのテストを放置すると本物の回帰が埋もれる。 未実装仕様のテストは
skip にして経緯を書き、いま保証できることを別ファイルに書く -
「入力を変えたら出力も変わる」を直接アサートする。 文言一致より強く、
コピーの変更で壊れない -
生成結果を保存しているなら、コード修正では直らない。
再生成が別作業。保存レコードに生成ロジックの版を入れておく
テンプレートでも LLM でも、文章を生成するコードは
**「例外を出さずに、内容の薄い出力を返す」**のが標準的な壊れ方です。
型もテストもそこを見てくれません。
一番安かったのは、本番の出力を SQL で数えることでした。
count(distinct ...) を1回投げるだけで、15ページ全部が同じ文だと分かりました。
生成系の機能を持っているなら、一度やってみてください。