💻 源码
"""用户输入实体抽取节点

输入: state['input_semantic_trans']  语义转写节点补全后的提问
输出: state['user_input_effects'] / _diseases / _symptoms / _formulas / _herbs / _sources

读转写结果而不是 state['input']:原话里的指代词("它"、"那个方子")抽不出实体,
转写节点已经把它们还原成具体名字了。

抽取的六个实体类型和 `_002__extract_information` 的 EntityType 一一对应,
抽出来的名字后面要拿去和 Neo4j 里的节点名做匹配,所以只抽名字、不抽关系,
也不做任何改写("人参"不能变成"人参片",否则匹配不上)。
"""

from typing import List

from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
from pydantic import BaseModel, Field

from _004_langgraph_more_nodes.agent_state import AgentState
from common.llm import my_llm


# ======================
# 输出结构
# ======================
class UserInputEntities(BaseModel):
    """用户提问里出现的实体,按类型分组。没有的类型给空列表。"""

    effects: List[str] = Field(default_factory=list, description="功效,如补气、活血、祛湿、止痛")
    diseases: List[str] = Field(default_factory=list, description="疾病,如感冒、肺炎、肾虚")
    symptoms: List[str] = Field(default_factory=list, description="症状,如咳嗽、腹痛")
    formulas: List[str] = Field(default_factory=list, description="方剂,如四君子汤、桂枝汤")
    herbs: List[str] = Field(default_factory=list, description="药材,如人参、黄芪、丁香")
    sources: List[str] = Field(default_factory=list, description="出处,如《本草纲目》《伤寒论》")


parser = JsonOutputParser(pydantic_object=UserInputEntities)

prompt = PromptTemplate(
    template=(
        "你是一个中医实体识别专家。请从用户的提问中抽取出现的中医实体。\n\n"
        "【实体类型说明】\n"
        "- symptoms:症状,如咳嗽、腹痛等\n"
        "- diseases:疾病,如感冒、肺炎、肾虚等\n"
        "- formulas:方剂,如四君子汤、桂枝汤等\n"
        "- herbs:药材,如人参、黄芪、丁香等\n"
        "- effects:功效,如补气、活血、祛湿、止痛等\n"
        "- sources:出处,如《本草纲目》《伤寒论》等\n\n"
        "【抽取要求】\n"
        "1. 只抽取用户提问中真实出现的词,不要补充、联想或推理出提问里没有的实体。\n"
        "2. 保持原文写法,不要改写、不要加后缀、不要翻译成术语,出处保留书名号。\n"
        "3. 一个词只归入最贴切的一个类型,不要重复出现在多个类型里。\n"
        "4. 去掉'的'、'吗'、'怎么'这类修饰,只留实体名本身。\n"
        "5. 某个类型没有对应实体时给空列表 [],不要省略这个键。\n"
        "6. 疑问词(什么、哪些、如何)和非中医词汇一律不抽。\n\n"
        "所有输出必须严格符合以下 JSON 格式:\n"
        "{format_instructions}\n\n"
        "用户提问:{text}"
    ),
    input_variables=["text"],
    partial_variables={"format_instructions": parser.get_format_instructions()},
)

# 模型返回的键 -> 写回 state 的键
FIELD_TO_STATE_KEY = {
    "effects": "user_input_effects",
    "diseases": "user_input_diseases",
    "symptoms": "user_input_symptoms",
    "formulas": "user_input_formulas",
    "herbs": "user_input_herbs",
    "sources": "user_input_sources",
}


def _clean(values) -> List[str]:
    """规整成去空、去重、保序的字符串列表。

    JsonOutputParser 返回的是普通 dict,模型偶尔会给出 null、单个字符串或
    重复项,所以这里不能直接信任它的形状。
    """
    if values is None:
        return []
    if isinstance(values, str):
        values = [values]

    cleaned, seen = [], set()
    for value in values:
        name = str(value).strip()
        if name and name not in seen:
            seen.add(name)
            cleaned.append(name)
    return cleaned


def extract_entity_from_user_input_node(state: AgentState) -> AgentState:
    """从用户输入中抽取六类实体,分别写回 state"""
    print("实体抽取节点执行开始了")
    user_input = (state.get("input_semantic_trans") or "").strip()

    # 空输入不必调用大模型,六个键仍然要写,下游节点直接取值不用判空
    if not user_input:
        for state_key in FIELD_TO_STATE_KEY.values():
            state[state_key] = []
        return state

    chain = prompt | my_llm | parser
    result = chain.invoke({"text": user_input}) or {}

    for field, state_key in FIELD_TO_STATE_KEY.items():
        state[state_key] = _clean(result.get(field))

    total = sum(len(state[key]) for key in FIELD_TO_STATE_KEY.values())
    print(f"实体抽取节点执行完成: 共 {total} 个")
    return state


if __name__ == "__main__":
    for text in [
        "人参和黄芪都能补气吗?",
        "桂枝汤出自《伤寒论》,主治什么病?",
        "我最近咳嗽还有点腹痛,肾虚会引起这些吗?",
        "四君子汤里有哪些药材?",
        "今天天气怎么样",
        "",
    ]:
        result = extract_entity_from_user_input_node({"input_semantic_trans": text})
        print(f"\n提问: {text!r}")
        for state_key in FIELD_TO_STATE_KEY.values():
            print(f"  {state_key}: {result[state_key]}")