Compare commits
4 Commits
cb649d83ee
...
ec2e0acea2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2e0acea2 | ||
|
|
195bd49236 | ||
|
|
73042c57a6 | ||
|
|
e2827c6a47 |
@ -45,6 +45,7 @@ from .mem0_config import Mem0Config
|
||||
from agent.prompt_loader import load_system_prompt_async, load_mcp_settings_async
|
||||
from agent.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,
|
||||
|
||||
@ -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)
|
||||
|
||||
95
agent/subagent_context_middleware.py
Normal file
95
agent/subagent_context_middleware.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Middleware that tags logs with the currently executing subagent name.
|
||||
|
||||
Each subagent receives its own instance of this middleware (carrying its name).
|
||||
The middleware writes the name into the request-scoped GlobalContext (`g.subagent`)
|
||||
for the duration of every model call and tool call, so the log Formatter can render
|
||||
which subagent produced each log line. The previous value is restored afterwards so
|
||||
that nested/parallel subagents and the main agent are not affected.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain.tools.tool_node import ToolCallRequest
|
||||
|
||||
from utils.log_util.context import g
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
# Context key consumed by utils/log_util/logger.py Formatter.
|
||||
_SUBAGENT_KEY = "subagent"
|
||||
|
||||
|
||||
class SubagentContextMiddleware(AgentMiddleware):
|
||||
"""Set `g.subagent` while this subagent's model/tool calls execute."""
|
||||
|
||||
def __init__(self, subagent_name: str) -> None:
|
||||
super().__init__()
|
||||
self._subagent_name = subagent_name
|
||||
|
||||
def _enter(self) -> dict:
|
||||
# Shallow-copy the whole context dict and rebind a PRIVATE copy for this
|
||||
# context. This is load-bearing: GlobalContext mutates a shared dict in
|
||||
# place, and asyncio task copies share that reference, so a plain
|
||||
# `g.subagent = name` would leak across parallel sibling subagents and
|
||||
# race on restore. Replacing the reference isolates each context.
|
||||
try:
|
||||
prev = dict(g.get_context())
|
||||
except LookupError:
|
||||
prev = {}
|
||||
new_ctx = dict(prev)
|
||||
new_ctx[_SUBAGENT_KEY] = self._subagent_name
|
||||
g.update_context(new_ctx)
|
||||
return prev
|
||||
|
||||
def _exit(self, prev: dict) -> None:
|
||||
# Restore by rebinding the previous dict (also a private copy).
|
||||
g.update_context(dict(prev))
|
||||
|
||||
# ----- model call -----
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
prev = self._enter()
|
||||
try:
|
||||
return handler(request)
|
||||
finally:
|
||||
self._exit(prev)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
prev = self._enter()
|
||||
try:
|
||||
return await handler(request)
|
||||
finally:
|
||||
self._exit(prev)
|
||||
|
||||
# ----- tool call -----
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Any],
|
||||
) -> Any:
|
||||
prev = self._enter()
|
||||
try:
|
||||
return handler(request)
|
||||
finally:
|
||||
self._exit(prev)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[Any]],
|
||||
) -> Any:
|
||||
prev = self._enter()
|
||||
try:
|
||||
return await handler(request)
|
||||
finally:
|
||||
self._exit(prev)
|
||||
@ -25,6 +25,7 @@ from langchain.tools import BaseTool
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from 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']}")
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
76
skills/developing/pmda-drug-info/README.md
Normal file
76
skills/developing/pmda-drug-info/README.md
Normal file
@ -0,0 +1,76 @@
|
||||
# pmda-drug-info — Claude Code MCP plugin
|
||||
|
||||
PMDA 添付文書ベース医薬指導 Q&A の MCP plugin(hu-sandbox/pmda v2e 98/100 baseline)。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
Claude Code (agent) → MCP stdio → pmda_server.py
|
||||
├─ PG queries (drug_master / interaction / restriction / dosing)
|
||||
└─ OS search (pmda_sections, sudachi C-mode)
|
||||
```
|
||||
|
||||
10 tools: `search_drugs`, `list_categories`, `list_drugs_in_category`,
|
||||
`get_drug_master`, `get_drug_interactions`, `get_drug_restrictions`,
|
||||
`get_drug_dosing`, `search_section_text`, `list_drug_chapters`,
|
||||
`read_drug_chapter`.
|
||||
|
||||
4 sub-agents(`agents/*.md`): `single_drug`, `interaction`,
|
||||
`patient_specific`, `adverse_event`.
|
||||
|
||||
## Plugin は独立配布
|
||||
|
||||
`mygpt.*` への依存なし。PG/OS への接続情報を環境変数で渡すだけで動く。
|
||||
`queries.py` / `db.py` / `os_client.py` / `taxonomy.py` / `drug_category.md`
|
||||
は hu-sandbox/pmda からコピーした自己完結セット。
|
||||
|
||||
## 環境変数
|
||||
|
||||
```
|
||||
PMDA_PG_HOST Postgres ホスト (例: tunnel / pg.example.com)
|
||||
PMDA_PG_PORT Postgres ポート (default 5432)
|
||||
PMDA_PG_DB Postgres DB (例: gptbase)
|
||||
PMDA_PG_USER Postgres ユーザ
|
||||
PMDA_PG_PASSWORD Postgres パスワード
|
||||
|
||||
PMDA_OPENSEARCH_URL OpenSearch URL (例: http://admin:admin@tunnel:9200)
|
||||
PMDA_OS_INDEX OS index 名(default: pmda_sections)
|
||||
```
|
||||
|
||||
## インストール
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
# Plugin ディレクトリを Claude Code に登録(PROJECT 単位で)
|
||||
/plugin install path/to/pmda-drug-info
|
||||
```
|
||||
|
||||
`.claude-plugin/plugin.json` の `mcpServers.pmda_drug_info` が
|
||||
`python ./pmda_server.py` を stdio MCP サーバとして起動する。
|
||||
|
||||
## データ準備
|
||||
|
||||
Plugin は読み取り専用。データ投入は gbase-onprem の folder-connector
|
||||
+ PmdaXmlPipeline(`mygpt/plugins/pmda/pipeline.py`)が一括管理する。
|
||||
|
||||
- PG: aerich migration `migrations/models/263_*_add_pmda_tables.py` で 4 表作成
|
||||
- OS: `pmda_sections` index は `mygpt/plugins/pmda/os_index.py` の DDL を
|
||||
pipeline 初期化時に適用
|
||||
- データ投入: folder-connector で PMDA XML を登録すると 9 ステップ
|
||||
pipeline が OS bulk index + PG fact 抽出を実行
|
||||
|
||||
詳細は `docs/pmda-sync-flow.md` を参照。
|
||||
|
||||
## 動作確認
|
||||
|
||||
```bash
|
||||
# stdio MCP リクエストを手動で投げる
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | python pmda_server.py
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | python pmda_server.py
|
||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_drugs","arguments":{"query":"ロサルタン"}}}' | python pmda_server.py
|
||||
```
|
||||
@ -6,26 +6,31 @@ description: Reverse lookup drugs by adverse event name. Find which drugs have r
|
||||
tools: search_section_text, search_drugs, get_drug_master, list_drug_chapters, read_drug_chapter
|
||||
---
|
||||
|
||||
あなたは「副作用 → 該当薬剤の逆引き」専門の 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 3–5 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.
|
||||
|
||||
@ -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 の側の出典 section(10.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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
80
skills/developing/pmda-drug-info/db.py
Normal file
80
skills/developing/pmda-drug-info/db.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Postgres 连接 helper。
|
||||
|
||||
配置全部走环境变量,默认指向 docker-compose 起的本地实例:
|
||||
|
||||
PMDA_PG_HOST (默认 localhost)
|
||||
PMDA_PG_PORT (默认 5432)
|
||||
PMDA_PG_DB (默认 pmda)
|
||||
PMDA_PG_USER (默认 pmda)
|
||||
PMDA_PG_PASSWORD (默认 pmda_local_dev — 仅本地开发,生产由 secret 注入)
|
||||
|
||||
`connect()` 返回 psycopg3 connection(autocommit=False)。
|
||||
长时跑批时使用 `pool()` 取 ConnectionPool。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
import psycopg
|
||||
from psycopg import Connection
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
PG_HOST = os.environ.get("PMDA_PG_HOST", "localhost")
|
||||
PG_PORT = int(os.environ.get("PMDA_PG_PORT", "5432"))
|
||||
PG_DB = os.environ.get("PMDA_PG_DB", "pmda")
|
||||
PG_USER = os.environ.get("PMDA_PG_USER", "pmda")
|
||||
PG_PASSWORD = os.environ.get("PMDA_PG_PASSWORD", "pmda_local_dev")
|
||||
|
||||
|
||||
def conninfo() -> str:
|
||||
return (
|
||||
f"host={PG_HOST} port={PG_PORT} dbname={PG_DB} "
|
||||
f"user={PG_USER} password={PG_PASSWORD}"
|
||||
)
|
||||
|
||||
|
||||
def connect(*, autocommit: bool = False) -> Connection:
|
||||
"""Open a single connection. Caller is responsible for closing."""
|
||||
return psycopg.connect(conninfo(), autocommit=autocommit)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session(*, autocommit: bool = False) -> Iterator[Connection]:
|
||||
"""`with session() as conn:` — auto close on exit."""
|
||||
conn = connect(autocommit=autocommit)
|
||||
try:
|
||||
yield conn
|
||||
if not autocommit:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
if not autocommit:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_pool: ConnectionPool | None = None
|
||||
|
||||
|
||||
def pool(min_size: int = 1, max_size: int = 8) -> ConnectionPool:
|
||||
"""Lazy-init module-level pool. Use for batch / agent-loop hot path."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = ConnectionPool(
|
||||
conninfo(),
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"autocommit": False},
|
||||
open=True,
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
def close_pool() -> None:
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
_pool.close()
|
||||
_pool = None
|
||||
206
skills/developing/pmda-drug-info/drug_category.md
Normal file
206
skills/developing/pmda-drug-info/drug_category.md
Normal file
@ -0,0 +1,206 @@
|
||||
|
||||
- 11 中枢神経系用薬
|
||||
- 111 全身麻酔剤
|
||||
- 112 催眠鎮静剤,抗不安剤
|
||||
- 113 抗てんかん剤
|
||||
- 114 解熱鎮痛消炎剤
|
||||
- 115 興奮剤,覚醒剤
|
||||
- 116 抗パーキンソン剤
|
||||
- 117 精神神経用剤
|
||||
- 118 総合感冒剤
|
||||
- 119 その他の中枢神経系用薬
|
||||
- 12 末梢神経用薬
|
||||
- 121 局所麻酔剤
|
||||
- 122 骨格筋弛緩剤
|
||||
- 123 自律神経剤
|
||||
- 124 鎮けい剤
|
||||
- 125 発汗剤,止汗剤
|
||||
- 129 その他の末梢神経系用薬
|
||||
- 13 感覚器用薬
|
||||
- 131 眼科用剤
|
||||
- 132 耳鼻科用剤
|
||||
- 133 鎮暈剤
|
||||
- 139 その他の感覚器官用薬
|
||||
- 19 その他の神経系及び感覚器官用医薬品
|
||||
- 21 循環器官用薬
|
||||
- 211 強心剤
|
||||
- 212 不整脈用剤
|
||||
- 213 利尿剤
|
||||
- 214 血圧降下剤
|
||||
- 215 血管補強剤
|
||||
- 216 血管収縮剤
|
||||
- 217 血管拡張剤
|
||||
- 218 高脂血症用剤
|
||||
- 219 その他の循環器官用薬
|
||||
- 22 呼吸器官用薬
|
||||
- 221 呼吸促進剤
|
||||
- 222 鎮咳剤
|
||||
- 223 去たん剤
|
||||
- 224 鎮咳去たん剤
|
||||
- 225 気管支拡張剤
|
||||
- 226 含嗽剤
|
||||
- 229 その他の呼吸器官用薬
|
||||
- 23 消化器官用薬
|
||||
- 231 止しゃ剤,整腸剤
|
||||
- 232 消化性潰瘍用剤
|
||||
- 233 健胃消化剤
|
||||
- 234 制酸剤
|
||||
- 235 下剤,浣腸剤
|
||||
- 236 利胆剤
|
||||
- 237 複合胃腸剤
|
||||
- 239 その他の消化器官用薬
|
||||
- 24 ホルモン剤(抗ホルモン剤を含む)
|
||||
- 241 脳下垂体ホルモン剤
|
||||
- 242 唾液腺ホルモン剤
|
||||
- 243 甲状腺,副甲状腺ホルモン剤
|
||||
- 244 たん白同化ステロイド剤
|
||||
- 245 副腎ホルモン剤
|
||||
- 246 男性ホルモン剤
|
||||
- 247 卵胞ホルモン及び黄体ホルモン剤
|
||||
- 248 混合ホルモン剤
|
||||
- 249 その他のホルモン剤(抗ホルモン剤を含む)
|
||||
- 25 泌尿生殖器官及び肛門用薬
|
||||
- 251 泌尿器官用剤
|
||||
- 252 生殖器官用剤(性病予防剤を含む。)
|
||||
- 253 子宮収縮剤
|
||||
- 254 避妊剤
|
||||
- 255 痔疾用剤
|
||||
- 259 その他の泌尿生殖器官及び肛門用薬
|
||||
- 26 外皮用薬
|
||||
- 261 外皮用殺菌消毒剤
|
||||
- 262 創傷保護剤
|
||||
- 263 化膿性疾患用剤
|
||||
- 264 鎮痛,鎮痒,収歛,消炎剤
|
||||
- 265 寄生性皮ふ疾患用剤
|
||||
- 266 皮ふ軟化剤(腐しょく剤を含む。)
|
||||
- 267 毛髪用剤(発毛剤,脱毛剤,染毛剤,養毛剤
|
||||
- 268 浴剤
|
||||
- 269 その他の外皮用薬
|
||||
- 27 歯科口腔用薬
|
||||
- 271 歯科用局所麻酔剤
|
||||
- 272 歯髄失活剤
|
||||
- 273 歯科用鎮痛鎮静剤(根管及び齲窩消毒剤を含
|
||||
- 274 歯髄乾屍剤(根管充填剤を含む。)
|
||||
- 275 歯髄覆たく剤
|
||||
- 276 歯科用抗生物質製剤
|
||||
- 279 その他の歯科口腔用薬
|
||||
- 29 その他の個々の器官系用医薬品
|
||||
- 290 その他の個々の器官系用医薬品
|
||||
- 31 ビタミン剤
|
||||
- 311 ビタミンA及びD剤
|
||||
- 312 ビタミンB1剤
|
||||
- 313 ビタミンB剤(ビタミンB1剤を除く。)
|
||||
- 314 ビタミンC剤
|
||||
- 315 ビタミンE剤
|
||||
- 316 ビタミンK剤
|
||||
- 317 混合ビタミン剤(ビタミンA・D混合製剤を除く)
|
||||
- 319 その他のビタミン剤
|
||||
- 32 滋養強壮薬
|
||||
- 321 カルシウム剤
|
||||
- 322 無機質製剤
|
||||
- 323 糖類剤
|
||||
- 324 有機酸製剤
|
||||
- 325 たん白アミノ酸製剤
|
||||
- 326 臓器製剤
|
||||
- 327 乳幼児用剤
|
||||
- 329 その他の滋養強壮薬
|
||||
- 33 血液・体液用薬
|
||||
- 331 血液代用剤
|
||||
- 332 止血剤
|
||||
- 333 血液凝固阻止剤
|
||||
- 339 その他の血液・体液用薬
|
||||
- 34 人工透析用薬
|
||||
- 341 人工腎臓透析用剤
|
||||
- 342 腹膜透析用剤
|
||||
- 349 その他の人工透析用薬
|
||||
- 39 その他の代謝性医薬品
|
||||
- 391 肝臓疾患用剤
|
||||
- 392 解毒剤
|
||||
- 393 習慣性中毒用剤
|
||||
- 394 痛風治療剤
|
||||
- 395 酵素製剤
|
||||
- 396 糖尿病用剤
|
||||
- 397 総合代謝性製剤
|
||||
- 399 他に分類されない代謝性医薬品
|
||||
- 41 細胞賦活用薬
|
||||
- 411 クロロフィル製剤
|
||||
- 412 色素製剤
|
||||
- 419 その他の細胞賦活用薬
|
||||
- 42 腫瘍用薬
|
||||
- 421 アルキル化剤
|
||||
- 422 代謝拮抗剤
|
||||
- 423 抗腫瘍性抗生物質製剤
|
||||
- 424 抗腫瘍性植物成分製剤
|
||||
- 429 その他の腫瘍用薬
|
||||
- 43 放射性医薬品
|
||||
- 430 放射性医薬品
|
||||
- 44 アレルギー用薬
|
||||
- 441 抗ヒスタミン剤
|
||||
- 442 刺激療法剤
|
||||
- 443 非特異性免疫原製剤
|
||||
- 449 その他のアレルギー用薬
|
||||
- 49 その他の組織細胞機能用医薬品
|
||||
- 490 その他の組織細胞機能用医薬品
|
||||
- 51 生薬
|
||||
- 510 生薬
|
||||
- 52 漢方製剤
|
||||
- 520 漢方製剤
|
||||
- 59 その他の生薬及び漢方処方に基づく医薬品
|
||||
- 590 その他の生薬及び漢方処方に基づく医薬品
|
||||
- 61 抗生物質製剤
|
||||
- 611 主としてグラム陽性菌に作用するもの
|
||||
- 612 主としてグラム陰性菌に作用するもの
|
||||
- 613 主としてグラム陽性・陰性菌に作用するもの
|
||||
- 614 主としてグラム陽性菌,マイコプラズマに作用するもの
|
||||
- 615 主としてグラム陽性・陰性菌,リケッチア,クラミジアに作用するもの
|
||||
- 616 主として抗酸菌に作用するもの
|
||||
- 617 主としてカビに作用するもの
|
||||
- 619 その他の抗生物質製剤(複合抗生物質製剤を含む)
|
||||
- 62 化学療法剤
|
||||
- 621 サルファ剤
|
||||
- 622 抗結核剤
|
||||
- 623 抗ハンセン病剤
|
||||
- 624 合成抗菌剤
|
||||
- 625 抗ウイルス剤
|
||||
- 629 その他の化学療法剤
|
||||
- 63 生物学的製剤
|
||||
- 631 ワクチン類
|
||||
- 632 毒素及びトキソイド類
|
||||
- 633 抗毒素類及び抗レプトスピラ血清類
|
||||
- 634 血液製剤類
|
||||
- 635 生物学的試験用製剤類
|
||||
- 636 混合生物学的製剤
|
||||
- 639 その他の生物学的製剤
|
||||
- 64 寄生動物用薬
|
||||
- 641 抗原虫剤
|
||||
- 642 駆虫剤
|
||||
- 649 その他の寄生動物用薬
|
||||
- 69 その他の病原生物に対する医薬品
|
||||
- 690 その他の病原生物に対する医薬品
|
||||
- 71 調剤用薬
|
||||
- 711 賦形剤
|
||||
- 712 軟膏基剤
|
||||
- 713 溶解剤
|
||||
- 714 矯味,矯臭,着色剤
|
||||
- 715 乳化剤
|
||||
- 719 その他の調剤用薬
|
||||
- 72 診断用薬(体外診断用医薬品を除く)
|
||||
- 721 X線造影剤
|
||||
- 722 機能検査用試薬
|
||||
- 729 その他の診断用薬
|
||||
- 73 公衆衛生用薬
|
||||
- 731 防腐剤
|
||||
- 732 防疫用殺菌消毒剤
|
||||
- 733 防虫剤
|
||||
- 734 殺虫剤
|
||||
- 735 殺そ剤
|
||||
- 739 その他の公衆衛生用薬
|
||||
- 79 その他の治療を主目的としない医薬品
|
||||
- 791 ばん創こう
|
||||
- 799 他に分類されない治療を主目的としない医薬品
|
||||
- 81 アルカロイド系麻薬(天然麻薬)
|
||||
- 811 アヘンアルカロイド系麻薬
|
||||
- 812 コカアルカロイド系製剤
|
||||
- 819 その他のアルカロイド系麻薬(天然麻薬)
|
||||
- 82 非アルカロイド系麻薬
|
||||
- 821 合成麻薬
|
||||
@ -2,19 +2,92 @@
|
||||
|
||||
You have access to Japanese pharmaceutical package insert (添付文書) data via the following tools.
|
||||
|
||||
## 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
|
||||
|
||||
157
skills/developing/pmda-drug-info/os_client.py
Normal file
157
skills/developing/pmda-drug-info/os_client.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""OpenSearch `pmda_sections` index spec + client helper.
|
||||
|
||||
Mapping 与 wiki-skill 的 sudachi 配置共用 plugin(同一 OS 集群、同一 sudachi
|
||||
core 字典)。每个 doc 对应一份说明书的一个章节节点,冗余存药品 metadata 以避
|
||||
免 JOIN(详见 design.md §2.1.2)。
|
||||
|
||||
环境变量:
|
||||
OS_HOST (默认 http://localhost:9200,与 wiki-skill `_common.py` 一致)
|
||||
PMDA_OS_INDEX (默认 pmda_sections)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from opensearchpy import OpenSearch
|
||||
|
||||
# Plugin env vars: PMDA_OPENSEARCH_URL(推奨)/ OPENSEARCH_URL / OPENSEARCH_HOST
|
||||
OS_HOST = (
|
||||
os.environ.get("PMDA_OPENSEARCH_URL")
|
||||
or os.environ.get("OPENSEARCH_URL")
|
||||
or os.environ.get("OPENSEARCH_HOST")
|
||||
or "http://localhost:9200"
|
||||
)
|
||||
INDEX_NAME = os.environ.get("PMDA_OS_INDEX", "pmda_sections")
|
||||
|
||||
|
||||
# ---- Mapping spec --------------------------------------------------------
|
||||
|
||||
INDEX_BODY: dict = {
|
||||
"settings": {
|
||||
"index": {
|
||||
"number_of_shards": 1,
|
||||
"number_of_replicas": 0,
|
||||
"refresh_interval": "1s",
|
||||
},
|
||||
"analysis": {
|
||||
"tokenizer": {
|
||||
"sudachi_tokenizer": {
|
||||
"type": "sudachi_tokenizer",
|
||||
"split_mode": "C",
|
||||
"discard_punctuation": True,
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
# "med_synonyms": {
|
||||
# "type": "synonym",
|
||||
# 初期最小集 — 命中错例后扩充。同义词条之间逗号分隔代表
|
||||
# 等价、空格视为词内字符。
|
||||
# "synonyms": [
|
||||
# "Stevens-Johnson, 皮膚粘膜眼症候群, SJS",
|
||||
# "中毒性表皮壊死融解症, TEN, ライエル症候群",
|
||||
# "QT延長, トルサード, Torsades de pointes",
|
||||
# "間質性肺炎, 肺臓炎",
|
||||
# "横紋筋融解症, ラブドミオリーシス",
|
||||
# "アナフィラキシー, アナフィラキシーショック",
|
||||
# "無顆粒球症, 顆粒球減少症",
|
||||
# ],
|
||||
# },
|
||||
"jp_pos": {
|
||||
"type": "sudachi_part_of_speech",
|
||||
},
|
||||
"jp_stop": {
|
||||
"type": "sudachi_ja_stop",
|
||||
},
|
||||
},
|
||||
"analyzer": {
|
||||
"jp_med": {
|
||||
"type": "custom",
|
||||
# icu_normalizer はデフォルト image に未含、sudachi_
|
||||
# normalizedform で全角半角・正規化はカバーされる。
|
||||
"tokenizer": "sudachi_tokenizer",
|
||||
"filter": [
|
||||
"sudachi_baseform",
|
||||
"sudachi_normalizedform",
|
||||
"jp_pos",
|
||||
"jp_stop",
|
||||
"lowercase",
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"yj_full": {"type": "keyword"},
|
||||
"yj_code": {"type": "keyword"},
|
||||
"l1_code": {"type": "keyword"},
|
||||
"l2_code": {"type": "keyword"},
|
||||
"l2_name": {"type": "keyword"},
|
||||
"category_name": {"type": "keyword"},
|
||||
"brand_names": {"type": "keyword"},
|
||||
"generic_name": {"type": "keyword"},
|
||||
"section_title": {
|
||||
"type": "text",
|
||||
"analyzer": "jp_med",
|
||||
"fields": {"raw": {"type": "keyword"}},
|
||||
},
|
||||
"line_num": {"type": "integer"},
|
||||
"text": {"type": "text", "analyzer": "jp_med"},
|
||||
"revision_date": {"type": "date"},
|
||||
"_md_sha256": {"type": "keyword"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---- Client --------------------------------------------------------------
|
||||
|
||||
|
||||
def client() -> OpenSearch:
|
||||
"""Return an OpenSearch client bound to OS_HOST."""
|
||||
return OpenSearch(hosts=[OS_HOST], http_compress=True, timeout=60)
|
||||
|
||||
|
||||
# ---- 章節アクセス helpers(PageIndex 退役後の verbatim 取得経路) -------
|
||||
|
||||
|
||||
def list_drug_sections(yj_full: str, *, limit: int = 200) -> list[dict]:
|
||||
"""1 薬の全章節を line_num 昇順で返す。
|
||||
|
||||
各 element: {section_title, line_num, text_len, brand, generic}
|
||||
"""
|
||||
cli = client()
|
||||
resp = cli.search(index=INDEX_NAME, body={
|
||||
"size": min(limit, 500),
|
||||
"_source": ["section_title", "line_num", "text", "brand_names", "generic_name"],
|
||||
"query": {"term": {"yj_full": yj_full}},
|
||||
"sort": [{"line_num": "asc"}],
|
||||
})
|
||||
out = []
|
||||
for h in resp["hits"]["hits"]:
|
||||
s = h["_source"]
|
||||
out.append({
|
||||
"section_title": s.get("section_title", ""),
|
||||
"line_num": s.get("line_num"),
|
||||
"text_len": len(s.get("text", "") or ""),
|
||||
"brand": (s.get("brand_names") or [""])[0],
|
||||
"generic": s.get("generic_name") or "",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_drug_section_text(yj_full: str, section_title: str) -> str:
|
||||
"""指定 (yj_full, section_title) の verbatim 章節 text。見つからなければ ""。"""
|
||||
cli = client()
|
||||
resp = cli.search(index=INDEX_NAME, body={
|
||||
"size": 1,
|
||||
"_source": ["text"],
|
||||
"query": {"bool": {"must": [
|
||||
{"term": {"yj_full": yj_full}},
|
||||
{"term": {"section_title.raw": section_title}},
|
||||
]}},
|
||||
})
|
||||
hits = resp["hits"]["hits"]
|
||||
if not hits:
|
||||
return ""
|
||||
return hits[0]["_source"].get("text", "") or ""
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,12 @@
|
||||
},
|
||||
"kind": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
427
skills/developing/pmda-drug-info/queries.py
Normal file
427
skills/developing/pmda-drug-info/queries.py
Normal file
@ -0,0 +1,427 @@
|
||||
"""SQL 查询接口(关系小库侧)。
|
||||
|
||||
Phase 1 已承接:
|
||||
- `search_drugs` 的 商品名 / 一般名 / YJ 子串检索
|
||||
- `list_categories` 的 L1/L2 + drug_count
|
||||
- `list_drugs_in_category` 的 一般名 → 販売名
|
||||
|
||||
后续 Phase 2 会接 drug_interaction / drug_restriction / drug_dosing。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from taxonomy import load_taxonomy
|
||||
from db import session
|
||||
|
||||
# Plugin 自包含: drug_category.md 与 queries.py 同目录
|
||||
_TAXONOMY_PATH = Path(__file__).resolve().parent / "drug_category.md"
|
||||
_TAXONOMY_CACHE = None
|
||||
|
||||
|
||||
def _taxonomy():
|
||||
global _TAXONOMY_CACHE
|
||||
if _TAXONOMY_CACHE is None:
|
||||
_TAXONOMY_CACHE = load_taxonomy(_TAXONOMY_PATH)
|
||||
return _TAXONOMY_CACHE
|
||||
|
||||
# 12 字母数字 → YJ code 候选;前几位即足够触发自动 kind=yj 的判断
|
||||
_YJ_RE = re.compile(r"^[0-9A-Z]{4,12}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DrugHit:
|
||||
yj_full: str
|
||||
yj_code: str
|
||||
brand_name: str # "/" 分隔多品名
|
||||
generic_name: str
|
||||
category_code: str
|
||||
category_name: str
|
||||
score: float # 50-100
|
||||
|
||||
|
||||
def _detect_kind(q: str) -> str:
|
||||
"""auto-detect: pure alnum & uppercase 4+ chars → yj, otherwise any."""
|
||||
if _YJ_RE.match(q.upper()):
|
||||
return "yj"
|
||||
return "any"
|
||||
|
||||
|
||||
def _score_expr(q_lower: str, q_like: str) -> str:
|
||||
"""Postgres expression returning relevance score 50–100."""
|
||||
# NB: doubles each pattern; psycopg expands %s positionally so caller
|
||||
# must pass q_lower / q_like in matching repetitions.
|
||||
return (
|
||||
"GREATEST("
|
||||
" CASE WHEN lower(brand_name) = %s THEN 100.0 "
|
||||
" WHEN lower(brand_name) LIKE %s || '%%' THEN 90.0 "
|
||||
" WHEN brand_name ILIKE %s THEN 70.0 ELSE 0 END,"
|
||||
" CASE WHEN lower(generic_name_jp) = %s THEN 95.0 "
|
||||
" WHEN lower(generic_name_jp) LIKE %s || '%%' THEN 85.0 "
|
||||
" WHEN generic_name_jp ILIKE %s THEN 65.0 ELSE 0 END,"
|
||||
" CASE WHEN yj_code = %s THEN 100.0 ELSE 0 END"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def search_drugs_in_db(
|
||||
query: str,
|
||||
*,
|
||||
kind: str = "auto",
|
||||
limit: int = 20,
|
||||
) -> list[DrugHit]:
|
||||
"""Drop-in replacement for the in-memory ``CorpusIndex.search``.
|
||||
|
||||
`kind` ∈ {"auto", "brand", "generic", "yj"}.
|
||||
|
||||
Returns DrugHit list (max ``limit``) ordered by relevance score desc.
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
if kind == "auto":
|
||||
kind = _detect_kind(q)
|
||||
|
||||
q_lower = q.lower()
|
||||
q_like = f"%{q}%"
|
||||
q_upper = q.upper()
|
||||
|
||||
if kind == "yj":
|
||||
sql = """
|
||||
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||
category_code, category_name,
|
||||
CASE WHEN yj_code = %s THEN 100.0
|
||||
WHEN yj_full LIKE %s || '%%' THEN 95.0
|
||||
ELSE 80.0 END AS score
|
||||
FROM drug_master
|
||||
WHERE yj_code LIKE %s OR yj_full LIKE %s
|
||||
ORDER BY score DESC, yj_full ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
params = (q_upper, q_upper, f"{q_upper}%", f"{q_upper}%", limit)
|
||||
elif kind == "brand":
|
||||
sql = """
|
||||
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||
category_code, category_name,
|
||||
CASE WHEN lower(brand_name) = %s THEN 100.0
|
||||
WHEN lower(brand_name) LIKE %s || '%%' THEN 90.0
|
||||
ELSE 70.0 END AS score
|
||||
FROM drug_master
|
||||
WHERE brand_name ILIKE %s
|
||||
ORDER BY score DESC, length(brand_name) ASC, yj_full ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
params = (q_lower, q_lower, q_like, limit)
|
||||
elif kind == "generic":
|
||||
sql = """
|
||||
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||
category_code, category_name,
|
||||
CASE WHEN lower(generic_name_jp) = %s THEN 95.0
|
||||
WHEN lower(generic_name_jp) LIKE %s || '%%' THEN 85.0
|
||||
ELSE 65.0 END AS score
|
||||
FROM drug_master
|
||||
WHERE generic_name_jp ILIKE %s
|
||||
ORDER BY score DESC, length(generic_name_jp) ASC, yj_full ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
params = (q_lower, q_lower, q_like, limit)
|
||||
else: # any
|
||||
sql = f"""
|
||||
SELECT yj_full, yj_code, brand_name, generic_name_jp,
|
||||
category_code, category_name,
|
||||
{_score_expr(q_lower, q_like)} AS score
|
||||
FROM drug_master
|
||||
WHERE brand_name ILIKE %s OR generic_name_jp ILIKE %s
|
||||
OR yj_code LIKE %s OR yj_full LIKE %s
|
||||
ORDER BY score DESC, length(brand_name) ASC, yj_full ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
# _score_expr 占位符顺序:brand=, brand LIKE, brand ILIKE,
|
||||
# generic=, generic LIKE, generic ILIKE, yj_code=
|
||||
# 然后 WHERE: brand ILIKE, generic ILIKE, yj LIKE, yj_full LIKE
|
||||
params = (
|
||||
q_lower, q_lower, q_like,
|
||||
q_lower, q_lower, q_like,
|
||||
q_upper,
|
||||
q_like, q_like, f"{q_upper}%", f"{q_upper}%",
|
||||
limit,
|
||||
)
|
||||
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
DrugHit(
|
||||
yj_full=r[0],
|
||||
yj_code=r[1],
|
||||
brand_name=r[2] or "",
|
||||
generic_name=r[3] or "",
|
||||
category_code=r[4] or "",
|
||||
category_name=r[5] or "",
|
||||
score=float(r[6] or 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---- 类别导航 ------------------------------------------------------------
|
||||
|
||||
|
||||
def list_categories_with_counts() -> list[dict]:
|
||||
"""全 L1 / L2 分类 + 各 L2 的 drug 数。
|
||||
|
||||
分类层级名取自 drug_category.md(不用 PMDA 的 category_name 自由文,
|
||||
因为后者一药一表达,难以聚合);drug_count 取自 drug_master 的实际行数。
|
||||
"""
|
||||
tax = _taxonomy()
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT category_code, COUNT(*) FROM drug_master "
|
||||
"WHERE category_code IS NOT NULL "
|
||||
"GROUP BY category_code"
|
||||
)
|
||||
counts: dict[str, int] = dict(cur.fetchall())
|
||||
|
||||
by_l1: dict[str, dict] = {}
|
||||
for l2_code, l2 in tax.items():
|
||||
c = counts.get(l2_code, 0)
|
||||
if c == 0:
|
||||
continue
|
||||
l1 = by_l1.setdefault(
|
||||
l2.l1_code,
|
||||
{"l1_code": l2.l1_code, "l1_name": l2.l1_name, "l2": []},
|
||||
)
|
||||
l1["l2"].append({"code": l2_code, "name": l2.name, "drug_count": c})
|
||||
# 内层按 code 排序,外层按 l1_code 排序
|
||||
for l1 in by_l1.values():
|
||||
l1["l2"].sort(key=lambda x: x["code"])
|
||||
return [by_l1[k] for k in sorted(by_l1)]
|
||||
|
||||
|
||||
def list_drugs_in_category(
|
||||
l2_code: str,
|
||||
*,
|
||||
limit_generics: int = 50,
|
||||
brands_per_generic: int = 5,
|
||||
) -> dict:
|
||||
"""指定 L2 类目下的「一般名 → [販売名]」一览。
|
||||
|
||||
Returns the same JSON shape `_corpus_tools.list_drugs_in_category` previously
|
||||
yielded so the agent prompt 不变。
|
||||
"""
|
||||
tax = _taxonomy()
|
||||
l2 = tax.get(l2_code)
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT generic_name_jp, yj_full, brand_name "
|
||||
"FROM drug_master WHERE category_code = %s "
|
||||
"ORDER BY generic_name_jp, yj_full",
|
||||
(l2_code,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
by_gen: "OrderedDict[str, list[dict]]" = OrderedDict()
|
||||
for gen, yj_full, brand in rows:
|
||||
by_gen.setdefault(gen or "(一般名不明)", []).append(
|
||||
{"brand": brand or "", "yj_full": yj_full}
|
||||
)
|
||||
|
||||
payload: list[dict] = []
|
||||
for gen in list(by_gen)[:limit_generics]:
|
||||
drugs = by_gen[gen]
|
||||
shown = drugs[:brands_per_generic]
|
||||
extra = len(drugs) - len(shown)
|
||||
entry = {"generic": gen, "drugs": list(shown)}
|
||||
if extra > 0:
|
||||
entry["drugs"].append({"_more": f"+{extra} more brands"})
|
||||
payload.append(entry)
|
||||
|
||||
out = {
|
||||
"l2_code": l2_code,
|
||||
"l2_name": l2.name if l2 else "",
|
||||
"generics": payload,
|
||||
}
|
||||
if len(by_gen) > limit_generics:
|
||||
out["_more_generics"] = len(by_gen) - limit_generics
|
||||
return out
|
||||
|
||||
|
||||
# ---- fact 查询:drug_master / interaction / restriction / dosing ----------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DrugMasterRow:
|
||||
yj_code: str
|
||||
yj_full: str
|
||||
brand_name: str
|
||||
generic_name_jp: str
|
||||
category_code: str
|
||||
category_name: str
|
||||
regulation: str | None
|
||||
manufacturer: str | None
|
||||
revision_date: str | None # ISO date string
|
||||
|
||||
|
||||
def drug_master_get(yj_code: str) -> DrugMasterRow | None:
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT yj_code, yj_full, brand_name, generic_name_jp, "
|
||||
" category_code, category_name, regulation, manufacturer, "
|
||||
" to_char(revision_date, 'YYYY-MM-DD') "
|
||||
"FROM drug_master WHERE yj_code = %s",
|
||||
(yj_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return DrugMasterRow(*row)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InteractionRow:
|
||||
id: str
|
||||
drug_a_yj: str
|
||||
drug_b_yj: str | None
|
||||
drug_b_class: str | None
|
||||
severity: str
|
||||
mechanism: str | None
|
||||
clinical_effect: str | None
|
||||
source_section: str
|
||||
source_drug_yj: str
|
||||
|
||||
|
||||
def drug_interaction_query(
|
||||
drug_a_yj: str | None = None,
|
||||
drug_b_yj: str | None = None,
|
||||
*,
|
||||
severity: str | None = None,
|
||||
keyword: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[InteractionRow]:
|
||||
"""検索条件:
|
||||
drug_a_yj alone → drug_a の全相互作用(drug_b 任意)
|
||||
drug_a_yj + drug_b_yj → 双向(A→B もしくは B→A 両方)
|
||||
keyword → drug_b_class や mechanism / clinical_effect の ILIKE
|
||||
"""
|
||||
where = []
|
||||
params: list = []
|
||||
if drug_a_yj and drug_b_yj:
|
||||
where.append("((drug_a_yj=%s AND drug_b_yj=%s) OR "
|
||||
"(drug_a_yj=%s AND drug_b_yj=%s))")
|
||||
params += [drug_a_yj, drug_b_yj, drug_b_yj, drug_a_yj]
|
||||
elif drug_a_yj:
|
||||
where.append("drug_a_yj = %s")
|
||||
params.append(drug_a_yj)
|
||||
elif drug_b_yj:
|
||||
where.append("drug_b_yj = %s")
|
||||
params.append(drug_b_yj)
|
||||
if severity:
|
||||
where.append("severity = %s")
|
||||
params.append(severity)
|
||||
if keyword:
|
||||
where.append("(drug_b_class ILIKE %s OR mechanism ILIKE %s "
|
||||
" OR clinical_effect ILIKE %s)")
|
||||
kw = f"%{keyword}%"
|
||||
params += [kw, kw, kw]
|
||||
if not where:
|
||||
return []
|
||||
sql = (
|
||||
"SELECT id, drug_a_yj, drug_b_yj, drug_b_class, severity, "
|
||||
" mechanism, clinical_effect, source_section, source_drug_yj "
|
||||
"FROM drug_interaction WHERE " + " AND ".join(where) +
|
||||
" ORDER BY severity, drug_b_class NULLS LAST LIMIT %s"
|
||||
)
|
||||
params.append(limit)
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return [InteractionRow(*r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestrictionRow:
|
||||
id: str
|
||||
drug_yj: str
|
||||
condition_type: str
|
||||
condition_text: str
|
||||
condition_params: dict
|
||||
severity: str
|
||||
source_section: str
|
||||
|
||||
|
||||
def drug_restriction_query(
|
||||
drug_yj: str | None = None,
|
||||
*,
|
||||
condition_type: str | None = None,
|
||||
severity: str | None = None,
|
||||
keyword: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[RestrictionRow]:
|
||||
where = []
|
||||
params: list = []
|
||||
if drug_yj:
|
||||
where.append("drug_yj = %s")
|
||||
params.append(drug_yj)
|
||||
if condition_type:
|
||||
where.append("condition_type = %s")
|
||||
params.append(condition_type)
|
||||
if severity:
|
||||
where.append("severity = %s")
|
||||
params.append(severity)
|
||||
if keyword:
|
||||
where.append("condition_text ILIKE %s")
|
||||
params.append(f"%{keyword}%")
|
||||
if not where:
|
||||
return []
|
||||
sql = (
|
||||
"SELECT id, drug_yj, condition_type, condition_text, condition_params, "
|
||||
" severity, source_section "
|
||||
"FROM drug_restriction WHERE " + " AND ".join(where) +
|
||||
" ORDER BY severity, condition_type LIMIT %s"
|
||||
)
|
||||
params.append(limit)
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return [RestrictionRow(*r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DosingRow:
|
||||
id: str
|
||||
drug_yj: str
|
||||
indication_code: str | None
|
||||
patient_segment: str
|
||||
segment_params: dict
|
||||
dose_amount: float | None
|
||||
dose_unit: str | None
|
||||
frequency: str | None
|
||||
duration: str | None
|
||||
adjustment_text: str
|
||||
source_section: str
|
||||
|
||||
|
||||
def drug_dosing_query(
|
||||
drug_yj: str,
|
||||
*,
|
||||
patient_segment: str | None = None,
|
||||
limit: int = 30,
|
||||
) -> list[DosingRow]:
|
||||
where = ["drug_yj = %s"]
|
||||
params: list = [drug_yj]
|
||||
if patient_segment:
|
||||
where.append("patient_segment = %s")
|
||||
params.append(patient_segment)
|
||||
sql = (
|
||||
"SELECT id, drug_yj, indication_code, patient_segment, segment_params, "
|
||||
" dose_amount, dose_unit, frequency, duration, adjustment_text, "
|
||||
" source_section "
|
||||
"FROM drug_dosing WHERE " + " AND ".join(where) +
|
||||
" ORDER BY patient_segment, indication_code NULLS LAST LIMIT %s"
|
||||
)
|
||||
params.append(limit)
|
||||
with session() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return [DosingRow(*r) for r in cur.fetchall()]
|
||||
4
skills/developing/pmda-drug-info/requirements.txt
Normal file
4
skills/developing/pmda-drug-info/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
# Plugin self-contained: PG (psycopg) + OS (opensearch-py)
|
||||
psycopg[binary]>=3.2.0
|
||||
psycopg-pool>=3.2.0
|
||||
opensearch-py>=2.2.0
|
||||
64
skills/developing/pmda-drug-info/taxonomy.py
Normal file
64
skills/developing/pmda-drug-info/taxonomy.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Drug-category taxonomy loader.
|
||||
|
||||
Reads `pmda/drug_category.md` (the cleaned-up nested list with codes) and
|
||||
produces a `{l2_code: L2}` dict for joining with `DocMeta.l2_code` (first 3
|
||||
chars of the YJ code).
|
||||
|
||||
Source markdown shape:
|
||||
|
||||
- 11 中枢神経系用薬
|
||||
- 111 全身麻酔剤
|
||||
- 112 催眠鎮静剤,抗不安剤
|
||||
- 12 末梢神経用薬
|
||||
- 121 局所麻酔剤
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Top-level: `- {2-digit} {name}`
|
||||
_L1_RE = re.compile(r"^- (\d{2})\s+(.+)$")
|
||||
# Nested: ` - {3-digit} {name}` (indent 2 spaces)
|
||||
_L2_RE = re.compile(r"^ {2}- (\d{3})\s+(.+)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class L2:
|
||||
code: str # "111"
|
||||
name: str # "全身麻酔剤"
|
||||
l1_code: str # "11"
|
||||
l1_name: str # "中枢神経系用薬"
|
||||
|
||||
|
||||
def load_taxonomy(path: Path | str = "pmda/drug_category.md") -> dict[str, L2]:
|
||||
out: dict[str, L2] = {}
|
||||
current_l1_code = ""
|
||||
current_l1_name = ""
|
||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
m1 = _L1_RE.match(line)
|
||||
if m1:
|
||||
current_l1_code, current_l1_name = m1.group(1), m1.group(2).strip()
|
||||
continue
|
||||
m2 = _L2_RE.match(line)
|
||||
if m2:
|
||||
code = m2.group(1)
|
||||
name = m2.group(2).strip()
|
||||
if not current_l1_code:
|
||||
raise ValueError(f"L2 row {code} appears before any L1 in {path}")
|
||||
out[code] = L2(code=code, name=name, l1_code=current_l1_code, l1_name=current_l1_name)
|
||||
return out
|
||||
|
||||
|
||||
def lookup(taxonomy: dict[str, L2], l2_code: str) -> L2 | None:
|
||||
"""Return the L2 entry, or None if the YJ prefix isn't in the taxonomy."""
|
||||
return taxonomy.get(l2_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = load_taxonomy()
|
||||
print(f"Loaded {len(t)} L2 categories")
|
||||
for code in ("111", "214", "421", "999"):
|
||||
v = t.get(code)
|
||||
print(f" {code} → {v}")
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "rag-retrieve-disabled",
|
||||
"description": "rag_retrieve, table_rag_retrieve and local file retrieval are disabled."
|
||||
}
|
||||
1
skills/developing/rag-retrieve-disabled/README.md
Normal file
1
skills/developing/rag-retrieve-disabled/README.md
Normal file
@ -0,0 +1 @@
|
||||
# rag-retrieve-disabled
|
||||
254
skills/linggan/agnes-image/SKILL.md
Normal file
254
skills/linggan/agnes-image/SKILL.md
Normal file
@ -0,0 +1,254 @@
|
||||
---
|
||||
name: agnes-image
|
||||
description: 使用 Agnes AI(OpenAI 兼容网关)生成与理解图片、生成视频。支持文生图、图生图(保持角色一致性)、贴纸/卡通形象生成、白底转透明 PNG、多模态图片理解(描述/分析/问答/OCR),以及文生视频、图生视频、多图视频、关键帧动画。
|
||||
category: Creative Generation
|
||||
---
|
||||
|
||||
# Agnes Image
|
||||
|
||||
本 Skill 封装了 Agnes AI 的图像生成能力(模型 `agnes-image-2.1-flash`),接口兼容 OpenAI 风格,支持文生图、图生图,并内置「白底转透明」后处理,适合批量生成游戏/应用所需的角色、图标、贴纸等素材。
|
||||
|
||||
## 前置条件
|
||||
|
||||
设置 API Key(二选一):
|
||||
|
||||
```bash
|
||||
export AGNES_API_KEY="sk-xxxxxxxx"
|
||||
```
|
||||
|
||||
或在每次调用时通过 `--api-key` 传入。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 文生图
|
||||
|
||||
生成单张图片,输出可访问的图片链接:
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_image.py --prompt "一只圆润萌系的小火龙,大眼睛,贴纸风格"
|
||||
```
|
||||
|
||||
指定尺寸:
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_image.py --prompt "壮丽的山川日出" --size "1024x768"
|
||||
```
|
||||
|
||||
### 图生图(保持一致性)
|
||||
|
||||
提供参考图 URL 或 `data:image` Base64,让新图沿用参考图的角色/构图:
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_image.py \
|
||||
--prompt "把这只小龙变成展翅的成年形态,保持配色和脸部不变" \
|
||||
--image "https://example.com/baby-dragon.png"
|
||||
```
|
||||
|
||||
可多次指定 `--image` 传入多张参考图。
|
||||
|
||||
### 下载保存到本地
|
||||
|
||||
加 `--save`,脚本会下载图片并保存,输出 `SAVED:<path>`:
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_image.py --prompt "一只小猫贴纸" --save ./outputs/cat.png
|
||||
```
|
||||
|
||||
### 白底转透明 PNG
|
||||
|
||||
配合 `--save --transparent`,自动把纯白背景抠成透明(保留主体内部白色,如白描边、白肚皮):
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_image.py \
|
||||
--prompt "一只圆润萌系企鹅,白色背景,贴纸风格" \
|
||||
--save ./outputs/penguin.png --transparent
|
||||
```
|
||||
|
||||
边缘若有白色残留,可调高容差 `--thresh`(默认 60)。
|
||||
|
||||
## 参数说明
|
||||
|
||||
- `--prompt`: (必选) 图像生成的文本描述,支持中英文。
|
||||
- `--model`: (可选) 模型 ID,默认 `agnes-image-2.1-flash`。
|
||||
- `--size`: (可选) 图像尺寸,如 `1024x1024`、`1024x768`,默认 `1024x1024`。
|
||||
- `--image`: (可选) 参考图 URL 或 `data:image/...;base64,...`,用于图生图,可多次指定。
|
||||
- `--api-key`: (可选) Agnes API Key,未提供时读取 `AGNES_API_KEY` 环境变量。
|
||||
- `--save`: (可选) 下载并保存到指定本地路径。
|
||||
- `--transparent`: (可选) 配合 `--save`,把纯白背景转为透明 PNG(需 Pillow)。
|
||||
- `--thresh`: (可选) 透明化时的白色容差,默认 60,值越大清除越彻底。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 调用 `generate_image.py` 脚本。
|
||||
2. 脚本始终输出以 `MEDIA_URL: ` 开头的图片链接,用 Markdown 展示:``。
|
||||
3. 加了 `--save` 时,会额外下载到本地并输出以 `SAVED: ` 开头的文件路径(`MEDIA_URL` 仍会一并输出)。
|
||||
4. 生成的图片链接来自 Agnes 平台输出存储,可直接公网访问。
|
||||
|
||||
> 注:`--save --transparent` 时,`MEDIA_URL` 指向 Agnes 返回的原始白底图,本地 `SAVED` 文件才是抠好的透明 PNG。
|
||||
|
||||
## 批量生成小贴士
|
||||
|
||||
生成系列素材(如角色的多个进化阶段)时,建议:
|
||||
|
||||
- 在每个 prompt 中固定统一的「风格描述」(如 `chibi kawaii sticker, big eyes, white background`),保证视觉一致。
|
||||
- 需要严格保持同一角色时,用 `--image` 把上一阶段的图当参考做图生图。
|
||||
- 透明素材直接用 `--save --transparent`,省去手动抠图。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 接口兼容 OpenAI 风格,Base URL 为 `https://apihub.agnes-ai.com/v1`。
|
||||
- API Key 属于敏感信息,请勿提交到公开仓库或硬编码到代码中。
|
||||
- 图生图时,确保参考图 URL 可公开访问。
|
||||
- 透明化基于「边缘漫水填充」,仅对纯色(白)背景效果最佳;复杂背景请勿使用 `--transparent`。
|
||||
|
||||
---
|
||||
|
||||
## 图片理解(多模态)
|
||||
|
||||
使用 `understand_image.py` 让模型(默认 `agnes-2.0-flash`)基于图片进行描述、分析、问答或信息提取。支持公网图片 URL,也支持本地图片文件(自动转 Base64)。
|
||||
|
||||
### 理解图片 URL
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/understand_image.py \
|
||||
--prompt "用中文描述这张图片的内容和风格" \
|
||||
--image "https://example.com/image.jpg"
|
||||
```
|
||||
|
||||
### 理解本地图片
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/understand_image.py \
|
||||
--prompt "这是什么动物?什么颜色?" \
|
||||
--image-file ./outputs/fox.png
|
||||
```
|
||||
|
||||
### 多图对比 / 信息提取
|
||||
|
||||
可多次指定 `--image` 或 `--image-file` 传入多张图片:
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/understand_image.py \
|
||||
--prompt "比较这两张图的差异" \
|
||||
--image-file ./a.png --image-file ./b.png
|
||||
```
|
||||
|
||||
### 带系统提示
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/understand_image.py \
|
||||
--prompt "提取图中所有文字" \
|
||||
--image "https://example.com/poster.jpg" \
|
||||
--system "你是专业的 OCR 助手,只输出图中文字"
|
||||
```
|
||||
|
||||
### 图片理解参数说明
|
||||
|
||||
- `--prompt`: (必选) 对图片的问题或指令。
|
||||
- `--image`: (可选) 图片公网 URL 或 `data:image` Base64,可多次指定。
|
||||
- `--image-file`: (可选) 本地图片文件路径,自动转 Base64,可多次指定。
|
||||
- `--system`: (可选) 系统提示(system 消息)。
|
||||
- `--model`: (可选) 模型 ID,默认 `agnes-2.0-flash`。
|
||||
- `--temperature`: (可选) 采样温度,越低越确定。
|
||||
- `--max-tokens`: (可选) 最多生成的 token 数。
|
||||
- `--api-key`: (可选) Agnes API Key,未提供时读取 `AGNES_API_KEY` 环境变量。
|
||||
|
||||
### 图片理解工作流
|
||||
|
||||
1. 调用 `understand_image.py` 脚本。
|
||||
2. 脚本直接输出模型的文本回答(无前缀),可直接读取使用。
|
||||
3. 不传任何图片时,会退化为普通文本对话。
|
||||
|
||||
---
|
||||
|
||||
## 视频生成(异步)
|
||||
|
||||
使用 `generate_video.py` 生成视频(模型 `agnes-video-v2.0`)。视频生成是异步任务,脚本会自动创建任务并轮询,完成后输出视频链接。生成通常需要 1-3 分钟。
|
||||
|
||||
> ⚠️ **重要:视频生成用到的所有参考图(图生视频 / 多图 / 关键帧)必须是「可公网访问的图片 URL」**(`http://` 或 `https://`),**不支持本地文件路径,也不支持 Base64**。
|
||||
> 如果你手上只有本地图片,请先:
|
||||
> 1. 用 `generate_image.py` 生成图片,它返回的 `MEDIA_URL` 就是可直接使用的公网 URL;或
|
||||
> 2. 把本地图片上传到图床 / 对象存储(如 S3、R2)后,使用其公网 URL。
|
||||
>
|
||||
> 脚本会校验 `--image` 是否为合法 URL,传入本地路径会直接报错并给出提示。
|
||||
|
||||
### 文生视频
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_video.py \
|
||||
--prompt "A cinematic shot of a cat walking on the beach at sunset, warm golden lighting" \
|
||||
--duration 5
|
||||
```
|
||||
|
||||
### 图生视频(让单张图动起来)
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_video.py \
|
||||
--prompt "The character breathes gently, hair moving in the wind, keep face consistent" \
|
||||
--image "https://example.com/character.png" --duration 5
|
||||
```
|
||||
|
||||
### 多图视频(在多张图之间过渡)
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_video.py \
|
||||
--prompt "Smooth transformation between the two scenes, cinematic pacing" \
|
||||
--image "https://example.com/a.png" --image "https://example.com/b.png"
|
||||
```
|
||||
|
||||
### 关键帧动画
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_video.py \
|
||||
--prompt "Smooth transition between keyframes, maintain character identity" \
|
||||
--image "https://example.com/k1.png" --image "https://example.com/k2.png" \
|
||||
--keyframes
|
||||
```
|
||||
|
||||
### 下载保存
|
||||
|
||||
```bash
|
||||
python {baseDir}/scripts/generate_video.py --prompt "日出延时摄影" --duration 5 --save ./out.mp4
|
||||
```
|
||||
|
||||
### 视频参数说明
|
||||
|
||||
- `--prompt`: (必选) 视频内容的文本描述。
|
||||
- `--model`: (可选) 模型 ID,默认 `agnes-video-v2.0`。
|
||||
- `--image`: (可选) 参考图 URL;1 张=图生视频,2 张及以上=多图视频,可多次指定。
|
||||
- `--keyframes`: (可选) 关键帧动画模式(配合多张 `--image`)。
|
||||
- `--duration`: (可选) 目标时长(秒),按帧率自动换算帧数(覆盖 `--num-frames`)。
|
||||
- `--num-frames`: (可选) 帧数,必须 ≤441 且满足 8n+1,默认 121(约 5 秒);脚本会自动规整。
|
||||
- `--frame-rate`: (可选) 帧率 1-60,默认 24。
|
||||
- `--width` / `--height`: (可选) 分辨率,默认 1152×768;服务端会标准化到最接近的档位。
|
||||
- `--negative-prompt`: (可选) 负向提示词。
|
||||
- `--seed`: (可选) 随机种子,用于结果复现。
|
||||
- `--save`: (可选) 下载视频并保存到本地。
|
||||
- `--poll-interval`: (可选) 轮询间隔秒数,默认 5。
|
||||
- `--max-wait`: (可选) 最大等待秒数,默认 600。
|
||||
- `--api-key`: (可选) Agnes API Key,未提供时读取 `AGNES_API_KEY` 环境变量。
|
||||
|
||||
### 常用时长参数
|
||||
|
||||
| 目标时长 | 推荐参数 |
|
||||
|---------|---------|
|
||||
| 约 3 秒 | `--num-frames 81 --frame-rate 24` |
|
||||
| 约 5 秒 | `--num-frames 121 --frame-rate 24` |
|
||||
| 约 10 秒 | `--num-frames 241 --frame-rate 24` |
|
||||
| 约 18 秒 | `--num-frames 441 --frame-rate 24` |
|
||||
|
||||
> 也可以直接用 `--duration <秒数>` 让脚本自动换算(会规整到合法的 8n+1 帧数)。
|
||||
|
||||
### 视频生成工作流
|
||||
|
||||
1. 调用 `generate_video.py`,脚本自动创建任务(`POST /v1/videos`)。
|
||||
2. 用返回的 `video_id` 轮询查询(`GET /agnesapi?video_id=...`),进度打到 stderr。
|
||||
3. 任务 `completed` 后,stdout 输出以 `MEDIA_URL: ` 开头的视频链接。
|
||||
4. 加 `--save` 时额外下载并输出 `SAVED: ` 路径。
|
||||
|
||||
### 视频注意事项
|
||||
|
||||
- `num_frames` 必须 ≤441 且满足 8n+1(如 81、121、241、441),脚本会自动规整非法值。
|
||||
- 实际输出尺寸/时长以接口返回的 `size`、`seconds` 字段为准(服务端会标准化分辨率)。
|
||||
- **参考图必须是可公网访问的 URL(`http`/`https`),不支持本地文件或 Base64**;本地图片请先用 `generate_image.py` 拿到公网 URL,或上传图床后再传入。
|
||||
- 轮询期间对瞬时网络抖动有容错,会自动重试。
|
||||
186
skills/linggan/agnes-image/scripts/generate_image.py
Normal file
186
skills/linggan/agnes-image/scripts/generate_image.py
Normal file
@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "requests>=2.31.0",
|
||||
# "pillow>=10.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""使用 Agnes AI(OpenAI 兼容网关)生成图片。
|
||||
支持:文生图、图生图(参考图保持一致性)、URL/Base64 输出、下载保存、白底转透明。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import requests
|
||||
|
||||
API_URL = "https://apihub.agnes-ai.com/v1/images/generations"
|
||||
DEFAULT_MODEL = "agnes-image-2.1-flash"
|
||||
|
||||
|
||||
def call_api(prompt, model, size, api_key, images=None, want_b64=False, retries=3):
|
||||
"""调用 Agnes 图像生成接口,返回解析后的 JSON。
|
||||
对瞬时网络/SSL 抖动自动重试,重试间隔递增。
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
extra = {"response_format": "b64_json" if want_b64 else "url"}
|
||||
if images:
|
||||
# 图生图:参考图放在 extra_body.image,支持公网 URL 或 data:image Base64
|
||||
extra["image"] = images
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"extra_body": extra,
|
||||
}
|
||||
# 文生图需要 Base64 输出时,按文档使用顶层 return_base64
|
||||
if want_b64 and not images:
|
||||
payload["return_base64"] = True
|
||||
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = requests.post(API_URL, headers=headers, json=payload, timeout=180)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (requests.exceptions.SSLError,
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout) as e:
|
||||
# 瞬时网络抖动:重试
|
||||
last_err = e
|
||||
if attempt < retries:
|
||||
time.sleep(attempt * 2)
|
||||
continue
|
||||
raise
|
||||
raise last_err
|
||||
|
||||
|
||||
def fetch_bytes(url):
|
||||
"""下载 URL 内容为字节。"""
|
||||
with urllib.request.urlopen(url, timeout=180) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def make_transparent(png_bytes, thresh=60, white_min=225):
|
||||
"""把纯白背景变透明(保留内部白色)。
|
||||
从图像四边多个种子点做 flood fill,只清除与边缘连通的白色区域。
|
||||
"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||||
w, h = img.size
|
||||
seeds = []
|
||||
steps = 12
|
||||
for i in range(steps + 1):
|
||||
t = int(i * (w - 1) / steps)
|
||||
seeds += [(t, 0), (t, h - 1)]
|
||||
s = int(i * (h - 1) / steps)
|
||||
seeds += [(0, s), (w - 1, s)]
|
||||
for sx, sy in seeds:
|
||||
r, g, b, a = img.getpixel((sx, sy))
|
||||
if a > 0 and r >= white_min and g >= white_min and b >= white_min:
|
||||
ImageDraw.floodfill(img, (sx, sy), (255, 255, 255, 0), thresh=thresh)
|
||||
out = io.BytesIO()
|
||||
img.save(out, "PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="使用 Agnes AI 生成图片。")
|
||||
parser.add_argument("--prompt", required=True, help="图像生成的文本描述(必选)")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"模型 ID,默认 {DEFAULT_MODEL}")
|
||||
parser.add_argument("--size", default="1024x1024", help="图像尺寸,如 1024x1024、1024x768")
|
||||
parser.add_argument("--image", action="append", default=None,
|
||||
help="参考图 URL 或 data:image Base64(图生图,可多次指定)")
|
||||
parser.add_argument("--api-key", help="Agnes API Key(也可用 AGNES_API_KEY 环境变量)")
|
||||
parser.add_argument("--save", help="下载并保存到本地路径,输出 SAVED:<path>")
|
||||
parser.add_argument("--transparent", action="store_true",
|
||||
help="配合 --save:把纯白背景转为透明 PNG")
|
||||
parser.add_argument("--thresh", type=int, default=60, help="透明化白色容差,默认 60")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = args.api_key or os.environ.get("AGNES_API_KEY")
|
||||
if not api_key:
|
||||
print("ERROR: 缺少 API Key,请用 --api-key 或设置 AGNES_API_KEY 环境变量。")
|
||||
sys.exit(1)
|
||||
|
||||
# 需要保存且要透明时,直接请求 URL 再下载处理(Pillow 处理本地字节)
|
||||
want_b64 = False
|
||||
try:
|
||||
result = call_api(
|
||||
prompt=args.prompt,
|
||||
model=args.model,
|
||||
size=args.size,
|
||||
api_key=api_key,
|
||||
images=args.image,
|
||||
want_b64=want_b64,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"ERROR: API 请求失败: {e}")
|
||||
if getattr(e, "response", None) is not None:
|
||||
print(f"Response body: {e.response.text[:500]}")
|
||||
sys.exit(1)
|
||||
|
||||
data = result.get("data") or []
|
||||
if not data:
|
||||
print(f"ERROR: 返回中无图像数据。完整响应: {json.dumps(result)[:500]}")
|
||||
sys.exit(1)
|
||||
|
||||
item = data[0]
|
||||
url = item.get("url")
|
||||
b64 = item.get("b64_json")
|
||||
|
||||
if not args.save:
|
||||
# 仅输出链接(对齐 seedream 约定)
|
||||
if url:
|
||||
print(f"MEDIA_URL: {url}")
|
||||
elif b64:
|
||||
print("ERROR: 收到 Base64 但未指定 --save,无法直接展示。请加 --save 保存。")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"ERROR: 无 url 也无 b64_json。响应: {json.dumps(result)[:500]}")
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# 保存到本地
|
||||
try:
|
||||
if b64:
|
||||
import base64
|
||||
img_bytes = base64.b64decode(b64)
|
||||
elif url:
|
||||
img_bytes = fetch_bytes(url)
|
||||
else:
|
||||
print("ERROR: 无可保存的图像数据。")
|
||||
sys.exit(1)
|
||||
|
||||
if args.transparent:
|
||||
try:
|
||||
img_bytes = make_transparent(img_bytes, thresh=args.thresh)
|
||||
except ImportError:
|
||||
print("ERROR: --transparent 需要 Pillow,请先 `pip install pillow`。")
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.save)), exist_ok=True)
|
||||
with open(args.save, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
# 即使保存到本地,也输出原始图片链接(便于直接引用/展示)
|
||||
if url:
|
||||
print(f"MEDIA_URL: {url}")
|
||||
print(f"SAVED: {args.save}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: 保存失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
206
skills/linggan/agnes-image/scripts/generate_video.py
Normal file
206
skills/linggan/agnes-image/scripts/generate_video.py
Normal file
@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""使用 Agnes AI 生成视频(异步任务:创建 -> 轮询 -> 取结果)。
|
||||
支持文生视频、图生视频、多图视频、关键帧动画。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import requests
|
||||
|
||||
CREATE_URL = "https://apihub.agnes-ai.com/v1/videos"
|
||||
QUERY_URL = "https://apihub.agnes-ai.com/agnesapi"
|
||||
DEFAULT_MODEL = "agnes-video-v2.0"
|
||||
|
||||
|
||||
def log(msg):
|
||||
"""进度信息打到 stderr,保持 stdout 只输出最终结果。"""
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def normalize_frames(nf):
|
||||
"""num_frames 必须 <=441 且满足 8n+1,规整到最接近的合法值。"""
|
||||
nf = max(9, min(441, int(nf)))
|
||||
n = round((nf - 1) / 8)
|
||||
nf = 8 * n + 1
|
||||
return max(9, min(441, nf))
|
||||
|
||||
|
||||
def create_task(payload, api_key, retries=3):
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = requests.post(CREATE_URL, headers=headers, json=payload, timeout=120)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (requests.exceptions.SSLError,
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout) as e:
|
||||
last_err = e
|
||||
if attempt < retries:
|
||||
time.sleep(attempt * 2)
|
||||
continue
|
||||
raise
|
||||
raise last_err
|
||||
|
||||
|
||||
def query_result(video_id, api_key, model=None):
|
||||
"""用 video_id 查询任务结果(推荐方式)。"""
|
||||
params = f"?video_id={urllib.parse.quote(video_id)}"
|
||||
if model:
|
||||
params += f"&model_name={urllib.parse.quote(model)}"
|
||||
req = urllib.request.Request(
|
||||
QUERY_URL + params,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def download(url, path):
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
with urllib.request.urlopen(url, timeout=300) as r, open(path, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="使用 Agnes AI 生成视频。")
|
||||
parser.add_argument("--prompt", required=True, help="视频内容的文本描述(必选)")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"模型 ID,默认 {DEFAULT_MODEL}")
|
||||
parser.add_argument("--image", action="append", default=None,
|
||||
help="参考图 URL(图生视频/多图/关键帧,可多次指定)")
|
||||
parser.add_argument("--keyframes", action="store_true",
|
||||
help="关键帧动画模式(配合多张 --image 使用)")
|
||||
parser.add_argument("--width", type=int, default=1152, help="视频宽度,默认 1152")
|
||||
parser.add_argument("--height", type=int, default=768, help="视频高度,默认 768")
|
||||
parser.add_argument("--num-frames", type=int, default=121,
|
||||
help="帧数,<=441 且满足 8n+1,默认 121(约 5 秒)")
|
||||
parser.add_argument("--frame-rate", type=float, default=24, help="帧率 1-60,默认 24")
|
||||
parser.add_argument("--duration", type=float,
|
||||
help="目标时长(秒),会按帧率换算 num_frames(覆盖 --num-frames)")
|
||||
parser.add_argument("--negative-prompt", help="负向提示词,描述要避免的内容")
|
||||
parser.add_argument("--seed", type=int, help="随机种子,用于结果复现")
|
||||
parser.add_argument("--api-key", help="Agnes API Key(也可用 AGNES_API_KEY 环境变量)")
|
||||
parser.add_argument("--save", help="下载视频并保存到本地路径")
|
||||
parser.add_argument("--poll-interval", type=float, default=5, help="轮询间隔秒数,默认 5")
|
||||
parser.add_argument("--max-wait", type=float, default=600, help="最大等待秒数,默认 600")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = args.api_key or os.environ.get("AGNES_API_KEY")
|
||||
if not api_key:
|
||||
print("ERROR: 缺少 API Key,请用 --api-key 或设置 AGNES_API_KEY 环境变量。")
|
||||
sys.exit(1)
|
||||
|
||||
# 计算帧数
|
||||
if args.duration:
|
||||
num_frames = normalize_frames(args.duration * args.frame_rate)
|
||||
else:
|
||||
num_frames = normalize_frames(args.num_frames)
|
||||
|
||||
# 组装请求体
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"prompt": args.prompt,
|
||||
"width": args.width,
|
||||
"height": args.height,
|
||||
"num_frames": num_frames,
|
||||
"frame_rate": args.frame_rate,
|
||||
}
|
||||
if args.negative_prompt:
|
||||
payload["negative_prompt"] = args.negative_prompt
|
||||
if args.seed is not None:
|
||||
payload["seed"] = args.seed
|
||||
|
||||
images = args.image or []
|
||||
# 视频接口的参考图只支持可公网访问的 URL(不支持本地文件 / Base64)
|
||||
for u in images:
|
||||
if not (u.startswith("http://") or u.startswith("https://")):
|
||||
print("ERROR: 视频生成的参考图必须是可公网访问的图片 URL(http/https),"
|
||||
"不支持本地文件路径或 Base64。")
|
||||
print(f" 问题输入: {u}")
|
||||
print(" 建议:先用 generate_image.py 生成图片拿到其公网 URL,"
|
||||
"或把本地图片上传到图床/对象存储后再用其 URL 传入。")
|
||||
sys.exit(1)
|
||||
|
||||
if args.keyframes:
|
||||
# 关键帧动画:extra_body.image + mode=keyframes
|
||||
payload["extra_body"] = {"image": images, "mode": "keyframes"}
|
||||
elif len(images) == 1:
|
||||
# 图生视频:顶层 image
|
||||
payload["image"] = images[0]
|
||||
elif len(images) >= 2:
|
||||
# 多图视频:extra_body.image
|
||||
payload["extra_body"] = {"image": images}
|
||||
|
||||
# 1) 创建任务
|
||||
log(f"创建视频任务({num_frames} 帧 @ {args.frame_rate}fps ≈ {num_frames/args.frame_rate:.1f}s)...")
|
||||
try:
|
||||
task = create_task(payload, api_key)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"ERROR: 创建任务失败: {e}")
|
||||
if getattr(e, "response", None) is not None:
|
||||
print(f"Response body: {e.response.text[:500]}")
|
||||
sys.exit(1)
|
||||
|
||||
video_id = task.get("video_id")
|
||||
task_id = task.get("task_id") or task.get("id")
|
||||
if not video_id and not task_id:
|
||||
print(f"ERROR: 创建任务响应缺少 video_id/task_id: {json.dumps(task)[:500]}")
|
||||
sys.exit(1)
|
||||
log(f"任务已创建 video_id={video_id} task_id={task_id} status={task.get('status')}")
|
||||
|
||||
# 2) 轮询结果
|
||||
start = time.time()
|
||||
video_url = None
|
||||
while time.time() - start < args.max_wait:
|
||||
time.sleep(args.poll_interval)
|
||||
try:
|
||||
data = query_result(video_id or task_id, api_key, model=args.model)
|
||||
except Exception as e:
|
||||
log(f" 查询出错(将重试): {e}")
|
||||
continue
|
||||
status = data.get("status")
|
||||
progress = data.get("progress", 0)
|
||||
log(f" 状态={status} 进度={progress}%")
|
||||
if status == "completed":
|
||||
video_url = data.get("remixed_from_video_id") # 文档:该字段为最终视频 URL
|
||||
break
|
||||
if status == "failed":
|
||||
print(f"ERROR: 视频生成失败: {data.get('error')}")
|
||||
sys.exit(1)
|
||||
|
||||
if not video_url:
|
||||
print(f"ERROR: 等待超时({args.max_wait}s)或未返回视频 URL。")
|
||||
sys.exit(1)
|
||||
|
||||
# 3) 输出结果
|
||||
print(f"MEDIA_URL: {video_url}")
|
||||
if args.save:
|
||||
try:
|
||||
log("下载视频中 ...")
|
||||
download(video_url, args.save)
|
||||
print(f"SAVED: {args.save}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: 下载保存失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
126
skills/linggan/agnes-image/scripts/understand_image.py
Normal file
126
skills/linggan/agnes-image/scripts/understand_image.py
Normal file
@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""使用 Agnes AI 的多模态模型理解图片。
|
||||
传入图片(公网 URL 或本地文件)+ 文本问题,模型基于图片进行描述、分析、问答或信息提取。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
API_URL = "https://apihub.agnes-ai.com/v1/chat/completions"
|
||||
DEFAULT_MODEL = "agnes-2.0-flash"
|
||||
|
||||
|
||||
def file_to_data_uri(path):
|
||||
"""把本地图片文件转成 data:image Base64。"""
|
||||
mime = mimetypes.guess_type(path)[0] or "image/png"
|
||||
with open(path, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
def call_api(messages, model, api_key, temperature=None, max_tokens=None, retries=3):
|
||||
"""调用 Agnes chat/completions,返回解析后的 JSON。瞬时网络抖动自动重试。"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
payload = {"model": model, "messages": messages}
|
||||
if temperature is not None:
|
||||
payload["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = requests.post(API_URL, headers=headers, json=payload, timeout=180)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (requests.exceptions.SSLError,
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout) as e:
|
||||
last_err = e
|
||||
if attempt < retries:
|
||||
time.sleep(attempt * 2)
|
||||
continue
|
||||
raise
|
||||
raise last_err
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="使用 Agnes AI 理解/分析图片。")
|
||||
parser.add_argument("--prompt", required=True, help="对图片的问题或指令(必选)")
|
||||
parser.add_argument("--image", action="append", default=None,
|
||||
help="图片公网 URL 或 data:image Base64(可多次指定)")
|
||||
parser.add_argument("--image-file", action="append", default=None,
|
||||
help="本地图片文件路径,自动转 Base64(可多次指定)")
|
||||
parser.add_argument("--system", help="可选的系统提示(system 消息)")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"模型 ID,默认 {DEFAULT_MODEL}")
|
||||
parser.add_argument("--temperature", type=float, help="采样温度,0~1,越低越确定")
|
||||
parser.add_argument("--max-tokens", type=int, help="最多生成的 token 数")
|
||||
parser.add_argument("--api-key", help="Agnes API Key(也可用 AGNES_API_KEY 环境变量)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = args.api_key or os.environ.get("AGNES_API_KEY")
|
||||
if not api_key:
|
||||
print("ERROR: 缺少 API Key,请用 --api-key 或设置 AGNES_API_KEY 环境变量。")
|
||||
sys.exit(1)
|
||||
|
||||
# 收集所有图片输入
|
||||
image_urls = list(args.image or [])
|
||||
for p in (args.image_file or []):
|
||||
if not os.path.isfile(p):
|
||||
print(f"ERROR: 本地图片不存在: {p}")
|
||||
sys.exit(1)
|
||||
image_urls.append(file_to_data_uri(p))
|
||||
|
||||
# 组装多模态 content:文本 + 若干图片
|
||||
content = [{"type": "text", "text": args.prompt}]
|
||||
for url in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": url}})
|
||||
|
||||
messages = []
|
||||
if args.system:
|
||||
messages.append({"role": "system", "content": args.system})
|
||||
# 没有图片时退化为纯文本,content 用字符串更稳妥
|
||||
messages.append({"role": "user", "content": content if image_urls else args.prompt})
|
||||
|
||||
try:
|
||||
result = call_api(
|
||||
messages=messages,
|
||||
model=args.model,
|
||||
api_key=api_key,
|
||||
temperature=args.temperature,
|
||||
max_tokens=args.max_tokens,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"ERROR: API 请求失败: {e}")
|
||||
if getattr(e, "response", None) is not None:
|
||||
print(f"Response body: {e.response.text[:500]}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
answer = result["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
print(f"ERROR: 无法解析响应。完整响应: {json.dumps(result, ensure_ascii=False)[:500]}")
|
||||
sys.exit(1)
|
||||
|
||||
print(answer)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -51,6 +51,13 @@ class Formatter(logging.Formatter):
|
||||
record.trace_id = getattr(g, "trace_id")
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user