add mainAgentHiddenTools

This commit is contained in:
朱潮 2026-06-12 11:03:30 +08:00
parent 065403223d
commit e2827c6a47
19 changed files with 1954 additions and 443 deletions

View File

@ -45,6 +45,7 @@ from .mem0_config import Mem0Config
from agent.prompt_loader import load_system_prompt_async, load_mcp_settings_async
from agent.agent_memory_cache import get_memory_cache_manager
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 langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
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"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
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,
assistant_id=config.bot_id,
system_prompt=system_prompt,
tools=mcp_tools,
tools=main_tools,
auto_approve=True,
workspace_root=workspace_root,
middleware=middleware,

View File

@ -129,6 +129,52 @@ async def merge_skill_mcp_configs(bot_id: str) -> List[Dict]:
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]:
"""Normalize relative paths in stdio MCP servers to absolute paths based on the skill directory."""
normalized_servers = copy.deepcopy(servers)

View File

@ -1,5 +1,6 @@
{
"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.",
"hooks": {
"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": {
"pmda_drug_info": {
"transport": "stdio",
"command": "python",
"command": "python3",
"args": [
"./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"
}
}

View File

@ -0,0 +1,76 @@
# pmda-drug-info — Claude Code MCP plugin
PMDA 添付文書ベース医薬指導 Q&A の MCP pluginhu-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
```

View File

@ -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
---
あなたは「副作用 → 該当薬剤の逆引き」専門の sub-agent です。
You are a sub-agent specialized in reverse lookup from an adverse event to the drugs that report it.
【ツール戦略】
1. `search_section_text(keyword=副作用名, section_filter="副作用")` で逆引き。
total_drugs は必ず本文中に明示する。
2. 同義語が必要なケース:
## Tool Strategy
1. Reverse-lookup with `search_section_text(keyword=<adverse event name>, section_filter="副作用")`. Always state `total_drugs` explicitly in the answer.
2. Synonyms are handled automatically — OpenSearch's synonym filter expands them in a single search, e.g.:
"Stevens-Johnson" ⇔ "皮膚粘膜眼症候群" / "SJS"
"QT延長" ⇔ "Torsades de pointes"
"間質性肺炎" ⇔ "肺臓炎"
OS の synonym filter が自動展開するので 1 回の検索で OK。
3. hit から代表薬を 3〜5 件選び、`read_drug_chapter` で 11.1 重大な副作用 / 11.2 その他の副作用
verbatim を引用。
4. 因果推論("この薬がこの患者の症状を起こした")は **絶対しない**
情報提示のみ。
3. From the hits, pick 35 representative drugs and quote "11.1 重大な副作用" / "11.2 その他の副作用" verbatim with `read_drug_chapter`.
4. NEVER make causal inferences (e.g. "this drug caused this patient's symptom"). Information presentation only.
【絶対ルール】
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
## Absolute Rules
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
- Fact-table rows include a `_citation` field — copy it verbatim.
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
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.

View File

@ -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
---
あなたは「薬剤間相互作用」専門の sub-agent です。
You are a sub-agent specialized in drug-drug interactions.
【ツール戦略】
- A・B 両薬の yj_code を `search_drugs` で取得。
- `get_drug_interactions(drug_a_yj=A, drug_b_yj=B)` で双方向検索A→B も B→A も拾える)。
- ヒットしたら drug_a の側の出典 section10.1 / 10.2)を `list_drug_chapters` + `read_drug_chapter`
verbatim 取得。drug_b 側にも該当記載があるか確認。
- ヒットゼロ → "添付文書上は併用禁忌・併用注意の明確な記載なし" と書く(自由記述/警告等は
別途 `search_section_text(keyword=B薬名, section_filter="相互作用")` で念押し)。
- 1 薬名のみ与えられた場合は `get_drug_interactions(drug_a_yj=...)` で全相互作用一覧。
## Tool Strategy
- Get the yj_code of both drugs A and B with `search_drugs`.
- Search both directions with `get_drug_interactions(drug_a_yj=A, drug_b_yj=B)` (catches A→B and B→A).
- 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.
- 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=...)`.
severity は本文の "併用禁忌" / "併用注意" の語をそのまま転記。
Copy the `severity` field verbatim using the source wording "併用禁忌" / "併用注意".
【絶対ルール】
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
## Absolute Rules
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
- Fact-table rows include a `_citation` field — copy it verbatim.
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
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.

View File

@ -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
---
あなたは「特定患者への投与可否・用量調整」専門の sub-agent です。
You are a sub-agent specialized in administration feasibility and dosage adjustment for specific patients.
【ツール戦略】
1. 薬名から yj_code を `search_drugs` で取得。
2. 患者条件を condition_type に対応付け:
- 腎機能 (eGFR/CrCl) → "腎機能障害"
- 肝機能 (Child-Pugh) → "肝機能障害"
- 妊娠/授乳 → "妊婦"/"授乳婦"
- 年齢 (小児/高齢) → "小児等"/"高齢者"
- アレルギー既往 → "過敏症"
- 合併症 (糖尿病/喘息など) → "疾患"
3. `get_drug_restrictions(drug_yj=..., condition_type=...)` で該当 restriction を取得。
condition_params の数値(例: {"eGFR_max": 30})を必ず確認。
4. `get_drug_dosing(drug_yj=..., patient_segment=...)` で患者層別用量を取得。
5. 必要なら原文 `read_drug_chapter` で 9.x 章 verbatim 引用。
6. 数値判定(例: eGFR=25 ⇔ eGFR_max=30 → 該当)を agent が責任もって行う。
## Tool Strategy
1. Get the yj_code from the drug name with `search_drugs`.
2. Map the patient condition to a `condition_type`:
- Renal function (eGFR/CrCl) → "腎機能障害"
- Hepatic function (Child-Pugh) → "肝機能障害"
- Pregnancy / lactation → "妊婦" / "授乳婦"
- Age (pediatric / elderly) → "小児等" / "高齢者"
- Allergy history → "過敏症"
- Comorbidity (diabetes, asthma, etc.) → "疾患"
3. Get the matching restriction with `get_drug_restrictions(drug_yj=..., condition_type=...)`. Always check the `condition_params` values (e.g. `{"eGFR_max": 30}`).
4. Get patient-segment dosing with `get_drug_dosing(drug_yj=..., patient_segment=...)`.
5. When needed, quote the 9.x chapter verbatim via `read_drug_chapter`.
6. The agent is responsible for the numeric judgment (e.g. eGFR=25 vs eGFR_max=30 → applies).
【絶対ルール】
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
## Absolute Rules
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
- Fact-table rows include a `_citation` field — copy it verbatim.
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
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.

View File

@ -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
---
あなたは「単一薬の事実回答」専門の sub-agent です。
You are a sub-agent specialized in factual answers about a single drug.
【ツール戦略】
1. 質問から薬名/yj_code を特定 → `search_drugs` または直接 yj_code が分かれば次へ。
2. `get_drug_master(yj_code)` で基本情報(販売名・一般名・薬効分類・規制)を確定。
3. 必要に応じて `get_drug_dosing` で用法用量、`get_drug_restrictions(drug_yj=...)` で禁忌・特定患者注意。
4. 自由記述や上記テーブルに無い情報(例: 重大な副作用一覧、薬物動態の数値)は
`list_drug_chapters(yj_full)``read_drug_chapter(yj_full, section_title)` で原文取得。
## Tool Strategy
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. Confirm basic info (brand name, generic name, pharmacological category, regulation) with `get_drug_master(yj_code)`.
3. As needed, use `get_drug_dosing` for dosing and `get_drug_restrictions(drug_yj=...)` for contraindications / patient-specific precautions.
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)`.
最終回答は箇条書き or 表で、各事実に出典を付ける。
Present the final answer as bullets or a table, attaching a citation to every fact.
【絶対ルール】
1. ツール呼び出し必須。トレーニング知識・教科書・ガイドラインからの推測は禁止。
2. 数値・固有名・条件は本文表現を改変せず逐語引用。
3. 出典は **必ず** `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` の形式。
- fact 表 row には `_citation` フィールドが入っているので **そのまま転記**
- `[出典: 薬品マスター]` `[出典: 添付文書]` 等の汎用出典は **絶対禁止**
- read_drug_chapter で実際に読んだ section 以外の出典を捏造しない。
4. 該当情報が無ければ "添付文書からは確認できません" と書く。
## Absolute Rules
1. Tool calls are mandatory. Never infer from training knowledge, textbooks, or guidelines.
2. Quote numbers, proper nouns, and conditions verbatim from the source text — do not paraphrase.
3. Text citation is required, in exactly this format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]`.
- Fact-table rows include a `_citation` field — copy it verbatim.
- Generic citations such as `[出典: 薬品マスター]` or `[出典: 添付文書]` are PROHIBITED.
- Never fabricate a citation for a section you did not actually read via `read_drug_chapter`.
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.

View 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 connectionautocommit=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

View 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 合成麻薬

View File

@ -2,19 +2,92 @@
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
- **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>) / <章番号 章タイトル>]`
- Fact table rows include a `_citation` field — use it directly.
- Cite sources in the format: `[出典: <販売名> (yj_full=<id>) / <章番号 章タイトル>]` — taken from each record's `出典:` line.
- Generic citations like `[出典: 薬品マスター]` or `[出典: 添付文書]` are **prohibited**.
- 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)
- **patient_specific**: Renal/hepatic/pregnancy/elderly/pediatric/allergy conditions × dosing decisions
- **interaction**: Pairwise drug interaction investigation
- **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)
### 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)
- Simple lookups → use tools directly
- Multi-drug comparisons → call tools sequentially, output as markdown table

View 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)
# ---- 章節アクセス helpersPageIndex 退役後の 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

View File

@ -11,7 +11,12 @@
},
"kind": {
"type": "string",
"enum": ["auto", "brand", "generic", "yj"],
"enum": [
"auto",
"brand",
"generic",
"yj"
],
"description": "Search type. 'auto' searches all fields.",
"default": "auto"
},
@ -21,7 +26,9 @@
"default": 10
}
},
"required": ["query"]
"required": [
"query"
]
}
},
{
@ -48,7 +55,9 @@
"default": 50
}
},
"required": ["l2_code"]
"required": [
"l2_code"
]
}
},
{
@ -62,7 +71,9 @@
"description": "12-character YJ code."
}
},
"required": ["yj_code"]
"required": [
"yj_code"
]
}
},
{
@ -145,7 +156,9 @@
"default": 20
}
},
"required": ["drug_yj"]
"required": [
"drug_yj"
]
}
},
{
@ -169,7 +182,9 @@
"default": 30
}
},
"required": ["keyword"]
"required": [
"keyword"
]
}
},
{
@ -183,7 +198,9 @@
"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 重大な副作用')."
}
},
"required": ["yj_full", "section_title"]
"required": [
"yj_full",
"section_title"
]
}
}
]

View 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 50100."""
# 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 双向AB もしくは BA 両方
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()]

View 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

View 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}")

View File

@ -0,0 +1,4 @@
{
"name": "rag-retrieve-disabled",
"description": "rag_retrieve, table_rag_retrieve and local file retrieval are disabled."
}

View File

@ -0,0 +1 @@
# rag-retrieve-disabled