Skill: unisound-llm/unisound-chief-complaint-hpi-inconsistent (version 1.0.0)

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

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="门诊病历内涵质控：主诉和现病史描述不一致（chief-complaint-hpi-inconsistent）。",
    )
    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/chief-complaint-hpi-inconsistent.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 = "chief-complaint-hpi-inconsistent"
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_chief_complaint_hpi_inconsistent(fields: Dict[str, str], llm) -> str:
    """主诉和现病史描述不一致"""
    cc = fields["cc"]
    hpi = fields["hpi"]
    if not cc or not hpi:
        return "无缺陷"

    # LLM1：判断主诉类型（症状/疾病名/操作名/其他）
    cc_type = llm([user_msg(
        f"""你是一名医疗助理，负责分析患者的主诉（即患者到医院时描述的主要不适）。你的任务是判断这些主诉属于哪种类型：症状、疾病名、操作名、或其他。

症状：患者能直接感受到的身体或精神上的不适，例如"头痛"或"恶心"。
疾病名：已确诊的医学病症名称，例如"糖尿病"。
操作名：描述某些医疗操作或过程，例如"拔牙"或"换药"。
其他：不符合以上三类的主诉。
请基于输入的主诉，返回所属类别。

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

<example>
主诉：头晕乏力
症状
</example>

<example>
主诉：糖尿病
疾病名
</example>

<example>
主诉：咳嗽
症状
</example>

<example>
主诉：拔牙
操作名
</example>

<example>
主诉：健康检查
操作名
</example>

<example>
主诉：高血压
疾病名
</example>

<example>
主诉：胸闷气短
症状
</example>

<example>
主诉：复查
其他
</example>

现在请判断下面的主诉，请直接回答类型，避免任何分析
主诉：{cc}"""
    )])

    # 如果不是症状类型 → 无缺陷
    if "症状" not in cc_type:
        return "无缺陷"

    # 完整质控
    return llm([user_msg(
        f"""你是一位病历质控专家，给定门诊病历中的主诉和现病史，请判断主诉和现病史是否有描述不一致的情况。如果一致请直接回答"无缺陷"，不要分析原因；如果不一致请先回答"有缺陷"，然后另起一行分析原因。

以下6种情况都是不一致的情况，都算是缺陷病历，请必须注意，只有当主诉为症状描述时，才进行质控，如果主诉是疾病名、操作名或者其他任何非症状描述，请直接回答"无缺陷"
1.同一症状的部位不一致，如主诉中记录"右下肢水肿1周"，但是现病史中记录"1周前发现左下肢水肿"
2.同一症状描述不一致，如主诉中记录"间断气短2月，加重3天"，但是现病史中记录"2月前出现间断性气短，自行口服药物治疗，症状较前有所减轻"
3.同一症状持续时间不一致，如主诉中记录"咳痰20余天"，但是现病史中记录"1周前出现咳痰"
4.主诉中症状在现病史中未描述，如主诉中记录"咳嗽5天"，但是现病史中没有提及任何与咳嗽相关的内容；
5.现病史否认主诉中症状，如主诉中记录"口干1年"，但是现病史中记录"无口干、多饮、多尿等症状"
6.现病史未记录主诉中症状特点及其发展变化情况，如主诉中提及以下常见症状（发热、水肿、咳嗽与咳痰、咯血、胸痛、呼吸困难、呕吐、吞咽困难、呕血、便血、腹痛、腹泻、便秘、黄疸、关节痛、血尿、尿频、尿痛、少尿、多尿、消瘦、头痛、眩晕、晕厥、抽搐、意识障碍），现病史中需对该症状进行描述（包括但不限于症状部位、性质、程度、加重及缓解因素等），如有现病史中没有描述，判定为缺陷病历

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

<example>
主诉：口干1年
现病史：患者1年前无明显诱因出现口干症状，饮水后不能缓解，伴体重增加10kg，无口干、多饮、多尿等症状，未行相关检查。

有缺陷
主诉中记录"口干1年"，但是现病史中记录"无口干、多饮、多尿等症状"
</example>

<example>
主诉：右下肢水肿1周
现病史：1周前发现左侧下肢水肿，按压水肿部位回弹性差，伴尿量减少，未行相关诊治，今日为求进一步诊治，遂就诊于我院。

有缺陷
主诉中记录"右下肢"，但是现病史中记录"1周前发现左侧下肢水肿"
</example>

<example>
主诉：咳嗽、咳痰3天
现病史：痰不易咳出，自觉畏寒，未发热。口服强力枇杷

无缺陷
</example>

现在请对下面的主诉和现病史进行判断
主诉：{cc}
现病史：{hpi}"""
    )])


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_chief_complaint_hpi_inconsistent(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: clean (LOW)