Skill: unisound-llm/unisound-missing-tumor-name (version 1.0.0)

--- name: med-emr-qc-missing-tumor-name 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/missing-tumor-name/scripts/emr_qc.py \ --input data/med-emr-qc/record.txt \ --appkey <your-appkey> # 多格式入口 python3 doctor/emr-qc/missing-tumor-name/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/missing-tumor-name.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.missing-tumor-name
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 = "missing-tumor-name"

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="门诊病历内涵质控：未记录肿瘤名称（missing-tumor-name）。",
    )
    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/missing-tumor-name.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 = "missing-tumor-name"
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_missing_tumor_name(fields: Dict[str, str], llm) -> str:
    """未记录肿瘤名称"""
    hpi = fields["hpi"]
    pmh = fields["pmh"]

    # LLM1：是否有肿瘤/癌症病史
    has_tumor = llm([user_msg(
        f"""给定门诊病历中现病史和既往史字段，判断该患者是否有肿瘤或者癌症病史，请直接回答"有"或者"无"，不需要任何分析。

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

<example>
现病史：未用药，否认口干、多饮、多尿、体重下降
既往史：乳腺恶性肿瘤术后，现靶向治疗。否认胰腺炎。
有
</example>

<example>
现病史：甲状腺乳头状癌术后2年。术后病理：甲状腺乳头状癌，经典型及滤泡亚型。现优甲乐100ug，否认心慌、手抖、多汗、体重下降。
既往史：2014诊断甲亢，赛治治疗，现已停药。
有
</example>

<example>
现病史：少痰，不烧复查CT，伴关节疼痛
既往史：肺部结节病史，过敏史：无。传染病史：无。
无
</example>

现在请判断下面的门诊病历
现病史：{hpi}
既往史：{pmh}"""
    )])
    if "无" in has_tumor and "有" not in has_tumor:
        return "无缺陷"

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

缺陷描述：
1.既往史或现病史中记录患者有肿瘤或者癌症病史，没有记录具体的肿瘤或者癌症名称
2.任何带有部位的肿瘤或者癌症，都认为是有肿瘤名称，请直接回答"无缺陷"，比如"甲状腺Ca"、"肺癌"、"肺部肿瘤"、"结肠癌"、"淋巴瘤"、"脑膜瘤"、"脑淋巴瘤"、"乳腺癌"、"直肠癌"、"直肠肿瘤"等等

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

<example>
【病历】
现病史：体重下降
既往史：结肠癌术后，磺胺药敏史
【质控结果】
无缺陷
</example>

<example>
【病历】
现病史：未用药，否认口干、多饮、多尿、体重下降
既往史：乳腺恶性肿瘤术后，现靶向治疗。否认胰腺炎。
【质控结果】
无缺陷
</example>

<example>
【病历】
现病史：不烧，自觉胸背部不适，偶有咳痰。
既往史：肾功能不全，肿瘤病史，过敏史：无。传染病史：无。
【质控结果】
有缺陷
既往史中记录"肿瘤病史"，但是没有记录具体的肿瘤名称
</example>

现在请对下面的门诊病历进行质控
【病历】
现病史：{hpi}
既往史：{pmh}
【质控结果】"""
    )])


# ---------------------------------------------------------------------------


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_missing_tumor_name(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: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)