@@ -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
151153interface Point {
152154 lat : number ;
@@ -159,7 +161,26 @@ interface ChiezoSearchResponse {
159161
160162interface 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
165186interface 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 */
189215async 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+ */
215268async 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