Skip to content

Commit db801da

Browse files
committed
情報を集めさせるを、区画で回る網羅の収集へ乗せ替える
1本のカーソルでは「一周した」が言えなかった。次に回る地域をAIに書かせ、 回り終えた地名を積む形だったので、取りこぼしがどこかも全国を舐め終わったかも 分からない(市区町村を数時間に1つでは200日を超える)。収集済みの判定も AIが書いた地名の表記に依存していて、同じ場所の別の書き方を吸う処理と、 円周8方位を突いて市区町村を引く処理が要った。 知識サーバーの網羅(kind: stock)へ移す。日本全体のbboxを母集団の密度で 再帰的に二分した区画を配ってもらう形なので、区画は数え上げられて一周が言え、 進み具合が矩形なので表記の入り込む余地が無い。母集団は地図辞典を指す —— 収集の文書は座標を持てない(抽出が元のextraを運ばない)ので自分の点では割れず、 bboxを明示しないと「まだ何も無い範囲」が対象から落ちる。区画の細かさは 母集団の件数から逆算する(1回に見る区画数に上限があるため、区画数を抑えないと 一周が静かに延びる)。 巡回は3本。名簿は週1で地図辞典から機械的に引き直し(AIを呼ばない)、ざっとが 3日で全区画を舐めて精査し、じっくりが7日で1区画ずつ詳しく調べる。名簿は 足すだけの回と組にする —— 組にしないと、AIが肉付けした内容を名前と所在地だけの 薄い名簿で上書きする。じっくりはざっとが一周するまで走らせない —— 区画の中身が一度行き渡っていないと、何が足りないのかを判断する材料が無い。 割り込みは専用の巡回を置かず、じっくりの枠で走らせる。頼むのは同じ仕事なので 設定も依頼文もそこのものでよく、別に持つとじっくりを育てても割り込みだけ古い文で 走る。頼み方は区画の名指しと見出しの名指しの2つで、どちらも定時の巡回の予定も 区画の巡回記録も動かさない(予定を進める口で頼むと、割り込むたびに一周が伸びる)。 間違い報告のあったスポットは、報告された理由をそのまま依頼文にして名指しで渡す。 集めたい軸は種別ごとに違うので、抽出条件とプロンプトは決め打ちにできない。 ふつうの言葉の依頼文を知識サーバーへ渡してAIに書かせ、抽出条件のほうは その場で引いた件数と先頭数件を添えて返させる —— タグは完全一致でしか引けず、 それらしい名前を書かれると静かな0件になる。2回に分けてあるのは待ち時間が 積み上がるのを避けるためと、抽出条件が決まってから書いたプロンプトのほうが 良くなるため。疑う回のプロンプトだけはアプリが持つ定型にした —— 種別ごとに違うのは何を集めるかであって、どう疑うかではない。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d159d43 commit db801da

12 files changed

Lines changed: 1357 additions & 497 deletions

File tree

CLAUDE.md

Lines changed: 134 additions & 58 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,18 @@ AI が選んだ候補にはチェックと「AIが選定」の印が付き、ジ
181181
登録済みは灰色)。地図データは日本の種別のみ。
182182

183183
**その場で待たずに集めさせることもできる**(管理画面の「情報を集めさせる」)。
184-
何を集めさせるかを種別ごとに書いて依頼しておくと、知識サーバー側が数時間ごとに
185-
**地域を順に舐めながら**集めていく(1周目は粗く、2周目3周目で取りこぼしを埋める)。
184+
集めたいものをふつうの言葉で書くと、**AI が抽出条件とプロンプトを書く** ——
185+
まず地図データから名簿を機械的に作り、日本全体を密度で割った**区画**
186+
知識サーバーが順に配る。全区画を **3日で一周して精査**し、そのあと
187+
**7日で一周して1件ずつ詳しく調べる**(新しい店を足し、閉店や重複を落とす)。
186188
集まったものは地図の長押しメニューの**「この周辺の集めた情報」**から、周辺を探すと
187189
同じパネルで選んで追加する。座標は同じく地図データから引き直し、当てられなかったものは
188190
「座標なし」の印が付いて、住所から引き直すまで選べない。
189191

190-
**その範囲がまだ集まっていなければ、そう出る。** 円が跨ぐ市区町村のうち回り終えて
191-
いないものを挙げ、**「いま集める」でその地域を先に回らせられる**(予定の順番を飛ばす)。
192-
管理画面では、いま知識サーバーが収集中かどうかと、回り終えた地域の数も見られる。
192+
**その範囲がまだ集まっていなければ、そう出る。** 円に重なる区画のうちまだ見ていない
193+
数を挙げ、**「この範囲をいま集める」で先に回らせられる**(定時の巡回の予定は動かない)。
194+
間違いに気づいたスポットは、管理画面の間違い報告の一覧から**名指しで直させられる**
195+
管理画面では、いま知識サーバーが収集中かどうかと、巡回ごとの一周の進み具合も見られる。
193196

194197
**依頼しても勝手には走らない。** 知識サーバー側の管理画面で「有効にする」を押すまでは
195198
止まったままで、travel-log 側からは動かせない(外のアプリが勝手に AI を走らせないため)。

app/api/spots/collect/candidates/route.ts

Lines changed: 18 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ import {
1919
type CollectedDoc,
2020
} from "@/lib/spotCollect";
2121
import {
22-
areaProbePoints,
22+
partitionCoverage,
2323
type CollectCandidatesResult,
24-
type CollectCoverage,
2524
} from "@/lib/spotCollect";
2625
import {
2726
chiezo,
@@ -154,58 +153,16 @@ function mostCommon(values: string[]): string | null {
154153
return best;
155154
}
156155

157-
/** 画面と収集に渡す地域の書き方(都道府県が取れていれば付ける) */
158-
function areaLabel(area: PointArea): string {
159-
return area.prefecture ? `${area.prefecture}${area.municipality}` : area.municipality;
160-
}
161-
162-
/**
163-
* その地域を回り終えているか。**書き方の揺れを吸う** —— 回り終えた印はAIが書くので、
164-
* 「東京都新宿区」とも「新宿区」とも来る。市区町村が入っていることを必須にし、
165-
* 都道府県はこちらが分かっているときだけ併せて見る(同名の市が別の県にあるため)。
166-
*/
167-
function isCovered(area: PointArea, covered: string[]): boolean {
168-
return covered.some(
169-
(c) =>
170-
c.includes(area.municipality) &&
171-
(!area.prefecture || !hasOtherPrefecture(c, area.prefecture))
172-
);
173-
}
174-
175-
/** その印が、別の都道府県を名乗っているか(名乗っていなければ判断しない) */
176-
function hasOtherPrefecture(covered: string, prefecture: string): boolean {
177-
const named = (PREFECTURES as readonly string[]).find((p) => covered.includes(p));
178-
return !!named && named !== prefecture;
179-
}
180-
181156
/**
182-
* 円が跨いでいる地域を出し、収集済みかを見る。
157+
* 円の中が収集済みかは、**区画の巡回記録で見る**(`partitionCoverage`)。
158+
* かつては円周8方位を突いて市区町村を引き、回り終えた印(AIが書いた地名)と
159+
* 突き合わせていたが、「東京都新宿区」と「新宿区」の揺れを吸う処理が要った。
160+
* 区画は矩形なので、そこに表記の入り込む余地が無い。
183161
*
184-
* **中心だけでは足りない** —— 円が境界を跨いでいると隣の市区町村を見落とす。
185-
* 「円の中のすべてで取り終わっているか」を聞かれている以上、跨ぎを拾えないと
186-
* 答えにならないので、中心と円周8方位を突く(`areaProbePoints`)。
162+
* 中心の地域(`areaAt`)はいまも引く —— **座標を引き当てられなかった候補**が
163+
* 円の中の話かを見るのに要る(新しい店は地図辞典に載っていないのが普通で、
164+
* そこを落とすとこの機能で拾いたいものがちょうど落ちる)。
187165
*/
188-
async function checkCoverage(
189-
baseUrl: string,
190-
center: { lat: number; lng: number },
191-
radiusM: number,
192-
covered: string[]
193-
): Promise<CollectCoverage> {
194-
const found = await Promise.all(
195-
areaProbePoints(center, radiusM).map((p) => areaAt(baseUrl, p))
196-
);
197-
// 同じ地域は1つに畳む(円の中の複数の点が同じ市区町村に落ちるのが普通)
198-
const byLabel = new Map<string, PointArea>();
199-
for (const area of found) {
200-
if (area) byLabel.set(areaLabel(area), area);
201-
}
202-
const areas = [...byLabel.keys()];
203-
return {
204-
areas,
205-
covered: areas.filter((a) => isCovered(byLabel.get(a)!, covered)),
206-
missing: areas.filter((a) => !isCovered(byLabel.get(a)!, covered)),
207-
};
208-
}
209166

210167
interface ChiezoDocResponse {
211168
title?: unknown;
@@ -400,14 +357,16 @@ export async function GET(request: Request) {
400357
Number.isFinite(lat) && Number.isFinite(lng) && Number.isFinite(radius) && radius > 0
401358
? { center: { lat, lng }, radius }
402359
: null;
360+
// **区画の巡回記録から見る。** 円に重なる区画のうち、「ざっと」が一度でも
361+
// 見たものがいくつか —— 地名の突き合わせが要らないのがこの方式の要
362+
const collection = circle ? await fetchCollection(baseUrl, source) : null;
403363
const coverage = circle
404-
? await checkCoverage(
405-
baseUrl,
406-
circle.center,
407-
circle.radius,
408-
(await fetchCollection(baseUrl, source))?.covered ?? []
409-
)
364+
? partitionCoverage(circle.center, circle.radius, collection?.partitions ?? [])
410365
: null;
366+
// 座標を引き当てられなかった候補が円の中の話かを見るための、中心の地域。
367+
// **中心1点だけでよい** —— 円が跨ぐ隣まで拾う必要があったのは収集済みの
368+
// 判定のほうで、そちらは区画が答えるようになった
369+
const circleArea = circle ? await areaAt(baseUrl, circle.center) : null;
411370

412371
if (error) {
413372
// まだ1回も焼けていないとソース自体が無い。**それは失敗ではない**ので、
@@ -493,8 +452,9 @@ export async function GET(request: Request) {
493452
if (c.lat !== 0 || c.lng !== 0) {
494453
return distanceMeters(circle.center, { lat: c.lat, lng: c.lng }) <= circle.radius;
495454
}
455+
if (!circleArea) return true;
496456
const area = c.address ?? c.region ?? "";
497-
return (coverage?.areas ?? []).some((a) => areaWords(a).some((w) => area.includes(w)));
457+
return area.includes(circleArea.municipality);
498458
};
499459

500460
const result: CollectCandidatesResult = {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { NextResponse } from "next/server";
2+
import {
3+
draftExtract,
4+
draftPrompt,
5+
findCollection,
6+
resolveCollectContext,
7+
} from "@/lib/spotCollectServer";
8+
9+
/**
10+
* 抽出条件・プロンプトをAIに書かせる(spot_admin/admin専用)。**保存はしない**。
11+
*
12+
* **集めたい軸は種別ごとに違う**(ラーメン店・神社・駅・道の駅…)ので、どのソースの
13+
* どのタグを引くかも、どう調べさせるかも決め打ちにできない。ふつうの言葉で書いた
14+
* 依頼文をchiezoへ渡し、返ってきた案を画面で確かめてから依頼する。
15+
*
16+
* **2つに分けてあるのは、1回ずつ待たせるため。** どちらもAIが1回動くので
17+
* 十数秒〜数分かかる。まとめて走らせると待ち時間が積み上がるうえ、
18+
* **抽出条件が決まってから書いたプロンプトのほうが良くなる**(どの辞典から
19+
* 引いた名簿を精査するのかを、依頼文に書けるようになる)。
20+
*
21+
* | `kind` | 呼ぶ先 | 返るもの |
22+
* |---|---|---|
23+
* | `extract` | `/v1/collect/draft-extract` | 抽出条件と、**その場で引いた件数・先頭数件** |
24+
* | `prompt` | `/v1/collect/draft` | 巡回のプロンプト |
25+
*
26+
* 抽出条件のほうに件数が付くのが要 —— **タグは完全一致でしか引けない**ので、
27+
* それらしい名前を書かれると静かな0件になる。0件のときchiezoは実在するタグ名を
28+
* 候補として返すので、画面はそれを出して依頼文を書き直させる。
29+
*/
30+
export async function POST(request: Request) {
31+
const ctx = await resolveCollectContext(request);
32+
if ("error" in ctx) return ctx.error;
33+
const { baseUrl, source } = ctx;
34+
35+
const body = await request.json().catch(() => null);
36+
const text = (v: unknown) => (typeof v === "string" ? v.trim() : "");
37+
const want = text(body?.want);
38+
if (!want) {
39+
return NextResponse.json({ error: "依頼文を入力してください。" }, { status: 400 });
40+
}
41+
// **既にある収集にだけ名前を渡す。** 向こうは名前で相手・モデルと現在の指定を
42+
// 引くので、まだ無い名前を渡すと404で断られる
43+
const name = (await findCollection(baseUrl, source)) ? source : undefined;
44+
45+
const kind = new URL(request.url).searchParams.get("kind") ?? "prompt";
46+
const { data, error } =
47+
kind === "extract"
48+
? await draftExtract(baseUrl, {
49+
want,
50+
name,
51+
backend: text(body?.backend),
52+
model: text(body?.model),
53+
effort: text(body?.effort),
54+
})
55+
: await draftPrompt(baseUrl, {
56+
want,
57+
name,
58+
current: text(body?.current),
59+
feedback: text(body?.feedback),
60+
});
61+
if (error) return NextResponse.json({ error }, { status: 502 });
62+
return NextResponse.json({ data });
63+
}

0 commit comments

Comments
 (0)