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.
- ynosis_agent-0.1.0/PKG-INFO +79 -0
- ynosis_agent-0.1.0/README.md +64 -0
- ynosis_agent-0.1.0/pyproject.toml +29 -0
- ynosis_agent-0.1.0/setup.cfg +4 -0
- ynosis_agent-0.1.0/src/ynosis_agent/__init__.py +30 -0
- ynosis_agent-0.1.0/src/ynosis_agent/cli.py +291 -0
- ynosis_agent-0.1.0/src/ynosis_agent/client.py +262 -0
- ynosis_agent-0.1.0/src/ynosis_agent/csv_import.py +78 -0
- ynosis_agent-0.1.0/src/ynosis_agent/mcp_server.py +196 -0
- ynosis_agent-0.1.0/src/ynosis_agent/prepare.py +28 -0
- ynosis_agent-0.1.0/src/ynosis_agent/tags.py +17 -0
- ynosis_agent-0.1.0/src/ynosis_agent.egg-info/PKG-INFO +79 -0
- ynosis_agent-0.1.0/src/ynosis_agent.egg-info/SOURCES.txt +15 -0
- ynosis_agent-0.1.0/src/ynosis_agent.egg-info/dependency_links.txt +1 -0
- ynosis_agent-0.1.0/src/ynosis_agent.egg-info/entry_points.txt +3 -0
- ynosis_agent-0.1.0/src/ynosis_agent.egg-info/requires.txt +4 -0
- ynosis_agent-0.1.0/src/ynosis_agent.egg-info/top_level.txt +1 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ynosis_agent
|