Compare commits
4 Commits
cb649d83ee
...
ec2e0acea2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2e0acea2 | ||
|
|
195bd49236 | ||
|
|
73042c57a6 | ||
|
|
e2827c6a47 |
@ -45,6 +45,7 @@ from .mem0_config import Mem0Config
|
|||||||
from agent.prompt_loader import load_system_prompt_async, load_mcp_settings_async
|
from agent.prompt_loader import load_system_prompt_async, load_mcp_settings_async
|
||||||
from agent.agent_memory_cache import get_memory_cache_manager
|
from agent.agent_memory_cache import get_memory_cache_manager
|
||||||
from .subagent_loader import load_subagents
|
from .subagent_loader import load_subagents
|
||||||
|
from agent.plugin_hook_loader import collect_main_agent_hidden_tools
|
||||||
from .checkpoint_manager import get_checkpointer_manager
|
from .checkpoint_manager import get_checkpointer_manager
|
||||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
@ -310,6 +311,15 @@ async def init_agent(config: AgentConfig):
|
|||||||
logger.info(f"Loaded {len(mcp_tools)} MCP tools")
|
logger.info(f"Loaded {len(mcp_tools)} MCP tools")
|
||||||
logger.info(f"init_agent mcp tools ready, elapsed: {time.time() - create_start:.3f}s")
|
logger.info(f"init_agent mcp tools ready, elapsed: {time.time() - create_start:.3f}s")
|
||||||
|
|
||||||
|
# Build the main agent's tool list by hiding tools blacklisted in plugin.json.
|
||||||
|
# Sub-agents still receive the full mcp_tools set, so hidden tools remain usable by them.
|
||||||
|
hidden_tools = collect_main_agent_hidden_tools(config.bot_id)
|
||||||
|
main_tools = [t for t in mcp_tools if t.name not in hidden_tools] if hidden_tools else mcp_tools
|
||||||
|
if hidden_tools:
|
||||||
|
logger.info(
|
||||||
|
f"Main agent hides {len(mcp_tools) - len(main_tools)} tools: {sorted(hidden_tools)}"
|
||||||
|
)
|
||||||
|
|
||||||
sandbox, sandbox_type, workspace_root = await sandbox_task
|
sandbox, sandbox_type, workspace_root = await sandbox_task
|
||||||
logger.info(f"init_agent sandbox ready, elapsed: {time.time() - create_start:.3f}s")
|
logger.info(f"init_agent sandbox ready, elapsed: {time.time() - create_start:.3f}s")
|
||||||
|
|
||||||
@ -342,7 +352,7 @@ async def init_agent(config: AgentConfig):
|
|||||||
model=llm_instance,
|
model=llm_instance,
|
||||||
assistant_id=config.bot_id,
|
assistant_id=config.bot_id,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=mcp_tools,
|
tools=main_tools,
|
||||||
auto_approve=True,
|
auto_approve=True,
|
||||||
workspace_root=workspace_root,
|
workspace_root=workspace_root,
|
||||||
middleware=middleware,
|
middleware=middleware,
|
||||||
|
|||||||
@ -129,6 +129,52 @@ async def merge_skill_mcp_configs(bot_id: str) -> List[Dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def collect_main_agent_hidden_tools(bot_id: str) -> set:
|
||||||
|
"""Collect tool names that must be hidden from the main agent.
|
||||||
|
|
||||||
|
Scans every skill's plugin.json for a top-level "mainAgentHiddenTools" list
|
||||||
|
and merges them into a single set. These tools are removed from the main
|
||||||
|
agent's tool list but remain available to sub-agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_id: Bot ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[str]: Union of all hidden tool names. Empty set if none configured.
|
||||||
|
"""
|
||||||
|
hidden_tools = set()
|
||||||
|
skill_dirs = _get_skill_dirs(bot_id)
|
||||||
|
|
||||||
|
for skill_dir in skill_dirs:
|
||||||
|
if not os.path.exists(skill_dir):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for skill_name in os.listdir(skill_dir):
|
||||||
|
skill_path = os.path.join(skill_dir, skill_name)
|
||||||
|
if not os.path.isdir(skill_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_json = os.path.join(skill_path, '.claude-plugin', 'plugin.json')
|
||||||
|
if not os.path.exists(plugin_json):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin_config = _load_plugin_config(plugin_json)
|
||||||
|
names = plugin_config.get('mainAgentHiddenTools', [])
|
||||||
|
if isinstance(names, list):
|
||||||
|
for name in names:
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
hidden_tools.add(name.strip())
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Invalid 'mainAgentHiddenTools' in {skill_name}, expected list"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load mainAgentHiddenTools from {skill_name}: {e}")
|
||||||
|
|
||||||
|
return hidden_tools
|
||||||
|
|
||||||
|
|
||||||
def _normalize_skill_mcp_servers(servers: Dict[str, Any], skill_path: str) -> Dict[str, Any]:
|
def _normalize_skill_mcp_servers(servers: Dict[str, Any], skill_path: str) -> Dict[str, Any]:
|
||||||
"""Normalize relative paths in stdio MCP servers to absolute paths based on the skill directory."""
|
"""Normalize relative paths in stdio MCP servers to absolute paths based on the skill directory."""
|
||||||
normalized_servers = copy.deepcopy(servers)
|
normalized_servers = copy.deepcopy(servers)
|
||||||
|
|||||||
95
agent/subagent_context_middleware.py
Normal file
95
agent/subagent_context_middleware.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"""Middleware that tags logs with the currently executing subagent name.
|
||||||
|
|
||||||
|
Each subagent receives its own instance of this middleware (carrying its name).
|
||||||
|
The middleware writes the name into the request-scoped GlobalContext (`g.subagent`)
|
||||||
|
for the duration of every model call and tool call, so the log Formatter can render
|
||||||
|
which subagent produced each log line. The previous value is restored afterwards so
|
||||||
|
that nested/parallel subagents and the main agent are not affected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||||
|
from langchain.tools.tool_node import ToolCallRequest
|
||||||
|
|
||||||
|
from utils.log_util.context import g
|
||||||
|
|
||||||
|
logger = logging.getLogger("app")
|
||||||
|
|
||||||
|
# Context key consumed by utils/log_util/logger.py Formatter.
|
||||||
|
_SUBAGENT_KEY = "subagent"
|
||||||
|
|
||||||
|
|
||||||
|
class SubagentContextMiddleware(AgentMiddleware):
|
||||||
|
"""Set `g.subagent` while this subagent's model/tool calls execute."""
|
||||||
|
|
||||||
|
def __init__(self, subagent_name: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._subagent_name = subagent_name
|
||||||
|
|
||||||
|
def _enter(self) -> dict:
|
||||||
|
# Shallow-copy the whole context dict and rebind a PRIVATE copy for this
|
||||||
|
# context. This is load-bearing: GlobalContext mutates a shared dict in
|
||||||
|
# place, and asyncio task copies share that reference, so a plain
|
||||||
|
# `g.subagent = name` would leak across parallel sibling subagents and
|
||||||
|
# race on restore. Replacing the reference isolates each context.
|
||||||
|
try:
|
||||||
|
prev = dict(g.get_context())
|
||||||
|
except LookupError:
|
||||||
|
prev = {}
|
||||||
|
new_ctx = dict(prev)
|
||||||
|
new_ctx[_SUBAGENT_KEY] = self._subagent_name
|
||||||
|
g.update_context(new_ctx)
|
||||||
|
return prev
|
||||||
|
|
||||||
|
def _exit(self, prev: dict) -> None:
|
||||||
|
# Restore by rebinding the previous dict (also a private copy).
|
||||||
|
g.update_context(dict(prev))
|
||||||
|
|
||||||
|
# ----- model call -----
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelResponse:
|
||||||
|
prev = self._enter()
|
||||||
|
try:
|
||||||
|
return handler(request)
|
||||||
|
finally:
|
||||||
|
self._exit(prev)
|
||||||
|
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelResponse:
|
||||||
|
prev = self._enter()
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
finally:
|
||||||
|
self._exit(prev)
|
||||||
|
|
||||||
|
# ----- tool call -----
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Any],
|
||||||
|
) -> Any:
|
||||||
|
prev = self._enter()
|
||||||
|
try:
|
||||||
|
return handler(request)
|
||||||
|
finally:
|
||||||
|
self._exit(prev)
|
||||||
|
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||||
|
) -> Any:
|
||||||
|
prev = self._enter()
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
finally:
|
||||||
|
self._exit(prev)
|
||||||
@ -25,6 +25,7 @@ from langchain.tools import BaseTool
|
|||||||
from langchain_core.language_models import BaseChatModel
|
from langchain_core.language_models import BaseChatModel
|
||||||
|
|
||||||
from agent.plugin_hook_loader import _get_skill_dirs
|
from agent.plugin_hook_loader import _get_skill_dirs
|
||||||
|
from agent.subagent_context_middleware import SubagentContextMiddleware
|
||||||
|
|
||||||
logger = logging.getLogger('app')
|
logger = logging.getLogger('app')
|
||||||
|
|
||||||
@ -181,6 +182,8 @@ async def load_subagents(
|
|||||||
"system_prompt": parsed["system_prompt"],
|
"system_prompt": parsed["system_prompt"],
|
||||||
"model": model,
|
"model": model,
|
||||||
"tools": filtered_tools,
|
"tools": filtered_tools,
|
||||||
|
# Tag this subagent's model/tool logs with its name.
|
||||||
|
"middleware": [SubagentContextMiddleware(name)],
|
||||||
}
|
}
|
||||||
subagents.append(subagent)
|
subagents.append(subagent)
|
||||||
logger.info(f"Loaded sub-agent '{name}' with {len(filtered_tools)} tools from {parsed['source']}")
|
logger.info(f"Loaded sub-agent '{name}' with {len(filtered_tools)} tools from {parsed['source']}")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pmda-drug-info",
|
"name": "pmda-drug-info",
|
||||||
|
"version": "0.1.0",
|
||||||
"description": "PMDA drug information tools for Japanese pharmaceutical package insert queries. Provides drug search, master info, interactions, restrictions, dosing, and full-text chapter retrieval via PostgreSQL + OpenSearch.",
|
"description": "PMDA drug information tools for Japanese pharmaceutical package insert queries. Provides drug search, master info, interactions, restrictions, dosing, and full-text chapter retrieval via PostgreSQL + OpenSearch.",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PrePrompt": [
|
"PrePrompt": [
|
||||||
@ -9,14 +10,34 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"mainAgentHiddenTools": [
|
||||||
|
"search_drugs",
|
||||||
|
"list_categories",
|
||||||
|
"list_drugs_in_category",
|
||||||
|
"get_drug_master",
|
||||||
|
"get_drug_interactions",
|
||||||
|
"get_drug_restrictions",
|
||||||
|
"get_drug_dosing",
|
||||||
|
"search_section_text",
|
||||||
|
"list_drug_chapters",
|
||||||
|
"read_drug_chapter"
|
||||||
|
],
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"pmda_drug_info": {
|
"pmda_drug_info": {
|
||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
"command": "python",
|
"command": "python3",
|
||||||
"args": [
|
"args": [
|
||||||
"./pmda_server.py"
|
"./pmda_server.py"
|
||||||
]
|
],
|
||||||
|
"env": {
|
||||||
|
"PMDA_PG_HOST": "postgres-db",
|
||||||
|
"PMDA_PG_PORT": "5432",
|
||||||
|
"PMDA_PG_DB": "gptbase",
|
||||||
|
"PMDA_PG_USER": "postgres",
|
||||||
|
"PMDA_PG_PASSWORD": "yRhnjSnhufuxNcCxFtPctXnTbAKS2jT2",
|
||||||
|
"PMDA_OPENSEARCH_URL": "http://admin:admin@opensearch-node:9200",
|
||||||
|
"PMDA_OS_INDEX": "pmda_sections"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"category": "Developer Tools"
|
|
||||||
}
|
}
|
||||||
|
|||||||
76
skills/developing/pmda-drug-info/README.md
Normal file
76
skills/developing/pmda-drug-info/README.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# pmda-drug-info — Claude Code MCP plugin
|
||||||
|
|
||||||
|
PMDA 添付文書ベース医薬指導 Q&A の MCP plugin(hu-sandbox/pmda v2e 98/100 baseline)。
|
||||||
|
|
||||||
|
## アーキテクチャ
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude Code (agent) → MCP stdio → pmda_server.py
|
||||||
|
├─ PG queries (drug_master / interaction / restriction / dosing)
|
||||||
|
└─ OS search (pmda_sections, sudachi C-mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
10 tools: `search_drugs`, `list_categories`, `list_drugs_in_category`,
|
||||||
|
`get_drug_master`, `get_drug_interactions`, `get_drug_restrictions`,
|
||||||
|
`get_drug_dosing`, `search_section_text`, `list_drug_chapters`,
|
||||||
|
`read_drug_chapter`.
|
||||||
|
|
||||||
|
4 sub-agents(`agents/*.md`): `single_drug`, `interaction`,
|
||||||
|
`patient_specific`, `adverse_event`.
|
||||||
|
|
||||||
|
## Plugin は独立配布
|
||||||
|
|
||||||
|
`mygpt.*` への依存なし。PG/OS への接続情報を環境変数で渡すだけで動く。
|
||||||
|
`queries.py` / `db.py` / `os_client.py` / `taxonomy.py` / `drug_category.md`
|
||||||
|
は hu-sandbox/pmda からコピーした自己完結セット。
|
||||||
|
|
||||||
|
## 環境変数
|
||||||
|
|
||||||
|
```
|
||||||
|
PMDA_PG_HOST Postgres ホスト (例: tunnel / pg.example.com)
|
||||||
|
PMDA_PG_PORT Postgres ポート (default 5432)
|
||||||
|
PMDA_PG_DB Postgres DB (例: gptbase)
|
||||||
|
PMDA_PG_USER Postgres ユーザ
|
||||||
|
PMDA_PG_PASSWORD Postgres パスワード
|
||||||
|
|
||||||
|
PMDA_OPENSEARCH_URL OpenSearch URL (例: http://admin:admin@tunnel:9200)
|
||||||
|
PMDA_OS_INDEX OS index 名(default: pmda_sections)
|
||||||
|
```
|
||||||
|
|
||||||
|
## インストール
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plugin ディレクトリを Claude Code に登録(PROJECT 単位で)
|
||||||
|
/plugin install path/to/pmda-drug-info
|
||||||
|
```
|
||||||
|
|
||||||
|
`.claude-plugin/plugin.json` の `mcpServers.pmda_drug_info` が
|
||||||
|
`python ./pmda_server.py` を stdio MCP サーバとして起動する。
|
||||||
|
|
||||||
|
## データ準備
|
||||||
|
|
||||||
|
Plugin は読み取り専用。データ投入は gbase-onprem の folder-connector
|
||||||
|
+ PmdaXmlPipeline(`mygpt/plugins/pmda/pipeline.py`)が一括管理する。
|
||||||
|
|
||||||
|
- PG: aerich migration `migrations/models/263_*_add_pmda_tables.py` で 4 表作成
|
||||||
|
- OS: `pmda_sections` index は `mygpt/plugins/pmda/os_index.py` の DDL を
|
||||||
|
pipeline 初期化時に適用
|
||||||
|
- データ投入: folder-connector で PMDA XML を登録すると 9 ステップ
|
||||||
|
pipeline が OS bulk index + PG fact 抽出を実行
|
||||||
|
|
||||||
|
詳細は `docs/pmda-sync-flow.md` を参照。
|
||||||
|
|
||||||
|
## 動作確認
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# stdio MCP リクエストを手動で投げる
|
||||||
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | python pmda_server.py
|
||||||
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | python pmda_server.py
|
||||||
|
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_drugs","arguments":{"query":"ロサルタン"}}}' | python pmda_server.py
|
||||||
|
```
|
||||||
@ -6,26 +6,31 @@ description: Reverse lookup drugs by adverse event name. Find which drugs have r
|
|||||||
tools: search_section_text, search_drugs, get_drug_master, list_drug_chapters, read_drug_chapter
|
tools: search_section_text, search_drugs, get_drug_master, list_drug_chapters, read_drug_chapter
|
||||||
---
|
---
|
||||||
|
|
||||||
あなたは「副作用 → 該当薬剤の逆引き」専門の sub-agent です。
|
You are a sub-agent specialized in reverse lookup from an adverse event to the drugs that report it.
|
||||||
|
|
||||||
【ツール戦略】
|
## Tool Strategy
|
||||||
1. `search_section_text(keyword=副作用名, section_filter="副作用")` で逆引き。
|
1. Reverse-lookup with `search_section_text(keyword=<adverse event name>, section_filter="副作用")`. Always state `total_drugs` explicitly in the answer.
|
||||||
total_drugs は必ず本文中に明示する。
|
2. Synonyms are handled automatically — OpenSearch's synonym filter expands them in a single search, e.g.:
|
||||||
2. 同義語が必要なケース:
|
|
||||||
"Stevens-Johnson" ⇔ "皮膚粘膜眼症候群" / "SJS"
|
"Stevens-Johnson" ⇔ "皮膚粘膜眼症候群" / "SJS"
|
||||||
"QT延長" ⇔ "Torsades de pointes"
|
"QT延長" ⇔ "Torsades de pointes"
|
||||||
"間質性肺炎" ⇔ "肺臓炎"
|
"間質性肺炎" ⇔ "肺臓炎"
|
||||||
OS の synonym filter が自動展開するので 1 回の検索で OK。
|
3. From the hits, pick 3–5 representative drugs and quote "11.1 重大な副作用" / "11.2 その他の副作用" verbatim with `read_drug_chapter`.
|
||||||
3. hit から代表薬を 3〜5 件選び、`read_drug_chapter` で 11.1 重大な副作用 / 11.2 その他の副作用
|
4. NEVER make causal inferences (e.g. "this drug caused this patient's symptom"). Information presentation only.
|
||||||
verbatim を引用。
|
|
||||||
4. 因果推論("この薬がこの患者の症状を起こした")は **絶対しない**。
|
|
||||||
情報提示のみ。
|
|
||||||
|
|
||||||
【絶対ルール】
|
## Absolute Rules
|
||||||
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
|
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
|
||||||
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
|
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
|
||||||
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
|
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
|
||||||
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**。
|
- Fact-table rows include a `_citation` field — copy it verbatim.
|
||||||
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**。
|
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
|
||||||
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
|
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
|
||||||
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
|
4. If the information cannot be found, write "添付文書からは確認できません".
|
||||||
|
|
||||||
|
## Citation Requirements (clickable `<CITATION>` tags)
|
||||||
|
Every tool result record ALSO ends with a `CITATION:` line — a pre-built `<CITATION file="..." filename="..." />` clickable tag that the frontend PDF-highlight pipeline depends on. Your FINAL answer (the text returned to the main agent) MUST include these tags, in addition to the `[出典: ...]` text — otherwise the citation is not clickable and the tag is lost.
|
||||||
|
- Copy the record's `CITATION:` line VERBATIM (byte-for-byte) immediately after the fact-grounded paragraph or bullet. NEVER collect tags at the end of the answer.
|
||||||
|
- Do NOT add, modify, reorder, or remove any attribute. Do NOT construct a `<CITATION>` tag yourself.
|
||||||
|
- At most one `<CITATION>` per unique file.
|
||||||
|
- `read_drug_chapter` returns the `<CITATION>` already embedded in its header/footer — copy it as-is.
|
||||||
|
- Records without a `CITATION:` line → emit the `[出典: ...]` text only; never fabricate an empty tag.
|
||||||
|
- An answer that states facts but contains zero `<CITATION>` tags is a failed answer.
|
||||||
|
|||||||
@ -5,24 +5,31 @@ description: Investigate drug-drug interactions between two drugs, or list all i
|
|||||||
tools: search_drugs, get_drug_master, get_drug_interactions, search_section_text, list_drug_chapters, read_drug_chapter
|
tools: search_drugs, get_drug_master, get_drug_interactions, search_section_text, list_drug_chapters, read_drug_chapter
|
||||||
---
|
---
|
||||||
|
|
||||||
あなたは「薬剤間相互作用」専門の sub-agent です。
|
You are a sub-agent specialized in drug-drug interactions.
|
||||||
|
|
||||||
【ツール戦略】
|
## Tool Strategy
|
||||||
- A・B 両薬の yj_code を `search_drugs` で取得。
|
- Get the yj_code of both drugs A and B with `search_drugs`.
|
||||||
- `get_drug_interactions(drug_a_yj=A, drug_b_yj=B)` で双方向検索(A→B も B→A も拾える)。
|
- Search both directions with `get_drug_interactions(drug_a_yj=A, drug_b_yj=B)` (catches A→B and B→A).
|
||||||
- ヒットしたら drug_a の側の出典 section(10.1 / 10.2)を `list_drug_chapters` + `read_drug_chapter` で
|
- On a hit, retrieve the citing section on drug A's side (10.1 / 10.2) verbatim with `list_drug_chapters` + `read_drug_chapter`. Also check whether drug B's side carries a matching statement.
|
||||||
verbatim 取得。drug_b 側にも該当記載があるか確認。
|
- On zero hits, write "添付文書上は併用禁忌・併用注意の明確な記載なし" (free-text warnings can be double-checked separately with `search_section_text(keyword=<drug B name>, section_filter="相互作用")`).
|
||||||
- ヒットゼロ → "添付文書上は併用禁忌・併用注意の明確な記載なし" と書く(自由記述/警告等は
|
- If only one drug name is given, list all interactions with `get_drug_interactions(drug_a_yj=...)`.
|
||||||
別途 `search_section_text(keyword=B薬名, section_filter="相互作用")` で念押し)。
|
|
||||||
- 1 薬名のみ与えられた場合は `get_drug_interactions(drug_a_yj=...)` で全相互作用一覧。
|
|
||||||
|
|
||||||
severity は本文の "併用禁忌" / "併用注意" の語をそのまま転記。
|
Copy the `severity` field verbatim using the source wording "併用禁忌" / "併用注意".
|
||||||
|
|
||||||
【絶対ルール】
|
## Absolute Rules
|
||||||
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
|
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
|
||||||
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
|
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
|
||||||
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
|
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
|
||||||
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**。
|
- Fact-table rows include a `_citation` field — copy it verbatim.
|
||||||
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**。
|
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
|
||||||
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
|
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
|
||||||
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
|
4. If the information cannot be found, write "添付文書からは確認できません".
|
||||||
|
|
||||||
|
## Citation Requirements (clickable `<CITATION>` tags)
|
||||||
|
Every tool result record ALSO ends with a `CITATION:` line — a pre-built `<CITATION file="..." filename="..." />` clickable tag that the frontend PDF-highlight pipeline depends on. Your FINAL answer (the text returned to the main agent) MUST include these tags, in addition to the `[出典: ...]` text — otherwise the citation is not clickable and the tag is lost.
|
||||||
|
- Copy the record's `CITATION:` line VERBATIM (byte-for-byte) immediately after the fact-grounded paragraph or bullet. NEVER collect tags at the end of the answer.
|
||||||
|
- Do NOT add, modify, reorder, or remove any attribute. Do NOT construct a `<CITATION>` tag yourself.
|
||||||
|
- At most one `<CITATION>` per unique file.
|
||||||
|
- `read_drug_chapter` returns the `<CITATION>` already embedded in its header/footer — copy it as-is.
|
||||||
|
- Records without a `CITATION:` line → emit the `[出典: ...]` text only; never fabricate an empty tag.
|
||||||
|
- An answer that states facts but contains zero `<CITATION>` tags is a failed answer.
|
||||||
|
|||||||
@ -5,28 +5,36 @@ description: Determine drug administration feasibility and dosage adjustment for
|
|||||||
tools: search_drugs, get_drug_master, get_drug_restrictions, get_drug_dosing, list_drug_chapters, read_drug_chapter
|
tools: search_drugs, get_drug_master, get_drug_restrictions, get_drug_dosing, list_drug_chapters, read_drug_chapter
|
||||||
---
|
---
|
||||||
|
|
||||||
あなたは「特定患者への投与可否・用量調整」専門の sub-agent です。
|
You are a sub-agent specialized in administration feasibility and dosage adjustment for specific patients.
|
||||||
|
|
||||||
【ツール戦略】
|
## Tool Strategy
|
||||||
1. 薬名から yj_code を `search_drugs` で取得。
|
1. Get the yj_code from the drug name with `search_drugs`.
|
||||||
2. 患者条件を condition_type に対応付け:
|
2. Map the patient condition to a `condition_type`:
|
||||||
- 腎機能 (eGFR/CrCl) → "腎機能障害"
|
- Renal function (eGFR/CrCl) → "腎機能障害"
|
||||||
- 肝機能 (Child-Pugh) → "肝機能障害"
|
- Hepatic function (Child-Pugh) → "肝機能障害"
|
||||||
- 妊娠/授乳 → "妊婦"/"授乳婦"
|
- Pregnancy / lactation → "妊婦" / "授乳婦"
|
||||||
- 年齢 (小児/高齢) → "小児等"/"高齢者"
|
- Age (pediatric / elderly) → "小児等" / "高齢者"
|
||||||
- アレルギー既往 → "過敏症"
|
- Allergy history → "過敏症"
|
||||||
- 合併症 (糖尿病/喘息など) → "疾患"
|
- Comorbidity (diabetes, asthma, etc.) → "疾患"
|
||||||
3. `get_drug_restrictions(drug_yj=..., condition_type=...)` で該当 restriction を取得。
|
3. Get the matching restriction with `get_drug_restrictions(drug_yj=..., condition_type=...)`. Always check the `condition_params` values (e.g. `{"eGFR_max": 30}`).
|
||||||
condition_params の数値(例: {"eGFR_max": 30})を必ず確認。
|
4. Get patient-segment dosing with `get_drug_dosing(drug_yj=..., patient_segment=...)`.
|
||||||
4. `get_drug_dosing(drug_yj=..., patient_segment=...)` で患者層別用量を取得。
|
5. When needed, quote the 9.x chapter verbatim via `read_drug_chapter`.
|
||||||
5. 必要なら原文 `read_drug_chapter` で 9.x 章 verbatim 引用。
|
6. The agent is responsible for the numeric judgment (e.g. eGFR=25 vs eGFR_max=30 → applies).
|
||||||
6. 数値判定(例: eGFR=25 ⇔ eGFR_max=30 → 該当)を agent が責任もって行う。
|
|
||||||
|
|
||||||
【絶対ルール】
|
## Absolute Rules
|
||||||
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
|
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
|
||||||
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
|
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
|
||||||
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
|
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
|
||||||
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**。
|
- Fact-table rows include a `_citation` field — copy it verbatim.
|
||||||
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**。
|
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
|
||||||
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
|
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
|
||||||
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
|
4. If the information cannot be found, write "添付文書からは確認できません".
|
||||||
|
|
||||||
|
## Citation Requirements (clickable `<CITATION>` tags)
|
||||||
|
Every tool result record ALSO ends with a `CITATION:` line — a pre-built `<CITATION file="..." filename="..." />` clickable tag that the frontend PDF-highlight pipeline depends on. Your FINAL answer (the text returned to the main agent) MUST include these tags, in addition to the `[出典: ...]` text — otherwise the citation is not clickable and the tag is lost.
|
||||||
|
- Copy the record's `CITATION:` line VERBATIM (byte-for-byte) immediately after the fact-grounded paragraph or bullet. NEVER collect tags at the end of the answer.
|
||||||
|
- Do NOT add, modify, reorder, or remove any attribute. Do NOT construct a `<CITATION>` tag yourself.
|
||||||
|
- At most one `<CITATION>` per unique file.
|
||||||
|
- `read_drug_chapter` returns the `<CITATION>` already embedded in its header/footer — copy it as-is.
|
||||||
|
- Records without a `CITATION:` line → emit the `[出典: ...]` text only; never fabricate an empty tag.
|
||||||
|
- An answer that states facts but contains zero `<CITATION>` tags is a failed answer.
|
||||||
|
|||||||
@ -5,22 +5,30 @@ description: Answer factual questions about a single drug (brand name, generic n
|
|||||||
tools: search_drugs, get_drug_master, get_drug_dosing, get_drug_restrictions, list_drug_chapters, read_drug_chapter
|
tools: search_drugs, get_drug_master, get_drug_dosing, get_drug_restrictions, list_drug_chapters, read_drug_chapter
|
||||||
---
|
---
|
||||||
|
|
||||||
あなたは「単一薬の事実回答」専門の sub-agent です。
|
You are a sub-agent specialized in factual answers about a single drug.
|
||||||
|
|
||||||
【ツール戦略】
|
## Tool Strategy
|
||||||
1. 質問から薬名/yj_code を特定 → `search_drugs` または直接 yj_code が分かれば次へ。
|
1. Identify the drug name / yj_code from the question → use `search_drugs`, or go straight ahead if the yj_code is already known.
|
||||||
2. `get_drug_master(yj_code)` で基本情報(販売名・一般名・薬効分類・規制)を確定。
|
2. Confirm basic info (brand name, generic name, pharmacological category, regulation) with `get_drug_master(yj_code)`.
|
||||||
3. 必要に応じて `get_drug_dosing` で用法用量、`get_drug_restrictions(drug_yj=...)` で禁忌・特定患者注意。
|
3. As needed, use `get_drug_dosing` for dosing and `get_drug_restrictions(drug_yj=...)` for contraindications / patient-specific precautions.
|
||||||
4. 自由記述や上記テーブルに無い情報(例: 重大な副作用一覧、薬物動態の数値)は
|
4. For free-text details not in the fact tables (e.g. the full list of serious adverse reactions, pharmacokinetic values), retrieve the source text with `list_drug_chapters(yj_full)` → `read_drug_chapter(yj_full, section_title)`.
|
||||||
`list_drug_chapters(yj_full)` → `read_drug_chapter(yj_full, section_title)` で原文取得。
|
|
||||||
|
|
||||||
最終回答は箇条書き or 表で、各事実に出典を付ける。
|
Present the final answer as bullets or a table, attaching a citation to every fact.
|
||||||
|
|
||||||
【絶対ルール】
|
## Absolute Rules
|
||||||
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
|
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
|
||||||
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
|
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
|
||||||
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
|
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
|
||||||
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**。
|
- Fact-table rows include a `_citation` field — copy it verbatim.
|
||||||
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**。
|
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
|
||||||
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
|
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
|
||||||
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
|
4. If the information cannot be found, write "添付文書からは確認できません".
|
||||||
|
|
||||||
|
## Citation Requirements (clickable `<CITATION>` tags)
|
||||||
|
Every tool result record ALSO ends with a `CITATION:` line — a pre-built `<CITATION file="..." filename="..." />` clickable tag that the frontend PDF-highlight pipeline depends on. Your FINAL answer (the text returned to the main agent) MUST include these tags, in addition to the `[出典: ...]` text — otherwise the citation is not clickable and the tag is lost.
|
||||||
|
- Copy the record's `CITATION:` line VERBATIM (byte-for-byte) immediately after the fact-grounded paragraph or bullet. NEVER collect tags at the end of the answer.
|
||||||
|
- Do NOT add, modify, reorder, or remove any attribute. Do NOT construct a `<CITATION>` tag yourself.
|
||||||
|
- At most one `<CITATION>` per unique file.
|
||||||
|
- `read_drug_chapter` returns the `<CITATION>` already embedded in its header/footer — copy it as-is.
|
||||||
|
- Records without a `CITATION:` line → emit the `[出典: ...]` text only; never fabricate an empty tag.
|
||||||
|
- An answer that states facts but contains zero `<CITATION>` tags is a failed answer.
|
||||||
|
|||||||
80
skills/developing/pmda-drug-info/db.py
Normal file
80
skills/developing/pmda-drug-info/db.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""Postgres 连接 helper。
|
||||||
|
|
||||||
|
配置全部走环境变量,默认指向 docker-compose 起的本地实例:
|
||||||
|
|
||||||
|
PMDA_PG_HOST (默认 localhost)
|
||||||
|
PMDA_PG_PORT (默认 5432)
|
||||||
|
PMDA_PG_DB (默认 pmda)
|
||||||
|
PMDA_PG_USER (默认 pmda)
|
||||||
|
PMDA_PG_PASSWORD (默认 pmda_local_dev — 仅本地开发,生产由 secret 注入)
|
||||||
|
|
||||||
|
`connect()` 返回 psycopg3 connection(autocommit=False)。
|
||||||
|
长时跑批时使用 `pool()` 取 ConnectionPool。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg import Connection
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
PG_HOST = os.environ.get("PMDA_PG_HOST", "localhost")
|
||||||
|
PG_PORT = int(os.environ.get("PMDA_PG_PORT", "5432"))
|
||||||
|
PG_DB = os.environ.get("PMDA_PG_DB", "pmda")
|
||||||
|
PG_USER = os.environ.get("PMDA_PG_USER", "pmda")
|
||||||
|
PG_PASSWORD = os.environ.get("PMDA_PG_PASSWORD", "pmda_local_dev")
|
||||||
|
|
||||||
|
|
||||||
|
def conninfo() -> str:
|
||||||
|
return (
|
||||||
|
f"host={PG_HOST} port={PG_PORT} dbname={PG_DB} "
|
||||||
|
f"user={PG_USER} password={PG_PASSWORD}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connect(*, autocommit: bool = False) -> Connection:
|
||||||
|
"""Open a single connection. Caller is responsible for closing."""
|
||||||
|
return psycopg.connect(conninfo(), autocommit=autocommit)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session(*, autocommit: bool = False) -> Iterator[Connection]:
|
||||||
|
"""`with session() as conn:` — auto close on exit."""
|
||||||
|
conn = connect(autocommit=autocommit)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
if not autocommit:
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
if not autocommit:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_pool: ConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def pool(min_size: int = 1, max_size: int = 8) -> ConnectionPool:
|
||||||
|
"""Lazy-init module-level pool. Use for batch / agent-loop hot path."""
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = ConnectionPool(
|
||||||
|
conninfo(),
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
kwargs={"autocommit": False},
|
||||||
|
open=True,
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
def close_pool() -> None:
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
_pool.close()
|
||||||
|
_pool = None
|
||||||
206
skills/developing/pmda-drug-info/drug_category.md
Normal file
206
skills/developing/pmda-drug-info/drug_category.md
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
|
||||||
|
- 11 中枢神経系用薬
|
||||||
|
- 111 全身麻酔剤
|
||||||
|
- 112 催眠鎮静剤,抗不安剤
|
||||||
|
- 113 抗てんかん剤
|
||||||
|
- 114 解熱鎮痛消炎剤
|
||||||
|
- 115 興奮剤,覚醒剤
|
||||||
|
- 116 抗パーキンソン剤
|
||||||
|
- 117 精神神経用剤
|
||||||
|
- 118 総合感冒剤
|
||||||
|
- 119 その他の中枢神経系用薬
|
||||||
|
- 12 末梢神経用薬
|
||||||
|
- 121 局所麻酔剤
|
||||||
|
- 122 骨格筋弛緩剤
|
||||||
|
- 123 自律神経剤
|
||||||
|
- 124 鎮けい剤
|
||||||
|
- 125 発汗剤,止汗剤
|
||||||
|
- 129 その他の末梢神経系用薬
|
||||||
|
- 13 感覚器用薬
|
||||||
|
- 131 眼科用剤
|
||||||
|
- 132 耳鼻科用剤
|
||||||
|
- 133 鎮暈剤
|
||||||
|
- 139 その他の感覚器官用薬
|
||||||
|
- 19 その他の神経系及び感覚器官用医薬品
|
||||||
|
- 21 循環器官用薬
|
||||||
|
- 211 強心剤
|
||||||
|
- 212 不整脈用剤
|
||||||
|
- 213 利尿剤
|
||||||
|
- 214 血圧降下剤
|
||||||
|
- 215 血管補強剤
|
||||||
|
- 216 血管収縮剤
|
||||||
|
- 217 血管拡張剤
|
||||||
|
- 218 高脂血症用剤
|
||||||
|
- 219 その他の循環器官用薬
|
||||||
|
- 22 呼吸器官用薬
|
||||||
|
- 221 呼吸促進剤
|
||||||
|
- 222 鎮咳剤
|
||||||
|
- 223 去たん剤
|
||||||
|
- 224 鎮咳去たん剤
|
||||||
|
- 225 気管支拡張剤
|
||||||
|
- 226 含嗽剤
|
||||||
|
- 229 その他の呼吸器官用薬
|
||||||
|
- 23 消化器官用薬
|
||||||
|
- 231 止しゃ剤,整腸剤
|
||||||
|
- 232 消化性潰瘍用剤
|
||||||
|
- 233 健胃消化剤
|
||||||
|
- 234 制酸剤
|
||||||
|
- 235 下剤,浣腸剤
|
||||||
|
- 236 利胆剤
|
||||||
|
- 237 複合胃腸剤
|
||||||
|
- 239 その他の消化器官用薬
|
||||||
|
- 24 ホルモン剤(抗ホルモン剤を含む)
|
||||||
|
- 241 脳下垂体ホルモン剤
|
||||||
|
- 242 唾液腺ホルモン剤
|
||||||
|
- 243 甲状腺,副甲状腺ホルモン剤
|
||||||
|
- 244 たん白同化ステロイド剤
|
||||||
|
- 245 副腎ホルモン剤
|
||||||
|
- 246 男性ホルモン剤
|
||||||
|
- 247 卵胞ホルモン及び黄体ホルモン剤
|
||||||
|
- 248 混合ホルモン剤
|
||||||
|
- 249 その他のホルモン剤(抗ホルモン剤を含む)
|
||||||
|
- 25 泌尿生殖器官及び肛門用薬
|
||||||
|
- 251 泌尿器官用剤
|
||||||
|
- 252 生殖器官用剤(性病予防剤を含む。)
|
||||||
|
- 253 子宮収縮剤
|
||||||
|
- 254 避妊剤
|
||||||
|
- 255 痔疾用剤
|
||||||
|
- 259 その他の泌尿生殖器官及び肛門用薬
|
||||||
|
- 26 外皮用薬
|
||||||
|
- 261 外皮用殺菌消毒剤
|
||||||
|
- 262 創傷保護剤
|
||||||
|
- 263 化膿性疾患用剤
|
||||||
|
- 264 鎮痛,鎮痒,収歛,消炎剤
|
||||||
|
- 265 寄生性皮ふ疾患用剤
|
||||||
|
- 266 皮ふ軟化剤(腐しょく剤を含む。)
|
||||||
|
- 267 毛髪用剤(発毛剤,脱毛剤,染毛剤,養毛剤
|
||||||
|
- 268 浴剤
|
||||||
|
- 269 その他の外皮用薬
|
||||||
|
- 27 歯科口腔用薬
|
||||||
|
- 271 歯科用局所麻酔剤
|
||||||
|
- 272 歯髄失活剤
|
||||||
|
- 273 歯科用鎮痛鎮静剤(根管及び齲窩消毒剤を含
|
||||||
|
- 274 歯髄乾屍剤(根管充填剤を含む。)
|
||||||
|
- 275 歯髄覆たく剤
|
||||||
|
- 276 歯科用抗生物質製剤
|
||||||
|
- 279 その他の歯科口腔用薬
|
||||||
|
- 29 その他の個々の器官系用医薬品
|
||||||
|
- 290 その他の個々の器官系用医薬品
|
||||||
|
- 31 ビタミン剤
|
||||||
|
- 311 ビタミンA及びD剤
|
||||||
|
- 312 ビタミンB1剤
|
||||||
|
- 313 ビタミンB剤(ビタミンB1剤を除く。)
|
||||||
|
- 314 ビタミンC剤
|
||||||
|
- 315 ビタミンE剤
|
||||||
|
- 316 ビタミンK剤
|
||||||
|
- 317 混合ビタミン剤(ビタミンA・D混合製剤を除く)
|
||||||
|
- 319 その他のビタミン剤
|
||||||
|
- 32 滋養強壮薬
|
||||||
|
- 321 カルシウム剤
|
||||||
|
- 322 無機質製剤
|
||||||
|
- 323 糖類剤
|
||||||
|
- 324 有機酸製剤
|
||||||
|
- 325 たん白アミノ酸製剤
|
||||||
|
- 326 臓器製剤
|
||||||
|
- 327 乳幼児用剤
|
||||||
|
- 329 その他の滋養強壮薬
|
||||||
|
- 33 血液・体液用薬
|
||||||
|
- 331 血液代用剤
|
||||||
|
- 332 止血剤
|
||||||
|
- 333 血液凝固阻止剤
|
||||||
|
- 339 その他の血液・体液用薬
|
||||||
|
- 34 人工透析用薬
|
||||||
|
- 341 人工腎臓透析用剤
|
||||||
|
- 342 腹膜透析用剤
|
||||||
|
- 349 その他の人工透析用薬
|
||||||
|
- 39 その他の代謝性医薬品
|
||||||
|
- 391 肝臓疾患用剤
|
||||||
|
- 392 解毒剤
|
||||||
|
- 393 習慣性中毒用剤
|
||||||
|
- 394 痛風治療剤
|
||||||
|
- 395 酵素製剤
|
||||||
|
- 396 糖尿病用剤
|
||||||
|
- 397 総合代謝性製剤
|
||||||
|
- 399 他に分類されない代謝性医薬品
|
||||||
|
- 41 細胞賦活用薬
|
||||||
|
- 411 クロロフィル製剤
|
||||||
|
- 412 色素製剤
|
||||||
|
- 419 その他の細胞賦活用薬
|
||||||
|
- 42 腫瘍用薬
|
||||||
|
- 421 アルキル化剤
|
||||||
|
- 422 代謝拮抗剤
|
||||||
|
- 423 抗腫瘍性抗生物質製剤
|
||||||
|
- 424 抗腫瘍性植物成分製剤
|
||||||
|
- 429 その他の腫瘍用薬
|
||||||
|
- 43 放射性医薬品
|
||||||
|
- 430 放射性医薬品
|
||||||
|
- 44 アレルギー用薬
|
||||||
|
- 441 抗ヒスタミン剤
|
||||||
|
- 442 刺激療法剤
|
||||||
|
- 443 非特異性免疫原製剤
|
||||||
|
- 449 その他のアレルギー用薬
|
||||||
|
- 49 その他の組織細胞機能用医薬品
|
||||||
|
- 490 その他の組織細胞機能用医薬品
|
||||||
|
- 51 生薬
|
||||||
|
- 510 生薬
|
||||||
|
- 52 漢方製剤
|
||||||
|
- 520 漢方製剤
|
||||||
|
- 59 その他の生薬及び漢方処方に基づく医薬品
|
||||||
|
- 590 その他の生薬及び漢方処方に基づく医薬品
|
||||||
|
- 61 抗生物質製剤
|
||||||
|
- 611 主としてグラム陽性菌に作用するもの
|
||||||
|
- 612 主としてグラム陰性菌に作用するもの
|
||||||
|
- 613 主としてグラム陽性・陰性菌に作用するもの
|
||||||
|
- 614 主としてグラム陽性菌,マイコプラズマに作用するもの
|
||||||
|
- 615 主としてグラム陽性・陰性菌,リケッチア,クラミジアに作用するもの
|
||||||
|
- 616 主として抗酸菌に作用するもの
|
||||||
|
- 617 主としてカビに作用するもの
|
||||||
|
- 619 その他の抗生物質製剤(複合抗生物質製剤を含む)
|
||||||
|
- 62 化学療法剤
|
||||||
|
- 621 サルファ剤
|
||||||
|
- 622 抗結核剤
|
||||||
|
- 623 抗ハンセン病剤
|
||||||
|
- 624 合成抗菌剤
|
||||||
|
- 625 抗ウイルス剤
|
||||||
|
- 629 その他の化学療法剤
|
||||||
|
- 63 生物学的製剤
|
||||||
|
- 631 ワクチン類
|
||||||
|
- 632 毒素及びトキソイド類
|
||||||
|
- 633 抗毒素類及び抗レプトスピラ血清類
|
||||||
|
- 634 血液製剤類
|
||||||
|
- 635 生物学的試験用製剤類
|
||||||
|
- 636 混合生物学的製剤
|
||||||
|
- 639 その他の生物学的製剤
|
||||||
|
- 64 寄生動物用薬
|
||||||
|
- 641 抗原虫剤
|
||||||
|
- 642 駆虫剤
|
||||||
|
- 649 その他の寄生動物用薬
|
||||||
|
- 69 その他の病原生物に対する医薬品
|
||||||
|
- 690 その他の病原生物に対する医薬品
|
||||||
|
- 71 調剤用薬
|
||||||
|
- 711 賦形剤
|
||||||
|
- 712 軟膏基剤
|
||||||
|
- 713 溶解剤
|
||||||
|
- 714 矯味,矯臭,着色剤
|
||||||
|
- 715 乳化剤
|
||||||
|
- 719 その他の調剤用薬
|
||||||
|
- 72 診断用薬(体外診断用医薬品を除く)
|
||||||
|
- 721 X線造影剤
|
||||||
|
- 722 機能検査用試薬
|
||||||
|
- 729 その他の診断用薬
|
||||||
|
- 73 公衆衛生用薬
|
||||||
|
- 731 防腐剤
|
||||||
|
- 732 防疫用殺菌消毒剤
|
||||||
|
- 733 防虫剤
|
||||||
|
- 734 殺虫剤
|
||||||
|
- 735 殺そ剤
|
||||||
|
- 739 その他の公衆衛生用薬
|
||||||
|
- 79 その他の治療を主目的としない医薬品
|
||||||
|
- 791 ばん創こう
|
||||||
|
- 799 他に分類されない治療を主目的としない医薬品
|
||||||
|
- 81 アルカロイド系麻薬(天然麻薬)
|
||||||
|
- 811 アヘンアルカロイド系麻薬
|
||||||
|
- 812 コカアルカロイド系製剤
|
||||||
|
- 819 その他のアルカロイド系麻薬(天然麻薬)
|
||||||
|
- 82 非アルカロイド系麻薬
|
||||||
|
- 821 合成麻薬
|
||||||
@ -2,19 +2,92 @@
|
|||||||
|
|
||||||
You have access to Japanese pharmaceutical package insert (添付文書) data via the following tools.
|
You have access to Japanese pharmaceutical package insert (添付文書) data via the following tools.
|
||||||
|
|
||||||
|
## Tool output format
|
||||||
|
|
||||||
|
Tools return **plain text**, not JSON. Each result has:
|
||||||
|
- A `=== CITATION INSTRUCTIONS ===` header (only when the result carries citable sources).
|
||||||
|
- A `Found N ...:` summary line, then one numbered record block per row.
|
||||||
|
- Inside each block: indented `label: value` fields, an optional `出典: [...]` line, and a
|
||||||
|
`CITATION: <CITATION ... />` line (the pre-built clickable tag).
|
||||||
|
|
||||||
|
When a query matches nothing, the tool instead returns a short English message starting with
|
||||||
|
`No matching ... were found` and **no** citation instructions. In that case tell the user no
|
||||||
|
relevant material was retrieved — **do NOT** invent or emit any `<CITATION>` tag.
|
||||||
|
|
||||||
## Core Rules
|
## Core Rules
|
||||||
- **Tool calls are mandatory.** Never answer from training knowledge alone. All facts must come from tool results.
|
- **Tool calls are mandatory.** Never answer from training knowledge alone. All facts must come from tool results.
|
||||||
- Cite sources in the format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`
|
- Cite sources in the format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` — taken from each record's `出典:` line.
|
||||||
- Fact table rows include a `_citation` field — use it directly.
|
|
||||||
- Generic citations like `[出典: 薬品マスター]` or `[出典: 添付文書]` are **prohibited**.
|
- Generic citations like `[出典: 薬品マスター]` or `[出典: 添付文書]` are **prohibited**.
|
||||||
- For urgent questions (suicide/drug abuse/severe acute symptoms), state: "緊急対応として担当医・薬剤師に直接相談してください"
|
- For urgent questions (suicide/drug abuse/severe acute symptoms), state: "緊急対応として担当医・薬剤師に直接相談してください"
|
||||||
|
|
||||||
|
## Clickable Citation (<CITATION> tag) — MUST copy the record's `CITATION:` line
|
||||||
|
|
||||||
|
After each fact-grounded paragraph or bullet list, copy that record's **`CITATION:` line VERBATIM**. Do NOT construct the tag yourself.
|
||||||
|
|
||||||
|
### Why verbatim copy
|
||||||
|
|
||||||
|
The tool already built the full `<CITATION ... />` string for you on the `CITATION:` line. It contains:
|
||||||
|
- Generic CITATION core attributes (`file`, `filename`, `page`) for the existing PDF highlight pipeline.
|
||||||
|
- PMDA-specific attributes (`yj_full`, `brand`, `section`) for richer frontend display.
|
||||||
|
|
||||||
|
If you rebuild it yourself, you risk hallucinating `file=` filenames or dropping attributes. **Just copy the `CITATION:` line byte-for-byte** (drop the leading `CITATION: ` label, keep the `<CITATION ... />` tag).
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Each record's `CITATION:` line is the complete `<CITATION ... />` string.
|
||||||
|
- **Emit it exactly as-is. Do not modify, paraphrase, summarize, reorder, add, or remove any character.**
|
||||||
|
- Do NOT assemble a tag from the `出典:` text or other fields — they are for reference only.
|
||||||
|
- If a record has **no** `CITATION:` line, emit only the `[出典: ...]` text — never invent any CITATION attributes.
|
||||||
|
|
||||||
|
### Multiple citations within the same paragraph
|
||||||
|
|
||||||
|
- Each fact record gets its own `<CITATION>` tag — emit the `CITATION:` line from that record.
|
||||||
|
- Within the same paragraph, if the same `(file, section)` pair would repeat — emit it only once.
|
||||||
|
- Same drug × different sections: one tag per section, back-to-back.
|
||||||
|
- Different drugs: each tag stands alone.
|
||||||
|
|
||||||
|
### Example (LLM-side view)
|
||||||
|
|
||||||
|
Tool returns (plain text):
|
||||||
|
```
|
||||||
|
[1] 〔東洋〕半夏厚朴湯エキス細粒
|
||||||
|
generic: 半夏厚朴湯
|
||||||
|
yj_full: 1399999X9999_1_01
|
||||||
|
出典: [出典: 〔東洋〕半夏厚朴湯エキス細粒 (yj_full=1399999X9999_1_01) / 6. 用法及び用量]
|
||||||
|
CITATION: <CITATION file="abc-uuid" filename="999999_1399999X9999_1_01.xml" page=0 yj_full="1399999X9999_1_01" brand="〔東洋〕半夏厚朴湯エキス細粒" section="6. 用法及び用量" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Your reply (correct):
|
||||||
|
```
|
||||||
|
用法は 1日3回。
|
||||||
|
<CITATION file="abc-uuid" filename="999999_1399999X9999_1_01.xml" page=0 yj_full="1399999X9999_1_01" brand="〔東洋〕半夏厚朴湯エキス細粒" section="6. 用法及び用量" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Your reply (WRONG — reconstructed by hand):
|
||||||
|
```
|
||||||
|
用法は 1日3回。
|
||||||
|
<CITATION file="千里牛香_添付文書.pdf" /> ← hallucinated, missing attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Citation Requirements
|
||||||
|
|
||||||
|
- You MUST emit a `<CITATION ... />` tag whenever you use a tool result. Copy the record's `CITATION:` line verbatim — never construct one.
|
||||||
|
- Place each citation IMMEDIATELY AFTER the paragraph or bullet list that uses the fact. NEVER collect citations at the end of the response.
|
||||||
|
- At most one tag per unique file. At least one `<CITATION>` is required whenever the answer is grounded in tool results.
|
||||||
|
- An answer that states tool-grounded facts but contains zero `<CITATION>` tags is a failed answer.
|
||||||
|
|
||||||
## When to Use Sub-agents (task tool)
|
## When to Use Sub-agents (task tool)
|
||||||
- **patient_specific**: Renal/hepatic/pregnancy/elderly/pediatric/allergy conditions × dosing decisions
|
- **patient_specific**: Renal/hepatic/pregnancy/elderly/pediatric/allergy conditions × dosing decisions
|
||||||
- **interaction**: Pairwise drug interaction investigation
|
- **interaction**: Pairwise drug interaction investigation
|
||||||
- **adverse_event**: Reverse lookup from adverse event name to drugs
|
- **adverse_event**: Reverse lookup from adverse event name to drugs
|
||||||
- **single_drug**: Detailed info not in fact tables (e.g., full adverse event list, pharmacokinetics)
|
- **single_drug**: Detailed info not in fact tables (e.g., full adverse event list, pharmacokinetics)
|
||||||
|
|
||||||
|
### Sub-agent citation pass-through (CRITICAL)
|
||||||
|
- A sub-agent's returned text already contains `<CITATION ... />` tags built from the tools it called. The original tag attributes (`file`/`filename`) only exist inside that returned text — you cannot reconstruct them.
|
||||||
|
- You MUST preserve every `<CITATION ... />` tag from the sub-agent output VERBATIM and re-emit it in your final answer, keeping it immediately after the fact it supports.
|
||||||
|
- NEVER strip, summarize away, paraphrase, or merge these tags when integrating sub-agent results.
|
||||||
|
- A final answer that relies on sub-agent facts but contains zero `<CITATION>` tags is a failed answer.
|
||||||
|
|
||||||
## Direct Tool Usage (do NOT delegate)
|
## Direct Tool Usage (do NOT delegate)
|
||||||
- Simple lookups → use tools directly
|
- Simple lookups → use tools directly
|
||||||
- Multi-drug comparisons → call tools sequentially, output as markdown table
|
- Multi-drug comparisons → call tools sequentially, output as markdown table
|
||||||
|
|||||||
157
skills/developing/pmda-drug-info/os_client.py
Normal file
157
skills/developing/pmda-drug-info/os_client.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""OpenSearch `pmda_sections` index spec + client helper.
|
||||||
|
|
||||||
|
Mapping 与 wiki-skill 的 sudachi 配置共用 plugin(同一 OS 集群、同一 sudachi
|
||||||
|
core 字典)。每个 doc 对应一份说明书的一个章节节点,冗余存药品 metadata 以避
|
||||||
|
免 JOIN(详见 design.md §2.1.2)。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
OS_HOST (默认 http://localhost:9200,与 wiki-skill `_common.py` 一致)
|
||||||
|
PMDA_OS_INDEX (默认 pmda_sections)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from opensearchpy import OpenSearch
|
||||||
|
|
||||||
|
# Plugin env vars: PMDA_OPENSEARCH_URL(推奨)/ OPENSEARCH_URL / OPENSEARCH_HOST
|
||||||
|
OS_HOST = (
|
||||||
|
os.environ.get("PMDA_OPENSEARCH_URL")
|
||||||
|
or os.environ.get("OPENSEARCH_URL")
|
||||||
|
or os.environ.get("OPENSEARCH_HOST")
|
||||||
|
or "http://localhost:9200"
|
||||||
|
)
|
||||||
|
INDEX_NAME = os.environ.get("PMDA_OS_INDEX", "pmda_sections")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Mapping spec --------------------------------------------------------
|
||||||
|
|
||||||
|
INDEX_BODY: dict = {
|
||||||
|
"settings": {
|
||||||
|
"index": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0,
|
||||||
|
"refresh_interval": "1s",
|
||||||
|
},
|
||||||
|
"analysis": {
|
||||||
|
"tokenizer": {
|
||||||
|
"sudachi_tokenizer": {
|
||||||
|
"type": "sudachi_tokenizer",
|
||||||
|
"split_mode": "C",
|
||||||
|
"discard_punctuation": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
# "med_synonyms": {
|
||||||
|
# "type": "synonym",
|
||||||
|
# 初期最小集 — 命中错例后扩充。同义词条之间逗号分隔代表
|
||||||
|
# 等价、空格视为词内字符。
|
||||||
|
# "synonyms": [
|
||||||
|
# "Stevens-Johnson, 皮膚粘膜眼症候群, SJS",
|
||||||
|
# "中毒性表皮壊死融解症, TEN, ライエル症候群",
|
||||||
|
# "QT延長, トルサード, Torsades de pointes",
|
||||||
|
# "間質性肺炎, 肺臓炎",
|
||||||
|
# "横紋筋融解症, ラブドミオリーシス",
|
||||||
|
# "アナフィラキシー, アナフィラキシーショック",
|
||||||
|
# "無顆粒球症, 顆粒球減少症",
|
||||||
|
# ],
|
||||||
|
# },
|
||||||
|
"jp_pos": {
|
||||||
|
"type": "sudachi_part_of_speech",
|
||||||
|
},
|
||||||
|
"jp_stop": {
|
||||||
|
"type": "sudachi_ja_stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"analyzer": {
|
||||||
|
"jp_med": {
|
||||||
|
"type": "custom",
|
||||||
|
# icu_normalizer はデフォルト image に未含、sudachi_
|
||||||
|
# normalizedform で全角半角・正規化はカバーされる。
|
||||||
|
"tokenizer": "sudachi_tokenizer",
|
||||||
|
"filter": [
|
||||||
|
"sudachi_baseform",
|
||||||
|
"sudachi_normalizedform",
|
||||||
|
"jp_pos",
|
||||||
|
"jp_stop",
|
||||||
|
"lowercase",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"properties": {
|
||||||
|
"yj_full": {"type": "keyword"},
|
||||||
|
"yj_code": {"type": "keyword"},
|
||||||
|
"l1_code": {"type": "keyword"},
|
||||||
|
"l2_code": {"type": "keyword"},
|
||||||
|
"l2_name": {"type": "keyword"},
|
||||||
|
"category_name": {"type": "keyword"},
|
||||||
|
"brand_names": {"type": "keyword"},
|
||||||
|
"generic_name": {"type": "keyword"},
|
||||||
|
"section_title": {
|
||||||
|
"type": "text",
|
||||||
|
"analyzer": "jp_med",
|
||||||
|
"fields": {"raw": {"type": "keyword"}},
|
||||||
|
},
|
||||||
|
"line_num": {"type": "integer"},
|
||||||
|
"text": {"type": "text", "analyzer": "jp_med"},
|
||||||
|
"revision_date": {"type": "date"},
|
||||||
|
"_md_sha256": {"type": "keyword"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Client --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> OpenSearch:
|
||||||
|
"""Return an OpenSearch client bound to OS_HOST."""
|
||||||
|
return OpenSearch(hosts=[OS_HOST], http_compress=True, timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 章節アクセス helpers(PageIndex 退役後の verbatim 取得経路) -------
|
||||||
|
|
||||||
|
|
||||||
|
def list_drug_sections(yj_full: str, *, limit: int = 200) -> list[dict]:
|
||||||
|
"""1 薬の全章節を line_num 昇順で返す。
|
||||||
|
|
||||||
|
各 element: {section_title, line_num, text_len, brand, generic}
|
||||||
|
"""
|
||||||
|
cli = client()
|
||||||
|
resp = cli.search(index=INDEX_NAME, body={
|
||||||
|
"size": min(limit, 500),
|
||||||
|
"_source": ["section_title", "line_num", "text", "brand_names", "generic_name"],
|
||||||
|
"query": {"term": {"yj_full": yj_full}},
|
||||||
|
"sort": [{"line_num": "asc"}],
|
||||||
|
})
|
||||||
|
out = []
|
||||||
|
for h in resp["hits"]["hits"]:
|
||||||
|
s = h["_source"]
|
||||||
|
out.append({
|
||||||
|
"section_title": s.get("section_title", ""),
|
||||||
|
"line_num": s.get("line_num"),
|
||||||
|
"text_len": len(s.get("text", "") or ""),
|
||||||
|
"brand": (s.get("brand_names") or [""])[0],
|
||||||
|
"generic": s.get("generic_name") or "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_drug_section_text(yj_full: str, section_title: str) -> str:
|
||||||
|
"""指定 (yj_full, section_title) の verbatim 章節 text。見つからなければ ""。"""
|
||||||
|
cli = client()
|
||||||
|
resp = cli.search(index=INDEX_NAME, body={
|
||||||
|
"size": 1,
|
||||||
|
"_source": ["text"],
|
||||||
|
"query": {"bool": {"must": [
|
||||||
|
{"term": {"yj_full": yj_full}},
|
||||||
|
{"term": {"section_title.raw": section_title}},
|
||||||
|
]}},
|
||||||
|
})
|
||||||
|
hits = resp["hits"]["hits"]
|
||||||
|
if not hits:
|
||||||
|
return ""
|
||||||
|
return hits[0]["_source"].get("text", "") or ""
|
||||||
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,12 @@
|
|||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["auto", "brand", "generic", "yj"],
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"brand",
|
||||||
|
"generic",
|
||||||
|
"yj"
|
||||||
|
],
|
||||||
"description": "Search type. 'auto' searches all fields.",
|
"description": "Search type. 'auto' searches all fields.",
|
||||||
"default": "auto"
|
"default": "auto"
|
||||||
},
|
},
|
||||||
@ -21,7 +26,9 @@
|
|||||||
"default": 10
|
"default": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["query"]
|
"required": [
|
||||||
|
"query"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -48,7 +55,9 @@
|
|||||||
"default": 50
|
"default": 50
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["l2_code"]
|
"required": [
|
||||||
|
"l2_code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -62,7 +71,9 @@
|
|||||||
"description": "12-character YJ code."
|
"description": "12-character YJ code."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["yj_code"]
|
"required": [
|
||||||
|
"yj_code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -145,7 +156,9 @@
|
|||||||
"default": 20
|
"default": 20
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["drug_yj"]
|
"required": [
|
||||||
|
"drug_yj"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -169,7 +182,9 @@
|
|||||||
"default": 30
|
"default": 30
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["keyword"]
|
"required": [
|
||||||
|
"keyword"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -183,7 +198,9 @@
|
|||||||
"description": "Full YJ code (with revision suffix, e.g., 3399007H1021_1_21)."
|
"description": "Full YJ code (with revision suffix, e.g., 3399007H1021_1_21)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["yj_full"]
|
"required": [
|
||||||
|
"yj_full"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -201,7 +218,10 @@
|
|||||||
"description": "Exact section title from list_drug_chapters (e.g., '9.2 腎機能障害患者', '11.1 重大な副作用')."
|
"description": "Exact section title from list_drug_chapters (e.g., '9.2 腎機能障害患者', '11.1 重大な副作用')."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["yj_full", "section_title"]
|
"required": [
|
||||||
|
"yj_full",
|
||||||
|
"section_title"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
427
skills/developing/pmda-drug-info/queries.py
Normal file
427
skills/developing/pmda-drug-info/queries.py
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
"""SQL 查询接口(关系小库侧)。
|
||||||
|
|
||||||
|
Phase 1 已承接:
|
||||||
|
- `search_drugs` 的 商品名 / 一般名 / YJ 子串检索
|
||||||
|
- `list_categories` 的 L1/L2 + drug_count
|
||||||
|
- `list_drugs_in_category` 的 一般名 → 販売名
|
||||||
|
|
||||||
|
后续 Phase 2 会接 drug_interaction / drug_restriction / drug_dosing。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from taxonomy import load_taxonomy
|
||||||
|
from db import session
|
||||||
|
|
||||||
|
# Plugin 自包含: drug_category.md 与 queries.py 同目录
|
||||||
|
_TAXONOMY_PATH = Path(__file__).resolve().parent / "drug_category.md"
|
||||||
|
_TAXONOMY_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _taxonomy():
|
||||||
|
global _TAXONOMY_CACHE
|
||||||
|
if _TAXONOMY_CACHE is None:
|
||||||
|
_TAXONOMY_CACHE = load_taxonomy(_TAXONOMY_PATH)
|
||||||
|
return _TAXONOMY_CACHE
|
||||||
|
|
||||||
|
# 12 字母数字 → YJ code 候选;前几位即足够触发自动 kind=yj 的判断
|
||||||
|
_YJ_RE = re.compile(r"^[0-9A-Z]{4,12}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DrugHit:
|
||||||
|
yj_full: str
|
||||||
|
yj_code: str
|
||||||
|
brand_name: str # "/" 分隔多品名
|
||||||
|
generic_name: str
|
||||||
|
category_code: str
|
||||||
|
category_name: str
|
||||||
|
score: float # 50-100
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_kind(q: str) -> str:
|
||||||
|
"""auto-detect: pure alnum & uppercase 4+ chars → yj, otherwise any."""
|
||||||
|
if _YJ_RE.match(q.upper()):
|
||||||
|
return "yj"
|
||||||
|
return "any"
|
||||||
|
|
||||||
|
|
||||||
|
def _score_expr(q_lower: str, q_like: str) -> str:
|
||||||
|
"""Postgres expression returning relevance score 50–100."""
|
||||||
|
# NB: doubles each pattern; psycopg expands %s positionally so caller
|
||||||
|
# must pass q_lower / q_like in matching repetitions.
|
||||||
|
return (
|
||||||
|
"GREATEST("
|
||||||
|
" CASE WHEN lower(brand_name) = %s THEN 100.0 "
|
||||||
|
" WHEN lower(brand_name) LIKE %s || '%%' THEN 90.0 "
|
||||||
|
" WHEN brand_name ILIKE %s THEN 70.0 ELSE 0 END,"
|
||||||
|
" CASE WHEN lower(generic_name_jp) = %s THEN 95.0 "
|
||||||
|
" WHEN lower(generic_name_jp) LIKE %s || '%%' THEN 85.0 "
|
||||||
|
" WHEN generic_name_jp ILIKE %s THEN 65.0 ELSE 0 END,"
|
||||||
|
" CASE WHEN yj_code = %s THEN 100.0 ELSE 0 END"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_drugs_in_db(
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
kind: str = "auto",
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[DrugHit]:
|
||||||
|
"""Drop-in replacement for the in-memory ``CorpusIndex.search``.
|
||||||
|
|
||||||
|
`kind` ∈ {"auto", "brand", "generic", "yj"}.
|
||||||
|
|
||||||
|
Returns DrugHit list (max ``limit``) ordered by relevance score desc.
|
||||||
|
"""
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
if kind == "auto":
|
||||||
|
kind = _detect_kind(q)
|
||||||
|
|
||||||
|
q_lower = q.lower()
|
||||||
|
q_like = f"%{q}%"
|
||||||
|
q_upper = q.upper()
|
||||||
|
|
||||||
|
if kind == "yj":
|
||||||
|
sql = """
|
||||||
|
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||||
|
category_code, category_name,
|
||||||
|
CASE WHEN yj_code = %s THEN 100.0
|
||||||
|
WHEN yj_full LIKE %s || '%%' THEN 95.0
|
||||||
|
ELSE 80.0 END AS score
|
||||||
|
FROM drug_master
|
||||||
|
WHERE yj_code LIKE %s OR yj_full LIKE %s
|
||||||
|
ORDER BY score DESC, yj_full ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
params = (q_upper, q_upper, f"{q_upper}%", f"{q_upper}%", limit)
|
||||||
|
elif kind == "brand":
|
||||||
|
sql = """
|
||||||
|
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||||
|
category_code, category_name,
|
||||||
|
CASE WHEN lower(brand_name) = %s THEN 100.0
|
||||||
|
WHEN lower(brand_name) LIKE %s || '%%' THEN 90.0
|
||||||
|
ELSE 70.0 END AS score
|
||||||
|
FROM drug_master
|
||||||
|
WHERE brand_name ILIKE %s
|
||||||
|
ORDER BY score DESC, length(brand_name) ASC, yj_full ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
params = (q_lower, q_lower, q_like, limit)
|
||||||
|
elif kind == "generic":
|
||||||
|
sql = """
|
||||||
|
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||||
|
category_code, category_name,
|
||||||
|
CASE WHEN lower(generic_name_jp) = %s THEN 95.0
|
||||||
|
WHEN lower(generic_name_jp) LIKE %s || '%%' THEN 85.0
|
||||||
|
ELSE 65.0 END AS score
|
||||||
|
FROM drug_master
|
||||||
|
WHERE generic_name_jp ILIKE %s
|
||||||
|
ORDER BY score DESC, length(generic_name_jp) ASC, yj_full ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
params = (q_lower, q_lower, q_like, limit)
|
||||||
|
else: # any
|
||||||
|
sql = f"""
|
||||||
|
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||||
|
category_code, category_name,
|
||||||
|
{_score_expr(q_lower, q_like)} AS score
|
||||||
|
FROM drug_master
|
||||||
|
WHERE brand_name ILIKE %s OR generic_name_jp ILIKE %s
|
||||||
|
OR yj_code LIKE %s OR yj_full LIKE %s
|
||||||
|
ORDER BY score DESC, length(brand_name) ASC, yj_full ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
# _score_expr 占位符顺序:brand=, brand LIKE, brand ILIKE,
|
||||||
|
# generic=, generic LIKE, generic ILIKE, yj_code=
|
||||||
|
# 然后 WHERE: brand ILIKE, generic ILIKE, yj LIKE, yj_full LIKE
|
||||||
|
params = (
|
||||||
|
q_lower, q_lower, q_like,
|
||||||
|
q_lower, q_lower, q_like,
|
||||||
|
q_upper,
|
||||||
|
q_like, q_like, f"{q_upper}%", f"{q_upper}%",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DrugHit(
|
||||||
|
yj_full=r[0],
|
||||||
|
yj_code=r[1],
|
||||||
|
brand_name=r[2] or "",
|
||||||
|
generic_name=r[3] or "",
|
||||||
|
category_code=r[4] or "",
|
||||||
|
category_name=r[5] or "",
|
||||||
|
score=float(r[6] or 0),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 类别导航 ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def list_categories_with_counts() -> list[dict]:
|
||||||
|
"""全 L1 / L2 分类 + 各 L2 的 drug 数。
|
||||||
|
|
||||||
|
分类层级名取自 drug_category.md(不用 PMDA 的 category_name 自由文,
|
||||||
|
因为后者一药一表达,难以聚合);drug_count 取自 drug_master 的实际行数。
|
||||||
|
"""
|
||||||
|
tax = _taxonomy()
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT category_code, COUNT(*) FROM drug_master "
|
||||||
|
"WHERE category_code IS NOT NULL "
|
||||||
|
"GROUP BY category_code"
|
||||||
|
)
|
||||||
|
counts: dict[str, int] = dict(cur.fetchall())
|
||||||
|
|
||||||
|
by_l1: dict[str, dict] = {}
|
||||||
|
for l2_code, l2 in tax.items():
|
||||||
|
c = counts.get(l2_code, 0)
|
||||||
|
if c == 0:
|
||||||
|
continue
|
||||||
|
l1 = by_l1.setdefault(
|
||||||
|
l2.l1_code,
|
||||||
|
{"l1_code": l2.l1_code, "l1_name": l2.l1_name, "l2": []},
|
||||||
|
)
|
||||||
|
l1["l2"].append({"code": l2_code, "name": l2.name, "drug_count": c})
|
||||||
|
# 内层按 code 排序,外层按 l1_code 排序
|
||||||
|
for l1 in by_l1.values():
|
||||||
|
l1["l2"].sort(key=lambda x: x["code"])
|
||||||
|
return [by_l1[k] for k in sorted(by_l1)]
|
||||||
|
|
||||||
|
|
||||||
|
def list_drugs_in_category(
|
||||||
|
l2_code: str,
|
||||||
|
*,
|
||||||
|
limit_generics: int = 50,
|
||||||
|
brands_per_generic: int = 5,
|
||||||
|
) -> dict:
|
||||||
|
"""指定 L2 类目下的「一般名 → [販売名]」一览。
|
||||||
|
|
||||||
|
Returns the same JSON shape `_corpus_tools.list_drugs_in_category` previously
|
||||||
|
yielded so the agent prompt 不变。
|
||||||
|
"""
|
||||||
|
tax = _taxonomy()
|
||||||
|
l2 = tax.get(l2_code)
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT generic_name_jp, yj_full, brand_name "
|
||||||
|
"FROM drug_master WHERE category_code = %s "
|
||||||
|
"ORDER BY generic_name_jp, yj_full",
|
||||||
|
(l2_code,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
by_gen: "OrderedDict[str, list[dict]]" = OrderedDict()
|
||||||
|
for gen, yj_full, brand in rows:
|
||||||
|
by_gen.setdefault(gen or "(一般名不明)", []).append(
|
||||||
|
{"brand": brand or "", "yj_full": yj_full}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: list[dict] = []
|
||||||
|
for gen in list(by_gen)[:limit_generics]:
|
||||||
|
drugs = by_gen[gen]
|
||||||
|
shown = drugs[:brands_per_generic]
|
||||||
|
extra = len(drugs) - len(shown)
|
||||||
|
entry = {"generic": gen, "drugs": list(shown)}
|
||||||
|
if extra > 0:
|
||||||
|
entry["drugs"].append({"_more": f"+{extra} more brands"})
|
||||||
|
payload.append(entry)
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"l2_code": l2_code,
|
||||||
|
"l2_name": l2.name if l2 else "",
|
||||||
|
"generics": payload,
|
||||||
|
}
|
||||||
|
if len(by_gen) > limit_generics:
|
||||||
|
out["_more_generics"] = len(by_gen) - limit_generics
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- fact 查询:drug_master / interaction / restriction / dosing ----------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DrugMasterRow:
|
||||||
|
yj_code: str
|
||||||
|
yj_full: str
|
||||||
|
brand_name: str
|
||||||
|
generic_name_jp: str
|
||||||
|
category_code: str
|
||||||
|
category_name: str
|
||||||
|
regulation: str | None
|
||||||
|
manufacturer: str | None
|
||||||
|
revision_date: str | None # ISO date string
|
||||||
|
|
||||||
|
|
||||||
|
def drug_master_get(yj_code: str) -> DrugMasterRow | None:
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT yj_code, yj_full, brand_name, generic_name_jp, "
|
||||||
|
" category_code, category_name, regulation, manufacturer, "
|
||||||
|
" to_char(revision_date, 'YYYY-MM-DD') "
|
||||||
|
"FROM drug_master WHERE yj_code = %s",
|
||||||
|
(yj_code,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return DrugMasterRow(*row)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InteractionRow:
|
||||||
|
id: str
|
||||||
|
drug_a_yj: str
|
||||||
|
drug_b_yj: str | None
|
||||||
|
drug_b_class: str | None
|
||||||
|
severity: str
|
||||||
|
mechanism: str | None
|
||||||
|
clinical_effect: str | None
|
||||||
|
source_section: str
|
||||||
|
source_drug_yj: str
|
||||||
|
|
||||||
|
|
||||||
|
def drug_interaction_query(
|
||||||
|
drug_a_yj: str | None = None,
|
||||||
|
drug_b_yj: str | None = None,
|
||||||
|
*,
|
||||||
|
severity: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[InteractionRow]:
|
||||||
|
"""検索条件:
|
||||||
|
drug_a_yj alone → drug_a の全相互作用(drug_b 任意)
|
||||||
|
drug_a_yj + drug_b_yj → 双向(A→B もしくは B→A 両方)
|
||||||
|
keyword → drug_b_class や mechanism / clinical_effect の ILIKE
|
||||||
|
"""
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
if drug_a_yj and drug_b_yj:
|
||||||
|
where.append("((drug_a_yj=%s AND drug_b_yj=%s) OR "
|
||||||
|
"(drug_a_yj=%s AND drug_b_yj=%s))")
|
||||||
|
params += [drug_a_yj, drug_b_yj, drug_b_yj, drug_a_yj]
|
||||||
|
elif drug_a_yj:
|
||||||
|
where.append("drug_a_yj = %s")
|
||||||
|
params.append(drug_a_yj)
|
||||||
|
elif drug_b_yj:
|
||||||
|
where.append("drug_b_yj = %s")
|
||||||
|
params.append(drug_b_yj)
|
||||||
|
if severity:
|
||||||
|
where.append("severity = %s")
|
||||||
|
params.append(severity)
|
||||||
|
if keyword:
|
||||||
|
where.append("(drug_b_class ILIKE %s OR mechanism ILIKE %s "
|
||||||
|
" OR clinical_effect ILIKE %s)")
|
||||||
|
kw = f"%{keyword}%"
|
||||||
|
params += [kw, kw, kw]
|
||||||
|
if not where:
|
||||||
|
return []
|
||||||
|
sql = (
|
||||||
|
"SELECT id, drug_a_yj, drug_b_yj, drug_b_class, severity, "
|
||||||
|
" mechanism, clinical_effect, source_section, source_drug_yj "
|
||||||
|
"FROM drug_interaction WHERE " + " AND ".join(where) +
|
||||||
|
" ORDER BY severity, drug_b_class NULLS LAST LIMIT %s"
|
||||||
|
)
|
||||||
|
params.append(limit)
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [InteractionRow(*r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestrictionRow:
|
||||||
|
id: str
|
||||||
|
drug_yj: str
|
||||||
|
condition_type: str
|
||||||
|
condition_text: str
|
||||||
|
condition_params: dict
|
||||||
|
severity: str
|
||||||
|
source_section: str
|
||||||
|
|
||||||
|
|
||||||
|
def drug_restriction_query(
|
||||||
|
drug_yj: str | None = None,
|
||||||
|
*,
|
||||||
|
condition_type: str | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[RestrictionRow]:
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
if drug_yj:
|
||||||
|
where.append("drug_yj = %s")
|
||||||
|
params.append(drug_yj)
|
||||||
|
if condition_type:
|
||||||
|
where.append("condition_type = %s")
|
||||||
|
params.append(condition_type)
|
||||||
|
if severity:
|
||||||
|
where.append("severity = %s")
|
||||||
|
params.append(severity)
|
||||||
|
if keyword:
|
||||||
|
where.append("condition_text ILIKE %s")
|
||||||
|
params.append(f"%{keyword}%")
|
||||||
|
if not where:
|
||||||
|
return []
|
||||||
|
sql = (
|
||||||
|
"SELECT id, drug_yj, condition_type, condition_text, condition_params, "
|
||||||
|
" severity, source_section "
|
||||||
|
"FROM drug_restriction WHERE " + " AND ".join(where) +
|
||||||
|
" ORDER BY severity, condition_type LIMIT %s"
|
||||||
|
)
|
||||||
|
params.append(limit)
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [RestrictionRow(*r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DosingRow:
|
||||||
|
id: str
|
||||||
|
drug_yj: str
|
||||||
|
indication_code: str | None
|
||||||
|
patient_segment: str
|
||||||
|
segment_params: dict
|
||||||
|
dose_amount: float | None
|
||||||
|
dose_unit: str | None
|
||||||
|
frequency: str | None
|
||||||
|
duration: str | None
|
||||||
|
adjustment_text: str
|
||||||
|
source_section: str
|
||||||
|
|
||||||
|
|
||||||
|
def drug_dosing_query(
|
||||||
|
drug_yj: str,
|
||||||
|
*,
|
||||||
|
patient_segment: str | None = None,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> list[DosingRow]:
|
||||||
|
where = ["drug_yj = %s"]
|
||||||
|
params: list = [drug_yj]
|
||||||
|
if patient_segment:
|
||||||
|
where.append("patient_segment = %s")
|
||||||
|
params.append(patient_segment)
|
||||||
|
sql = (
|
||||||
|
"SELECT id, drug_yj, indication_code, patient_segment, segment_params, "
|
||||||
|
" dose_amount, dose_unit, frequency, duration, adjustment_text, "
|
||||||
|
" source_section "
|
||||||
|
"FROM drug_dosing WHERE " + " AND ".join(where) +
|
||||||
|
" ORDER BY patient_segment, indication_code NULLS LAST LIMIT %s"
|
||||||
|
)
|
||||||
|
params.append(limit)
|
||||||
|
with session() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [DosingRow(*r) for r in cur.fetchall()]
|
||||||
4
skills/developing/pmda-drug-info/requirements.txt
Normal file
4
skills/developing/pmda-drug-info/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Plugin self-contained: PG (psycopg) + OS (opensearch-py)
|
||||||
|
psycopg[binary]>=3.2.0
|
||||||
|
psycopg-pool>=3.2.0
|
||||||
|
opensearch-py>=2.2.0
|
||||||
64
skills/developing/pmda-drug-info/taxonomy.py
Normal file
64
skills/developing/pmda-drug-info/taxonomy.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""Drug-category taxonomy loader.
|
||||||
|
|
||||||
|
Reads `pmda/drug_category.md` (the cleaned-up nested list with codes) and
|
||||||
|
produces a `{l2_code: L2}` dict for joining with `DocMeta.l2_code` (first 3
|
||||||
|
chars of the YJ code).
|
||||||
|
|
||||||
|
Source markdown shape:
|
||||||
|
|
||||||
|
- 11 中枢神経系用薬
|
||||||
|
- 111 全身麻酔剤
|
||||||
|
- 112 催眠鎮静剤,抗不安剤
|
||||||
|
- 12 末梢神経用薬
|
||||||
|
- 121 局所麻酔剤
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Top-level: `- {2-digit} {name}`
|
||||||
|
_L1_RE = re.compile(r"^- (\d{2})\s+(.+)$")
|
||||||
|
# Nested: ` - {3-digit} {name}` (indent 2 spaces)
|
||||||
|
_L2_RE = re.compile(r"^ {2}- (\d{3})\s+(.+)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class L2:
|
||||||
|
code: str # "111"
|
||||||
|
name: str # "全身麻酔剤"
|
||||||
|
l1_code: str # "11"
|
||||||
|
l1_name: str # "中枢神経系用薬"
|
||||||
|
|
||||||
|
|
||||||
|
def load_taxonomy(path: Path | str = "pmda/drug_category.md") -> dict[str, L2]:
|
||||||
|
out: dict[str, L2] = {}
|
||||||
|
current_l1_code = ""
|
||||||
|
current_l1_name = ""
|
||||||
|
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||||
|
m1 = _L1_RE.match(line)
|
||||||
|
if m1:
|
||||||
|
current_l1_code, current_l1_name = m1.group(1), m1.group(2).strip()
|
||||||
|
continue
|
||||||
|
m2 = _L2_RE.match(line)
|
||||||
|
if m2:
|
||||||
|
code = m2.group(1)
|
||||||
|
name = m2.group(2).strip()
|
||||||
|
if not current_l1_code:
|
||||||
|
raise ValueError(f"L2 row {code} appears before any L1 in {path}")
|
||||||
|
out[code] = L2(code=code, name=name, l1_code=current_l1_code, l1_name=current_l1_name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(taxonomy: dict[str, L2], l2_code: str) -> L2 | None:
|
||||||
|
"""Return the L2 entry, or None if the YJ prefix isn't in the taxonomy."""
|
||||||
|
return taxonomy.get(l2_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
t = load_taxonomy()
|
||||||
|
print(f"Loaded {len(t)} L2 categories")
|
||||||
|
for code in ("111", "214", "421", "999"):
|
||||||
|
v = t.get(code)
|
||||||
|
print(f" {code} → {v}")
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "rag-retrieve-disabled",
|
||||||
|
"description": "rag_retrieve, table_rag_retrieve and local file retrieval are disabled."
|
||||||
|
}
|
||||||
1
skills/developing/rag-retrieve-disabled/README.md
Normal file
1
skills/developing/rag-retrieve-disabled/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# rag-retrieve-disabled
|
||||||
254
skills/linggan/agnes-image/SKILL.md
Normal file
254
skills/linggan/agnes-image/SKILL.md
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
name: agnes-image
|
||||||
|
description: 使用 Agnes AI(OpenAI 兼容网关)生成与理解图片、生成视频。支持文生图、图生图(保持角色一致性)、贴纸/卡通形象生成、白底转透明 PNG、多模态图片理解(描述/分析/问答/OCR),以及文生视频、图生视频、多图视频、关键帧动画。
|
||||||
|
category: Creative Generation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agnes Image
|
||||||
|
|
||||||
|
本 Skill 封装了 Agnes AI 的图像生成能力(模型 `agnes-image-2.1-flash`),接口兼容 OpenAI 风格,支持文生图、图生图,并内置「白底转透明」后处理,适合批量生成游戏/应用所需的角色、图标、贴纸等素材。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
设置 API Key(二选一):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGNES_API_KEY="sk-xxxxxxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
或在每次调用时通过 `--api-key` 传入。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 文生图
|
||||||
|
|
||||||
|
生成单张图片,输出可访问的图片链接:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_image.py --prompt "一只圆润萌系的小火龙,大眼睛,贴纸风格"
|
||||||
|
```
|
||||||
|
|
||||||
|
指定尺寸:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_image.py --prompt "壮丽的山川日出" --size "1024x768"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图生图(保持一致性)
|
||||||
|
|
||||||
|
提供参考图 URL 或 `data:image` Base64,让新图沿用参考图的角色/构图:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_image.py \
|
||||||
|
--prompt "把这只小龙变成展翅的成年形态,保持配色和脸部不变" \
|
||||||
|
--image "https://example.com/baby-dragon.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
可多次指定 `--image` 传入多张参考图。
|
||||||
|
|
||||||
|
### 下载保存到本地
|
||||||
|
|
||||||
|
加 `--save`,脚本会下载图片并保存,输出 `SAVED:<path>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_image.py --prompt "一只小猫贴纸" --save ./outputs/cat.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 白底转透明 PNG
|
||||||
|
|
||||||
|
配合 `--save --transparent`,自动把纯白背景抠成透明(保留主体内部白色,如白描边、白肚皮):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_image.py \
|
||||||
|
--prompt "一只圆润萌系企鹅,白色背景,贴纸风格" \
|
||||||
|
--save ./outputs/penguin.png --transparent
|
||||||
|
```
|
||||||
|
|
||||||
|
边缘若有白色残留,可调高容差 `--thresh`(默认 60)。
|
||||||
|
|
||||||
|
## 参数说明
|
||||||
|
|
||||||
|
- `--prompt`: (必选) 图像生成的文本描述,支持中英文。
|
||||||
|
- `--model`: (可选) 模型 ID,默认 `agnes-image-2.1-flash`。
|
||||||
|
- `--size`: (可选) 图像尺寸,如 `1024x1024`、`1024x768`,默认 `1024x1024`。
|
||||||
|
- `--image`: (可选) 参考图 URL 或 `data:image/...;base64,...`,用于图生图,可多次指定。
|
||||||
|
- `--api-key`: (可选) Agnes API Key,未提供时读取 `AGNES_API_KEY` 环境变量。
|
||||||
|
- `--save`: (可选) 下载并保存到指定本地路径。
|
||||||
|
- `--transparent`: (可选) 配合 `--save`,把纯白背景转为透明 PNG(需 Pillow)。
|
||||||
|
- `--thresh`: (可选) 透明化时的白色容差,默认 60,值越大清除越彻底。
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
1. 调用 `generate_image.py` 脚本。
|
||||||
|
2. 脚本始终输出以 `MEDIA_URL: ` 开头的图片链接,用 Markdown 展示:``。
|
||||||
|
3. 加了 `--save` 时,会额外下载到本地并输出以 `SAVED: ` 开头的文件路径(`MEDIA_URL` 仍会一并输出)。
|
||||||
|
4. 生成的图片链接来自 Agnes 平台输出存储,可直接公网访问。
|
||||||
|
|
||||||
|
> 注:`--save --transparent` 时,`MEDIA_URL` 指向 Agnes 返回的原始白底图,本地 `SAVED` 文件才是抠好的透明 PNG。
|
||||||
|
|
||||||
|
## 批量生成小贴士
|
||||||
|
|
||||||
|
生成系列素材(如角色的多个进化阶段)时,建议:
|
||||||
|
|
||||||
|
- 在每个 prompt 中固定统一的「风格描述」(如 `chibi kawaii sticker, big eyes, white background`),保证视觉一致。
|
||||||
|
- 需要严格保持同一角色时,用 `--image` 把上一阶段的图当参考做图生图。
|
||||||
|
- 透明素材直接用 `--save --transparent`,省去手动抠图。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 接口兼容 OpenAI 风格,Base URL 为 `https://apihub.agnes-ai.com/v1`。
|
||||||
|
- API Key 属于敏感信息,请勿提交到公开仓库或硬编码到代码中。
|
||||||
|
- 图生图时,确保参考图 URL 可公开访问。
|
||||||
|
- 透明化基于「边缘漫水填充」,仅对纯色(白)背景效果最佳;复杂背景请勿使用 `--transparent`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 图片理解(多模态)
|
||||||
|
|
||||||
|
使用 `understand_image.py` 让模型(默认 `agnes-2.0-flash`)基于图片进行描述、分析、问答或信息提取。支持公网图片 URL,也支持本地图片文件(自动转 Base64)。
|
||||||
|
|
||||||
|
### 理解图片 URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/understand_image.py \
|
||||||
|
--prompt "用中文描述这张图片的内容和风格" \
|
||||||
|
--image "https://example.com/image.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 理解本地图片
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/understand_image.py \
|
||||||
|
--prompt "这是什么动物?什么颜色?" \
|
||||||
|
--image-file ./outputs/fox.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多图对比 / 信息提取
|
||||||
|
|
||||||
|
可多次指定 `--image` 或 `--image-file` 传入多张图片:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/understand_image.py \
|
||||||
|
--prompt "比较这两张图的差异" \
|
||||||
|
--image-file ./a.png --image-file ./b.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带系统提示
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/understand_image.py \
|
||||||
|
--prompt "提取图中所有文字" \
|
||||||
|
--image "https://example.com/poster.jpg" \
|
||||||
|
--system "你是专业的 OCR 助手,只输出图中文字"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图片理解参数说明
|
||||||
|
|
||||||
|
- `--prompt`: (必选) 对图片的问题或指令。
|
||||||
|
- `--image`: (可选) 图片公网 URL 或 `data:image` Base64,可多次指定。
|
||||||
|
- `--image-file`: (可选) 本地图片文件路径,自动转 Base64,可多次指定。
|
||||||
|
- `--system`: (可选) 系统提示(system 消息)。
|
||||||
|
- `--model`: (可选) 模型 ID,默认 `agnes-2.0-flash`。
|
||||||
|
- `--temperature`: (可选) 采样温度,越低越确定。
|
||||||
|
- `--max-tokens`: (可选) 最多生成的 token 数。
|
||||||
|
- `--api-key`: (可选) Agnes API Key,未提供时读取 `AGNES_API_KEY` 环境变量。
|
||||||
|
|
||||||
|
### 图片理解工作流
|
||||||
|
|
||||||
|
1. 调用 `understand_image.py` 脚本。
|
||||||
|
2. 脚本直接输出模型的文本回答(无前缀),可直接读取使用。
|
||||||
|
3. 不传任何图片时,会退化为普通文本对话。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 视频生成(异步)
|
||||||
|
|
||||||
|
使用 `generate_video.py` 生成视频(模型 `agnes-video-v2.0`)。视频生成是异步任务,脚本会自动创建任务并轮询,完成后输出视频链接。生成通常需要 1-3 分钟。
|
||||||
|
|
||||||
|
> ⚠️ **重要:视频生成用到的所有参考图(图生视频 / 多图 / 关键帧)必须是「可公网访问的图片 URL」**(`http://` 或 `https://`),**不支持本地文件路径,也不支持 Base64**。
|
||||||
|
> 如果你手上只有本地图片,请先:
|
||||||
|
> 1. 用 `generate_image.py` 生成图片,它返回的 `MEDIA_URL` 就是可直接使用的公网 URL;或
|
||||||
|
> 2. 把本地图片上传到图床 / 对象存储(如 S3、R2)后,使用其公网 URL。
|
||||||
|
>
|
||||||
|
> 脚本会校验 `--image` 是否为合法 URL,传入本地路径会直接报错并给出提示。
|
||||||
|
|
||||||
|
### 文生视频
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_video.py \
|
||||||
|
--prompt "A cinematic shot of a cat walking on the beach at sunset, warm golden lighting" \
|
||||||
|
--duration 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图生视频(让单张图动起来)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_video.py \
|
||||||
|
--prompt "The character breathes gently, hair moving in the wind, keep face consistent" \
|
||||||
|
--image "https://example.com/character.png" --duration 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多图视频(在多张图之间过渡)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_video.py \
|
||||||
|
--prompt "Smooth transformation between the two scenes, cinematic pacing" \
|
||||||
|
--image "https://example.com/a.png" --image "https://example.com/b.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键帧动画
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_video.py \
|
||||||
|
--prompt "Smooth transition between keyframes, maintain character identity" \
|
||||||
|
--image "https://example.com/k1.png" --image "https://example.com/k2.png" \
|
||||||
|
--keyframes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 下载保存
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python {baseDir}/scripts/generate_video.py --prompt "日出延时摄影" --duration 5 --save ./out.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 视频参数说明
|
||||||
|
|
||||||
|
- `--prompt`: (必选) 视频内容的文本描述。
|
||||||
|
- `--model`: (可选) 模型 ID,默认 `agnes-video-v2.0`。
|
||||||
|
- `--image`: (可选) 参考图 URL;1 张=图生视频,2 张及以上=多图视频,可多次指定。
|
||||||
|
- `--keyframes`: (可选) 关键帧动画模式(配合多张 `--image`)。
|
||||||
|
- `--duration`: (可选) 目标时长(秒),按帧率自动换算帧数(覆盖 `--num-frames`)。
|
||||||
|
- `--num-frames`: (可选) 帧数,必须 ≤441 且满足 8n+1,默认 121(约 5 秒);脚本会自动规整。
|
||||||
|
- `--frame-rate`: (可选) 帧率 1-60,默认 24。
|
||||||
|
- `--width` / `--height`: (可选) 分辨率,默认 1152×768;服务端会标准化到最接近的档位。
|
||||||
|
- `--negative-prompt`: (可选) 负向提示词。
|
||||||
|
- `--seed`: (可选) 随机种子,用于结果复现。
|
||||||
|
- `--save`: (可选) 下载视频并保存到本地。
|
||||||
|
- `--poll-interval`: (可选) 轮询间隔秒数,默认 5。
|
||||||
|
- `--max-wait`: (可选) 最大等待秒数,默认 600。
|
||||||
|
- `--api-key`: (可选) Agnes API Key,未提供时读取 `AGNES_API_KEY` 环境变量。
|
||||||
|
|
||||||
|
### 常用时长参数
|
||||||
|
|
||||||
|
| 目标时长 | 推荐参数 |
|
||||||
|
|---------|---------|
|
||||||
|
| 约 3 秒 | `--num-frames 81 --frame-rate 24` |
|
||||||
|
| 约 5 秒 | `--num-frames 121 --frame-rate 24` |
|
||||||
|
| 约 10 秒 | `--num-frames 241 --frame-rate 24` |
|
||||||
|
| 约 18 秒 | `--num-frames 441 --frame-rate 24` |
|
||||||
|
|
||||||
|
> 也可以直接用 `--duration <秒数>` 让脚本自动换算(会规整到合法的 8n+1 帧数)。
|
||||||
|
|
||||||
|
### 视频生成工作流
|
||||||
|
|
||||||
|
1. 调用 `generate_video.py`,脚本自动创建任务(`POST /v1/videos`)。
|
||||||
|
2. 用返回的 `video_id` 轮询查询(`GET /agnesapi?video_id=...`),进度打到 stderr。
|
||||||
|
3. 任务 `completed` 后,stdout 输出以 `MEDIA_URL: ` 开头的视频链接。
|
||||||
|
4. 加 `--save` 时额外下载并输出 `SAVED: ` 路径。
|
||||||
|
|
||||||
|
### 视频注意事项
|
||||||
|
|
||||||
|
- `num_frames` 必须 ≤441 且满足 8n+1(如 81、121、241、441),脚本会自动规整非法值。
|
||||||
|
- 实际输出尺寸/时长以接口返回的 `size`、`seconds` 字段为准(服务端会标准化分辨率)。
|
||||||
|
- **参考图必须是可公网访问的 URL(`http`/`https`),不支持本地文件或 Base64**;本地图片请先用 `generate_image.py` 拿到公网 URL,或上传图床后再传入。
|
||||||
|
- 轮询期间对瞬时网络抖动有容错,会自动重试。
|
||||||
186
skills/linggan/agnes-image/scripts/generate_image.py
Normal file
186
skills/linggan/agnes-image/scripts/generate_image.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "requests>=2.31.0",
|
||||||
|
# "pillow>=10.0.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""使用 Agnes AI(OpenAI 兼容网关)生成图片。
|
||||||
|
支持:文生图、图生图(参考图保持一致性)、URL/Base64 输出、下载保存、白底转透明。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API_URL = "https://apihub.agnes-ai.com/v1/images/generations"
|
||||||
|
DEFAULT_MODEL = "agnes-image-2.1-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def call_api(prompt, model, size, api_key, images=None, want_b64=False, retries=3):
|
||||||
|
"""调用 Agnes 图像生成接口,返回解析后的 JSON。
|
||||||
|
对瞬时网络/SSL 抖动自动重试,重试间隔递增。
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
extra = {"response_format": "b64_json" if want_b64 else "url"}
|
||||||
|
if images:
|
||||||
|
# 图生图:参考图放在 extra_body.image,支持公网 URL 或 data:image Base64
|
||||||
|
extra["image"] = images
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
"extra_body": extra,
|
||||||
|
}
|
||||||
|
# 文生图需要 Base64 输出时,按文档使用顶层 return_base64
|
||||||
|
if want_b64 and not images:
|
||||||
|
payload["return_base64"] = True
|
||||||
|
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = requests.post(API_URL, headers=headers, json=payload, timeout=180)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except (requests.exceptions.SSLError,
|
||||||
|
requests.exceptions.ConnectionError,
|
||||||
|
requests.exceptions.Timeout) as e:
|
||||||
|
# 瞬时网络抖动:重试
|
||||||
|
last_err = e
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(attempt * 2)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_bytes(url):
|
||||||
|
"""下载 URL 内容为字节。"""
|
||||||
|
with urllib.request.urlopen(url, timeout=180) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
|
||||||
|
def make_transparent(png_bytes, thresh=60, white_min=225):
|
||||||
|
"""把纯白背景变透明(保留内部白色)。
|
||||||
|
从图像四边多个种子点做 flood fill,只清除与边缘连通的白色区域。
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||||||
|
w, h = img.size
|
||||||
|
seeds = []
|
||||||
|
steps = 12
|
||||||
|
for i in range(steps + 1):
|
||||||
|
t = int(i * (w - 1) / steps)
|
||||||
|
seeds += [(t, 0), (t, h - 1)]
|
||||||
|
s = int(i * (h - 1) / steps)
|
||||||
|
seeds += [(0, s), (w - 1, s)]
|
||||||
|
for sx, sy in seeds:
|
||||||
|
r, g, b, a = img.getpixel((sx, sy))
|
||||||
|
if a > 0 and r >= white_min and g >= white_min and b >= white_min:
|
||||||
|
ImageDraw.floodfill(img, (sx, sy), (255, 255, 255, 0), thresh=thresh)
|
||||||
|
out = io.BytesIO()
|
||||||
|
img.save(out, "PNG")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="使用 Agnes AI 生成图片。")
|
||||||
|
parser.add_argument("--prompt", required=True, help="图像生成的文本描述(必选)")
|
||||||
|
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"模型 ID,默认 {DEFAULT_MODEL}")
|
||||||
|
parser.add_argument("--size", default="1024x1024", help="图像尺寸,如 1024x1024、1024x768")
|
||||||
|
parser.add_argument("--image", action="append", default=None,
|
||||||
|
help="参考图 URL 或 data:image Base64(图生图,可多次指定)")
|
||||||
|
parser.add_argument("--api-key", help="Agnes API Key(也可用 AGNES_API_KEY 环境变量)")
|
||||||
|
parser.add_argument("--save", help="下载并保存到本地路径,输出 SAVED:<path>")
|
||||||
|
parser.add_argument("--transparent", action="store_true",
|
||||||
|
help="配合 --save:把纯白背景转为透明 PNG")
|
||||||
|
parser.add_argument("--thresh", type=int, default=60, help="透明化白色容差,默认 60")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
api_key = args.api_key or os.environ.get("AGNES_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("ERROR: 缺少 API Key,请用 --api-key 或设置 AGNES_API_KEY 环境变量。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 需要保存且要透明时,直接请求 URL 再下载处理(Pillow 处理本地字节)
|
||||||
|
want_b64 = False
|
||||||
|
try:
|
||||||
|
result = call_api(
|
||||||
|
prompt=args.prompt,
|
||||||
|
model=args.model,
|
||||||
|
size=args.size,
|
||||||
|
api_key=api_key,
|
||||||
|
images=args.image,
|
||||||
|
want_b64=want_b64,
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"ERROR: API 请求失败: {e}")
|
||||||
|
if getattr(e, "response", None) is not None:
|
||||||
|
print(f"Response body: {e.response.text[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data = result.get("data") or []
|
||||||
|
if not data:
|
||||||
|
print(f"ERROR: 返回中无图像数据。完整响应: {json.dumps(result)[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
item = data[0]
|
||||||
|
url = item.get("url")
|
||||||
|
b64 = item.get("b64_json")
|
||||||
|
|
||||||
|
if not args.save:
|
||||||
|
# 仅输出链接(对齐 seedream 约定)
|
||||||
|
if url:
|
||||||
|
print(f"MEDIA_URL: {url}")
|
||||||
|
elif b64:
|
||||||
|
print("ERROR: 收到 Base64 但未指定 --save,无法直接展示。请加 --save 保存。")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"ERROR: 无 url 也无 b64_json。响应: {json.dumps(result)[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 保存到本地
|
||||||
|
try:
|
||||||
|
if b64:
|
||||||
|
import base64
|
||||||
|
img_bytes = base64.b64decode(b64)
|
||||||
|
elif url:
|
||||||
|
img_bytes = fetch_bytes(url)
|
||||||
|
else:
|
||||||
|
print("ERROR: 无可保存的图像数据。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.transparent:
|
||||||
|
try:
|
||||||
|
img_bytes = make_transparent(img_bytes, thresh=args.thresh)
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: --transparent 需要 Pillow,请先 `pip install pillow`。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.save)), exist_ok=True)
|
||||||
|
with open(args.save, "wb") as f:
|
||||||
|
f.write(img_bytes)
|
||||||
|
# 即使保存到本地,也输出原始图片链接(便于直接引用/展示)
|
||||||
|
if url:
|
||||||
|
print(f"MEDIA_URL: {url}")
|
||||||
|
print(f"SAVED: {args.save}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: 保存失败: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
206
skills/linggan/agnes-image/scripts/generate_video.py
Normal file
206
skills/linggan/agnes-image/scripts/generate_video.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "requests>=2.31.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""使用 Agnes AI 生成视频(异步任务:创建 -> 轮询 -> 取结果)。
|
||||||
|
支持文生视频、图生视频、多图视频、关键帧动画。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
CREATE_URL = "https://apihub.agnes-ai.com/v1/videos"
|
||||||
|
QUERY_URL = "https://apihub.agnes-ai.com/agnesapi"
|
||||||
|
DEFAULT_MODEL = "agnes-video-v2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
"""进度信息打到 stderr,保持 stdout 只输出最终结果。"""
|
||||||
|
print(msg, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_frames(nf):
|
||||||
|
"""num_frames 必须 <=441 且满足 8n+1,规整到最接近的合法值。"""
|
||||||
|
nf = max(9, min(441, int(nf)))
|
||||||
|
n = round((nf - 1) / 8)
|
||||||
|
nf = 8 * n + 1
|
||||||
|
return max(9, min(441, nf))
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(payload, api_key, retries=3):
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = requests.post(CREATE_URL, headers=headers, json=payload, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except (requests.exceptions.SSLError,
|
||||||
|
requests.exceptions.ConnectionError,
|
||||||
|
requests.exceptions.Timeout) as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(attempt * 2)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
|
||||||
|
def query_result(video_id, api_key, model=None):
|
||||||
|
"""用 video_id 查询任务结果(推荐方式)。"""
|
||||||
|
params = f"?video_id={urllib.parse.quote(video_id)}"
|
||||||
|
if model:
|
||||||
|
params += f"&model_name={urllib.parse.quote(model)}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
QUERY_URL + params,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def download(url, path):
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||||
|
with urllib.request.urlopen(url, timeout=300) as r, open(path, "wb") as f:
|
||||||
|
shutil.copyfileobj(r, f)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="使用 Agnes AI 生成视频。")
|
||||||
|
parser.add_argument("--prompt", required=True, help="视频内容的文本描述(必选)")
|
||||||
|
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"模型 ID,默认 {DEFAULT_MODEL}")
|
||||||
|
parser.add_argument("--image", action="append", default=None,
|
||||||
|
help="参考图 URL(图生视频/多图/关键帧,可多次指定)")
|
||||||
|
parser.add_argument("--keyframes", action="store_true",
|
||||||
|
help="关键帧动画模式(配合多张 --image 使用)")
|
||||||
|
parser.add_argument("--width", type=int, default=1152, help="视频宽度,默认 1152")
|
||||||
|
parser.add_argument("--height", type=int, default=768, help="视频高度,默认 768")
|
||||||
|
parser.add_argument("--num-frames", type=int, default=121,
|
||||||
|
help="帧数,<=441 且满足 8n+1,默认 121(约 5 秒)")
|
||||||
|
parser.add_argument("--frame-rate", type=float, default=24, help="帧率 1-60,默认 24")
|
||||||
|
parser.add_argument("--duration", type=float,
|
||||||
|
help="目标时长(秒),会按帧率换算 num_frames(覆盖 --num-frames)")
|
||||||
|
parser.add_argument("--negative-prompt", help="负向提示词,描述要避免的内容")
|
||||||
|
parser.add_argument("--seed", type=int, help="随机种子,用于结果复现")
|
||||||
|
parser.add_argument("--api-key", help="Agnes API Key(也可用 AGNES_API_KEY 环境变量)")
|
||||||
|
parser.add_argument("--save", help="下载视频并保存到本地路径")
|
||||||
|
parser.add_argument("--poll-interval", type=float, default=5, help="轮询间隔秒数,默认 5")
|
||||||
|
parser.add_argument("--max-wait", type=float, default=600, help="最大等待秒数,默认 600")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
api_key = args.api_key or os.environ.get("AGNES_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("ERROR: 缺少 API Key,请用 --api-key 或设置 AGNES_API_KEY 环境变量。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 计算帧数
|
||||||
|
if args.duration:
|
||||||
|
num_frames = normalize_frames(args.duration * args.frame_rate)
|
||||||
|
else:
|
||||||
|
num_frames = normalize_frames(args.num_frames)
|
||||||
|
|
||||||
|
# 组装请求体
|
||||||
|
payload = {
|
||||||
|
"model": args.model,
|
||||||
|
"prompt": args.prompt,
|
||||||
|
"width": args.width,
|
||||||
|
"height": args.height,
|
||||||
|
"num_frames": num_frames,
|
||||||
|
"frame_rate": args.frame_rate,
|
||||||
|
}
|
||||||
|
if args.negative_prompt:
|
||||||
|
payload["negative_prompt"] = args.negative_prompt
|
||||||
|
if args.seed is not None:
|
||||||
|
payload["seed"] = args.seed
|
||||||
|
|
||||||
|
images = args.image or []
|
||||||
|
# 视频接口的参考图只支持可公网访问的 URL(不支持本地文件 / Base64)
|
||||||
|
for u in images:
|
||||||
|
if not (u.startswith("http://") or u.startswith("https://")):
|
||||||
|
print("ERROR: 视频生成的参考图必须是可公网访问的图片 URL(http/https),"
|
||||||
|
"不支持本地文件路径或 Base64。")
|
||||||
|
print(f" 问题输入: {u}")
|
||||||
|
print(" 建议:先用 generate_image.py 生成图片拿到其公网 URL,"
|
||||||
|
"或把本地图片上传到图床/对象存储后再用其 URL 传入。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.keyframes:
|
||||||
|
# 关键帧动画:extra_body.image + mode=keyframes
|
||||||
|
payload["extra_body"] = {"image": images, "mode": "keyframes"}
|
||||||
|
elif len(images) == 1:
|
||||||
|
# 图生视频:顶层 image
|
||||||
|
payload["image"] = images[0]
|
||||||
|
elif len(images) >= 2:
|
||||||
|
# 多图视频:extra_body.image
|
||||||
|
payload["extra_body"] = {"image": images}
|
||||||
|
|
||||||
|
# 1) 创建任务
|
||||||
|
log(f"创建视频任务({num_frames} 帧 @ {args.frame_rate}fps ≈ {num_frames/args.frame_rate:.1f}s)...")
|
||||||
|
try:
|
||||||
|
task = create_task(payload, api_key)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"ERROR: 创建任务失败: {e}")
|
||||||
|
if getattr(e, "response", None) is not None:
|
||||||
|
print(f"Response body: {e.response.text[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
video_id = task.get("video_id")
|
||||||
|
task_id = task.get("task_id") or task.get("id")
|
||||||
|
if not video_id and not task_id:
|
||||||
|
print(f"ERROR: 创建任务响应缺少 video_id/task_id: {json.dumps(task)[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
log(f"任务已创建 video_id={video_id} task_id={task_id} status={task.get('status')}")
|
||||||
|
|
||||||
|
# 2) 轮询结果
|
||||||
|
start = time.time()
|
||||||
|
video_url = None
|
||||||
|
while time.time() - start < args.max_wait:
|
||||||
|
time.sleep(args.poll_interval)
|
||||||
|
try:
|
||||||
|
data = query_result(video_id or task_id, api_key, model=args.model)
|
||||||
|
except Exception as e:
|
||||||
|
log(f" 查询出错(将重试): {e}")
|
||||||
|
continue
|
||||||
|
status = data.get("status")
|
||||||
|
progress = data.get("progress", 0)
|
||||||
|
log(f" 状态={status} 进度={progress}%")
|
||||||
|
if status == "completed":
|
||||||
|
video_url = data.get("remixed_from_video_id") # 文档:该字段为最终视频 URL
|
||||||
|
break
|
||||||
|
if status == "failed":
|
||||||
|
print(f"ERROR: 视频生成失败: {data.get('error')}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not video_url:
|
||||||
|
print(f"ERROR: 等待超时({args.max_wait}s)或未返回视频 URL。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 3) 输出结果
|
||||||
|
print(f"MEDIA_URL: {video_url}")
|
||||||
|
if args.save:
|
||||||
|
try:
|
||||||
|
log("下载视频中 ...")
|
||||||
|
download(video_url, args.save)
|
||||||
|
print(f"SAVED: {args.save}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: 下载保存失败: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
126
skills/linggan/agnes-image/scripts/understand_image.py
Normal file
126
skills/linggan/agnes-image/scripts/understand_image.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "requests>=2.31.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""使用 Agnes AI 的多模态模型理解图片。
|
||||||
|
传入图片(公网 URL 或本地文件)+ 文本问题,模型基于图片进行描述、分析、问答或信息提取。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API_URL = "https://apihub.agnes-ai.com/v1/chat/completions"
|
||||||
|
DEFAULT_MODEL = "agnes-2.0-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def file_to_data_uri(path):
|
||||||
|
"""把本地图片文件转成 data:image Base64。"""
|
||||||
|
mime = mimetypes.guess_type(path)[0] or "image/png"
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
b64 = base64.b64encode(f.read()).decode()
|
||||||
|
return f"data:{mime};base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def call_api(messages, model, api_key, temperature=None, max_tokens=None, retries=3):
|
||||||
|
"""调用 Agnes chat/completions,返回解析后的 JSON。瞬时网络抖动自动重试。"""
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
payload = {"model": model, "messages": messages}
|
||||||
|
if temperature is not None:
|
||||||
|
payload["temperature"] = temperature
|
||||||
|
if max_tokens is not None:
|
||||||
|
payload["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = requests.post(API_URL, headers=headers, json=payload, timeout=180)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except (requests.exceptions.SSLError,
|
||||||
|
requests.exceptions.ConnectionError,
|
||||||
|
requests.exceptions.Timeout) as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(attempt * 2)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="使用 Agnes AI 理解/分析图片。")
|
||||||
|
parser.add_argument("--prompt", required=True, help="对图片的问题或指令(必选)")
|
||||||
|
parser.add_argument("--image", action="append", default=None,
|
||||||
|
help="图片公网 URL 或 data:image Base64(可多次指定)")
|
||||||
|
parser.add_argument("--image-file", action="append", default=None,
|
||||||
|
help="本地图片文件路径,自动转 Base64(可多次指定)")
|
||||||
|
parser.add_argument("--system", help="可选的系统提示(system 消息)")
|
||||||
|
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"模型 ID,默认 {DEFAULT_MODEL}")
|
||||||
|
parser.add_argument("--temperature", type=float, help="采样温度,0~1,越低越确定")
|
||||||
|
parser.add_argument("--max-tokens", type=int, help="最多生成的 token 数")
|
||||||
|
parser.add_argument("--api-key", help="Agnes API Key(也可用 AGNES_API_KEY 环境变量)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
api_key = args.api_key or os.environ.get("AGNES_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("ERROR: 缺少 API Key,请用 --api-key 或设置 AGNES_API_KEY 环境变量。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 收集所有图片输入
|
||||||
|
image_urls = list(args.image or [])
|
||||||
|
for p in (args.image_file or []):
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
print(f"ERROR: 本地图片不存在: {p}")
|
||||||
|
sys.exit(1)
|
||||||
|
image_urls.append(file_to_data_uri(p))
|
||||||
|
|
||||||
|
# 组装多模态 content:文本 + 若干图片
|
||||||
|
content = [{"type": "text", "text": args.prompt}]
|
||||||
|
for url in image_urls:
|
||||||
|
content.append({"type": "image_url", "image_url": {"url": url}})
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
if args.system:
|
||||||
|
messages.append({"role": "system", "content": args.system})
|
||||||
|
# 没有图片时退化为纯文本,content 用字符串更稳妥
|
||||||
|
messages.append({"role": "user", "content": content if image_urls else args.prompt})
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = call_api(
|
||||||
|
messages=messages,
|
||||||
|
model=args.model,
|
||||||
|
api_key=api_key,
|
||||||
|
temperature=args.temperature,
|
||||||
|
max_tokens=args.max_tokens,
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"ERROR: API 请求失败: {e}")
|
||||||
|
if getattr(e, "response", None) is not None:
|
||||||
|
print(f"Response body: {e.response.text[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
answer = result["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
print(f"ERROR: 无法解析响应。完整响应: {json.dumps(result, ensure_ascii=False)[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(answer)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -51,6 +51,13 @@ class Formatter(logging.Formatter):
|
|||||||
record.trace_id = getattr(g, "trace_id")
|
record.trace_id = getattr(g, "trace_id")
|
||||||
except LookupError:
|
except LookupError:
|
||||||
record.trace_id = "N/A"
|
record.trace_id = "N/A"
|
||||||
|
# Handle subagent - default to "main" for the orchestrator / no-context paths.
|
||||||
|
# Catch KeyError too: GlobalContext.__getattr__ raises KeyError on a missing key.
|
||||||
|
if not hasattr(record, "subagent"):
|
||||||
|
try:
|
||||||
|
record.subagent = getattr(g, "subagent")
|
||||||
|
except (KeyError, LookupError):
|
||||||
|
record.subagent = "main"
|
||||||
# Handle user_id
|
# Handle user_id
|
||||||
# if not hasattr(record, "user_id"):
|
# if not hasattr(record, "user_id"):
|
||||||
# record.user_id = getattr(g, "user_id")
|
# record.user_id = getattr(g, "user_id")
|
||||||
@ -65,7 +72,7 @@ class Formatter(logging.Formatter):
|
|||||||
def init_logger_once(name,level):
|
def init_logger_once(name,level):
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
logger.setLevel(level=level)
|
logger.setLevel(level=level)
|
||||||
formatter = Formatter("%(timestamp)s | %(levelname)-5s | %(trace_id)s | %(name)s:%(funcName)s:%(lineno)s - %(message)s", datefmt='%Y-%m-%d %H:%M:%S.%f')
|
formatter = Formatter("%(timestamp)s | %(levelname)-5s | %(trace_id)s | %(subagent)s | %(name)s:%(funcName)s:%(lineno)s - %(message)s", datefmt='%Y-%m-%d %H:%M:%S.%f')
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user