Skip to content

Commit ef6d9ca

Browse files
committed
周辺を探すに Overture を足し、ファウルではなく事実で候補を選べるようにする
地図データを OSM 辞典だけから、Overture Places と OSM の両方を引いて重ねる形にした。 穴の位置が互いに違うため。新宿 500m の実測で、飲食店は Overture 1,651 件に対して OSM 403 件、店の URL は 200 件中 193 件に対して 51 件。逆に Overture は日本の extra.area(都道府県)がほぼ空で、そこは OSM が埋める。片方だけにすると必ず落ちる。 ライセンスが辞典ごとに違う(OSM は ODbL、Overture は CDLA Permissive 2.0)ので、 どちらから来たかを候補ごとに持たせ、説明文の出どころもそこから決める。還元用 エクスポートで外すべきは OSM 由来のぶんだけなので、混ぜたまま区別できないと困る。 AI の候補は「事実は地図データから取り直す」形にした。当たった候補は座標・住所・ 公式サイトを地図側の値で置き換える。AI の座標は当たった店でも数十m、遠いもので 446m ずれていた。照合は問い合わせ語を段階的に緩める(括弧の中 → 支店の名乗り → いちばん長い語)—— 生の名前だけで引いていた頃は、アカシア 新宿本店 が地図の アカシア本店 に当たらず「確認できなかった」扱いになっていた。緩めたぶんの歯止めは 正規化した名前が互いを含むことで、ここを緩めると別の店を掴む(掴むと座標も URL も 黙って別の店のものに置き換わるので、取りこぼすより悪い)。 ファウル…ではなく「地図データに無い」の判定と表示も直した。最初から選んでおくかを 参照 URL の有無で決めていたが、URL は AI が書いた文字列で確かめる相手がいないうえ、 quick の AI は URL を 1 件も返さない(実測)。地図に在るかどうかのほうが強い根拠。 地図データ側は件数の上限をやめた(半径の中を全部)。chiezo の filter は 1 回 500 件 までなので offset で継ぐが、残りの頁は一度に投げる —— 1 頁ずつ待つと半径 1km で 11 秒かかった。既定の半径は 300m にした(半径がそのまま件数になるため)。 あわせて、探す範囲を薄い青円で出す、探索中は左クリックでも「ここでもう一度探す」を 出す、各行から座標と店名を 1 枚の地図で見比べられるようにする(経路検索の出発地に 座標・目的地に店名を入れると、ずれが距離として読める)。 通し番号は prev から決めるようにした。useRef を ++ するのは更新関数の中の副作用で、 StrictMode が更新関数を 2 回呼ぶ開発時には番号が件数ぶんずれる(上限を外して数千件 返るようになって表面化した)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7967e08 commit ef6d9ca

12 files changed

Lines changed: 1403 additions & 300 deletions

File tree

CLAUDE.md

Lines changed: 160 additions & 22 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
| **重ね表示** | 別のスポット種別を半透明で同じ地図に重ねられる |
3636
| **対象地域** | 種別ごとに日本(都道府県)・特定の国(州・県)・世界全体(国)から選べる |
3737
| **AIに聞く** | スポットの詳細から、Claude・ChatGPT・Perplexity・Grok・Geminiに所在地と座標を添えて質問できる(営業時間とアクセスを先に答えるよう頼んである) |
38-
| **周辺を探す** | 管理者が地図の右クリック/長押しから検索語(「ランチ」など)を入れると、周辺の候補を地図に番号つきで並べる。**まず地図データ(OpenStreetMap)から1秒かからず出し**、足りなければAIのweb検索で同じ一覧に探し足せる。右側のパネルで外す・選ぶ・まとめて追加ができる。任意機能で、知識サーバー(chiezo)への接続先を設定し、種別ごとに有効にしたときだけ出る |
38+
| **周辺を探す** | 管理者が地図の右クリック/長押しから検索語(「ランチ」など)を入れると、周辺の候補を地図に番号つきで並べる。**まず地図データ(Overture Maps と OpenStreetMap)から1秒かからず出し**、足りなければAIのweb検索で同じ一覧に探し足せる。右側のパネルで外す・選ぶ・まとめて追加ができる。任意機能で、知識サーバー(chiezo)への接続先を設定し、種別ごとに有効にしたときだけ出る |
3939
| **PWA対応** | ホーム画面に追加して、アドレスバーなしの独立アプリとして起動できる(オフライン対応は未実装) |
4040

4141
## スクリーンショット
@@ -146,15 +146,34 @@ PUBLIC_BASE_URL=https://travel.example.com
146146
印で並ぶ。右側のパネルで外す・チェックする・ランクを直す・まとめて追加する。
147147
追加したものだけが通常のスポット(手動追加扱い)になり、候補そのものは保存されない。
148148

149-
探し方は2つあり、**既定は速いほう**
149+
**まず地図データを引き、AI は任意の後段**として足りないぶんを補う
150150

151-
| 探し方 | かかる時間 | 得意なもの |
151+
| | かかる時間 | 得意なもの |
152152
|---|---|---|
153-
| **地図データ**(既定) | 1秒かからない | 正確な座標。AIの枠を使わない |
154-
| **AIでweb検索** | 15秒〜2分 | 地図に無い新しい店、一言の説明 |
153+
| **地図データ**(必ず走る) | 数秒(半径による) | 正確な座標。AI の枠を使わない |
154+
| **AI で補う**(チェックしたときだけ) | 15秒〜2分 | 地図に無い新しい店、一言の説明 |
155155

156-
まず地図データで雑に集め、足りなければパネルの「もう一度探す」からAIで探し足す
157-
(同じ一覧に混ざり、行に出どころの印が付く)、が想定の流れ。地図データは日本の種別のみ。
156+
「AI に足りないぶんを補わせる」にチェックを入れると、**地図データの結果が先に出たあとで**
157+
AI にも聞いて同じ一覧に足す(待っている間もパネルと地図は見られる)。
158+
159+
地図データは**件数の上限を持たない**(半径の中にあるものを全部並べる)。件数を決めるのは
160+
半径で、新宿での実測は 300m=1,798件(2.2秒)/ 500m=3,300件(3.9秒)/ 1km=5,267件(6.6秒)。
161+
広く取るほど一覧が長くなるので、まずは狭い半径から。**件数の選択はAIで探すときだけ**出る
162+
(返させる件数がそのまま待ち時間になるのはAI側の事情のため)。
163+
164+
探す範囲は地図に**薄い青の円**で出る(半径を選び直すとその場で変わる)。探している間は
165+
**左クリックでも「ここでもう一度探す」**が出るので、隣の区画へ続けて探せる。
166+
各行の「座標と店名を見比べる」は、**候補の座標から店名で引いた場所までの経路**を Google
167+
マップで開く。1枚の地図に両方の印が出て、**ずれがそのまま距離で読める**(同じ場所なら
168+
数十m、違う場所を指していれば数百m以上)。
169+
170+
まず地図データで雑に集め、足りなければパネルの「もう一度探す」からAIで探し足す、が想定の流れ。
171+
同じ一覧に混ざるので、**地図の印もパネルの行も色で出どころが分かる**(地図データは青、
172+
AI は紫、登録済みは灰色)。地図データは日本の種別のみ。
173+
174+
地図データは **Overture Maps と OpenStreetMap の両方**を引いて重ねる。穴の位置が違うためで、
175+
店の数と店のURLは Overture(新宿500mの飲食店で 1,651件 / URL は見た200件中193件)、
176+
都道府県と構造化された住所は OpenStreetMap(同 403件 / 51件)が埋める。
158177

159178
AI で探すときは**念の入れ方**も選ぶ(既定は「さっくり」)。
160179

@@ -186,14 +205,22 @@ AI で探すときの相手(chiezo に登録された AI のうち web 検索を
186205
逃がすためと、その場の目的で速さを選ぶためのもの。選んだ内容はその端末に覚える。
187206

188207
- 軽いモデル・低い深さほど早い。深さは未選択なら自動で `low` を使う(相手が対応するとき)
189-
- AI の候補の位置は chiezo の OSM 辞典で確かめる。新しい店は OSM に無いのが普通なので
190-
「位置未確認」は珍しくない。地図で見て違えば、追加後に「位置を修正」で直す
191-
- OpenStreetMap のデータは ODbL。**自分のインスタンスで周辺を引く用途**を想定していて、
192-
地図表示には出典を出している。travel-log-data へ還元するときは、OSM 由来の名前・座標を
193-
そのまま持ち込まないこと(本体は MIT)
194-
- 位置の確認と重複判定はこちらで行うが、**内容の真偽はAIの答え次第**。参照URLの無い
195-
候補は薄く出し、既定では選ばれない。説明文には「AIがwebから収集(日付)」と参照URLが
196-
自動で入るので、手で書いた説明と見分けがつく
208+
- AI の候補の位置は chiezo の地図辞典(Overture → OSM の順)で確かめる。開いたばかりの
209+
店は載っていないのが普通なので「位置未確認」は珍しくない。地図で見て違えば、
210+
追加後に「位置を修正」で直す
211+
- ライセンスは辞典ごとに違う(OpenStreetMap は ODbL、Overture Maps は CDLA Permissive 2.0
212+
/ Apache 2.0)。**自分のインスタンスで周辺を引く用途**を想定していて、地図表示には
213+
出典を出している。travel-log-data へ還元するときは、**継承条件のある OSM 由来の
214+
名前・座標をそのまま持ち込まないこと**(本体は MIT)。どちらの辞典から来たかは
215+
説明文に書かれる
216+
- **当たった候補は、座標・住所・公式サイトを地図データの値で取り直す。** AIに残るのは
217+
「どの店を挙げるか」と一言の要約だけ。実測で AI の座標は当たった店でも数十m、
218+
遠いものは 446m ずれていた(地図上では別の建物に立つ)
219+
- **地図データで見つからなかった候補は「地図データに無い」と出し、既定では選ばれない。**
220+
開いたばかりの店なら普通のことなので、地図で確かめてから足せばよい。
221+
**頼んだ半径の外に出た候補にも印を出す**
222+
- 説明文には「AIがwebから収集(日付)」と参照URLが自動で入るので、手で書いた説明と
223+
見分けがつく。**要約の中身の真偽はAIの答え次第**
197224
- 商用のグルメサイト・地図サービスのAPIは「自前DBへの保存・再配布禁止」の条件があり
198225
出どころにできないため、web検索を持つAIに調べさせる形にしている
199226

app/api/spots/discover/route.ts

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
DISCOVERY_DEPTHS,
1919
MAX_DISCOVERY_QUERY_LENGTH,
2020
distanceMeters,
21+
looksLikeSameSpot,
22+
mapLookupQueries,
2123
normalizeSpotName,
2224
type DiscoveryBackend,
2325
type DiscoveryCandidate,
@@ -39,7 +41,7 @@ import { bboxAround } from "@/lib/osmNearby";
3941
*
4042
* **なぜAIに調べさせるか。** 飲食店のような「新しい店がよく入れ替わる」種別は、
4143
* 商用のAPI(グルメサイト・地図サービス)が自前DBへの保存と再配布を禁じているため
42-
* 出どころにできず、OSMは地方だと10年前で止まっている地物が多くて新店が載らない
44+
* 出どころにできず、地図データは開いたばかりの店が載らない
4345
* そこでweb検索を持つAIに周辺を調べさせ、機械的に決めにくいランク付けまで任せる。
4446
*
4547
* **AIへの中継は知識サーバー(chiezo)の`/v1/ai/complete`。** 鍵はあちらが握っていて
@@ -58,9 +60,9 @@ import { bboxAround } from "@/lib/osmNearby";
5860
* **候補ごとにNominatimを叩かない**(1秒1回の間隔制限があり、10件で10秒以上かかっていた)。
5961
*
6062
* **AIの答えは信用せず、こちらで確かめてから出す。**
61-
* - 位置: chiezoのOSM辞典(`osm_japan`。ローカルで数ms、レート制限が無いので**並列に**引く)を
63+
* - 位置: chiezoの地図辞典(`overture_japan`→`osm_japan`の順。ローカルで数ms、レート制限が無いので**並列に**引く)を
6264
* 店名+半径のbboxで引き、当たればその座標に置き換えて「位置確認済み」。
63-
* 当たらなければAIの座標のまま「未確認」の印(新しい店はOSMに無いのが普通なので
65+
* 当たらなければAIの座標のまま「未確認」の印(開いたばかりの店は地図データに無いのが普通なので
6466
* 未確認は珍しくない。地図で見て違えば追加後に「位置を修正」で直す)
6567
* - 重複: 同じ種別の公開・承認待ちスポットと名前一致か50m以内なら「登録済み」の印
6668
* - 出どころ: 参照URLの無い候補はそのまま返す(画面が薄く出す)。口コミ本文や点数の
@@ -146,7 +148,7 @@ export async function GET(request: Request) {
146148
return NextResponse.json({ data: options });
147149
}
148150

149-
// ---- 位置の確認(chiezoのOSM辞典だけ。並列に引く) ----
151+
// ---- 位置の確認(chiezoの地図辞典。並列に引く) ----
150152

151153
interface Point {
152154
lat: number;
@@ -159,7 +161,26 @@ interface ChiezoSearchResponse {
159161

160162
interface ChiezoDocResponse {
161163
title?: unknown;
162-
extra?: { lat?: unknown; lon?: unknown; area?: unknown };
164+
extra?: {
165+
lat?: unknown;
166+
lon?: unknown;
167+
area?: unknown;
168+
/** Overture側にはURL・電話・住所が入っている(OSMは tags 側なのでここでは見ない) */
169+
website?: unknown;
170+
address?: unknown;
171+
locality?: unknown;
172+
};
173+
}
174+
175+
/** 地図辞典で見つかった1件。位置だけでなく**確かめられた事実**を持ち帰る */
176+
interface MapMatch {
177+
point: Point;
178+
/** 地図側の見出し(画面には出さない。突き合わせの確認用) */
179+
title: string;
180+
/** 公式サイト。AIが答えたURLより優先する(出どころが辿れるため) */
181+
website: string | null;
182+
/** 地図側の住所。日本語のときだけ使う(Overtureはローマ字表記が混ざる) */
183+
address: string | null;
163184
}
164185

165186
interface ChiezoFilterResponse {
@@ -182,36 +203,68 @@ async function chiezoGet<T>(baseUrl: string, path: string, params: URLSearchPara
182203
}
183204

184205
/**
185-
* chiezoのOSM辞典で店名を半径内から探し、当たった地物の正確な座標を返す。
206+
* chiezoの地図辞典で店名を半径内から探し、当たった地物の正確な座標を返す。
186207
* 検索の応答には座標が載らない(snippetに4桁までの文字があるだけ)ので、
187-
* 当たった題名で`doc`を引き直す。どちらもローカルで数msなので2往復してよい
208+
* 当たった題名で`doc`を引き直す。どちらもローカルで数msなので2往復してよい。
209+
*
210+
* **Overtureを先に見る**。AIが挙げるのは新しい店・話題の店が多く、そこがいちばん
211+
* OSMの穴と重なる(実測: AIが挙げた有名店4件のうちOSMで引けたのは1件)。
212+
* Overtureは日本で301万件あってOSMの倍近い。当たらなければOSMへ落ちる ——
213+
* 寺社や公園のように、Overture側の分類が薄いものはOSMのほうが確実。
188214
*/
189215
async function verifyWithChiezo(
190216
baseUrl: string,
191217
name: string,
192218
center: Point,
193219
radiusM: number
194-
): Promise<Point | null> {
195-
const found = await chiezoGet<ChiezoSearchResponse>(
196-
baseUrl,
197-
"/v1/osm_japan/search",
198-
new URLSearchParams({ q: name, limit: "3", bbox: bboxAround(center, radiusM * VERIFY_RADIUS_FACTOR) })
199-
);
200-
for (const hit of found?.results ?? []) {
201-
if (typeof hit.title !== "string") continue;
202-
const doc = await chiezoGet<ChiezoDocResponse>(
203-
baseUrl,
204-
"/v1/osm_japan/doc",
205-
new URLSearchParams({ title: hit.title, fields: "title,extra" })
220+
): Promise<MapMatch | null> {
221+
const bbox = bboxAround(center, radiusM * VERIFY_RADIUS_FACTOR);
222+
const sources = ["overture_japan", "osm_japan"] as const;
223+
// **問い合わせ語を段階的に緩める**(`mapLookupQueries`)。生の名前だけで引くと、
224+
// 実在する店を「確認できなかった」と扱ってしまう。緩めるぶんは`looksLikeSameSpot`で
225+
// 別の店を掴まないよう歯止めをかける
226+
for (const q of mapLookupQueries(name)) {
227+
const founds = await Promise.all(
228+
sources.map((source) =>
229+
chiezoGet<ChiezoSearchResponse>(
230+
baseUrl,
231+
`/v1/${source}/search`,
232+
new URLSearchParams({ q, limit: "5", bbox })
233+
)
234+
)
206235
);
207-
const lat = Number(doc?.extra?.lat);
208-
const lng = Number(doc?.extra?.lon);
209-
if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng };
236+
for (const [i, source] of sources.entries()) {
237+
for (const hit of founds[i]?.results ?? []) {
238+
if (typeof hit.title !== "string") continue;
239+
if (!looksLikeSameSpot(name, hit.title)) continue;
240+
const doc = await chiezoGet<ChiezoDocResponse>(
241+
baseUrl,
242+
`/v1/${source}/doc`,
243+
new URLSearchParams({ title: hit.title, fields: "title,extra" })
244+
);
245+
const lat = Number(doc?.extra?.lat);
246+
const lng = Number(doc?.extra?.lon);
247+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
248+
const text = (v: unknown) =>
249+
typeof v === "string" && v.trim() ? v.trim() : null;
250+
const address = text(doc?.extra?.address);
251+
return {
252+
point: { lat, lng },
253+
title: hit.title,
254+
website: text(doc?.extra?.website),
255+
// ローマ字の住所(`1 Chome-6-3 Kabukicho`)はAIの日本語の答えより読みにくい
256+
address: address && /[--鿿]/.test(address) ? address : null,
257+
};
258+
}
259+
}
210260
}
211261
return null;
212262
}
213263

214-
/** 中心座標の都道府県をchiezoのOSM辞典から引く(近くの地物の`area`)。無ければnull */
264+
/**
265+
* 中心座標の都道府県をchiezoのOSM辞典から引く(近くの地物の`area`)。無ければnull。
266+
* **ここだけはOSM** —— Overtureの`area`はJISのコードで、しかも日本ではほぼ空
267+
*/
215268
async function regionWithChiezo(baseUrl: string, center: Point): Promise<string | null> {
216269
// 中心のすぐ近くに地物が無いこともあるので、狭い枠から広げて2回まで見る
217270
for (const radius of [100, 1000]) {
@@ -290,7 +343,7 @@ async function regionWithNominatim(center: Point, scope: string): Promise<string
290343
}
291344

292345
/**
293-
* 候補の位置を確かめる。**chiezoのOSM辞典だけ**を引く(ローカルで数ms、
346+
* 候補の位置を確かめる。**chiezoの地図辞典だけ**を引く(ローカルで数ms、
294347
* レート制限が無いので候補ぶんを並列に投げられる)。当たらなければnull =
295348
* AIの座標のまま「未確認」。
296349
* かつてはここでNominatimにも聞いていたが、1秒1回の間隔制限がそのまま待ちになり、
@@ -303,7 +356,7 @@ async function verifyLocation(
303356
center: Point,
304357
radiusM: number,
305358
scope: string
306-
): Promise<Point | null> {
359+
): Promise<MapMatch | null> {
307360
if (scope !== "jp") return null;
308361
return verifyWithChiezo(baseUrl, candidate.name, center, radiusM);
309362
}
@@ -319,7 +372,8 @@ function buildSystemPrompt(depth: DiscoveryDepth): string {
319372
if (depth === "quick") {
320373
return [
321374
"周辺の店を手早く挙げる。**webの検索は多くても2回**。1回の検索で分かる範囲で答え、裏取りに時間をかけない。",
322-
"知っている店をそのまま挙げてよい。閉店が明らかなものだけ外す。",
375+
"**実在すると確信できる店だけ**を挙げる。名前・場所があやふやなものは数合わせに入れず、件数が足りなくてもそのまま返す。",
376+
"**指定された半径の外は入れない。**",
323377
"口コミの本文・点数・順位は書き写さない。",
324378
"出力はJSONの配列だけ。前置き・説明・コードブロックの記号は付けない。",
325379
].join("\n");
@@ -674,13 +728,13 @@ export async function POST(request: Request) {
674728

675729
// 位置の確認は**並列**に投げる(chiezoはローカルでレート制限が無い)。
676730
// 直列に1件ずつ待っていた頃は、件数がそのまま待ち時間に乗っていた
677-
const verifiedPoints = await Promise.all(
731+
const matches = await Promise.all(
678732
raws.map((raw) => verifyLocation(baseUrl, raw, center, radius, scope))
679733
);
680734

681735
const candidates: DiscoveryCandidate[] = raws.map((raw, i) => {
682-
const verified = verifiedPoints[i];
683-
const position = verified ?? { lat: raw.lat, lng: raw.lng };
736+
const verified = matches[i];
737+
const position = verified?.point ?? { lat: raw.lat, lng: raw.lng };
684738
const existing =
685739
existingByName.get(normalizeSpotName(raw.name)) ??
686740
existingSpots.find(
@@ -691,7 +745,9 @@ export async function POST(request: Request) {
691745
name: raw.name,
692746
// よみがなとランクの根拠はAIに聞かない(項目を増やすと待ちが延びる)
693747
name_kana: null,
694-
address: raw.address,
748+
// **確かめられた住所があればそちらを使う**(AIの住所は確かめる相手がいない)。
749+
// 地図側がローマ字表記のときはnullで返るので、その場合はAIの答えを残す
750+
address: verified?.address ?? raw.address,
695751
// 地域はAIに聞かず、中心座標から引いたものを全候補に使う
696752
// (半径は数kmまでなので、中心の県で足りる)
697753
region: centerRegion,
@@ -701,9 +757,15 @@ export async function POST(request: Request) {
701757
summary: raw.summary,
702758
rank: raw.rank,
703759
rank_reason: null,
704-
url: raw.url,
760+
// **地図データの公式サイトを優先する**。AIが答えたURLは確かめる相手がいないが、
761+
// こちらは実在を確かめた地物に紐づいている。実測では`quick`のAIは
762+
// URLを1件も返さない一方、地図側は10件中9件で持っていた ——
763+
// 根拠が無いように見えていた候補のほとんどに、実は出どころがあった
764+
url: verified?.website ?? raw.url,
705765
location_verified: verified !== null,
706766
distance_m: Math.round(distanceMeters(center, position)),
767+
// 地図データではないので辞典は無い(説明文は「AIがwebから収集」になる)
768+
dataset: null,
707769
existing: existing ? { id: existing.id, name: existing.name } : null,
708770
};
709771
});

0 commit comments

Comments
 (0)