ynosis-agent-mcp 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/package.json +6 -1
- package/src/client.mjs +26 -0
- package/src/csv_import.mjs +54 -0
- package/src/facility_match.mjs +142 -0
- package/src/facility_prepare.mjs +153 -0
- package/src/facility_remediate.mjs +102 -0
- package/src/kuroshiro_romaji.mjs +29 -0
- package/src/places.mjs +96 -0
- package/src/sequence_ratio.mjs +20 -0
- package/src/server.mjs +137 -5
package/README.md
CHANGED
|
@@ -32,4 +32,26 @@ Ynosis Agent API 用の **MCP(stdio)**。Cursor など Node がある環境
|
|
|
32
32
|
|
|
33
33
|
接続先の既定は `https://ynosis.jp`(リダイレクトを追います)。
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
## 施設登録フロー(Python MCP と同一)
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
find_existing_facility → update_facility(既存・認定・タグ不足時も編集依頼)
|
|
39
|
+
→ resolve_place_candidates → prepare_facility_for_register → register_facility(新規)
|
|
40
|
+
誤登録整理: remediate_duplicate_facility(正規へ依頼 + withdraw)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
主要ツール: `find_existing_facility`, `prepare_facility_for_register`, `resolve_place_candidates`, `withdraw_facility`, `remediate_duplicate_facility`, `get_tag_options`
|
|
44
|
+
|
|
45
|
+
## 運営: npm 公開
|
|
46
|
+
|
|
47
|
+
リポジトリ内手順: [docs/agent/publishing.md](../../docs/agent/publishing.md#npmynosis-agent-mcp)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cd packages/ynosis-agent-mcp
|
|
51
|
+
npm install && npm test
|
|
52
|
+
npm publish --access public
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
公開前に Python パッケージ(`ynosis-agent`)との MCP パリティを確認: `.cursor/rules/ynosis-agent-mcp-parity.mdc`
|
|
56
|
+
|
|
57
|
+
手順の詳細(利用者向け): <https://ynosis.jp/notes/usage-037>
|
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ynosis-agent-mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"test": "node --test test/*.test.mjs"
|
|
6
|
+
},
|
|
4
7
|
"description": "MCP stdio server for the Ynosis Agent API (facilities and spring analyses)",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"bin": {
|
|
@@ -23,6 +26,8 @@
|
|
|
23
26
|
],
|
|
24
27
|
"dependencies": {
|
|
25
28
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
29
|
+
"kuroshiro": "^1.2.0",
|
|
30
|
+
"kuroshiro-analyzer-kuromoji": "^1.1.0",
|
|
26
31
|
"zod": "^3.24.2"
|
|
27
32
|
}
|
|
28
33
|
}
|
package/src/client.mjs
CHANGED
|
@@ -101,6 +101,32 @@ export class AgentApiClient {
|
|
|
101
101
|
return res.json;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
async findExistingFacility(name, prefecture = null, options = {}) {
|
|
105
|
+
const { findExistingFacility: findExisting } = await import("./facility_match.mjs");
|
|
106
|
+
return findExisting(this, name, prefecture, options);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async withdrawFacility(facilityId) {
|
|
110
|
+
const res = await this.request("POST", `/api/agent/facilities/${facilityId}/withdraw`);
|
|
111
|
+
return res.json;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async remediateDuplicateFacility(duplicateId, { canonicalId = null, dryRun = false } = {}) {
|
|
115
|
+
const { remediateDuplicateFacility: remediate } = await import("./facility_remediate.mjs");
|
|
116
|
+
return remediate(this, duplicateId, { canonicalId, dryRun });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async prepareFacilityPayload(payload, { place = null, maxResults = 5 } = {}) {
|
|
120
|
+
const { filterValidTags, resolvePlaceForPayload } = await import("./facility_prepare.mjs");
|
|
121
|
+
const prepared = { ...payload };
|
|
122
|
+
if (prepared.tags) prepared.tags = filterValidTags(prepared.tags);
|
|
123
|
+
const { payload: out, status, detail } = await resolvePlaceForPayload(prepared, {
|
|
124
|
+
place,
|
|
125
|
+
maxResults,
|
|
126
|
+
});
|
|
127
|
+
return { payload: out, place_status: status, detail };
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
async registerFacilitiesBatch(facilities, { continueOnError = true } = {}) {
|
|
105
131
|
const results = [];
|
|
106
132
|
let created = 0;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** 調査 CSV → FacilityCreate(Python ynosis_agent.csv_import と同期)。 */
|
|
2
|
+
|
|
3
|
+
const STATUS_MAP = {
|
|
4
|
+
営業中: "active",
|
|
5
|
+
休業: "dormant",
|
|
6
|
+
閉業: "closed",
|
|
7
|
+
不明: "unknown",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function splitTags(raw) {
|
|
11
|
+
if (!raw?.trim()) return [];
|
|
12
|
+
return raw
|
|
13
|
+
.replace(/,/g, ",")
|
|
14
|
+
.split(";")
|
|
15
|
+
.map((t) => t.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildNotes(row) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
for (const key of ["湧出タイプ", "利用形態", "備考", "情報源"]) {
|
|
22
|
+
const val = (row[key] || "").trim();
|
|
23
|
+
if (val) parts.push(key === "備考" ? val : `${key}: ${val}`);
|
|
24
|
+
}
|
|
25
|
+
return parts.length ? parts.join("\n") : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function facilityName(row) {
|
|
29
|
+
const base = (row["施設名"] || "").trim();
|
|
30
|
+
const bath = (row["浴場名"] || "").trim();
|
|
31
|
+
if (base && bath) return `${base} ${bath}`;
|
|
32
|
+
return base || bath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function rowToFacilityPayload(row) {
|
|
36
|
+
const name = facilityName(row);
|
|
37
|
+
if (!name) throw new Error("施設名・浴場名のいずれかが必要です");
|
|
38
|
+
|
|
39
|
+
const prefecture = (row["都道府県"] || "").trim() || null;
|
|
40
|
+
const onsenName = (row["温泉地名"] || "").trim() || null;
|
|
41
|
+
const statusRaw = (row["営業状況(営業中/休業/閉業/不明)"] || row["営業状況"] || "").trim();
|
|
42
|
+
const status = STATUS_MAP[statusRaw] || (statusRaw ? "unknown" : "active");
|
|
43
|
+
|
|
44
|
+
const payload = {
|
|
45
|
+
name,
|
|
46
|
+
prefecture,
|
|
47
|
+
onsen_name: onsenName,
|
|
48
|
+
status_operational: status,
|
|
49
|
+
tags: splitTags(row["タグ(;区切り)"] || row["タグ"]),
|
|
50
|
+
};
|
|
51
|
+
const notes = buildNotes(row);
|
|
52
|
+
if (notes) payload.notes = notes;
|
|
53
|
+
return payload;
|
|
54
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** 施設名類似度・既存照合(Python ynosis_agent.facility_match と同期)。 */
|
|
2
|
+
|
|
3
|
+
import { toRomajiHepburn } from "./kuroshiro_romaji.mjs";
|
|
4
|
+
import { sequenceRatio } from "./sequence_ratio.mjs";
|
|
5
|
+
|
|
6
|
+
export const NEARBY_RADIUS_METERS = 50;
|
|
7
|
+
export const NAME_SIMILARITY_HIGH = 0.8;
|
|
8
|
+
export const NAME_SIMILARITY_LOW_WITH_LOCATION_HINT = 0.5;
|
|
9
|
+
export const MATCH_SCORE_MIN = 85;
|
|
10
|
+
|
|
11
|
+
export async function normalizeFacilityName(name) {
|
|
12
|
+
let text = String(name || "").normalize("NFKC");
|
|
13
|
+
text = text.replace(/[\s・・\-‐‑–—_/]+/g, "");
|
|
14
|
+
text = text.replace(/[((][^))]*[))]/g, "");
|
|
15
|
+
text = text.replace(/(温泉|旅館|ホテル)$/, "");
|
|
16
|
+
if (!text) return "";
|
|
17
|
+
return toRomajiHepburn(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function nameSimilarity(a, b) {
|
|
21
|
+
const normA = await normalizeFacilityName(a);
|
|
22
|
+
const normB = await normalizeFacilityName(b);
|
|
23
|
+
if (!normA || !normB) return 0;
|
|
24
|
+
return sequenceRatio(normA, normB);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function haversineDistanceMeters(lat1, lng1, lat2, lng2) {
|
|
28
|
+
const r = 6371000;
|
|
29
|
+
const phi1 = (lat1 * Math.PI) / 180;
|
|
30
|
+
const phi2 = (lat2 * Math.PI) / 180;
|
|
31
|
+
const dPhi = ((lat2 - lat1) * Math.PI) / 180;
|
|
32
|
+
const dLambda = ((lng2 - lng1) * Math.PI) / 180;
|
|
33
|
+
const a =
|
|
34
|
+
Math.sin(dPhi / 2) ** 2 +
|
|
35
|
+
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) ** 2;
|
|
36
|
+
return 2 * r * Math.asin(Math.sqrt(a));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function facilitySearchQueries(name) {
|
|
40
|
+
const clean = String(name || "").trim();
|
|
41
|
+
if (!clean) return [];
|
|
42
|
+
const queries = [clean];
|
|
43
|
+
const noParen = clean.replace(/[((].*?[))]/g, "").trim();
|
|
44
|
+
if (noParen && !queries.includes(noParen)) queries.push(noParen);
|
|
45
|
+
return queries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function scoreFacilityItem(candidateName, item) {
|
|
49
|
+
const dbName = item?.name || "";
|
|
50
|
+
let score = await nameSimilarity(candidateName, dbName);
|
|
51
|
+
for (const alias of item?.aliases || []) {
|
|
52
|
+
const aliasName = typeof alias === "object" ? alias?.name : alias;
|
|
53
|
+
if (aliasName) score = Math.max(score, await nameSimilarity(candidateName, String(aliasName)));
|
|
54
|
+
}
|
|
55
|
+
if (score >= 0.95) return 100;
|
|
56
|
+
if (score >= NAME_SIMILARITY_HIGH) return 90;
|
|
57
|
+
if (score >= NAME_SIMILARITY_LOW_WITH_LOCATION_HINT) return 85;
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function scoreWithLocation(compareNames, item, lat, lng) {
|
|
62
|
+
let base = 0;
|
|
63
|
+
for (const cn of compareNames) {
|
|
64
|
+
base = Math.max(base, await scoreFacilityItem(cn, item));
|
|
65
|
+
}
|
|
66
|
+
if (base >= MATCH_SCORE_MIN) return base;
|
|
67
|
+
if (lat == null || lng == null) return base;
|
|
68
|
+
const itemLat = item?.lat;
|
|
69
|
+
const itemLng = item?.lng;
|
|
70
|
+
if (itemLat == null || itemLng == null) return base;
|
|
71
|
+
const distance = haversineDistanceMeters(lat, lng, Number(itemLat), Number(itemLng));
|
|
72
|
+
if (distance > NEARBY_RADIUS_METERS) return base;
|
|
73
|
+
let sim = 0;
|
|
74
|
+
for (const cn of compareNames) {
|
|
75
|
+
sim = Math.max(sim, await nameSimilarity(cn, item?.name || ""));
|
|
76
|
+
}
|
|
77
|
+
if (sim >= NAME_SIMILARITY_LOW_WITH_LOCATION_HINT) return Math.max(base, 85);
|
|
78
|
+
return base;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function findExistingFacility(
|
|
82
|
+
client,
|
|
83
|
+
name,
|
|
84
|
+
prefecture = null,
|
|
85
|
+
{
|
|
86
|
+
compareNames = null,
|
|
87
|
+
queries = null,
|
|
88
|
+
lat = null,
|
|
89
|
+
lng = null,
|
|
90
|
+
searchLimit = 30,
|
|
91
|
+
} = {}
|
|
92
|
+
) {
|
|
93
|
+
const primary = String(name || "").trim();
|
|
94
|
+
if (!primary) return null;
|
|
95
|
+
|
|
96
|
+
const names = compareNames ? [...compareNames] : [primary];
|
|
97
|
+
if (!names.includes(primary)) names.unshift(primary);
|
|
98
|
+
|
|
99
|
+
const searchQs = [];
|
|
100
|
+
if (queries) {
|
|
101
|
+
for (const q of queries) {
|
|
102
|
+
const s = String(q || "").trim();
|
|
103
|
+
if (s && !searchQs.includes(s)) searchQs.push(s);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const cn of names) {
|
|
107
|
+
for (const q of facilitySearchQueries(cn)) {
|
|
108
|
+
if (!searchQs.includes(q)) searchQs.push(q);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let bestScore = 0;
|
|
113
|
+
let bestItem = null;
|
|
114
|
+
let bestSim = 0;
|
|
115
|
+
|
|
116
|
+
for (const q of searchQs) {
|
|
117
|
+
const params = { q, limit: searchLimit };
|
|
118
|
+
if (prefecture) params.prefecture = prefecture;
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await client.searchFacilities(params);
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const item of res.items || []) {
|
|
126
|
+
if (prefecture && (item.prefecture || "") !== prefecture) continue;
|
|
127
|
+
const sc = await scoreWithLocation(names, item, lat, lng);
|
|
128
|
+
let sim = 0;
|
|
129
|
+
for (const cn of names) {
|
|
130
|
+
sim = Math.max(sim, await nameSimilarity(cn, item.name || ""));
|
|
131
|
+
}
|
|
132
|
+
if (sc > bestScore) {
|
|
133
|
+
bestScore = sc;
|
|
134
|
+
bestItem = item;
|
|
135
|
+
bestSim = sim;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (bestScore < MATCH_SCORE_MIN || !bestItem) return null;
|
|
141
|
+
return { facility: bestItem, match_score: bestScore, name_similarity: bestSim };
|
|
142
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** 施設登録前 payload 整形(Python ynosis_agent.facility_prepare と同期)。 */
|
|
2
|
+
|
|
3
|
+
import { FACILITY_TAG_OPTIONS } from "./client.mjs";
|
|
4
|
+
import {
|
|
5
|
+
attachPlaceToPayload,
|
|
6
|
+
buildPlaceQuery,
|
|
7
|
+
pickPlaceCandidate,
|
|
8
|
+
placeFieldsFromCandidate,
|
|
9
|
+
resolvePlaceCandidates,
|
|
10
|
+
} from "./places.mjs";
|
|
11
|
+
|
|
12
|
+
export const WATER_TREATMENT_FIELDS = ["added_water", "added_heat", "circulation", "disinfection"];
|
|
13
|
+
const TERNARY_STATES = new Set(["yes", "no", "unknown"]);
|
|
14
|
+
|
|
15
|
+
export function filterValidTags(tags) {
|
|
16
|
+
if (!tags?.length) return [];
|
|
17
|
+
const allowed = new Set(FACILITY_TAG_OPTIONS);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const tag of tags) {
|
|
20
|
+
const t = String(tag || "").trim();
|
|
21
|
+
if (t && allowed.has(t) && !out.includes(t)) out.push(t);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pickWaterTreatmentFields(payload) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const key of WATER_TREATMENT_FIELDS) {
|
|
29
|
+
const val = payload?.[key];
|
|
30
|
+
if (val == null) continue;
|
|
31
|
+
const s = String(val).trim().toLowerCase();
|
|
32
|
+
if (TERNARY_STATES.has(s)) out[key] = s;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mergeFacilityUpdates(existing, proposed, { mergeTags = true } = {}) {
|
|
38
|
+
const updates = {};
|
|
39
|
+
|
|
40
|
+
for (const key of ["hours", "price", "price_detail", "area", "address", "onsen_name", "notes"]) {
|
|
41
|
+
const val = proposed?.[key];
|
|
42
|
+
if (val != null && String(val).trim() && existing?.[key] !== val) updates[key] = val;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [key, val] of Object.entries(pickWaterTreatmentFields(proposed))) {
|
|
46
|
+
if (existing?.[key] !== val) updates[key] = val;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const certs = [...(existing?.certifications || [])];
|
|
50
|
+
const newCerts = proposed?.certifications;
|
|
51
|
+
if (Array.isArray(newCerts)) {
|
|
52
|
+
const mergedCerts = [...certs];
|
|
53
|
+
let changed = false;
|
|
54
|
+
for (const c of newCerts) {
|
|
55
|
+
if (c && !mergedCerts.includes(c)) {
|
|
56
|
+
mergedCerts.push(c);
|
|
57
|
+
changed = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (changed) updates.certifications = mergedCerts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const propUrls = proposed?.certification_urls;
|
|
64
|
+
if (propUrls && typeof propUrls === "object") {
|
|
65
|
+
const urls = { ...(existing?.certification_urls || {}) };
|
|
66
|
+
let urlChanged = false;
|
|
67
|
+
for (const [cert, url] of Object.entries(propUrls)) {
|
|
68
|
+
if (url && urls[cert] !== url) {
|
|
69
|
+
urls[cert] = url;
|
|
70
|
+
urlChanged = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (urlChanged) updates.certification_urls = urls;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mergeTags) {
|
|
77
|
+
const newTags = filterValidTags(proposed?.tags);
|
|
78
|
+
if (newTags.length) {
|
|
79
|
+
const merged = [...(existing?.tags || [])];
|
|
80
|
+
let changed = false;
|
|
81
|
+
for (const t of newTags) {
|
|
82
|
+
if (!merged.includes(t)) {
|
|
83
|
+
merged.push(t);
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (changed) updates.tags = merged;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Object.keys(updates).length ? updates : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function resolvePlaceForPayload(payload, { place = null, maxResults = 5 } = {}) {
|
|
95
|
+
if (place) {
|
|
96
|
+
return { payload: attachPlaceToPayload(payload, place), status: "attached_given", detail: place };
|
|
97
|
+
}
|
|
98
|
+
if (payload?.lat != null && payload?.lng != null) {
|
|
99
|
+
return { payload, status: "skipped_has_location", detail: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const name = String(payload?.name || "").trim();
|
|
103
|
+
const prefecture = String(payload?.prefecture || "").trim();
|
|
104
|
+
const query = buildPlaceQuery({
|
|
105
|
+
name,
|
|
106
|
+
prefecture: prefecture || undefined,
|
|
107
|
+
address: payload?.address,
|
|
108
|
+
onsen_name: payload?.onsen_name,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let candidates;
|
|
112
|
+
try {
|
|
113
|
+
candidates = await resolvePlaceCandidates(query, { maxResults });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { payload, status: "places_error", detail: e instanceof Error ? e.message : String(e) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const picked = pickPlaceCandidate(name, prefecture, candidates);
|
|
119
|
+
if (picked) {
|
|
120
|
+
return {
|
|
121
|
+
payload: attachPlaceToPayload(payload, picked),
|
|
122
|
+
status: "attached_picked",
|
|
123
|
+
detail: picked,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { payload, status: "needs_llm", detail: candidates };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function resolvePlaceUpdatesForFacility(facility, { maxResults = 5 } = {}) {
|
|
130
|
+
if (facility?.lat != null && facility?.lng != null) {
|
|
131
|
+
return { updates: null, status: "skipped_has_location", detail: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const name = String(facility?.name || "").trim();
|
|
135
|
+
const prefecture = String(facility?.prefecture || "").trim();
|
|
136
|
+
const query = buildPlaceQuery({
|
|
137
|
+
name,
|
|
138
|
+
prefecture: prefecture || undefined,
|
|
139
|
+
address: facility?.address,
|
|
140
|
+
onsen_name: facility?.onsen_name,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let candidates;
|
|
144
|
+
try {
|
|
145
|
+
candidates = await resolvePlaceCandidates(query, { maxResults });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return { updates: null, status: "places_error", detail: e instanceof Error ? e.message : String(e) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const picked = pickPlaceCandidate(name, prefecture, candidates);
|
|
151
|
+
if (!picked) return { updates: null, status: "needs_llm", detail: candidates };
|
|
152
|
+
return { updates: placeFieldsFromCandidate(picked), status: "attached_picked", detail: picked };
|
|
153
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Agent 重複誤登録の整理(Python ynosis_agent.facility_remediate と同期)。 */
|
|
2
|
+
|
|
3
|
+
import { MATCH_SCORE_MIN, facilitySearchQueries, scoreFacilityItem } from "./facility_match.mjs";
|
|
4
|
+
import { mergeFacilityUpdates } from "./facility_prepare.mjs";
|
|
5
|
+
|
|
6
|
+
function appendNote(existing, line) {
|
|
7
|
+
const base = String(existing || "").trim();
|
|
8
|
+
if (base.includes(line)) return base;
|
|
9
|
+
return base ? `${base}\n${line}` : line;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function findCanonicalFacility(client, duplicate, { searchLimit = 30 } = {}) {
|
|
13
|
+
const dupId = Number(duplicate.id);
|
|
14
|
+
const name = String(duplicate.name || "").trim();
|
|
15
|
+
const prefecture = duplicate.prefecture;
|
|
16
|
+
if (!name) return null;
|
|
17
|
+
|
|
18
|
+
let bestScore = 0;
|
|
19
|
+
let bestItem = null;
|
|
20
|
+
const queries = facilitySearchQueries(name);
|
|
21
|
+
|
|
22
|
+
for (const q of queries) {
|
|
23
|
+
const params = { q, limit: searchLimit };
|
|
24
|
+
if (prefecture) params.prefecture = prefecture;
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await client.searchFacilities(params);
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const item of res.items || []) {
|
|
32
|
+
if (Number(item.id) === dupId) continue;
|
|
33
|
+
if (prefecture && (item.prefecture || "") !== prefecture) continue;
|
|
34
|
+
const sc = await scoreFacilityItem(name, item);
|
|
35
|
+
if (sc > bestScore) {
|
|
36
|
+
bestScore = sc;
|
|
37
|
+
bestItem = item;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (bestScore < MATCH_SCORE_MIN || !bestItem) return null;
|
|
43
|
+
return bestItem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function remediateDuplicateFacility(
|
|
47
|
+
client,
|
|
48
|
+
duplicateId,
|
|
49
|
+
{ canonicalId = null, dryRun = false } = {}
|
|
50
|
+
) {
|
|
51
|
+
const duplicate = await client.getFacility(duplicateId);
|
|
52
|
+
if (duplicate.is_agent_approved) {
|
|
53
|
+
return { ok: false, error: "承認済みのため取り下げ不可", duplicate_id: duplicateId };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let canonical = canonicalId != null ? await client.getFacility(canonicalId) : await findCanonicalFacility(client, duplicate);
|
|
57
|
+
if (!canonical) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
action: "no_canonical",
|
|
61
|
+
duplicate_id: duplicateId,
|
|
62
|
+
duplicate_name: duplicate.name,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const canonId = Number(canonical.id);
|
|
67
|
+
canonical = await client.getFacility(canonId);
|
|
68
|
+
const marker =
|
|
69
|
+
`[Agent重複整理] 未承認ID ${duplicateId}「${duplicate.name}」は` +
|
|
70
|
+
` 当施設(ID ${canonId})と同一の誤登録。Agent 側で取り下げ${dryRun ? "予定" : "済み"}。`;
|
|
71
|
+
const proposed = { ...duplicate, notes: appendNote(canonical.notes, marker) };
|
|
72
|
+
const updates = mergeFacilityUpdates(canonical, proposed) || {};
|
|
73
|
+
if (proposed.notes !== canonical.notes) updates.notes = proposed.notes;
|
|
74
|
+
|
|
75
|
+
const result = {
|
|
76
|
+
ok: true,
|
|
77
|
+
action: "remediate",
|
|
78
|
+
duplicate_id: duplicateId,
|
|
79
|
+
duplicate_name: duplicate.name,
|
|
80
|
+
canonical_id: canonId,
|
|
81
|
+
canonical_name: canonical.name,
|
|
82
|
+
updates,
|
|
83
|
+
dry_run: dryRun,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (dryRun) return result;
|
|
87
|
+
|
|
88
|
+
let editRequestId = null;
|
|
89
|
+
if (!Object.keys(updates).length) {
|
|
90
|
+
result.warning = "正規施設への追記差分なし(認定・タグは既に付与済みの可能性)";
|
|
91
|
+
} else {
|
|
92
|
+
const resp = await client.updateFacility(canonId, updates);
|
|
93
|
+
editRequestId = resp.edit_request_id;
|
|
94
|
+
if (!editRequestId) {
|
|
95
|
+
result.warning = "編集依頼が作成されなかった(差分なし扱い)";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const withdraw = await client.withdrawFacility(duplicateId);
|
|
99
|
+
result.edit_request_id = editRequestId;
|
|
100
|
+
result.withdraw = withdraw;
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** pykakasi hepburn と同等の romaji 化(kuroshiro + kuromoji)。 */
|
|
2
|
+
|
|
3
|
+
let kuroshiroPromise = null;
|
|
4
|
+
|
|
5
|
+
async function getKuroshiro() {
|
|
6
|
+
if (!kuroshiroPromise) {
|
|
7
|
+
kuroshiroPromise = (async () => {
|
|
8
|
+
const KuroshiroModule = await import("kuroshiro");
|
|
9
|
+
const KuromojiAnalyzerModule = await import("kuroshiro-analyzer-kuromoji");
|
|
10
|
+
const Kuroshiro =
|
|
11
|
+
KuroshiroModule.default?.default || KuroshiroModule.default || KuroshiroModule;
|
|
12
|
+
const KuromojiAnalyzer = KuromojiAnalyzerModule.default || KuromojiAnalyzerModule;
|
|
13
|
+
const instance = new Kuroshiro();
|
|
14
|
+
await instance.init(new KuromojiAnalyzer());
|
|
15
|
+
return instance;
|
|
16
|
+
})();
|
|
17
|
+
}
|
|
18
|
+
return kuroshiroPromise;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function toRomajiHepburn(text) {
|
|
22
|
+
const k = await getKuroshiro();
|
|
23
|
+
const romaji = await k.convert(text, {
|
|
24
|
+
to: "romaji",
|
|
25
|
+
mode: "normal",
|
|
26
|
+
romajiSystem: "hepburn",
|
|
27
|
+
});
|
|
28
|
+
return romaji.replace(/\s+/g, "").toLowerCase();
|
|
29
|
+
}
|
package/src/places.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const TEXT_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText";
|
|
2
|
+
const FIELD_MASK = "places.id,places.displayName,places.formattedAddress,places.location";
|
|
3
|
+
|
|
4
|
+
function resolveGoogleMapsApiKey() {
|
|
5
|
+
const key = process.env.GOOGLE_MAPS_API_KEY || "";
|
|
6
|
+
if (!key) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
"GOOGLE_MAPS_API_KEY が未設定です。座標候補の取得には Google Maps API キーが必要です。"
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return key;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildPlaceQuery({ name, prefecture, address, onsen_name }) {
|
|
15
|
+
const parts = [String(name || "").trim()];
|
|
16
|
+
if (address?.trim()) parts.push(address.trim());
|
|
17
|
+
else if (prefecture?.trim()) parts.push(prefecture.trim());
|
|
18
|
+
if (onsen_name?.trim() && !parts.includes(onsen_name.trim())) parts.push(onsen_name.trim());
|
|
19
|
+
return parts.filter(Boolean).join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Text Search で候補を返す。採用は LLM / 人間が行う。 */
|
|
23
|
+
export async function resolvePlaceCandidates(query, { maxResults = 5 } = {}) {
|
|
24
|
+
const q = String(query || "").trim();
|
|
25
|
+
if (!q) throw new Error("query が空です");
|
|
26
|
+
const key = resolveGoogleMapsApiKey();
|
|
27
|
+
const limit = Math.max(1, Math.min(maxResults, 10));
|
|
28
|
+
|
|
29
|
+
const res = await fetch(TEXT_SEARCH_URL, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"X-Goog-Api-Key": key,
|
|
34
|
+
"X-Goog-FieldMask": FIELD_MASK,
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
textQuery: q,
|
|
38
|
+
languageCode: "ja",
|
|
39
|
+
regionCode: "JP",
|
|
40
|
+
maxResultCount: limit,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
throw new Error(`Places Text Search 失敗: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
46
|
+
}
|
|
47
|
+
const body = JSON.parse(text);
|
|
48
|
+
const places = body.places || [];
|
|
49
|
+
return places.slice(0, limit).map((place) => {
|
|
50
|
+
const loc = place.location || {};
|
|
51
|
+
return {
|
|
52
|
+
place_id: place.id,
|
|
53
|
+
name: place.displayName?.text,
|
|
54
|
+
formatted_address: place.formattedAddress,
|
|
55
|
+
lat: loc.latitude,
|
|
56
|
+
lng: loc.longitude,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normName(s) {
|
|
62
|
+
return String(s || "")
|
|
63
|
+
.normalize("NFKC")
|
|
64
|
+
.replace(/\s+/g, "")
|
|
65
|
+
.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function placeFieldsFromCandidate(candidate) {
|
|
69
|
+
const out = {};
|
|
70
|
+
if (candidate?.place_id) out.place_id = candidate.place_id;
|
|
71
|
+
if (candidate?.lat != null) out.lat = candidate.lat;
|
|
72
|
+
if (candidate?.lng != null) out.lng = candidate.lng;
|
|
73
|
+
if (candidate?.formatted_address) out.address = candidate.formatted_address;
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function acceptPlaceCandidate(facilityName, prefecture, candidate) {
|
|
78
|
+
const addr = candidate?.formatted_address || "";
|
|
79
|
+
if (prefecture && !addr.includes(prefecture)) return false;
|
|
80
|
+
const fn = normName(facilityName);
|
|
81
|
+
const cn = normName(candidate?.name || "");
|
|
82
|
+
if (!fn || !cn) return false;
|
|
83
|
+
if (fn === cn) return true;
|
|
84
|
+
if (fn.includes(cn) || cn.includes(fn)) return Math.min(fn.length, cn.length) >= 4;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function pickPlaceCandidate(facilityName, prefecture, candidates) {
|
|
89
|
+
if (!candidates?.length) return null;
|
|
90
|
+
const accepted = candidates.filter((c) => acceptPlaceCandidate(facilityName, prefecture, c));
|
|
91
|
+
return accepted.length === 1 ? accepted[0] : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function attachPlaceToPayload(payload, candidate) {
|
|
95
|
+
return { ...payload, ...placeFieldsFromCandidate(candidate) };
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** difflib.SequenceMatcher.ratio 相当(2*M/T)。 */
|
|
2
|
+
|
|
3
|
+
function lcsLength(a, b) {
|
|
4
|
+
const m = a.length;
|
|
5
|
+
const n = b.length;
|
|
6
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
7
|
+
for (let i = 1; i <= m; i += 1) {
|
|
8
|
+
for (let j = 1; j <= n; j += 1) {
|
|
9
|
+
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
10
|
+
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return dp[m][n];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sequenceRatio(a, b) {
|
|
17
|
+
if (!a || !b) return 0;
|
|
18
|
+
const matches = lcsLength(a, b);
|
|
19
|
+
return (2 * matches) / (a.length + b.length);
|
|
20
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
FACILITY_TAG_OPTIONS,
|
|
9
9
|
prepareParsedForSave,
|
|
10
10
|
} from "./client.mjs";
|
|
11
|
+
import { rowToFacilityPayload } from "./csv_import.mjs";
|
|
12
|
+
import { buildPlaceQuery, resolvePlaceCandidates } from "./places.mjs";
|
|
11
13
|
|
|
12
14
|
function client() {
|
|
13
15
|
return new AgentApiClient();
|
|
@@ -27,22 +29,139 @@ function textResult(text) {
|
|
|
27
29
|
|
|
28
30
|
const server = new McpServer({
|
|
29
31
|
name: "ynosis-agent",
|
|
30
|
-
version: "0.1
|
|
32
|
+
version: "0.2.1",
|
|
31
33
|
instructions:
|
|
32
34
|
"Ynosis(温泉データベース)への施設・成分表登録・検索用ツール。" +
|
|
33
|
-
"
|
|
35
|
+
"新規施設登録前: find_existing_facility で重複確認 → 既存なら update_facility、" +
|
|
36
|
+
"新規のみ resolve_place_candidates → LLM が同一候補を選び prepare_facility_for_register → register_facility(推測禁止)。" +
|
|
37
|
+
"検索は search_facilities / get_facility / find_existing_facility。" +
|
|
34
38
|
"施設登録は register_facilities_batch または register_facility。" +
|
|
35
39
|
"成分表は手元 Vision なら get_parse_prompt → 手元 LLM → parse_transcript、" +
|
|
36
40
|
"または analyze_source_image(Ynosis OCR)。" +
|
|
37
41
|
"その後 register_source。新規源泉は image_path 必須。" +
|
|
38
42
|
"OCR / サーバー抽出の parse は並列禁止。" +
|
|
39
|
-
"YNOSIS_AGENT_API_KEY
|
|
40
|
-
"CLI は PyPI の ynosis-agent。CSV ファイル一括は CLI /
|
|
43
|
+
"YNOSIS_AGENT_API_KEY と座標候補用 GOOGLE_MAPS_API_KEY が必要。" +
|
|
44
|
+
"CLI は PyPI の ynosis-agent。CSV ファイル一括は CLI / MCP。",
|
|
41
45
|
});
|
|
42
46
|
|
|
43
47
|
const dict = z.record(z.any());
|
|
44
48
|
|
|
45
|
-
server.tool(
|
|
49
|
+
server.tool(
|
|
50
|
+
"resolve_place_candidates",
|
|
51
|
+
"Google Places で座標候補を返す(最大5件)。採用は LLM が name/住所/県で同一施設か判断してから register_facility へ。env: GOOGLE_MAPS_API_KEY。",
|
|
52
|
+
{
|
|
53
|
+
query: z.string().optional(),
|
|
54
|
+
name: z.string().optional(),
|
|
55
|
+
prefecture: z.string().optional(),
|
|
56
|
+
address: z.string().optional(),
|
|
57
|
+
onsen_name: z.string().optional(),
|
|
58
|
+
max_results: z.number().optional(),
|
|
59
|
+
},
|
|
60
|
+
async ({ query, name, prefecture, address, onsen_name, max_results }) => {
|
|
61
|
+
try {
|
|
62
|
+
const q = query?.trim() || buildPlaceQuery({ name, prefecture, address, onsen_name });
|
|
63
|
+
const candidates = await resolvePlaceCandidates(q, { maxResults: max_results ?? 5 });
|
|
64
|
+
return textResult(
|
|
65
|
+
dump({
|
|
66
|
+
query: q,
|
|
67
|
+
candidates,
|
|
68
|
+
llm_hint:
|
|
69
|
+
"候補から同一施設を1件選び、register_facility の payload に lat/lng/place_id/address を含める。曖昧なら座標は付けない。",
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return textResult(errText(e));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.tool(
|
|
79
|
+
"prepare_facility_for_register",
|
|
80
|
+
"register 前にタグ整理・座標候補付与。place は LLM が resolve_place_candidates から選んだ1件。",
|
|
81
|
+
{ facility: dict, place: dict.optional() },
|
|
82
|
+
async ({ facility, place }) => {
|
|
83
|
+
try {
|
|
84
|
+
return textResult(dump(await client().prepareFacilityPayload(facility, { place })));
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return textResult(errText(e));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
server.tool(
|
|
92
|
+
"withdraw_facility",
|
|
93
|
+
"未承認 Agent 新規施設を取り下げ(削除)。重複誤登録の整理用。",
|
|
94
|
+
{ facility_id: z.number() },
|
|
95
|
+
async ({ facility_id }) => {
|
|
96
|
+
try {
|
|
97
|
+
return textResult(dump(await client().withdrawFacility(facility_id)));
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return textResult(errText(e));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
server.tool(
|
|
105
|
+
"remediate_duplicate_facility",
|
|
106
|
+
"正規施設へ編集依頼(備考に重複整理メモ)→ 誤登録を withdraw。",
|
|
107
|
+
{
|
|
108
|
+
duplicate_id: z.number(),
|
|
109
|
+
canonical_id: z.number().optional(),
|
|
110
|
+
dry_run: z.boolean().optional(),
|
|
111
|
+
},
|
|
112
|
+
async ({ duplicate_id, canonical_id, dry_run }) => {
|
|
113
|
+
try {
|
|
114
|
+
return textResult(
|
|
115
|
+
dump(
|
|
116
|
+
await client().remediateDuplicateFacility(duplicate_id, {
|
|
117
|
+
canonicalId: canonical_id ?? null,
|
|
118
|
+
dryRun: dry_run ?? false,
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return textResult(errText(e));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
server.tool(
|
|
129
|
+
"find_existing_facility",
|
|
130
|
+
"登録前に既存施設を照合(未承認含む)。ヒット時は update_facility、無ければ新規 register_facility。",
|
|
131
|
+
{
|
|
132
|
+
name: z.string(),
|
|
133
|
+
prefecture: z.string().optional(),
|
|
134
|
+
lat: z.number().optional(),
|
|
135
|
+
lng: z.number().optional(),
|
|
136
|
+
search_limit: z.number().optional(),
|
|
137
|
+
},
|
|
138
|
+
async ({ name, prefecture, lat, lng, search_limit }) => {
|
|
139
|
+
try {
|
|
140
|
+
const result = await client().findExistingFacility(name, prefecture, {
|
|
141
|
+
lat,
|
|
142
|
+
lng,
|
|
143
|
+
searchLimit: search_limit ?? 30,
|
|
144
|
+
});
|
|
145
|
+
if (!result) {
|
|
146
|
+
return textResult(
|
|
147
|
+
dump({
|
|
148
|
+
match: null,
|
|
149
|
+
hint: "既存施設なし。resolve_place_candidates の後 register_facility。",
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return textResult(dump({ match: result }));
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return textResult(errText(e));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
server.tool(
|
|
161
|
+
"register_facility",
|
|
162
|
+
"施設を1件登録する(FacilityCreate 形式)。新規前に find_existing_facility → prepare_facility_for_register 推奨。",
|
|
163
|
+
{ facility: dict },
|
|
164
|
+
async ({ facility }) => {
|
|
46
165
|
try {
|
|
47
166
|
return textResult(dump(await client().registerFacility(facility)));
|
|
48
167
|
} catch (e) {
|
|
@@ -199,6 +318,19 @@ server.tool("get_tag_options", "登録可能な施設タグ一覧を返す。",
|
|
|
199
318
|
textResult(dump({ tags: FACILITY_TAG_OPTIONS }))
|
|
200
319
|
);
|
|
201
320
|
|
|
321
|
+
server.tool(
|
|
322
|
+
"preview_csv_row",
|
|
323
|
+
"調査 CSV の1行を FacilityCreate にマッピングした結果をプレビューする。",
|
|
324
|
+
{ row: z.record(z.string()) },
|
|
325
|
+
async ({ row }) => {
|
|
326
|
+
try {
|
|
327
|
+
return textResult(dump(rowToFacilityPayload(row)));
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return textResult(errText(e));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
|
|
202
334
|
server.tool(
|
|
203
335
|
"upload_source_image",
|
|
204
336
|
"成分表の証憑画像をアップロードする。",
|