Merge branch 'developing' into bot_manager

This commit is contained in:
朱潮 2026-06-13 19:28:37 +08:00
commit 195bd49236
22 changed files with 2060 additions and 444 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

@ -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)

View File

@ -25,6 +25,7 @@ from langchain.tools import BaseTool
from langchain_core.language_models import BaseChatModel
from agent.plugin_hook_loader import _get_skill_dirs
from agent.subagent_context_middleware import SubagentContextMiddleware
logger = logging.getLogger('app')
@ -181,6 +182,8 @@ async def load_subagents(
"system_prompt": parsed["system_prompt"],
"model": model,
"tools": filtered_tools,
# Tag this subagent's model/tool logs with its name.
"middleware": [SubagentContextMiddleware(name)],
}
subagents.append(subagent)
logger.info(f"Loaded sub-agent '{name}' with {len(filtered_tools)} tools from {parsed['source']}")

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

View File

@ -51,6 +51,13 @@ class Formatter(logging.Formatter):
record.trace_id = getattr(g, "trace_id")
except LookupError:
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
# if not hasattr(record, "user_id"):
# record.user_id = getattr(g, "user_id")
@ -65,7 +72,7 @@ class Formatter(logging.Formatter):
def init_logger_once(name,level):
logger = logging.getLogger(name)
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.setFormatter(formatter)
logger.addHandler(handler)