Skill: unisound-llm/unisound-pe-missing-negative (version 1.0.0)

--- name: med-emr-qc-pe-missing-negative description: 门诊病历内涵质控：体格检查缺少与诊断相关的体征。给定门诊病历文本，调用内部医疗大模型，输出无缺陷或有缺陷及原因。 metadata: { "openclaw": { "emoji": "🔍" } } --- # 体格检查缺少与诊断相关的体征 概述 ---- 本 skill 仅针对**「体格检查缺少与诊断相关的体征」**这一条内涵质控规则。将字段解析、多步 LLM 推理与条件分支实现在本目录 `scripts/emr_qc_impl.py` 中；`scripts/emr_qc.py`、`scripts/run.py` 为入口，本 skill 可单独打包发布。调用 **HiVoice MaaS** 医疗大模型（OpenAI 兼容 `chat/completions`），输出 `无缺陷` 或 `有缺陷` + 原因。 数据安全、隐私与伦理声明 ------------------------ - **最小必要原则**：仅处理质控所必需的病历文本内容；不要求提供姓名、证件号等身份信息。 - **严格脱敏**：在发送至任何接口前，请确保病历文本已完成脱敏/去标识化处理。 - **不做本地持久化**：不将输入与中间结果写入本地持久化存储；**本次调用结束即销毁**。 - **医疗边界**：本技能为辅助质控工具，不构成医疗诊断或治疗建议；最终结论请由执业医生审核。 输入格式 -------- 纯文本门诊病历（UTF-8），各字段用中文标签开头，例如： ``` 主诉:发热3天 现病史:患者3天前无明显诱因出现发热，体温最高38.5℃... 既往史:高血压病史5年，规律服用氨氯地平，血压控制可... 体格检查:T 38.2℃，BP 150/90mmHg，双肺呼吸音清... 初步诊断:上呼吸道感染 处理意见:对症治疗 ``` 支持的字段标签：`主诉`、`现病史`、`既往史`、`体格检查`、`辅助检查`、`初步诊断`/`诊断`、`处理意见`/`处理`（中英文冒号均可）。 也支持通过 `scripts/run.py` 直接输入 [REDACTED_SECRET]。 快速开始 -------- ```bash # 文本入口（在仓库 skills 根目录运行） python3 doctor/emr-qc/pe-missing-negative/scripts/emr_qc.py \ --input data/med-emr-qc/record.txt \ --appkey <your-appkey> # 多格式入口 python3 doctor/emr-qc/pe-missing-negative/scripts/run.py \ --input data/med-emr-qc/record.xlsx \ --appkey <your-appkey> ``` 参数说明 -------- `scripts/emr_qc.py`： - `--input PATH`：**必填**。门诊病历文本文件路径（UTF-8）。 - `--appkey STRING`：**必填**。调用内部医疗大模型的鉴权 key，由平台分配；**不得写入仓库**。 - `--base URL`：大模型 base URL（默认：`https://maas-api.hivoice.cn/v1`）。 - `--model STRING`：模型名称（默认：`u1-insuremed`）。 - `--timeout SECONDS`：HTTP 超时秒数；`0` 表示一直等待（默认：0）。 - `--output PATH`：输出文件路径（默认：`../runs/med-emr-qc/pe-missing-negative.txt`）。 `scripts/run.py` 附加参数： - `--input-type auto|pdf|doc|docx|xls|xlsx|csv|txt|json`：输入类型；默认 `auto`。 - `--sheet STRING`：读取 Excel 时指定 sheet（可选）。 - `--encoding STRING`：`txt/csv` 编码（默认：`utf-8`）。 - `--save-prepared`：保存预处理后的文本，便于调试。 输出约定 -------- - 输出为 UTF-8 文本： - 无缺陷：输出 `无缺陷` - 有缺陷：输出 `有缺陷` + 换行 + 原因说明 依赖 ---- ### 自包含实现 质控与 LLM 调用逻辑均在 `scripts/emr_qc_impl.py`（与同 skill 一并发布）。 ### 预处理（仅 `run.py`） `scripts/run.py` 依赖 **`_shared/doc-preprocess`**（`preprocess.py`），须位于仓库 `skills/` 根下。 ### 运行环境 - Python 3.7+ - 无需第三方包（仅标准库；多格式输入时可选 `openpyxl`、`pypdf`） ### 外部 API - 医疗大模型：`https://maas-api.hivoice.cn/v1/chat/completions`（POST，OpenAI 兼容格式）

path: _meta.json
content: ownerId: kn76wejkeqxfc03j0rfxp2jaj982m7aa
slug: doctor.emr-qc.pe-missing-negative
version: 1.0.0
publishedAt: 0

path: scripts/run.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""统一入口：支持 pdf/doc/docx/xls/xlsx/csv/txt/json，预处理后执行本质控点。"""

import argparse
import sys
from pathlib import Path

RULE_KEY = "pe-missing-negative"

SCRIPT_DIR = Path(__file__).resolve().parent
SKILLS_ROOT = SCRIPT_DIR.parents[3]
_preprocess_dir = SKILLS_ROOT / "_shared" / "doc-preprocess" / "scripts"
for p in (_preprocess_dir, SCRIPT_DIR):
    s = str(p)
    if s not in sys.path:
        sys.path.insert(0, s)

from preprocess import (  # noqa: E402
    PreprocessError,
    SUPPORTED_FILE_TYPES,
    detect_input_type,
    load_input_artifact,
)
from emr_qc_impl import DEFAULT_LLM_BASE, DEFAULT_LLM_MODEL, run_qc  # noqa: E402


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "病历内涵质控（体格检查缺少与诊断相关的体征）：支持 pdf/doc/docx/xls/xlsx/csv/txt/json，"
            "预处理为文本后调用医疗大模型。"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--input", required=True, help="输入文件路径。")
    parser.add_argument(
        "--input-type",
        default="auto",
        choices=["auto", *sorted(SUPPORTED_FILE_TYPES)],
        help="输入类型；默认 auto（自动识别）。",
    )
    parser.add_argument("--sheet", default="", help="读取 Excel 时指定 sheet（可选）。")
    parser.add_argument("--encoding", default="utf-8", help="txt/csv 编码（默认：utf-8）。")
    parser.add_argument(
        "--appkey", required=True,
        help="调用内部医疗大模型的鉴权 key，由平台分配。",
    )
    parser.add_argument(
        "--base", default=DEFAULT_LLM_BASE,
        help=f"大模型 base URL（默认：{DEFAULT_LLM_BASE}）。",
    )
    parser.add_argument(
        "--model", default=DEFAULT_LLM_MODEL,
        help=f"模型名称（默认：{DEFAULT_LLM_MODEL}）。",
    )
    parser.add_argument("--timeout", type=int, default=0, help="HTTP 超时秒数；0 表示一直等待（默认：0）。")
    parser.add_argument("--output", default="", help="输出文件路径（默认自动生成）。")
    parser.add_argument("--save-prepared", action="store_true", help="保存预处理后的文本，便于调试。")
    return parser.parse_args()


def extract_text(path: Path, input_type: str, encoding: str, sheet: str) -> str:
    import json as _json
    artifact = load_input_artifact(path, input_type, encoding, sheet, pdf_as_single_text=True)
    kind = artifact["kind"]
    if kind == "text":
        return artifact["text"]
    if kind == "json":
        data = artifact["data"]
        if isinstance(data, str):
            return data
        if isinstance(data, dict):
            for key in ("record", "text", "content"):
                v = data.get(key)
                if isinstance(v, str) and v.strip():
                    return v
        return _json.dumps(data, ensure_ascii=False)
    if kind == "tables":
        rows_text = []
        for table in artifact.get("tables", []):
            for row in table.get("rows", []):
                rows_text.append("\t".join(str(c) for c in row))
        return "\n".join(rows_text)
    raise PreprocessError(f"Unsupported artifact kind: {kind}")


def main() -> int:
    args = parse_args()
    input_path = Path(args.input)
    if not input_path.exists():
        print(f"✗ Error: Input file not found: {input_path}", file=sys.stderr)
        return 1

    try:
        input_type = detect_input_type(input_path, args.input_type)
        record_text = extract_text(input_path, input_type, args.encoding, args.sheet)
    except PreprocessError as e:
        print(f"✗ Preprocess Error: {e}", file=sys.stderr)
        return 1
    except Exception as e:
        print(f"✗ Preprocess Unexpected Error: {e}", file=sys.stderr)
        return 1

    if args.save_prepared:
        save_dir = Path(args.output).parent if args.output else Path("..") / "runs" / "med-emr-qc"
        save_dir.mkdir(parents=True, exist_ok=True)
        prep_path = save_dir / f"{RULE_KEY}.prepared.txt"
        prep_path.write_text(record_text, encoding="utf-8")
        print(f"✓ 预处理文本已保存至：{prep_path}")

    try:
        run_qc(
            record=record_text,
            appkey=args.appkey,
            base=args.base,
            model=args.model,
            timeout=args.timeout,
            output_path=args.output or None,
        )
        return 0
    except Exception as e:
        print(f"✗ Error: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/emr_qc.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""本质控点 skill 的文本入口；逻辑见同目录 `emr_qc_impl.py`（可单独打包）。"""

import argparse
import sys

from emr_qc_impl import (
    DEFAULT_LLM_BASE,
    DEFAULT_LLM_MODEL,
    read_record,
    run_qc,
)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="门诊病历内涵质控：体格检查缺少与诊断相关的体征（pe-missing-negative）。",
    )
    parser.add_argument("--input", required=True, help="门诊病历文本文件路径（UTF-8）。")
    parser.add_argument(
        "--appkey", required=True,
        help="调用内部医疗大模型的鉴权 key，由平台分配。",
    )
    parser.add_argument(
        "--base", default=DEFAULT_LLM_BASE,
        help=f"大模型 base URL（默认：{DEFAULT_LLM_BASE}）。",
    )
    parser.add_argument(
        "--model", default=DEFAULT_LLM_MODEL,
        help=f"模型名称（默认：{DEFAULT_LLM_MODEL}）。",
    )
    parser.add_argument("--timeout", type=int, default=0, help="HTTP 超时秒数；0 表示一直等待（默认：0）。")
    parser.add_argument("--output", default="", help="输出文件路径（默认：../runs/med-emr-qc/pe-missing-negative.txt）。")
    args = parser.parse_args()

    try:
        record = read_record(args.input)
        run_qc(
            record=record,
            appkey=args.appkey,
            base=args.base,
            model=args.model,
            timeout=args.timeout,
            output_path=args.output or None,
        )
        return 0
    except (ValueError, FileNotFoundError) as e:
        print(f"✗ Error: {e}", file=sys.stderr)
        return 1
    except Exception as e:
        print(f"✗ Unexpected error: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/emr_qc_impl.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
本 skill 独立质控实现：与同目录 `emr_qc.py`、`run.py` 一并打包发布，无其它 emr-qc 子目录依赖。
LLM：HiVoice MaaS OpenAI 兼容接口（默认 https://maas-api.hivoice.cn/v1）。
"""

import json
from pathlib import Path
from typing import Any, Dict, List, Optional

import urllib.error
import urllib.request


# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------

DEFAULT_LLM_BASE = "https://maas-api.hivoice.cn/v1"
DEFAULT_LLM_MODEL = "u1-insuremed"


# ---------------------------------------------------------------------------
# 本 skill 固定规则
# ---------------------------------------------------------------------------

RULE_KEY = "pe-missing-negative"
RULE_CN = "体格检查缺少与诊断相关的体征"

# ---------------------------------------------------------------------------
# 本条质控规则
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# LLM 调用（OpenAI 兼容接口，使用标准库，无额外依赖）
# ---------------------------------------------------------------------------

def _http_post(url: str, payload: Dict[str, Any], headers: Dict[str, str], *, timeout: int = 0) -> Any:
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        url=url,
        data=data,
        method="POST",
        headers={"Content-Type": "application/json", **headers},
    )
    try:
        ctx = urllib.request.urlopen(req) if not timeout else urllib.request.urlopen(req, timeout=timeout)
        with ctx as resp:
            body = resp.read().decode("utf-8", errors="replace")
            return json.loads(body)
    except urllib.error.HTTPError as e:
        detail = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {e.code}: {detail}") from e
    except urllib.error.URLError as e:
        raise RuntimeError(f"Network error: {e}") from e


def make_llm_caller(appkey: str, base: str = DEFAULT_LLM_BASE, model: str = DEFAULT_LLM_MODEL, timeout: int = 0):
    """返回一个 llm(messages) → str 的调用函数。"""
    url = f"{base.rstrip('/')}/chat/completions"
    headers = {"Authorization": f"Bearer {appkey}"}

    def llm(messages: List[Dict[str, str]]) -> str:
        payload = {"model": model, "messages": messages, "temperature": 0}
        resp = _http_post(url, payload, headers, timeout=timeout)
        try:
            return resp["choices"][0]["message"]["content"].strip()
        except (KeyError, IndexError, TypeError) as e:
            raise RuntimeError(f"Unexpected LLM response: {resp}") from e

    return llm


def user_msg(content: str) -> Dict[str, str]:
    return {"role": "user", "content": content}


def sys_msg(content: str) -> Dict[str, str]:
    return {"role": "system", "content": content}


# ---------------------------------------------------------------------------
# 病历字段解析（与 Dify 代码节点逻辑相同）
# ---------------------------------------------------------------------------

def parse_record(record: str) -> Dict[str, str]:
    """解析门诊病历文本，提取各字段。"""
    fields = {k: "" for k in ("cc", "hpi", "pmh", "pe", "lab", "dx", "plan")}
    record = record.replace("：", ":")
    for line in record.split("\n"):
        line = line.strip()
        if line.startswith("主诉:"):
            fields["cc"] = line.replace("主诉:", "", 1).strip()
        elif line.startswith("现病史:"):
            fields["hpi"] = line.replace("现病史:", "", 1).strip()
        elif line.startswith("既往史:"):
            fields["pmh"] = line.replace("既往史:", "", 1).strip()
        elif line.startswith("体格检查:"):
            fields["pe"] = line.replace("体格检查:", "", 1).strip()
        elif line.startswith("辅助检查:"):
            fields["lab"] = line.replace("辅助检查:", "", 1).strip()
        elif line.startswith("初步诊断:") or line.startswith("诊断:"):
            fields["dx"] = line.replace("初步诊断:", "", 1).replace("诊断:", "", 1).strip()
        elif line.startswith("处理意见:") or line.startswith("处理:"):
            fields["plan"] = line.replace("处理意见:", "", 1).replace("处理:", "", 1).strip()
    return fields


def qc_pe_missing_negative(fields: Dict[str, str], llm) -> str:
    """体格检查缺少与诊断相关的体征"""
    pe = fields["pe"]
    dx = fields["dx"]
    if not pe or not dx:
        return "无缺陷"
    # 若体格检查已包含呼吸/心脏体征关键词，可跳过
    if "音" in pe or "律" in pe:
        return "无缺陷"

    return llm([user_msg(
        f"""你是一位病历质控专家，请对门诊病历进行质控，如果没有缺陷请直接回答"无缺陷"，不要进行任何分析；如果有缺陷请先回答"有缺陷"再另起一行分析原因

缺陷描述：
体格检查中缺少与诊断相关的体征，具体：患者诊断为"肝硬化失代偿期"
查找"肝硬化失代偿期"对应的主要体征，根据《实用内科学》对"失代偿期肝硬化"体征的介绍，认为"肝硬化失代偿期"主要体征包括"面色晦暗，肝掌、蜘蛛痣，腹壁静脉曲张"等。
在"体格检查"中查找对应体征是否书写，无论阳性阴性，比如写成"患者面色红润，无肝掌，无蜘蛛痣，无腹壁静脉曲张…"，则判定为"无缺陷"；如果没有提及，则判定为"有缺陷"

以下是一些示例，用<example></example>标记，请参考

<example>
【病历】
体格检查：T:37℃ P:84次/分 R:20次/分 BP:114/64mmHg
诊断：腹腔积液，高血压病3级（很高危）
【质控结果】
有缺陷
体格检查中未提及"移动性浊音，液波震颤"
</example>

<example>
【病历】
体格检查：T:37℃ P:84次/分 R:20次/分 BP:114/64mmHg
诊断：慢性阻塞性肺疾病急性发作
【质控结果】
有缺陷
体格检查中未提及"杵状指，语音震颤情况，双肺叩诊情况"
</example>

<example>
【病历】
体格检查：双肺未闻及啰音
诊断：肺部感染肺部阴影
【质控结果】
无缺陷
</example>

现在请对下面的门诊病历进行质控，诊断类型仅限于以下几种，如果不是，请直接回答"无缺陷"
1.肺部感染、社区获得性肺炎、肺炎、支气管扩张伴感染，体格检查中未提及"呼吸音（呼吸音、啰音、叩诊）"
2.慢性阻塞性肺疾病，体格检查中未提及"呼吸音（呼吸音、啰音、叩诊、杵状指）"
3.心律失常、室性期前收缩、室性早搏、心房颤动、阵发性心房颤动、持续性心房颤动、房性期前收缩，体格检查中未提及"心音（心率、心律、心音、杂音或额外心音）"
4.脾大，体格检查中未提及"脾脏触诊"
5.肾结石，体格检查中未提及"（肾区叩击痛）"
6.腰椎间盘突出，体格检查中未提及"股神经牵拉试验,直腿抬高试验,状肌试验,直腿抬高加强试验（直腿抬高试验、脊柱生理弯曲或脊柱无畸形、棘突压痛或脊柱压痛）"
7.腹水、腹腔积液，体格检查中未提及"移动性浊音,液波震颤"
8.胸腔积液，体格检查中未提及"语音震颤减弱,叩诊浊音,异常呼吸音（语音震颤或语音共振、叩诊、呼吸音、胸膜摩擦感、胸膜摩擦音）"
9.白血病，体格检查中未提及"全身皮肤有无出血点,瘀斑瘀点（胸骨压痛，皮肤黏膜出血点或瘀斑瘀点）"
10.低蛋白血症，体格检查中未提及"浮肿（眼睑有无浮肿，双下肢水肿）"
11.二尖瓣狭窄，体格检查中未提及"二尖瓣面容（面容、心率、心律、心音、杂音或额外心音、震颤）"
12.肝硬化，体格检查中未提及"腹水,腹壁静脉曲张（肝掌、蜘蛛痣、皮肤黏膜有无黄染、腹壁静脉曲张、肝脏触诊）"

【病历】
体格检查：{pe}
诊断：{dx}
【质控结果】"""
    )])


def read_record(path: str) -> str:
    p = Path(path)
    if not p.exists():
        fallback = Path(__file__).resolve().parents[4] / "data" / "med-emr-qc" / p.name
        if fallback.exists():
            p = fallback
        else:
            raise FileNotFoundError(f"Input file not found: {path}")
    return p.read_text(encoding="utf-8")


def run_qc(
    record: str,
    appkey: str,
    *,
    base: str = DEFAULT_LLM_BASE,
    model: str = DEFAULT_LLM_MODEL,
    timeout: int = 0,
    output_path: Optional[str] = None,
) -> str:
    print(f"质控规则：{RULE_CN}（{RULE_KEY}）")

    fields = parse_record(record)
    llm = make_llm_caller(appkey, base=base, model=model, timeout=timeout)
    qc_result = qc_pe_missing_negative(fields, llm)

    default_dir = Path("..") / "runs" / "med-emr-qc"
    out = Path(output_path) if output_path else (default_dir / f"{RULE_KEY}.txt")
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(qc_result, encoding="utf-8")

    print(f"✓ 质控结果已保存至：{out}")
    print("\n--- 质控结果 ---")
    print(qc_result)
    return qc_result


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)