ynosis-agent 0.1.0__tar.gz

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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: ynosis-agent
3
+ Version: 0.1.0
4
+ Summary: CLI and MCP client for the Ynosis Agent API (facilities and spring analyses)
5
+ Author: Ynosis
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://ynosis.jp
8
+ Project-URL: Documentation, https://ynosis.jp/notes/usage-037
9
+ Keywords: ynosis,onsen,agent,mcp
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp<2,>=1.0; extra == "mcp"
15
+
16
+ # ynosis-agent
17
+
18
+ Ynosis Agent API 用の **公開クライアント**(CLI / MCP)。
19
+
20
+ リポジトリは非公開です。ソース clone は不要です。
21
+
22
+ ## CLI
23
+
24
+ ```bash
25
+ pip install ynosis-agent
26
+ export YNOSIS_AGENT_API_KEY='yna_...' # マイページ → … → Agent API
27
+ ynosis-agent search --q 草津 --limit 5
28
+ ```
29
+
30
+ 接続先の既定は `https://ynosis.jp`。
31
+
32
+ ## MCP(アプリに設定を貼る・推奨)
33
+
34
+ 事前 pip 不要。[`uv`](https://docs.astral.sh/uv/)(`uvx`)があるマシンで、アプリの MCP 設定に次を追加します。
35
+
36
+ **起動コマンド:**
37
+
38
+ ```text
39
+ uvx --from ynosis-agent[mcp] ynosis-agent-mcp
40
+ ```
41
+
42
+ **環境変数:** `YNOSIS_AGENT_API_KEY=yna_…`(必須)
43
+
44
+ ### Cursor(mcp.json)
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "ynosis-agent": {
50
+ "command": "uvx",
51
+ "args": ["--from", "ynosis-agent[mcp]", "ynosis-agent-mcp"],
52
+ "env": {
53
+ "YNOSIS_AGENT_API_KEY": "yna_REPLACE_ME",
54
+ "YNOSIS_AGENT_API_BASE": "https://ynosis.jp"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### Claude Code
62
+
63
+ ```bash
64
+ claude mcp add --transport stdio ynosis-agent \
65
+ --env YNOSIS_AGENT_API_KEY=yna_REPLACE_ME \
66
+ -- uvx --from 'ynosis-agent[mcp]' ynosis-agent-mcp
67
+ ```
68
+
69
+ `uv` が無い場合のフォールバック: `pip install 'ynosis-agent[mcp]'` のあと `command` を `ynosis-agent-mcp` にする。
70
+
71
+ ## 運営向け: PyPI 公開
72
+
73
+ ```bash
74
+ cd packages/ynosis-agent
75
+ python -m build
76
+ twine upload dist/*
77
+ ```
78
+
79
+ バージョンは `pyproject.toml` の `version` を上げてからビルドする。
@@ -0,0 +1,64 @@
1
+ # ynosis-agent
2
+
3
+ Ynosis Agent API 用の **公開クライアント**(CLI / MCP)。
4
+
5
+ リポジトリは非公開です。ソース clone は不要です。
6
+
7
+ ## CLI
8
+
9
+ ```bash
10
+ pip install ynosis-agent
11
+ export YNOSIS_AGENT_API_KEY='yna_...' # マイページ → … → Agent API
12
+ ynosis-agent search --q 草津 --limit 5
13
+ ```
14
+
15
+ 接続先の既定は `https://ynosis.jp`。
16
+
17
+ ## MCP(アプリに設定を貼る・推奨)
18
+
19
+ 事前 pip 不要。[`uv`](https://docs.astral.sh/uv/)(`uvx`)があるマシンで、アプリの MCP 設定に次を追加します。
20
+
21
+ **起動コマンド:**
22
+
23
+ ```text
24
+ uvx --from ynosis-agent[mcp] ynosis-agent-mcp
25
+ ```
26
+
27
+ **環境変数:** `YNOSIS_AGENT_API_KEY=yna_…`(必須)
28
+
29
+ ### Cursor(mcp.json)
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "ynosis-agent": {
35
+ "command": "uvx",
36
+ "args": ["--from", "ynosis-agent[mcp]", "ynosis-agent-mcp"],
37
+ "env": {
38
+ "YNOSIS_AGENT_API_KEY": "yna_REPLACE_ME",
39
+ "YNOSIS_AGENT_API_BASE": "https://ynosis.jp"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Claude Code
47
+
48
+ ```bash
49
+ claude mcp add --transport stdio ynosis-agent \
50
+ --env YNOSIS_AGENT_API_KEY=yna_REPLACE_ME \
51
+ -- uvx --from 'ynosis-agent[mcp]' ynosis-agent-mcp
52
+ ```
53
+
54
+ `uv` が無い場合のフォールバック: `pip install 'ynosis-agent[mcp]'` のあと `command` を `ynosis-agent-mcp` にする。
55
+
56
+ ## 運営向け: PyPI 公開
57
+
58
+ ```bash
59
+ cd packages/ynosis-agent
60
+ python -m build
61
+ twine upload dist/*
62
+ ```
63
+
64
+ バージョンは `pyproject.toml` の `version` を上げてからビルドする。
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ynosis-agent"
7
+ version = "0.1.0"
8
+ description = "CLI and MCP client for the Ynosis Agent API (facilities and spring analyses)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Ynosis" }]
13
+ keywords = ["ynosis", "onsen", "agent", "mcp"]
14
+ dependencies = [
15
+ "httpx>=0.27",
16
+ ]
17
+ [project.optional-dependencies]
18
+ mcp = ["mcp>=1.0,<2"]
19
+
20
+ [project.scripts]
21
+ ynosis-agent = "ynosis_agent.cli:main"
22
+ ynosis-agent-mcp = "ynosis_agent.mcp_server:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://ynosis.jp"
26
+ Documentation = "https://ynosis.jp/notes/usage-037"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ """Ynosis Agent API クライアント(CLI / MCP)。
2
+
3
+ インストール:
4
+ pip install ynosis-agent
5
+ pip install 'ynosis-agent[mcp]' # MCP 利用時
6
+
7
+ 環境変数:
8
+ YNOSIS_AGENT_API_KEY または AGENT_API_KEY
9
+ YNOSIS_AGENT_API_BASE または AGENT_API_BASE(既定 https://ynosis.jp)
10
+ """
11
+
12
+ from ynosis_agent.client import (
13
+ DEFAULT_AGENT_API_BASE,
14
+ AgentApiClient,
15
+ AgentApiError,
16
+ resolve_agent_api_base,
17
+ resolve_agent_api_key,
18
+ )
19
+ from ynosis_agent.tags import FACILITY_TAG_OPTIONS
20
+
21
+ __all__ = [
22
+ "DEFAULT_AGENT_API_BASE",
23
+ "AgentApiClient",
24
+ "AgentApiError",
25
+ "FACILITY_TAG_OPTIONS",
26
+ "resolve_agent_api_base",
27
+ "resolve_agent_api_key",
28
+ ]
29
+
30
+ __version__ = "0.1.0"
@@ -0,0 +1,291 @@
1
+ """Ynosis Agent CLI(PyPI: `ynosis-agent`)。
2
+
3
+ 環境変数:
4
+ YNOSIS_AGENT_API_KEY または AGENT_API_KEY
5
+ YNOSIS_AGENT_API_BASE または AGENT_API_BASE(既定 https://ynosis.jp)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from ynosis_agent.client import AgentApiClient, AgentApiError
17
+ from ynosis_agent.csv_import import facilities_from_csv
18
+ from ynosis_agent.prepare import prepare_parsed_for_save
19
+
20
+ DEFAULT_OCR_MIN_INTERVAL_SECONDS = 6.0
21
+ _IMAGE_SUFFIXES = {
22
+ ".jpg",
23
+ ".jpeg",
24
+ ".png",
25
+ ".webp",
26
+ ".bmp",
27
+ ".tif",
28
+ ".tiff",
29
+ ".heic",
30
+ ".heif",
31
+ ".pdf",
32
+ }
33
+
34
+
35
+ def _pace(last_start: float | None, min_interval: float) -> float:
36
+ now = time.monotonic()
37
+ if last_start is not None and min_interval > 0:
38
+ wait = min_interval - (now - last_start)
39
+ if wait > 0:
40
+ time.sleep(wait)
41
+ now = time.monotonic()
42
+ return now
43
+
44
+
45
+ def _cmd_facilities_csv(args: argparse.Namespace) -> int:
46
+ payloads = facilities_from_csv(args.csv_path)
47
+ if args.dry_run:
48
+ print(json.dumps({"count": len(payloads), "facilities": payloads}, ensure_ascii=False, indent=2))
49
+ return 0
50
+ try:
51
+ result = AgentApiClient().register_facilities_batch(
52
+ payloads,
53
+ continue_on_error=not args.stop_on_error,
54
+ )
55
+ except (AgentApiError, ValueError) as e:
56
+ print(str(e), file=sys.stderr)
57
+ return 1
58
+ print(json.dumps(result, ensure_ascii=False, indent=2))
59
+ print(
60
+ f"created={result['created']} updated={result['updated']} failed={result['failed']}",
61
+ file=sys.stderr,
62
+ )
63
+ return 1 if result["failed"] else 0
64
+
65
+
66
+ def _cmd_facility_json(args: argparse.Namespace) -> int:
67
+ payload = json.loads(Path(args.json_path).read_text(encoding="utf-8"))
68
+ try:
69
+ result = AgentApiClient().register_facility(payload)
70
+ except (AgentApiError, ValueError) as e:
71
+ print(str(e), file=sys.stderr)
72
+ return 1
73
+ print(json.dumps(result, ensure_ascii=False, indent=2))
74
+ return 0
75
+
76
+
77
+ def _cmd_register(args: argparse.Namespace) -> int:
78
+ payload = json.loads(Path(args.json_path).read_text(encoding="utf-8"))
79
+ try:
80
+ result = AgentApiClient().register_source(payload)
81
+ except (AgentApiError, ValueError) as e:
82
+ print(str(e), file=sys.stderr)
83
+ return 1
84
+ print(json.dumps(result, ensure_ascii=False, indent=2))
85
+ return 0
86
+
87
+
88
+ def _cmd_search(args: argparse.Namespace) -> int:
89
+ params = {
90
+ "q": args.q,
91
+ "prefecture": args.prefecture,
92
+ "area": args.area,
93
+ "onsen_name": args.onsen_name,
94
+ "spring_type": args.spring_type,
95
+ "tags": args.tags,
96
+ "limit": args.limit,
97
+ "skip": args.skip,
98
+ "sort": args.sort,
99
+ }
100
+ try:
101
+ result = AgentApiClient().search_facilities(params)
102
+ except (AgentApiError, ValueError) as e:
103
+ print(str(e), file=sys.stderr)
104
+ return 1
105
+ print(json.dumps(result, ensure_ascii=False, indent=2))
106
+ return 0
107
+
108
+
109
+ def _cmd_facility_get(args: argparse.Namespace) -> int:
110
+ try:
111
+ result = AgentApiClient().get_facility(args.facility_id)
112
+ except (AgentApiError, ValueError) as e:
113
+ print(str(e), file=sys.stderr)
114
+ return 1
115
+ print(json.dumps(result, ensure_ascii=False, indent=2))
116
+ return 0
117
+
118
+
119
+ def _cmd_analyze(args: argparse.Namespace) -> int:
120
+ try:
121
+ result = AgentApiClient().analyze_image(args.image_path)
122
+ except (AgentApiError, ValueError, FileNotFoundError) as e:
123
+ print(str(e), file=sys.stderr)
124
+ return 1
125
+ text = json.dumps(result, ensure_ascii=False, indent=2)
126
+ if args.out:
127
+ args.out.write_text(text + "\n", encoding="utf-8")
128
+ print(f"wrote {args.out}", file=sys.stderr)
129
+ else:
130
+ print(text)
131
+ return 0
132
+
133
+
134
+ def _build_register_payload_from_analyze(
135
+ analyzed: dict,
136
+ *,
137
+ name: str,
138
+ prefecture: str,
139
+ facility_id: int | None,
140
+ extra: dict | None,
141
+ ) -> dict:
142
+ parsed = prepare_parsed_for_save(dict(analyzed.get("parsed") or {}))
143
+ payload: dict = {
144
+ "name": name,
145
+ "prefecture": prefecture,
146
+ "raw_text": analyzed.get("raw_text") or "",
147
+ "parsed": parsed,
148
+ }
149
+ if facility_id is not None:
150
+ payload["facility_id"] = facility_id
151
+ if extra:
152
+ payload.update(extra)
153
+ return payload
154
+
155
+
156
+ def _cmd_register_from_image(args: argparse.Namespace) -> int:
157
+ extra = None
158
+ if args.extra_json:
159
+ extra = json.loads(Path(args.extra_json).read_text(encoding="utf-8"))
160
+ try:
161
+ analyzed = AgentApiClient().analyze_image(args.image_path)
162
+ payload = _build_register_payload_from_analyze(
163
+ analyzed,
164
+ name=args.name,
165
+ prefecture=args.prefecture,
166
+ facility_id=args.facility_id,
167
+ extra=extra,
168
+ )
169
+ if args.dry_run:
170
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
171
+ return 0
172
+ result = AgentApiClient().register_source(payload)
173
+ except (AgentApiError, ValueError, FileNotFoundError) as e:
174
+ print(str(e), file=sys.stderr)
175
+ return 1
176
+ print(json.dumps(result, ensure_ascii=False, indent=2))
177
+ return 0
178
+
179
+
180
+ def _cmd_analyze_batch(args: argparse.Namespace) -> int:
181
+ root = Path(args.images_dir)
182
+ if not root.is_dir():
183
+ print(f"ディレクトリがありません: {root}", file=sys.stderr)
184
+ return 1
185
+ paths = sorted(
186
+ p for p in root.iterdir() if p.is_file() and p.suffix.lower() in _IMAGE_SUFFIXES
187
+ )
188
+ if not paths:
189
+ print("対象画像がありません", file=sys.stderr)
190
+ return 1
191
+ out_dir = args.out_dir
192
+ if out_dir:
193
+ out_dir.mkdir(parents=True, exist_ok=True)
194
+ client = AgentApiClient()
195
+ results: list[dict] = []
196
+ last_start: float | None = None
197
+ failed = 0
198
+ for path in paths:
199
+ last_start = _pace(last_start, args.min_interval_seconds)
200
+ try:
201
+ analyzed = client.analyze_image(path)
202
+ entry: dict = {"ok": True, "path": str(path), "result": analyzed}
203
+ if out_dir:
204
+ out_path = out_dir / f"{path.stem}.json"
205
+ out_path.write_text(
206
+ json.dumps(analyzed, ensure_ascii=False, indent=2) + "\n",
207
+ encoding="utf-8",
208
+ )
209
+ entry["out"] = str(out_path)
210
+ except (AgentApiError, ValueError, FileNotFoundError) as e:
211
+ failed += 1
212
+ entry = {"ok": False, "path": str(path), "error": str(e)}
213
+ if args.stop_on_error:
214
+ results.append(entry)
215
+ break
216
+ results.append(entry)
217
+ print(f"{'ok' if entry['ok'] else 'fail'}: {path.name}", file=sys.stderr)
218
+ print(json.dumps({"failed": failed, "results": results}, ensure_ascii=False, indent=2))
219
+ return 1 if failed else 0
220
+
221
+
222
+ def main(argv: list[str] | None = None) -> None:
223
+ parser = argparse.ArgumentParser(
224
+ prog="ynosis-agent",
225
+ description="Ynosis Agent API CLI(施設・成分表の登録)",
226
+ )
227
+ sub = parser.add_subparsers(dest="command", required=True)
228
+
229
+ p_csv = sub.add_parser("facilities", help="施設登録")
230
+ p_csv_sub = p_csv.add_subparsers(dest="facilities_cmd", required=True)
231
+ p_csv_file = p_csv_sub.add_parser("csv", help="調査 CSV を一括登録")
232
+ p_csv_file.add_argument("csv_path", type=Path)
233
+ p_csv_file.add_argument("--dry-run", action="store_true")
234
+ p_csv_file.add_argument("--stop-on-error", action="store_true")
235
+ p_csv_file.set_defaults(func=_cmd_facilities_csv)
236
+
237
+ p_fac = sub.add_parser("facility", help="施設 JSON 1件")
238
+ p_fac.add_argument("json_path", type=Path)
239
+ p_fac.set_defaults(func=_cmd_facility_json)
240
+
241
+ p_reg = sub.add_parser("register", help="成分表 + 施設を登録")
242
+ p_reg.add_argument("json_path", type=Path)
243
+ p_reg.set_defaults(func=_cmd_register)
244
+
245
+ p_an = sub.add_parser("analyze", help="成分表画像を Ynosis OCR で解析(DB 未書込)")
246
+ p_an.add_argument("image_path", type=Path)
247
+ p_an.add_argument("--out", type=Path, help="結果 JSON の出力先")
248
+ p_an.set_defaults(func=_cmd_analyze)
249
+
250
+ p_anb = sub.add_parser("analyze-batch", help="ディレクトリ内の画像を逐次 OCR")
251
+ p_anb.add_argument("images_dir", type=Path)
252
+ p_anb.add_argument("--out-dir", type=Path, default=None)
253
+ p_anb.add_argument(
254
+ "--min-interval-seconds",
255
+ type=float,
256
+ default=DEFAULT_OCR_MIN_INTERVAL_SECONDS,
257
+ )
258
+ p_anb.add_argument("--stop-on-error", action="store_true")
259
+ p_anb.set_defaults(func=_cmd_analyze_batch)
260
+
261
+ p_rfi = sub.add_parser("register-from-image", help="Ynosis OCR → register")
262
+ p_rfi.add_argument("image_path", type=Path)
263
+ p_rfi.add_argument("--name", required=True)
264
+ p_rfi.add_argument("--prefecture", required=True)
265
+ p_rfi.add_argument("--facility-id", type=int, default=None)
266
+ p_rfi.add_argument("--extra-json", type=Path, default=None)
267
+ p_rfi.add_argument("--dry-run", action="store_true")
268
+ p_rfi.set_defaults(func=_cmd_register_from_image)
269
+
270
+ p_search = sub.add_parser("search", help="施設検索")
271
+ p_search.add_argument("--q", default=None)
272
+ p_search.add_argument("--prefecture", default=None)
273
+ p_search.add_argument("--area", default=None)
274
+ p_search.add_argument("--onsen-name", default=None, dest="onsen_name")
275
+ p_search.add_argument("--spring-type", default=None, dest="spring_type")
276
+ p_search.add_argument("--tags", default=None)
277
+ p_search.add_argument("--limit", type=int, default=20)
278
+ p_search.add_argument("--skip", type=int, default=0)
279
+ p_search.add_argument("--sort", default="default")
280
+ p_search.set_defaults(func=_cmd_search)
281
+
282
+ p_get = sub.add_parser("facility-get", help="施設詳細")
283
+ p_get.add_argument("facility_id", type=int)
284
+ p_get.set_defaults(func=_cmd_facility_get)
285
+
286
+ args = parser.parse_args(argv)
287
+ raise SystemExit(args.func(args))
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
@@ -0,0 +1,262 @@
1
+ """Ynosis エージェント API の HTTP クライアント(スクリプト・MCP 共通)。
2
+
3
+ サーバーは薄い `/api/agent/*` のみ。一括はクライアントループ、更新・画像・similar は通常 API。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ynosis_agent.tags import FACILITY_TAG_OPTIONS
15
+
16
+ DEFAULT_TIMEOUT = 120.0
17
+ DEFAULT_AGENT_API_BASE = "https://ynosis.jp"
18
+
19
+
20
+ def resolve_agent_api_key(explicit: str | None = None) -> str:
21
+ """AGENT_API_KEY / YNOSIS_AGENT_API_KEY(yna_ または運営キー)。"""
22
+ if explicit:
23
+ return explicit
24
+ return (
25
+ os.environ.get("AGENT_API_KEY")
26
+ or os.environ.get("YNOSIS_AGENT_API_KEY")
27
+ or ""
28
+ )
29
+
30
+
31
+ def resolve_agent_api_base(explicit: str | None = None) -> str:
32
+ """AGENT_API_BASE / YNOSIS_AGENT_API_BASE。未設定時は本番。"""
33
+ if explicit:
34
+ return explicit.rstrip("/")
35
+ return (
36
+ os.environ.get("AGENT_API_BASE")
37
+ or os.environ.get("YNOSIS_AGENT_API_BASE")
38
+ or os.environ.get("API_BASE")
39
+ or DEFAULT_AGENT_API_BASE
40
+ ).rstrip("/")
41
+
42
+
43
+ class AgentApiError(Exception):
44
+ """エージェント API の HTTP エラー。"""
45
+
46
+ def __init__(self, status_code: int, detail: str):
47
+ self.status_code = status_code
48
+ self.detail = detail
49
+ super().__init__(f"HTTP {status_code}: {detail}")
50
+
51
+
52
+ class AgentApiClient:
53
+ """`X-API-Key` 認証で Agent / 通常 API を呼ぶ。"""
54
+
55
+ def __init__(
56
+ self,
57
+ base_url: str | None = None,
58
+ api_key: str | None = None,
59
+ *,
60
+ timeout: float = DEFAULT_TIMEOUT,
61
+ ):
62
+ self.base_url = resolve_agent_api_base(base_url)
63
+ self.api_key = resolve_agent_api_key(api_key)
64
+ self.timeout = timeout
65
+
66
+ def _require_key(self) -> None:
67
+ if not self.api_key:
68
+ raise ValueError(
69
+ "API キーが未設定です。"
70
+ "AGENT_API_KEY または YNOSIS_AGENT_API_KEY に yna_…(または運営キー)を設定してください"
71
+ )
72
+
73
+ def _headers(self, *, json_body: bool = True) -> dict[str, str]:
74
+ self._require_key()
75
+ headers = {"X-API-Key": self.api_key}
76
+ if json_body:
77
+ headers["Content-Type"] = "application/json"
78
+ return headers
79
+
80
+ def _request(
81
+ self,
82
+ method: str,
83
+ path: str,
84
+ *,
85
+ json: dict | list | None = None,
86
+ files: dict | None = None,
87
+ ) -> httpx.Response:
88
+ url = f"{self.base_url}{path}"
89
+ headers = self._headers(json_body=json is not None and files is None)
90
+ with httpx.Client(timeout=self.timeout) as client:
91
+ res = client.request(
92
+ method,
93
+ url,
94
+ headers=headers,
95
+ json=json,
96
+ files=files,
97
+ )
98
+ if res.is_error:
99
+ detail = res.text
100
+ try:
101
+ body = res.json()
102
+ if isinstance(body, dict) and "detail" in body:
103
+ detail = str(body["detail"])
104
+ except Exception:
105
+ pass
106
+ raise AgentApiError(res.status_code, detail)
107
+ return res
108
+
109
+ def register_facility(self, payload: dict[str, Any]) -> dict[str, Any]:
110
+ res = self._request("POST", "/api/agent/facilities", json=payload)
111
+ return {"status_code": res.status_code, "facility": res.json()}
112
+
113
+ def search_facilities(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
114
+ """施設検索。戻り値は `{ items, total_count }`(`X-Total-Count` ヘッダ含む)。"""
115
+ url = f"{self.base_url}/api/agent/facilities/search"
116
+ headers = self._headers(json_body=False)
117
+ clean = {
118
+ k: v
119
+ for k, v in (params or {}).items()
120
+ if v is not None and v != ""
121
+ }
122
+ with httpx.Client(timeout=self.timeout) as client:
123
+ res = client.get(url, headers=headers, params=clean)
124
+ if res.is_error:
125
+ detail = res.text
126
+ try:
127
+ body = res.json()
128
+ if isinstance(body, dict) and "detail" in body:
129
+ detail = str(body["detail"])
130
+ except Exception:
131
+ pass
132
+ raise AgentApiError(res.status_code, detail)
133
+ total = res.headers.get("X-Total-Count")
134
+ return {
135
+ "items": res.json(),
136
+ "total_count": int(total) if total is not None else len(res.json()),
137
+ }
138
+
139
+ def get_facility(self, facility_id: int) -> dict[str, Any]:
140
+ res = self._request("GET", f"/api/agent/facilities/{facility_id}")
141
+ return res.json()
142
+
143
+ def register_facilities_batch(
144
+ self,
145
+ facilities: list[dict[str, Any]],
146
+ *,
147
+ continue_on_error: bool = True,
148
+ ) -> dict[str, Any]:
149
+ """サーバー batch は持たず、クライアント側で 1 件ずつ登録する。"""
150
+ results: list[dict[str, Any]] = []
151
+ created = updated = failed = 0
152
+ for i, payload in enumerate(facilities):
153
+ try:
154
+ one = self.register_facility(payload)
155
+ status_code = int(one["status_code"])
156
+ facility = one["facility"]
157
+ if status_code == 201:
158
+ created += 1
159
+ action = "created"
160
+ else:
161
+ updated += 1
162
+ action = "updated"
163
+ results.append(
164
+ {
165
+ "index": i,
166
+ "ok": True,
167
+ "action": action,
168
+ "status_code": status_code,
169
+ "facility_id": facility.get("id"),
170
+ "name": facility.get("name"),
171
+ }
172
+ )
173
+ except AgentApiError as e:
174
+ failed += 1
175
+ results.append(
176
+ {
177
+ "index": i,
178
+ "ok": False,
179
+ "error": e.detail,
180
+ "status_code": e.status_code,
181
+ "name": payload.get("name"),
182
+ }
183
+ )
184
+ if not continue_on_error:
185
+ break
186
+ return {
187
+ "created": created,
188
+ "updated": updated,
189
+ "failed": failed,
190
+ "results": results,
191
+ }
192
+
193
+ def update_facility(self, facility_id: int, payload: dict[str, Any]) -> dict[str, Any]:
194
+ res = self._request("PUT", f"/api/facilities/{facility_id}", json=payload)
195
+ return res.json()
196
+
197
+ def register_source(self, payload: dict[str, Any]) -> dict[str, Any]:
198
+ res = self._request("POST", "/api/agent/register", json=payload)
199
+ return res.json()
200
+
201
+ def analyze_image(self, image_path: str | Path) -> dict[str, Any]:
202
+ """Ynosis 本番 OCR + LLM パース(`POST /api/agent/analyze`)。DB 未書込。"""
203
+ path = Path(image_path)
204
+ if not path.is_file():
205
+ raise FileNotFoundError(f"画像が見つかりません: {path}")
206
+ suffix = path.suffix.lower()
207
+ content_type = "image/jpeg"
208
+ if suffix == ".png":
209
+ content_type = "image/png"
210
+ elif suffix in (".webp", ".bmp", ".tif", ".tiff"):
211
+ content_type = f"image/{suffix.lstrip('.')}"
212
+ elif suffix in (".heic", ".heif"):
213
+ content_type = "image/heic"
214
+ elif suffix == ".pdf":
215
+ content_type = "application/pdf"
216
+ # OCR+LLM は時間がかかるため、証憑アップロードより長め
217
+ timeout = max(self.timeout, 180.0)
218
+ url = f"{self.base_url}/api/agent/analyze"
219
+ headers = {"X-API-Key": self.api_key}
220
+ self._require_key()
221
+ with path.open("rb") as f:
222
+ with httpx.Client(timeout=timeout) as client:
223
+ res = client.post(
224
+ url,
225
+ headers=headers,
226
+ files={"file": (path.name, f, content_type)},
227
+ )
228
+ if res.is_error:
229
+ detail = res.text
230
+ try:
231
+ body = res.json()
232
+ if isinstance(body, dict) and "detail" in body:
233
+ detail = str(body["detail"])
234
+ except Exception:
235
+ pass
236
+ raise AgentApiError(res.status_code, detail)
237
+ return res.json()
238
+
239
+ def find_similar_sources(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
240
+ res = self._request("POST", "/api/sources/similar", json=payload)
241
+ return res.json()
242
+
243
+ def get_tag_options(self) -> list[str]:
244
+ return list(FACILITY_TAG_OPTIONS)
245
+
246
+ def upload_source_image(self, source_id: int, image_path: str | Path) -> dict[str, Any]:
247
+ path = Path(image_path)
248
+ if not path.is_file():
249
+ raise FileNotFoundError(f"画像が見つかりません: {path}")
250
+ suffix = path.suffix.lower()
251
+ content_type = "image/jpeg"
252
+ if suffix == ".png":
253
+ content_type = "image/png"
254
+ elif suffix == ".pdf":
255
+ content_type = "application/pdf"
256
+ with path.open("rb") as f:
257
+ res = self._request(
258
+ "POST",
259
+ f"/api/sources/{source_id}/image",
260
+ files={"file": (path.name, f, content_type)},
261
+ )
262
+ return res.json()
@@ -0,0 +1,78 @@
1
+ """調査 CSV → FacilityCreate 形式へのマッピング。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ _STATUS_MAP = {
10
+ "営業中": "active",
11
+ "休業": "dormant",
12
+ "閉業": "closed",
13
+ "不明": "unknown",
14
+ }
15
+
16
+
17
+ def _split_tags(raw: str | None) -> list[str]:
18
+ if not raw or not raw.strip():
19
+ return []
20
+ return [t.strip() for t in raw.replace(",", ",").split(";") if t.strip()]
21
+
22
+
23
+ def _build_notes(row: dict[str, str]) -> str | None:
24
+ parts: list[str] = []
25
+ for key in ("湧出タイプ", "利用形態", "備考", "情報源"):
26
+ val = (row.get(key) or "").strip()
27
+ if val:
28
+ parts.append(f"{key}: {val}" if key != "備考" else val)
29
+ return "\n".join(parts) if parts else None
30
+
31
+
32
+ def _facility_name(row: dict[str, str]) -> str:
33
+ base = (row.get("施設名") or "").strip()
34
+ bath = (row.get("浴場名") or "").strip()
35
+ if base and bath:
36
+ return f"{base} {bath}"
37
+ return base or bath
38
+
39
+
40
+ def row_to_facility_payload(row: dict[str, str]) -> dict[str, Any]:
41
+ """1行を `FacilityCreate` 相当の dict に変換する。"""
42
+ name = _facility_name(row)
43
+ if not name:
44
+ raise ValueError("施設名・浴場名のいずれかが必要です")
45
+
46
+ prefecture = (row.get("都道府県") or "").strip() or None
47
+ onsen_name = (row.get("温泉地名") or "").strip() or None
48
+ status_raw = (row.get("営業状況(営業中/休業/閉業/不明)") or row.get("営業状況") or "").strip()
49
+ status = _STATUS_MAP.get(status_raw, "unknown" if status_raw else "active")
50
+
51
+ payload: dict[str, Any] = {
52
+ "name": name,
53
+ "prefecture": prefecture,
54
+ "onsen_name": onsen_name,
55
+ "status_operational": status,
56
+ "tags": _split_tags(row.get("タグ(;区切り)") or row.get("タグ")),
57
+ }
58
+ notes = _build_notes(row)
59
+ if notes:
60
+ payload["notes"] = notes
61
+ return payload
62
+
63
+
64
+ def facilities_from_csv(path: str | Path) -> list[dict[str, Any]]:
65
+ """UTF-8 CSV を読み、施設 payload のリストを返す。"""
66
+ csv_path = Path(path)
67
+ if not csv_path.is_file():
68
+ raise FileNotFoundError(f"CSV が見つかりません: {csv_path}")
69
+
70
+ payloads: list[dict[str, Any]] = []
71
+ with csv_path.open(encoding="utf-8-sig", newline="") as f:
72
+ reader = csv.DictReader(f)
73
+ for i, row in enumerate(reader, start=2):
74
+ try:
75
+ payloads.append(row_to_facility_payload(row))
76
+ except ValueError as e:
77
+ raise ValueError(f"CSV {csv_path.name} 行 {i}: {e}") from e
78
+ return payloads
@@ -0,0 +1,196 @@
1
+ """Ynosis エージェント MCP サーバー(stdio)。`pip install 'ynosis-agent[mcp]'` 後に `ynosis-agent-mcp`。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ynosis_agent.client import AgentApiClient, AgentApiError
9
+ from ynosis_agent.csv_import import facilities_from_csv, row_to_facility_payload
10
+ from ynosis_agent.prepare import prepare_parsed_for_save
11
+
12
+ try:
13
+ from mcp.server.fastmcp import FastMCP
14
+ except ImportError as e: # pragma: no cover
15
+ raise SystemExit(
16
+ "MCP 依存がありません。次を実行してください: pip install 'ynosis-agent[mcp]'"
17
+ ) from e
18
+
19
+ mcp = FastMCP(
20
+ "ynosis-agent",
21
+ instructions=(
22
+ "Ynosis(温泉データベース)への施設・成分表登録・検索用ツール。"
23
+ "検索は search_facilities / get_facility。"
24
+ "施設登録は register_facilities_batch または register_facility。"
25
+ "成分表は analyze_source_image → register_source(Ynosis OCR)。"
26
+ "OCR は並列禁止・間隔を空ける。"
27
+ "YNOSIS_AGENT_API_KEY(または AGENT_API_KEY)が必要。"
28
+ "CLI は ynosis-agent コマンド。"
29
+ ),
30
+ )
31
+
32
+
33
+ def _client() -> AgentApiClient:
34
+ return AgentApiClient()
35
+
36
+
37
+ def _json(data: Any) -> str:
38
+ return json.dumps(data, ensure_ascii=False, indent=2)
39
+
40
+
41
+ @mcp.tool()
42
+ def register_facility(facility: dict[str, Any]) -> str:
43
+ """施設を1件登録する(FacilityCreate 形式)。"""
44
+ try:
45
+ return _json(_client().register_facility(facility))
46
+ except (AgentApiError, ValueError) as e:
47
+ return _json({"error": str(e)})
48
+
49
+
50
+ @mcp.tool()
51
+ def search_facilities(
52
+ q: str | None = None,
53
+ prefecture: str | None = None,
54
+ area: str | None = None,
55
+ onsen_name: str | None = None,
56
+ spring_type: str | None = None,
57
+ tags: str | None = None,
58
+ has_analysis: str | None = None,
59
+ status: str | None = None,
60
+ limit: int = 20,
61
+ skip: int = 0,
62
+ sort: str = "default",
63
+ ) -> str:
64
+ """施設を検索する。"""
65
+ try:
66
+ result = _client().search_facilities(
67
+ {
68
+ "q": q,
69
+ "prefecture": prefecture,
70
+ "area": area,
71
+ "onsen_name": onsen_name,
72
+ "spring_type": spring_type,
73
+ "tags": tags,
74
+ "has_analysis": has_analysis,
75
+ "status": status,
76
+ "limit": limit,
77
+ "skip": skip,
78
+ "sort": sort,
79
+ }
80
+ )
81
+ return _json(result)
82
+ except (AgentApiError, ValueError) as e:
83
+ return _json({"error": str(e)})
84
+
85
+
86
+ @mcp.tool()
87
+ def get_facility(facility_id: int) -> str:
88
+ """施設詳細を取得する。"""
89
+ try:
90
+ return _json(_client().get_facility(facility_id))
91
+ except (AgentApiError, ValueError) as e:
92
+ return _json({"error": str(e)})
93
+
94
+
95
+ @mcp.tool()
96
+ def register_facilities_batch(
97
+ facilities: list[dict[str, Any]] | None = None,
98
+ csv_path: str | None = None,
99
+ continue_on_error: bool = True,
100
+ dry_run: bool = False,
101
+ ) -> str:
102
+ """施設を一括登録する。JSON 配列または調査 CSV から。"""
103
+ try:
104
+ if csv_path:
105
+ payloads = facilities_from_csv(csv_path)
106
+ elif facilities:
107
+ payloads = facilities
108
+ else:
109
+ return _json({"error": "facilities または csv_path のいずれかを指定してください"})
110
+ if dry_run:
111
+ return _json({"dry_run": True, "count": len(payloads), "facilities": payloads})
112
+ return _json(
113
+ _client().register_facilities_batch(payloads, continue_on_error=continue_on_error)
114
+ )
115
+ except (AgentApiError, ValueError, FileNotFoundError) as e:
116
+ return _json({"error": str(e)})
117
+
118
+
119
+ @mcp.tool()
120
+ def update_facility(facility_id: int, updates: dict[str, Any]) -> str:
121
+ """既存施設を部分更新する(Agent キー時は編集依頼)。"""
122
+ try:
123
+ return _json(_client().update_facility(facility_id, updates))
124
+ except (AgentApiError, ValueError) as e:
125
+ return _json({"error": str(e)})
126
+
127
+
128
+ @mcp.tool()
129
+ def register_source(payload: dict[str, Any]) -> str:
130
+ """成分表付きで施設・源泉を登録する。"""
131
+ try:
132
+ return _json(_client().register_source(payload))
133
+ except (AgentApiError, ValueError) as e:
134
+ return _json({"error": str(e)})
135
+
136
+
137
+ @mcp.tool()
138
+ def analyze_source_image(image_path: str) -> str:
139
+ """成分表画像を Ynosis OCR で解析する(DB 未書込)。並列禁止。"""
140
+ try:
141
+ return _json(_client().analyze_image(image_path))
142
+ except (AgentApiError, ValueError, FileNotFoundError) as e:
143
+ return _json({"error": str(e)})
144
+
145
+
146
+ @mcp.tool()
147
+ def prepare_parsed(parsed: dict[str, Any]) -> str:
148
+ """register_source 用に dissolved_material を mg 換算する。"""
149
+ try:
150
+ return _json(prepare_parsed_for_save(parsed))
151
+ except Exception as e:
152
+ return _json({"error": str(e)})
153
+
154
+
155
+ @mcp.tool()
156
+ def find_similar_sources(source: dict[str, Any]) -> str:
157
+ """登録前に成分が近い既存源泉を検索する。"""
158
+ try:
159
+ return _json(_client().find_similar_sources(source))
160
+ except (AgentApiError, ValueError) as e:
161
+ return _json({"error": str(e)})
162
+
163
+
164
+ @mcp.tool()
165
+ def get_tag_options() -> str:
166
+ """登録可能な施設タグ一覧を返す。"""
167
+ try:
168
+ return _json({"tags": _client().get_tag_options()})
169
+ except (AgentApiError, ValueError) as e:
170
+ return _json({"error": str(e)})
171
+
172
+
173
+ @mcp.tool()
174
+ def preview_csv_row(row: dict[str, str]) -> str:
175
+ """調査 CSV の1行を FacilityCreate にマッピングした結果をプレビューする。"""
176
+ try:
177
+ return _json(row_to_facility_payload(row))
178
+ except ValueError as e:
179
+ return _json({"error": str(e)})
180
+
181
+
182
+ @mcp.tool()
183
+ def upload_source_image(source_id: int, image_path: str) -> str:
184
+ """成分表の証憑画像をアップロードする。"""
185
+ try:
186
+ return _json(_client().upload_source_image(source_id, image_path))
187
+ except (AgentApiError, ValueError, FileNotFoundError) as e:
188
+ return _json({"error": str(e)})
189
+
190
+
191
+ def main() -> None:
192
+ mcp.run(transport="stdio")
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
@@ -0,0 +1,28 @@
1
+ """登録直前の parsed 整形(Web UI の mg 換算に相当)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def prepare_parsed_for_save(parsed: dict) -> dict:
7
+ """dissolved_material の mg 換算と、登録に不要なキーの除去。"""
8
+ out = dict(parsed)
9
+ unit = out.get("dissolved_material_unit") or "mg"
10
+ dm = out.get("dissolved_material")
11
+ if isinstance(dm, (int, float)):
12
+ if unit == "g":
13
+ out["dissolved_material"] = round(float(dm) * 1000, 1)
14
+ else:
15
+ out["dissolved_material"] = round(float(dm), 1)
16
+ out.pop("dissolved_material_unit", None)
17
+ for key in (
18
+ "is_composition_table",
19
+ "ion_total_warnings",
20
+ "bisulfate_keyword_detected",
21
+ "metasilicate_ion_keyword_detected",
22
+ "metaborate_ion_keyword_detected",
23
+ "name",
24
+ "address",
25
+ "prefecture",
26
+ ):
27
+ out.pop(key, None)
28
+ return out
@@ -0,0 +1,17 @@
1
+ """施設タグ選択肢(フロント / 本番 API と一致させること)。"""
2
+
3
+ FACILITY_TAG_OPTIONS = [
4
+ "足元湧出",
5
+ "掛け流し",
6
+ "秘湯",
7
+ "野湯",
8
+ "露天あり",
9
+ "貸切湯あり",
10
+ "日帰り入浴可",
11
+ "共同浴場",
12
+ "造成泉",
13
+ "モール泉",
14
+ "混浴",
15
+ "ぬる湯",
16
+ "冷鉱泉",
17
+ ]
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: ynosis-agent
3
+ Version: 0.1.0
4
+ Summary: CLI and MCP client for the Ynosis Agent API (facilities and spring analyses)
5
+ Author: Ynosis
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://ynosis.jp
8
+ Project-URL: Documentation, https://ynosis.jp/notes/usage-037
9
+ Keywords: ynosis,onsen,agent,mcp
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp<2,>=1.0; extra == "mcp"
15
+
16
+ # ynosis-agent
17
+
18
+ Ynosis Agent API 用の **公開クライアント**(CLI / MCP)。
19
+
20
+ リポジトリは非公開です。ソース clone は不要です。
21
+
22
+ ## CLI
23
+
24
+ ```bash
25
+ pip install ynosis-agent
26
+ export YNOSIS_AGENT_API_KEY='yna_...' # マイページ → … → Agent API
27
+ ynosis-agent search --q 草津 --limit 5
28
+ ```
29
+
30
+ 接続先の既定は `https://ynosis.jp`。
31
+
32
+ ## MCP(アプリに設定を貼る・推奨)
33
+
34
+ 事前 pip 不要。[`uv`](https://docs.astral.sh/uv/)(`uvx`)があるマシンで、アプリの MCP 設定に次を追加します。
35
+
36
+ **起動コマンド:**
37
+
38
+ ```text
39
+ uvx --from ynosis-agent[mcp] ynosis-agent-mcp
40
+ ```
41
+
42
+ **環境変数:** `YNOSIS_AGENT_API_KEY=yna_…`(必須)
43
+
44
+ ### Cursor(mcp.json)
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "ynosis-agent": {
50
+ "command": "uvx",
51
+ "args": ["--from", "ynosis-agent[mcp]", "ynosis-agent-mcp"],
52
+ "env": {
53
+ "YNOSIS_AGENT_API_KEY": "yna_REPLACE_ME",
54
+ "YNOSIS_AGENT_API_BASE": "https://ynosis.jp"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### Claude Code
62
+
63
+ ```bash
64
+ claude mcp add --transport stdio ynosis-agent \
65
+ --env YNOSIS_AGENT_API_KEY=yna_REPLACE_ME \
66
+ -- uvx --from 'ynosis-agent[mcp]' ynosis-agent-mcp
67
+ ```
68
+
69
+ `uv` が無い場合のフォールバック: `pip install 'ynosis-agent[mcp]'` のあと `command` を `ynosis-agent-mcp` にする。
70
+
71
+ ## 運営向け: PyPI 公開
72
+
73
+ ```bash
74
+ cd packages/ynosis-agent
75
+ python -m build
76
+ twine upload dist/*
77
+ ```
78
+
79
+ バージョンは `pyproject.toml` の `version` を上げてからビルドする。
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/ynosis_agent/__init__.py
4
+ src/ynosis_agent/cli.py
5
+ src/ynosis_agent/client.py
6
+ src/ynosis_agent/csv_import.py
7
+ src/ynosis_agent/mcp_server.py
8
+ src/ynosis_agent/prepare.py
9
+ src/ynosis_agent/tags.py
10
+ src/ynosis_agent.egg-info/PKG-INFO
11
+ src/ynosis_agent.egg-info/SOURCES.txt
12
+ src/ynosis_agent.egg-info/dependency_links.txt
13
+ src/ynosis_agent.egg-info/entry_points.txt
14
+ src/ynosis_agent.egg-info/requires.txt
15
+ src/ynosis_agent.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ynosis-agent = ynosis_agent.cli:main
3
+ ynosis-agent-mcp = ynosis_agent.mcp_server:main
@@ -0,0 +1,4 @@
1
+ httpx>=0.27
2
+
3
+ [mcp]
4
+ mcp<2,>=1.0
@@ -0,0 +1 @@
1
+ ynosis_agent