LLMを使わず単一HTMLで旅程の破綻を検出する:オフライン動作させる設計判断
家族旅行の旅程を作る側の人、そして「小さいツールにLLMを載せるべきか」で迷っているエンジニア向けの記事です。旅程チェッカー「段取りたび」を作ったときに、LLMを使わず単一HTMLでオフライン動作させる設計を選んだ理由と、その中身をまとめます。
結論
旅程の破綻検出は、LLMに推論させる問題ではなく制約充足問題(CSP)として書く問題だった、という結論に落ち着きました。身長制限、時間指定チケットの重複、乳幼児の可否といった条件は、すべて機械的に真偽が決まる制約です。
制約をデータとして持ち、全制約を総当たりで検査する。これなら判定は決定的になり、通信も不要で、単一HTMLファイルに収まります。LLMは「それっぽい答え」を返すのが得意な道具で、「破綻しているかどうか」を毎回同じ結果で返す用途には合いませんでした。
問題の深掘り:なぜ旅程は出発前に破綻に気づけないのか
家族旅行の旅程が当日に崩れる原因は、だいたい次の4つに集約されます。
- 身長制限に出発前に気づかず、現地でアトラクションに乗れない
- 時間指定チケットの予約が重なっていることに当日まで気づかない
- 乳幼児同伴の可否をアトラクションごとに個別確認する手間がかかる
- 宿や新幹線の予約解禁日がいつか分からず、気づいたら埋まっている
共通しているのは、情報がバラバラの場所にあることです。身長制限は施設の公式ページ、チケット時刻は予約完了メール、解禁日は各社の告知。これらを人間が頭の中で突き合わせて、旅程表と矛盾がないか確認している状態です。
C言語の仕事で例えるなら、仕様書が5つに分散していて、レビュー担当がいない状態に近いです。誰も全体の整合性を機械的に検査していないので、破綻は現地で初めて顕在化します。
自分も家族旅行の段取りでこの種の見落としに困っていて、解決のために段取りたびというツールを作りました。以降は、そのツールの設計判断、特にLLMを使わなかった理由を技術的に説明します。
解決手順:旅程をCSPとして定式化する
ステップ1:LLMを使わない判断基準を決める
最初に、LLMを使うかどうかを次の3点で判断しました。
- 判定結果は毎回同じでなければならないか
- 入力は構造化できるか
- 通信できない環境で動く必要があるか
旅程チェックは3点ともYesでした。「この旅程は破綻していません」と言った直後に、同じ入力で「破綻しています」と言い出す道具は、出発前の安心材料として使えません。入力も、身長、年齢、時刻、日付といった構造化データで済みます。
自分が実際に確かめた範囲での実感ですが、この種の「真偽が決まる検査」にLLMを挟むと、実装が複雑になる割にメリットが残りませんでした。
ステップ2:制約を3種類に分類する
旅程の破綻条件を、データの形で3種類に分けました。
- 属性制約:参加者の属性(身長・年齢)と、アトラクションの条件との比較
- 時間制約:予約枠同士の区間が重なっていないか、移動時間を含めて成立するか
- 日付制約:予約の解禁日と、予約したい日付の関係
この分類があると、新しい制約を足すときに「どの種類か」を決めるだけで済みます。制約の種類が増えても、検査ロジックは「全制約を順に評価して違反を集める」という同じ形のままです。
ステップ3:データと検査ロジックを分離する
制約はコードに埋め込まず、データとして持ちます。
// 参加者
const members = [
{ name: "子A", heightCm: 98, ageYears: 4 },
{ name: "子B", heightCm: 78, ageYears: 1 },
];
// 予定された行動
const plans = [
{
id: "ride-1",
title: "アトラクションX",
start: "10:00",
end: "10:40",
minHeightCm: 102,
infantOk: false,
},
{
id: "ride-2",
title: "ショーY",
start: "10:30",
end: "11:15",
minHeightCm: 0,
infantOk: true,
},
];
制約は「違反を返す関数」の配列にしておきます。
// 時刻 "HH:MM" を分に変換
const toMin = (t) => {
const [h, m] = t.split(":").map(Number);
return h * 60 + m;
};
// 属性制約:身長・乳幼児可否
function checkAttributes(plans, members) {
const violations = [];
for (const p of plans) {
for (const m of members) {
if (m.heightCm < p.minHeightCm) {
violations.push(
`${p.title}:${m.name}は身長${m.heightCm}cmで制限${p.minHeightCm}cmに届かない`
);
}
if (m.ageYears < 3 && !p.infantOk) {
violations.push(`${p.title}:${m.name}は乳幼児のため同伴不可`);
}
}
}
return violations;
}
// 時間制約:予定の区間が重なっていないか
function checkOverlap(plans) {
const violations = [];
for (let i = 0; i < plans.length; i++) {
for (let j = i + 1; j < plans.length; j++) {
const a = plans[i];
const b = plans[j];
if (toMin(a.start) < toMin(b.end) && toMin(b.start) < toMin(a.end)) {
violations.push(`${a.title}と${b.title}の時間が重なっている`);
}
}
}
return violations;
}
// 全制約をまとめて評価
function validate(plans, members) {
return [...checkAttributes(plans, members), ...checkOverlap(plans)];
}
validate は純粋関数で、同じ入力なら必ず同じ出力を返します。これがLLMを使わない最大の利点です。テストも、入力と期待される違反リストを並べるだけで書けます。
ステップ4:単一HTMLにまとめる
HTML、CSS、JavaScriptを1ファイルに収め、外部ライブラリもCDNも使わない構成にしました。外部に依存する部品があると、オフラインで開いたときに壊れる可能性が出るためです。
配布も楽になります。ZIPに入れて渡し、ダブルクリックでブラウザが開けば動く。サーバーも、アカウントも、通信も要りません。旅先の電波が弱い場所でも、同じ結果が返ってきます。
ステップ5:「観測」と「推定」を分ける
予約の解禁日だけは、制約の種類が少し違いました。解禁日は各社の告知に依存する外部の事実で、単一HTMLの中では分かりません。
ここでは解禁日を推定しない方針を取りました。「通常は1か月前の10時に解禁されるはず」といった推定は、外れたときに「まだ予約できる」と誤って安心させます。そこで GitHub Actions で毎日、公開ページを観測して、実際に解禁されたかどうかを記録する構成にしました。
オフラインのHTML本体は「検査」、解禁日の監視は「観測」と役割を分けています。推定値を混ぜないことで、ツールが言うことの信頼性を保てます。
コード/実例
先ほどのデータで validate を呼ぶと、次の結果になります。
const result = validate(plans, members);
console.log(result);
[
"アトラクションX:子Aは身長98cmで制限102cmに届かない",
"アトラクションX:子Bは身長78cmで制限102cmに届かない",
"アトラクションX:子Bは乳幼児のため同伴不可",
"アトラクションXとショーYの時間が重なっている"
]
出発前にこの4行が見えていれば、現地で並んでから断られることはありません。自分の旅程に当てはめると、見落としていた制約が出発前に表に出てくる、という使い方になります。
ここで書いた属性制約と時間制約は、総当たりでもプラン数が数十件程度なら問題なく動きます。制約の数が増えて探索が重くなるのは、旅程を自動生成するような用途で、検査だけなら総当たりで足ります。
落とし穴と対処
落とし穴1:制約をコードに埋め込んだ
最初の試作では、身長制限をif文で直接書いていました。施設の条件が変わるたびにコードを直す羽目になり、手戻りが続きました。データとロジックを分離してからは、変更点がデータだけで済むようになりました。
落とし穴2:時刻の境界条件
10:00〜10:40の予定と、10:40開始の予定を「重なり」と判定してしまうバグを出しました。区間の比較を <= で書いていたのが原因で、< に直して解消しています。C言語でも区間の境界は毎回ミスしやすい箇所で、半開区間として扱うと決めておくとブレません。
落とし穴3:LLMに寄せたくなる誘惑
自然文で旅程を入力できたら便利だろう、という発想は何度か出ました。ただ、入力の解釈にLLMを入れた瞬間に、オフライン動作と決定性の両方が崩れます。入力は構造化フォームに割り切り、判定側は決定的に保つ、という線引きにしています。
関連ツール
この設計で作ったのが、家族の制約から旅程の破綻を出発前に検出するオフライン旅程チェッカーです。Google Drive 限定リンクでZIP配布(¥1,480)しています。
→ 段取りたび
まとめ
旅程の破綻検出は、真偽が決まる制約の検査なので、LLMではなくCSPとして書くのが合っていました。
制約をデータとして持ち、純粋関数で全件評価すれば、判定は決定的になり、単一HTMLでオフライン動作させられます。解禁日のように外部の事実に依存するものは、推定せず観測で扱うと、ツールの言うことを信頼できる状態に保てます。
小さいツールを作るときは、LLMを使う前に「その判定は真偽が決まる問題か」を一度確認してみる価値があります。
著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。
書いているもの:
- 段取りたび - 家族の制約から旅程の破綻を出発前に検出する、オフラインで動く旅程チェッカー
(その他:(なし))
同じ手で作った道具を、インストールなしで試せます — 自分が実際に使っている自作ツールを、ブラウザからそのまま動かせる形で並べています。登録は不要です。
→ RunStack(無料で試す)