第1节 · 项目介绍与环境搭建

🎯 理解知识图谱项目架构与技术栈(Neo4j / LangGraph / FastAPI / Streamlit)。
📎 资料

课程介绍导图与架构图

  • 📎 中医知识图谱day01.xmind237.3KB
  • 📎 今日课上截图.drawio580.6KB
  • 📎 数据源.png36.8KB
  • 📎 课程介绍.xmind278.0KB
🧠 中医知识图谱day01.xmind 大纲
中医知识图谱day01
项目整体模块
数据采集模块
图谱生成模块
实体关系抽取模块
lora微调语料生成模块
neo4j数据库构建模块
faiss向量数据库生成模块
agent工作流模块
1.中医垂类问答模块
2.大模型问答模块
3.小红书发布模块
前端模块
streamlit前端模块
fastapi后端接口模块
数据采集模块
数据源:www.zhongyibaike.com
技术栈
requests
BeautifulSoup
公共模块实现
配置信息工具
路径管理工具
配置信息获取模工具
大模型配置工具
neo4j管理工具
langgraph图保存工具(没有实现)
中医知识图谱数据爬取
业务知识
中药材(Herb)
方剂(Formula)
功效(Effect)
症状(Symptom)
疾病(Disease)
数据源
https://zhongyibaike.com/
爬虫的知识点
request数据爬虫介绍
BeautifulSoup介绍
需求分析
获取方剂的信息
获取药材的详情
优化点
断点续爬
进度展示
扩展知识:
爬虫工具
requests:发请求用的小工具,灵活但啥都要自己写
Scrapy:专业爬虫框架,全自动、高并发、企业级
Selenium:模拟浏览器爬 JS 页面,慢但强反爬能用
解析工具
正则表达式
XPath
在XML文档中定位节点的语言,它可以与HTML文档一样使用
BeautifulSoup
一个HTML和XML解析库,提供了简单灵活的API。
反爬虫的一些知识
https://developer.aliyun.com/article/1684382
实体和关系的提取模块
熟悉中医相关中医知识图谱的业务知识
prompt关键词
提取实体和关系
每次遍历都是从第一条数据开始抽取,下周解决
提取了Aipaca 微调语料集
🧠 课程介绍.xmind 大纲
课程介绍
关于老师
姓名
王梁武
大家可以称呼我:王老师,老王,梁武老师
课程介绍
1.面向人群
正在找工作的同学,加强项目简历的编写
2.课程内容
项目名称
草本通中医知识图谱
适用场景
医疗、金融、法律、电商、工业 教育等等
3.学后的收获
核心能力
数据收集
构建知识图谱
使用AI工具的能力
多智能体-langgraph
AI生成代码-修改bug-修改代码的能力
4.设计到技术点
数据获取
爬虫
requests+BeautifulSoup
知识建模
实体关系
neo4j
多智能体
langgraph
向量数据库和文本向量化
RAG
定义
检索增强生成
图数据库
大模型
提示词
微调
lora微调
vllm
部署
deepspeed
训练
5.课程特点
主要AI生成代码,提示词设计及任务建模
重再理解思路,知道一步一步怎么做的
先理解大框架,在理解小细节
代码编写
trea工具

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

📎 资料

线上课件链接(飞书)

  • 📎 中医知识图谱课件.txt63B
  • 📎 图像分类的线上课件.txt63B
  • 📎 多模态课件.txt63B
  • 📎 目标检测的线上课件.txt149B

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

📎 资料

MCP 展示

  • 📎 mcp展示.pdf9.1MB

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

第2节 · 数据采集:爬虫与 RAG 中的数据库角色

🎯 中药/方剂数据爬取;理解各类数据库在 RAG 中的角色。
📖 笔记

不同数据库在 RAG 中充当的角色

一、先直白区分:MySQL、向量数据库、知识图谱(核心本质)

1)MySQL(关系型数据库,结构化表格)

存储形态:二维表,行 + 列,精确文本、数字、分类;支持 where 精确匹配、模糊 like、多条件筛选

查询特点:

✅ 精准关键词匹配、字段过滤;

❌ 不能理解语义。搜 “手机充电慢”,库里写 “手机耗电快”,关键词不一样就搜不到。

数据关系:只能靠外键简单关联,很难表达复杂逻辑关系(A 导致 B、B 属于 C、规则约束)

2)向量数据库(Embedding 向量检索)

存储形态:文本转换成数字向量,保存【文本 + 向量】

查询特点:

✅ 语义相似检索。“充电慢”≈“耗电快”,向量距离近,能召回;

❌ 不懂实体、不懂关系,不知道 “手机” 是设备、“电池” 是配件,只判断两段文字像不像;

❌ 无法做逻辑推理、链式查询。

3)知识图谱 KG(Graph,实体 + 关系)

存储形态:三元组 (实体,关系,实体)

例子:(手机,故障,充电慢)、(充电慢,原因,电池老化)、(电池老化,方案,更换电池)

查询特点:

✅ 理解实体、层级、因果、从属关系,可以链式推理,多跳的查询;

❌ 原生不擅长长文本模糊语义匹配;纯图谱很难直接匹配用户口语化问句。

极简一句话总结

  • MySQL:精确关键词查表
  • 向量库:语义相似度匹配(只看文字像不像)
  • 知识图谱:实体与逻辑关系、因果推理(看懂事物之间的联系)

二、三者在标准 RAG 流程里各自负责什么?

标准 RAG 链路:用户提问 → 检索 → 召回素材 → 大模型生成答案

  1. MySQL 在 RAG 里一般不作为主检索引擎

适用场景:

  • 存储元数据、业务属性、权限、分类标签; milvus 可以存元数据,所以元数据就在milvus中存着,像faiss chroma, 这些不能存元数据的向量数据库,就需要将元数据存到mysql中。

例:知识库文档分类、文档生效时间、用户权限、工单编号、商品基础信息。

搭配用法:先用向量库召回候选文档,再用 MySQL 过滤【权限 / 分类】。

❌ 不要单独用 MySQL 做客服知识库检索:用户口语多变,关键词稍微不一致就检索失败。

  1. 向量数据库 = 通用 RAG 标准检索组件

适用场景:

  • 大量非结构化文本:FAQ、产品手册、通知、帮助文档、段落文章;

  • 用户自由口语提问,需要

语义模糊匹配

;

绝大多数通用问答场景首选。

局限:没有逻辑,只会 “找相似段落”,无法做推理。

  1. 知识图谱在 RAG 中 = 增强模块(很少单独使用)

适用场景:

满足下面任意多条才值得上图谱:

① 业务存在大量

实体关联、层级、因果关系

;

② 需要

多步推理

:故障溯源、多层属性查询;

③ 需要精准区分实体、避免歧义;

④ 想要支持:“A 和 B 有什么区别?”“出现问题 C,可能有哪些原因?依次怎么处理”

纯知识图谱不能直接应对开放式口语提问,一般架构:向量检索 + 知识图谱混合 RAG


三、重点问题:智能客服,选 RAG 还是知识图谱?

场景划分,直接给结论

场景 A:普通智能客服【90% 企业的情况】

需求:FAQ 问答、产品说明书查询、政策解读、简单咨询

👉 优先:向量 RAG(向量数据库 + MySQL)

架构参考:

  1. MySQL:维护 FAQ 元数据、分类、上下线状态
  2. 文档 / FAQ 向量化存入向量数据库
  3. 用户提问 → 向量检索召回相似 FAQ → 大模型整理回答

✅ 优点:

  • 搭建简单、成本低;文档直接导入,不需要人工抽取三元组;
  • 适配用户五花八门的口语;
  • 迭代方便,新增文档直接切片入库。

❌ 缺点:复杂连锁问题容易答漏,缺少因果逻辑。

场景 B:专业领域客服,满足下面特征【才考虑引入知识图谱】

典型行业:设备运维客服、医疗咨询、政企业务流程、汽车故障售后

特征:

  1. 大量故障溯源、因果推理;

用户问:设备报错 E01,可能有哪些原因?分别怎么排查?

  1. 实体繁多,容易混淆;需要区分零部件、型号、版本;

  2. 需要多层关联查询,不是简单一问一答;

👉 最佳方案:混合架构【RAG 为主 + 知识图谱增强】

工作流程示例:

  1. 用户提问先做意图识别;
  2. 简单咨询:走向量 RAG 召回文档;
  3. 复杂故障 / 关联查询:调用知识图谱做推理,获取因果链路;
  4. 把【检索段落 + 图谱推理结果】一起喂给 LLM 生成答案

⚠️ 重要提醒:

不要一上来只做知识图谱客服!

知识图谱最大痛点:成本极高

所有文本必须人工 / 大模型抽取三元组,数据维护很重;新增业务规则、产品资料都要更新图谱;中小企业很难持续运营。

反面例子(什么时候别硬上图谱)

电商客服:查发货、退换货政策、商品基础咨询

全部都是独立 FAQ,几乎没有复杂推理 → 只用向量 RAG 足够,图谱属于过度设计。

四、直观对比表(智能客服视角)

表格

方案 开发维护成本 擅长问题 短板 适合客服类型
仅 MySQL 关键词检索 低 标准固定问句 口语识别差,语义不理解 淘汰,不推荐
向量 RAG(向量库) 中 开放式口语、FAQ、文档查询 缺少逻辑推理,复杂问题容易碎片化回答 通用电商、政务、普通产品客服【首选】
纯知识图谱 高 实体关联、故障溯源、链式推理 无法直接处理自由口语,文本素材利用难 极少单独使用
RAG + 知识图谱混合 较高 兼顾语义匹配 + 逻辑推理 架构复杂、数据维护工作量大 工业设备、售后运维、专业领域客服

五、落地建议(直接照着决策)

  1. 如果你们是通用智能客服,需求就是回答常见问题、查阅知识库

→ 技术栈:向量数据库 (Chroma/Milvus) + MySQL + LLM,标准 RAG 足够,不用知识图谱。

  1. 如果是设备售后、故障排查、有大量因果推理需求

→ 先用 RAG 搭建基础客服上线;业务稳定后,挑选高频复杂问题,额外构建知识图谱做增强,不要一步到位全图谱。

  1. 避坑:很多人误以为 “知识图谱比 RAG 高级”,实际上两者不是替代关系,是互补关系。RAG 解决文本语义检索;知识图谱解决关系与推理。

如果你需要,我可以给你一份【智能客服 RAG 最简架构图】或者一份:如何判断你的业务是否有必要引入知识图谱的自检清单。

📎 资料

day02 导图

  • 📎 day02前情回顾.xmind281.6KB
  • 📎 中医知识图谱day02.xmind411.3KB
🧠 day02前情回顾.xmind 大纲
day01前情回顾
中医知识图谱的架构
1.数据采集模块
数据源:https://zhongyibaike.com/
技能点:requests BeautifulSoup
2.图谱生成模块
技能点: Prompt 撰写|大模型调用|LangChain|Faiss|Embedding模型
1.实体关系的抽取(大模型提示词)
2.Neo4j知识图谱的构建
3.Neo4j元数据的提取
为了后续NL2Cypher,提供Neo4j 的相关数据
4.Neo4j标准实体名库(Faiss向量数据库)
后续标准化,用户输入的实体
3.智能体工作流模块
技能点:Langgraph|文生图Api使用|playwright自动化发布框架
设计到的模块
小红书发布模块
中医知识图谱问答模块
大模型直接回答模块
4.前端展示模块
技能点:Streamlit FastApi
公共模块开发
.env
环境变量配置
config
获取配置文件信息
路径管理工具
根据项目相对路径,拼接绝对路径
大模型对象管理模块
neo4jmanager模块
操作neo4j
🧠 中医知识图谱day02.xmind 大纲
中医知识图谱day02
数据采集模块的实现
数据源
中医方剂:https://zhongyibaike.com/wiki/%E4%B8%AD%E5%8C%BB%E6%96%B9%E5%89%82
中医药材:https://zhongyibaike.com/wiki/%E4%B8%AD%E8%8D%AF%E5%A4%A7%E5%85%A8
技能点
BeautifulSoup
对响应的内容进行解析
requests
访问网站,获取响应内容
实现思路:
1.先爬取 列表
爬取 方剂和中药的名称和url 列表
2.根据列表遍历爬取详情
将详情写到名称.txt文档中
3.优化点
断点续爬
多线程爬取(容易被封)
知识图谱构建模块
中医知识图谱业务知识
实体
关系
实体和关系的抽取
方剂抽取
药材抽取
aipaca微调语料的沉淀
这个是用来微调大模型的

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

第3节 · 信息抽取

🎯 从非结构化文本抽取中药/方剂结构化信息。
📎 资料

day03-04 导图与笔记图

  • 📎 中医知识图谱前期知识复习.xmind165.5KB
  • 📎 中医知识图谱第三天.xmind429.2KB
  • 📎 昨日回顾.xmind411.3KB
  • 📎 笔记图整理.drawio992.8KB
🧠 中医知识图谱前期知识复习.xmind 大纲
day02复习
Neo4j
图数据库
概念
- 节点
- 关系
方向性的
- 属性
标签
一组节点
安装
安装jdk
解压安装包
配置环境变量
neo4j consle
cypher语句
cypher是我们和neo4j的交互语言
create命令
节点
关系
match命令
做匹配的,相当于mysql中的select
merge命令
merge是有就不创建,没有就创建
where命令
delete命令
如果两个节点有关系,要删除,必须节点和关系一块删除,如果只删除节点,会报错
sort命令
排序, order by |order by desc
字符串函数
toUpper()函数
字符串变大写
toLower()函数
substring()函数
截取一段字符串
replace()函数
字符串替换
聚合函数
count()函数
max()函数
min()函数
sum()函数
avg()函数
index
CREATE INDEX person_name_index FOR (e:Employee) ON (e.name)
DROP INDEX person_name_index;
Neo4j python操作
导入实体和关系
导出元数据
有哪些标签,有哪些关系,有哪些【标签1 关系 标签2】组成的三元组 。。。
描述neo4j数据库中
🧠 中医知识图谱第三天.xmind 大纲
中医知识图谱第三天
知识图谱构建模块
中医知识图谱业务知识
实体
关系
实体和关系的抽取
方剂抽取
药材抽取
微调语料的沉淀
aipaca微调语料的沉淀
这个是用来微调大模型的
Neo4j的讲解
知识图谱的构建
使用Faiss向量数据库存储实体信息
embedding模型的选取
beg系列
向量数据库的选取
Faiss
faiss向量数据库构建的流程
01.构建数据集
02.加载中文向量数据模型
3.数据集向量化处理
4.构建索引
索引的维度是根据embedding模型生成的维度来的
5.保存索引
两个文件
index.faiss,存储向量和索引
index.pkl:pickle 文件,ID 映射表
6.构建query
7.query向量化
必须和faiss存入的向量使用同一个embedding模型
配置数据(路径不能有中文)
index文件路径
id2text文件路径
Neo4j元数据的抽取
langgraph
langgraph的概念
状态
节点
边
langgraph实现中医知识问答(部分)
1.用户意图判断节点
2.用户输入实体抽取节点
3.实体标准化节点
4.利用用户提问+标准化实体+neo4j元数据,生成cypher语句
🧠 昨日回顾.xmind 大纲
中医知识图谱day02
数据采集模块的实现
数据源
中医方剂:https://zhongyibaike.com/wiki/%E4%B8%AD%E5%8C%BB%E6%96%B9%E5%89%82
中医药材:https://zhongyibaike.com/wiki/%E4%B8%AD%E8%8D%AF%E5%A4%A7%E5%85%A8
技能点
BeautifulSoup
对响应的内容进行解析
requests
访问网站,获取响应内容
实现思路:
1.先爬取 列表
爬取 方剂和中药的名称和url 列表
2.根据列表遍历爬取详情
将详情写到名称.txt文档中
3.优化点
断点续爬
多线程爬取(容易被封)
知识图谱构建模块
中医知识图谱业务知识
实体
关系
实体和关系的抽取
方剂抽取
药材抽取
aipaca微调语料的沉淀
这个是用来微调大模型的

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

第4节 · Neo4j 图谱构建

🎯 构建中医药知识图谱数据库。
📎 资料

day05 导图与笔记图

  • 📎 day04中医知识图谱.xmind149.6KB
  • 📎 前情回顾.xmind429.2KB
  • 📎 笔记整理.drawio264.3KB
🧠 day04中医知识图谱.xmind 大纲
day04中医知识图谱
langgraph简单案例
langgraph实现中医知识问答
1.用户意图判断节点
2.用户输入实体抽取节点
3.实体标准化节点
4.利用用户提问+标准化实体+neo4j元数据,生成cypher语句
🧠 前情回顾.xmind 大纲
中医知识图谱第三天
知识图谱构建模块
中医知识图谱业务知识
实体
关系
实体和关系的抽取
方剂抽取
药材抽取
微调语料的沉淀
aipaca微调语料的沉淀
这个是用来微调大模型的
Neo4j的讲解
知识图谱的构建
使用Faiss向量数据库存储实体信息(实体标准化)
embedding模型的选取
beg系列
向量数据库的选取
Faiss
faiss向量数据库构建的流程
01.构建数据集
02.加载中文向量数据模型
3.数据集向量化处理
4.构建索引
索引的维度是根据embedding模型生成的维度来的
5.保存索引
两个文件
index.faiss,存储向量和索引
index.pkl:pickle 文件,ID 映射表
6.构建query
7.query向量化
必须和faiss存入的向量使用同一个embedding模型
配置数据(路径不能有中文)
index文件路径
id2text文件路径
Neo4j元数据的抽取
让大模型知道我们的neo4j的schema结构
langgraph
langgraph的概念
状态
节点
边
langgraph实现中医知识问答(部分)
1.用户意图判断节点
2.用户输入实体抽取节点
3.实体标准化节点
4.利用用户提问+标准化实体+neo4j元数据,生成cypher语句

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

第5节 · LangGraph 多节点编排

🎯 用 LangGraph 编排图谱问答流程。
📎 资料

day06 导图与笔记图

  • 📎 day05_前情回顾.xmind149.6KB
  • 📎 langgraph图.png69.9KB
  • 📎 中医知识图谱day05.xmind159.8KB
  • 📎 笔记.drawio193.0KB
🧠 day05_前情回顾.xmind 大纲
day04中医知识图谱
langgraph简单案例
langgraph实现中医知识问答
1.用户意图判断节点
2.用户输入实体抽取节点
3.实体标准化节点
4.利用用户提问+标准化实体+neo4j元数据,生成cypher语句
🧠 中医知识图谱day05.xmind 大纲
中医知识图谱day05
langgraph实现中医知识回答
节点
cypher语句检查节点
cypher语句对neo4j图数据库查询节点
查询结果+用户输入的大模型输出节点
大模型直接问答节点
构建图
fastapi
web框架---实现后端接口
streamlit
前端框架--实现页面

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

第6节 · FastAPI 服务化与 Streamlit 应用

🎯 图谱问答 API 服务开发与 Streamlit 前端应用。
📎 资料

day07 导图与笔记图

  • 📎 前情回顾.xmind225.7KB
  • 📎 笔记.drawio137.6KB
🧠 前情回顾.xmind 大纲
中医知识图谱_前期回顾
langgraph实现中医知识回答
节点
cypher语句检查节点
Explain 语句 去neo4j中检查
cypher语句对neo4j图数据库查询节点
查询结果+用户输入的大模型输出节点
neo4j进行检索增强
大模型直接问答节点
构建图
材料
1.定义状态
2.定义节点
创建stateGraph(状态)
节点添加到状态图上
把边添加到状态图上
1.普通边
条件边
编译stateGraph图--compile
调用图:invoke
fastapi
web框架---实现后端接口
异步
fastapi
fastpai--返回静态页面
预制菜
传参
get
1.路径传参
2.json传参
3.可选参数和默认参数传参
post
1.form表单传参
校验输入
2.json方式传参
3.pydantic 方式传参
校验输入

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

第7节 · 记忆与思考模式、微调收尾

🎯 为 Agent 添加记忆与思考模式;微调数据准备。
📎 资料

day10 导图

  • 📎 前情回顾.xmind283.7KB
  • 📎 添加思考模式的逻辑.png28.4KB
  • 📎 添加记忆的逻辑.png114.1KB
🧠 前情回顾.xmind 大纲
前情回顾
langgraph高阶
1.config
invoke时候加上配置
thread_id
2.添加记忆
1.checkpoint的inMemory
compile时候添加
2.thread_id这个配置
invoke时候添加
3.记忆的内容,State中的状态
3.人工介入
前提:得有记忆
2.引入interrupt
打断的
3.Command
输入命令进行恢复
thread_id 打断前后必须一致
4.设置并行
基础并行
graph_builder.add_edge["节点A", "节点B"], "节点C")
前节点可以设置成列表[]
并行共享list节点
设置reducer
5.设置子图
为什么要设置子图
便于复用
子图,就是由多个节点组成的一个大节点
普通子图
子图就是主图的一个节点
传递参数的子图
子图的状态是主图状态的属性
6、流式输出
使用stream
使用for 循环,进行返回
7、异步操作
非流式输出的异步
ainvoke
流式输出的异步
astream
文生图api的使用
即梦文生图
字节
百炼文生图
阿里
知乎自动发布
文案生成
图片生成
图文校验
校验 图文是否为空,以及文字是否有敏感词
自动化发布
playwright
web测试的一种自动化工具
模拟人类点击浏览器
llamafactory_lora微调
微调原因
推理速度慢
部署成本高​
可能有幻觉
微调的目标
更快的推理速度
资源消耗少,便于本地部署
降低幻觉
基座模型
Qwen/Qwen2.5-1.5B-Instruct
微调解决啥问题?
解决了小模型抽取实体和关系效果不好的问题
准备工作
llamafactory的安装
torch的安装,一定要安装gpu版本的
数据集的准备,aipaca格式数据集
基座模型:qwen2.5-1.5b
fp16:3G显存
int8 :1.5G显存
int4:0.75G显存
模型训练的参数设置
batch_size设置小了,导致显存利用率不够
加大batch_size大小
lora微调的参数
R:秩:秩越大,训练的参数越多,消耗的显存越大
一般选8 16 32
作用模块:q_proj,v_proj--也可以不填
模型导出时候
选择基座模型
选择lora微调的权重
模型部署(服务端)--合并后的权重
vllm加速库
选择记忆
sglang加速库
lmdeploy加速库
模型调用(客户端)
openai的风格来调用私有化部署的模型模型
模型部署方式
vllm部署
指定基座模型
指定lora微调后的权重
好处:基座模型只占用一次显存
client端

ℹ️ 离线版不含附件原文件,需要请到在线网站对应课时下载。

📎 项目源码(day10 最终完整版)

💻 代码

chinese_medical_project 全流程工程(爬取的药材/方剂数据与抽取结果未收录)

109 个文件
  • 📁 .claude/
    • ⚙️ settings.local.json 8.5KB
  • 📁 _000_demo/
    • 📁 fastapi_demo/
      • 📁 static/
        • 📄 index.html 229B
          <!DOCTYPE html>
          <html lang="en">
          <head>
              <meta charset="UTF-8">
              <title>黑马</title>
          </head>
          <body>
          黑马程序员。
          <h1>黑马程序员</h1>
          <h2>黑马程序员</h2>
          <h3>黑马程序员</h3>
          </body>
          </html>
          
      • 📁 uploads/
        • 📄 fastapi_client.py 284B
          # 创建请求客户端
          import requests
          
          response = requests.get("http://127.0.0.1:8000/")
          print(f"请求码:{response.status_code}")  # 200
          print(f"获取到文本数据:{response.text}")
          # print(f"获取到的json数据为:{response.json()}")  # {'message': 'Hello World'}
          
      • 📄 fastapi_client.py 284B
        # 创建请求客户端
        import requests
        
        response = requests.get("http://127.0.0.1:8000/")
        print(f"请求码:{response.status_code}")  # 200
        print(f"获取到文本数据:{response.text}")
        # print(f"获取到的json数据为:{response.json()}")  # {'message': 'Hello World'}
        
      • 📄 fastapi_post_client.py 1.0KB
        # 表单数据传参数
        import requests
        
        response = requests.post("http://127.0.0.1:8000/login", data={"username": "小明", "password": "123456"})
        print(response.status_code)
        print(response.json())
        
        # response = requests.post("http://127.0.0.1:8000/receive_raw_json", json={"username": "小明", "password": "123456"})
        # print(response.json())
        #
        # response = requests.post("http://127.0.0.1:8000/receive_raw_json", json={"password": "123456"})
        # print(response.json())
        
        
        # response = requests.post("http://127.0.0.1:8000/items/",
        #                          json={"name": "钢笔", "description": "这是一个描述", "price": 10.5})
        # print(response.json())
        
        
        # {'detail': [{'type': 'missing', 'loc': ['body', 'name'], 'msg': 'Field required', 'input': {'price': 10.5}}, {'type': 'missing', 'loc': ['body', 'description'], 'msg': 'Field required', 'input': {'price': 10.5}}]}
        response = requests.post("http://127.0.0.1:8000/items/",
                                 json={"price": 10.5})
        print(response.json())
        
      • 📄 fastapi_post_params_server.py 1.1KB
        # 创建fastapi对象
        from fastapi import FastAPI, Form
        from pydantic import BaseModel
        
        # 创建一个FastAPI实例
        app = FastAPI()
        #写自己的接口
        # 表单数据
        @app.post("/login/")
        async def login(username: str = Form(...), password: str = Form(...)):
            print(f"用户名为:{username}")
            print(f"密码为:{password}")
            return {"username": username}
        
        @app.post("/receive_raw_json/")
        async def receive_raw_json(data: dict):
            print(f"接收到的数据为:{data}")
            return {"received_data": data}
        
        # 通过Pydantic方式进行传参
        class Item(BaseModel):
            name: str
            description: str
            price:float
        
        
        # 接收JSON数据的POST接口
        @app.post("/items/")
        async def create_item(item: Item):
            # 可以直接使用item对象,FastAPI会自动解析JSON
            print(item.model_dump())
            return {"item": item.model_dump(), "message": "Item created successfully"}
        
        
        
        #  把fastapi对象监听在某个端口上
        if __name__ == "__main__":
            import uvicorn
            #3.利用uviconrn将fastapi对象拉起来
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
      • 📄 fastapi_reqeusts_get_client.py 808B
        # 创建请求客户端
        import requests
        
        # 默认获取
        # response = requests.get("http://127.0.0.1:8000/")
        # print(f"请求码:{response.status_code}")  # 200
        # print(f"获取到文本数据:{response.text}")
        # print(f"获取到的json数据为:{response.json()}")  # {'message': 'Hello World'}
        
        print("*" * 100)
        # 路径传参数
        response = requests.get("http://127.0.0.1:8000/items/255")
        print(f"获取到的json数据为:{response.json()}")
        print("*" * 100)
        
        # 查询传参数
        response = requests.get("http://127.0.0.1:8000/users/", params={"name": "小明", "age": 18})
        print(f"获取到的json数据为:{response.json()}")
        
        print("*" * 100)
        # 默认参数
        response = requests.get("http://127.0.0.1:8000/read_user2/")
        print(f"获取到的json数据为:{response.json()}")
        
      • 📄 fastapi_server.py 299B
        from fastapi import FastAPI
        # 创建一个FastAPI实例
        app = FastAPI()
        
        
        @app.get("/")
        async def root():
            return {"message": "Hello World"}
        
        
        if __name__ == "__main__":
            import uvicorn
            #3.利用uviconrn将fastapi对象拉起来
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
      • 📄 fastapi_server_get_params.py 869B
        from typing import Optional
        
        from fastapi import FastAPI
        # 创建一个FastAPI实例
        app = FastAPI()
        #http://localhost:8000/items/123456
        # 路径参数示例
        @app.get("/items/{item_id}")
        async def read_item(item_id: int):
            print(item_id)
            return {"item_id": item_id}
        
        # http://localhost:8000/users/?name=yangguo&age=18
        # 查询参数示例
        @app.get("/users/")
        async def read_user(name: str, age: int):
            print(name, age)
            return {"name": name, "age": age}
        
        # http://localhost:8000/read_user2/?name=xiaolongnv&age=19
        # 可选参数和默认值
        @app.get("/read_user2/")
        async def read_user2(name: Optional[str] = None, age: int = 5):
            age = age + 1
            return {"name": name, "age": age}
        
        if __name__ == "__main__":
            import uvicorn
            #3.利用uviconrn将fastapi对象拉起来
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
      • 📄 fastapi_server_static_html.py 404B
        from fastapi import FastAPI
        from fastapi.responses import FileResponse
        
        app = FastAPI()
        
         #预制菜  static/index.html 提前写好页面直接进行返回了,速度快.
        @app.get("/")
        async def read_index():
            print(f"访问静态文件目录")
            return FileResponse("static/index.html")
        
        
        if __name__ == "__main__":
            import uvicorn
        
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
      • 📄 fastapi_stream_client_demo01.py 626B
        import requests
        
        
        def stream_client():
            url = "http://localhost:8000/stream"
        
            # 发送 POST 请求并设置 stream=True 以启用流式响应
            with requests.post(url, stream=True) as response:
                # 检查响应状态码
                if response.status_code == 200:
                    # 逐行读取流式响应
                    for chunk in response.iter_content(chunk_size=None):
                        if chunk:
                            print(chunk.decode("utf-8"), end="", flush=True)
                else:
                    print(f"请求失败,状态码: {response.status_code}")
        
        
        if __name__ == "__main__":
            stream_client()
        
      • 📄 fastapi_stream_response_queue_client.py 971B
        import json
        
        import requests
        
        
        def parse_chunk(chunk):
            json_str = chunk.decode("utf-8")
            chunk = json.loads(json_str)
            if chunk.get("type") == "msg":
                print(chunk.get("msg"), end="", flush=True)
                return False # False,表示数据还没有传输完成
            else:
                return True
        
        
        def stream_client():
            url = "http://localhost:8000/stream"
        
            # 发送 POST 请求并设置 stream=True 以启用流式响应
            with requests.post(url, stream=True) as response:
                # 检查响应状态码
                if response.status_code == 200:
                    # 逐行读取流式响应
                    for chunk in response.iter_content(chunk_size=None):
                        if chunk:
                            is_done = parse_chunk(chunk)
                            if is_done:
                                break
                else:
                    print(f"请求失败,状态码: {response.status_code}")
        
        
        if __name__ == "__main__":
            stream_client()
        
      • 📄 fastapi_stream_response_queue_server.py 1.1KB
        from fastapi import FastAPI
        from fastapi.responses import StreamingResponse
        import asyncio
        import uvicorn
        import json
        #异步队列
        msg_queue: asyncio.Queue = asyncio.Queue()
        app = FastAPI()
        
        
        async def my_test_project():
            res_list = ["我", "是", "一个", "测试", "程序"]
            for msg in res_list:
                await msg_queue.put({"type": "msg", "msg": msg})
                await asyncio.sleep(0.5)
            await msg_queue.put({"type": "done", "msg": ""})
        
        
        async def generate_data():
            # 异步执行任务  在后台齐了一个任务,负责生产数据. --生成者
            asyncio.create_task(my_test_project())
        
            #消费数据  ---消费者
            #生产者和消费者之间,通过一个队列进行通信,进行解耦
            while True:
                msg = await msg_queue.get()
                # 推送 SSE
                yield json.dumps(msg)
                if msg.get("type") == "done":
                    break
        
        
        @app.post("/stream")
        async def stream_data():
            return StreamingResponse(generate_data(), media_type="text/plain")
        
        
        if __name__ == "__main__":
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
      • 📄 fastapi_stream_response_server_demo01.py 707B
        from fastapi import FastAPI
        from fastapi.responses import StreamingResponse
        import asyncio
        import uvicorn
        
        app = FastAPI()
        
        
        #流式输出的接口StreamingResponse
        async def generate_data():
            res_list = ["我", "特别", "喜欢", "玩儿", "大模型"]
            for res in res_list:
                await asyncio.sleep(0.5)
                yield res
        
        
        @app.post("/stream")
        async def stream_data():
            # 创建一个流式响应对象 ,他的第一个参数的是一个生成器函数 或者迭代器,生成器或者迭代器的返回值要用yield返回
            return StreamingResponse(generate_data(), media_type="text/plain")
        
        if __name__ == "__main__":
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
      • 📄 fastapi_upload_file_server.py 1.1KB
        from fastapi import FastAPI, UploadFile, File
        from typing import List
        import uvicorn
        import os
        
        app = FastAPI()
        
        # 定义上传文件保存的目录
        UPLOAD_DIR = "uploads"  # 你可以修改为任何你想要的目录名
        # 确保上传目录存在
        os.makedirs(UPLOAD_DIR, exist_ok=True)
        
        
        # 文件上传
        @app.post("/uploadfile/")
        async def create_upload_file(file: UploadFile = File(...)):
            try:
                print(f"文件名: {file.filename}")
                # 构建文件保存路径
                file_path = os.path.join(UPLOAD_DIR, file.filename)
        
                # 保存文件到指定目录
                with open(file_path, "wb") as buffer:
                    buffer.write(await file.read())
        
                print(f"文件保存成功: {file_path}")
                return {
                    "filename": file.filename,
                    "saved_path": file_path,
                    "message": "文件上传成功"
                }
            except Exception as e:
                return {
                    "error": str(e),
                    "message": "文件上传失败"
                }
        
        
        if __name__ == "__main__":
            uvicorn.run(app, host="0.0.0.0", port=8000)
        
    • 📁 langgraph_high_level/
      • 📁 并行/
        • 📄 04_langgraph_paramlizm_demo.py 2.1KB
          import time
          from operator import add
          from typing import TypedDict, List, Annotated
          
          from langgraph.graph import StateGraph, START, END
          
          
          # 定义状态结构
          class MyState(TypedDict):
              menu: Annotated[List[str], add]  # ✅ 支持并行追加
              all_ready: bool
          
          
          # --- 节点定义 ---
          def start_cooking(state: MyState):
              print("👨‍🍳 厨房准备开始!")
              # 初始化共享菜单列表
              state["menu"] = []
              return state
          
          
          def chef_a(state: MyState):
              print("👩‍🍳 A 厨师在做:番茄炒蛋...")
              time.sleep(2)
              # 向共享 list 添加菜品
              state["menu"].append("番茄炒蛋")
              return state
          
          
          def chef_b(state: MyState):
              print("👨‍🍳 B 厨师在做:宫保鸡丁...")
              time.sleep(3)
              state["menu"].append("宫保鸡丁")
              return state
          
          
          def chef_c(state: MyState):
              print("👩‍🍳 C 厨师在做:鱼香肉丝...")
              time.sleep(1)
              state["menu"].append("鱼香肉丝")
              return state
          
          
          def serve_dish(state: MyState):
              print("\n🍽️ 所有菜都准备好了!菜单如下:")
              for dish in state["menu"]:
                  print(" -", dish)
              state["all_ready"] = True
              return state
          
          
          # --- 构建 LangGraph ---
          def build_graph():
              graph_builder = StateGraph(MyState)
              graph_builder.add_node("start", start_cooking)
              graph_builder.add_node("chef_a", chef_a)
              graph_builder.add_node("chef_b", chef_b)
              graph_builder.add_node("chef_c", chef_c)
              graph_builder.add_node("serve", serve_dish)
          
              # 启动 → 并行三个厨师
              graph_builder.add_edge(START, "start")
              graph_builder.add_edge("start", "chef_a")
              graph_builder.add_edge("start", "chef_b")
              graph_builder.add_edge("start", "chef_c")
          
              # 三个厨师汇总到 serve
              graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
          
              graph_builder.add_edge("serve", END)
          
              return graph_builder.compile()
          
          
          # --- 运行 ---
          if __name__ == "__main__":
              graph = build_graph()
              result = graph.invoke({})
              print("\n最终结果:", result)
          
        • 📄 04_并行与reducer说明.md 12.5KB
          # LangGraph 并行与 reducer:菜单为什么变成了 24 个菜
          
          配套代码:
          
          - `04_langgraph_paramlizm_demo.py` —— 错误写法的现场,就是本文要解释的那 24 个菜
          - `05_langgraph_paramlizm_demo2.py` —— 追踪版,把 menu 的每一次变化打出来(见第六节)
          
          本文所有输出都在 langgraph 1.2.1 上实测过。
          
          ## 一句话结论
          
          `menu` 字段挂了 reducer(`Annotated[List[str], add]`),**有 reducer 的字段,节点返回的值是"增量",会被追加到通道上,而不是覆盖它**。原代码既 `append` 改了列表本身,又 `return state` 把改完的整份列表当增量交回去,等于每个节点都写了两遍,逐层翻倍到 24。
          
          ## 现象
          
          三个厨师各做一道菜,期望 3 条,实际:
          
          ```
          🍽️ 所有菜都准备好了!菜单如下:   ← serve 里打印出 12 条
           - 鱼香肉丝
           - 番茄炒蛋
           ...(共 12 条)
          
          最终结果: {'menu': [...24 条...], 'all_ready': True}
          ```
          
          注意 `serve` 打印的是 12 条,而 `invoke` 返回的是 24 条——**这是两级不同的翻倍**,后面会拆开看。
          
          ## 一、reducer 是什么
          
          先说 LangGraph 里 state 的真实模型。你写的 `MyState` 不是一个普通字典,它的每个字段都是一个 **通道(channel)**,通道有自己保存的值,也有自己的"合并规则"。
          
          节点函数拿到的 `state` 是通道当前值的一份快照,节点的返回值是**要对通道做的更新**。至于这份更新怎么落到通道上,就由这个字段的合并规则决定——这个规则就是 **reducer**。
          
          reducer 就是一个两参数的函数:
          
          ```python
          def reducer(通道现在的值, 节点返回的值) -> 通道的新值
          ```
          
          字段声明成 `Annotated[类型, 函数]` 时,那个函数就是 reducer:
          
          ```python
          class MyState(TypedDict):
              menu: Annotated[List[str], add]   # add 就是 reducer,来自 operator
              all_ready: bool                   # 没写 Annotated,就没有 reducer
          ```
          
          `operator.add` 作用在两个 list 上就是拼接,所以 `add(["番茄炒蛋"], ["宫保鸡丁"])` 得到 `["番茄炒蛋", "宫保鸡丁"]`。
          
          **没有 reducer 的字段,默认行为是覆盖**,节点返回什么,通道就变成什么:
          
          ```python
          class S(TypedDict):
              menu: List[str]          # 无 reducer
          
          def a(s): return {"menu": ["番茄炒蛋"]}
          def b(s): return {"menu": ["宫保鸡丁"]}   # a 之后串行执行
          
          # 实测输出: {'menu': ['宫保鸡丁']}     ← 番茄炒蛋被覆盖掉了
          ```
          
          这就是两类字段的根本差别,也是后面所有坑的来源:
          
          | 字段 | 声明 | 节点返回值的含义 |
          |---|---|---|
          | `all_ready` | `bool` | 新值,**覆盖** |
          | `menu` | `Annotated[List[str], add]` | 增量,**追加** |
          
          ## 二、为什么并行必须有 reducer
          
          顺手回答一个问题:既然 reducer 这么容易踩坑,能不能不写?并行的时候不行。
          
          把 `menu` 的 `Annotated` 去掉,让 a、b 两个节点并行写它:
          
          ```python
          class S1(TypedDict):
              menu: List[str]          # 故意不加 reducer
          
          def a(s): return {"menu": ["A"]}
          def b(s): return {"menu": ["B"]}   # 与 a 并行
          ```
          
          实测直接报错:
          
          ```
          InvalidUpdateError At key 'menu': Can receive only one value per step.
          Use an Annotated key to handle multiple values.
          ```
          
          道理很直白:同一步里 a 和 b 各交回一个值,覆盖语义下 LangGraph 无法决定该听谁的——它不猜,直接报错让你给个规则。**reducer 就是你给的那个规则**,告诉它"两份都要,拼起来"。
          
          所以 `menu` 上那个 `add` 不是可选的装饰,是并行汇聚的必要条件。它同时也意味着你必须按增量的方式写节点。
          
          ## 三、reducer 是被逐个调用的
          
          这一点最影响直觉,所以单独验证。把 `add` 换成一个会打印的版本:
          
          ```python
          def traced_add(old, new):
              print(f"[reducer] old={old}  new={new}")
              return add(old, new)
          ```
          
          正确写法(各节点只返回自己的增量)下的实测输出:
          
          ```
          start: 收到 state = {'menu': []}
          a: 收到 state = {'menu': []}
          b: 收到 state = {'menu': []}
             [reducer] old=[]              new=['番茄炒蛋']
             [reducer] old=['番茄炒蛋']     new=['宫保鸡丁']
          serve: 收到 state = {'menu': ['番茄炒蛋', '宫保鸡丁']}
          ```
          
          三个要点:
          
          1. **reducer 不是一次收到所有并行结果**,而是每个节点的更新来一次,`old` 是累积到目前的通道值,`new` 只是那一个节点返回的东西。
          2. **并行节点看到的 state 是同一份起始快照**(a 和 b 都看到 `{'menu': []}`),a 的结果对 b 不可见——所以别指望在并行节点里读到兄弟节点写的东西。
          3. `menu` 有 reducer,所以通道初始值就是 `[]`,不需要 `start` 去初始化。原代码里的 `state["menu"] = []` 是多余的。
          
          ## 四、24 是怎么算出来的
          
          原代码每个厨师节点干了两件事:
          
          ```python
          def chef_a(state):
              state["menu"].append("番茄炒蛋")   # ① 就地改了列表
              return state                      # ② 又把整份列表当增量交回去
          ```
          
          **错误 ①:三个并行节点改的是同一个列表对象。** 打印 `id(state["menu"])` 实测:
          
          ```
          a   拿到 menu 的 id = 2635165391232
          b   拿到 menu 的 id = 2635165391232
          c   拿到 menu 的 id = 2635165391232      ← 同一个对象
          ```
          
          快照是浅的,`state` 字典是各自的,但里面的 list 是同一个。三个 `append` 全落在这一个列表上,于是它变成 3 条。这一步绕过了 reducer,是直接篡改通道里的数据。
          
          **错误 ②:`return state` 让这 3 条又被当作增量追加。** 节点结束时,LangGraph 拿返回值里的 `menu` 交给 reducer。此时那个列表已经是 3 条了,于是每个节点都贡献一次"3 条"的追加。
          
          两个错误叠在一起,用带打印的 reducer 实测原代码:
          
          ```
             [reducer] len(old)=0  len(new)=0  -> len=0      ← start 返回空 menu
             [reducer] len(old)=3  len(new)=3  -> len=6      ← 三个 append 已让列表变 3 条
             [reducer] len(old)=6  len(new)=3  -> len=9
             [reducer] len(old)=9  len(new)=3  -> len=12
          serve 看到的菜单条数: 12
             [reducer] len(old)=12 len(new)=12 -> len=24     ← serve 的 return state
          final len: 24
          ```
          
          逐行读这段就能把两级翻倍分清:
          
          - 第一级,**3 → 12**:三个 `append` 把共享列表变成 3 条(这时 `old` 已经是 3 了,因为 append 改的就是通道里那个对象),再被三次 `return state` 各追加 3 条。这是 `serve` 打印 12 条的原因。
          - 第二级,**12 → 24**:`serve` 自己也 `return state`,把它刚读到的 12 条整份又追加一遍。这是最终结果 24 条的原因。
          
          第二级值得单独强调,因为它跟 `append` 无关。只犯 `return state` 这一个错就够翻倍了:
          
          ```python
          def start(s): return {"menu": ["番茄炒蛋", "宫保鸡丁", "鱼香肉丝"]}
          def serve(s):
              s["all_ready"] = True
              return s          # ← 唯一的错
          
          # 实测: {'menu': [3 个菜 + 同样的 3 个菜], 'all_ready': True}   ← 6 条
          ```
          
          **汇聚节点(fan-in)是这个坑的高发地。** 它只想改 `all_ready`,但 `return state` 顺带把读到的完整 `menu` 又交了一遍。
          
          ## 五、正确写法
          
          规则一条:**节点只返回自己要写的那几个 key 组成的新 dict,不 `return state`,不改 `state` 里的可变对象。**
          
          ```python
          def start_cooking(state: MyState):
              print("👨‍🍳 厨房准备开始!")
              return {}                        # 什么都不写,就返回空 dict
          
          def chef_a(state: MyState):
              time.sleep(2)
              return {"menu": ["番茄炒蛋"]}     # 只交自己新增的那一份
          
          def serve_dish(state: MyState):
              for dish in state["menu"]:       # 读是随便读的
                  print(" -", dish)
              return {"all_ready": True}       # 千万不要带上 menu
          ```
          
          实测输出:
          
          ```
          🍽️ 所有菜都准备好了!菜单如下:
           - 番茄炒蛋
           - 宫保鸡丁
           - 鱼香肉丝
          
          最终结果: {'menu': ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝'], 'all_ready': True}
          ```
          
          菜品顺序是 a、b、c 的声明顺序,不是完成时间顺序(c 只睡 1 秒但排最后)。`add` 按 LangGraph 处理节点更新的顺序拼接,跟谁先跑完无关——**并行汇聚的结果顺序是稳定的,但别理解成"先完成先入列"**。
          
          ## 六、自己动手看:`05_langgraph_paramlizm_demo2.py`
          
          上面的日志都是这个脚本打出来的。它把 `add` 换成会打印的 `traced_add`,两种写法共用同一张图,只换节点函数:
          
          ```bash
          python -X utf8 05_langgraph_paramlizm_demo2.py              # 错误/正确 对比跑
          python -X utf8 05_langgraph_paramlizm_demo2.py --mode wrong # 只看错误写法
          python -X utf8 05_langgraph_paramlizm_demo2.py --stream     # 再用 stream 看增量/快照
          python -X utf8 05_langgraph_paramlizm_demo2.py --no-sleep   # 去掉 sleep,秒出
          ```
          
          三个观察点:
          
          **1. `🔧 [reducer #n]` 日志** —— 每行是一次合并,`old` 是通道现值,`new` 是某个节点交回来的东西。错误写法 5 次调用(`new` 全是 3 条或 12 条),正确写法 3 次(`new` 都是 1 条)。
          
          **2. `👀` 行的 `id=`** —— 错误写法里三个厨师的 `id` 相同,正确写法里也相同(快照是共享的),差别在于正确写法**不去改它**。这解释了为什么"共享"本身不是问题,`append` 才是。
          
          **3. `--stream` 的两种模式**,正好对应 reducer 的两个参数:
          
          ```
          stream_mode="updates"  → 节点交回的增量,就是 reducer 的 new
          stream_mode="values"   → 合并后的通道快照,就是 reducer 的 old
          ```
          
          错误写法在 `updates` 下最扎眼——`chef_a` 交回来的不是 1 个菜而是一整份:
          
          ```
          ── 更新 2 ── chef_a 交回 menu(3) = ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']
          ── 更新 3 ── chef_b 交回 menu(3) = ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']
          ── 更新 4 ── chef_c 交回 menu(3) = ['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']
          ── 更新 5 ── serve  交回 menu(12) = [...12 条...]
          ```
          
          `values` 下只有 3 个快照(0 条 → 12 条 → 24 条),比节点数少——**并行的三个厨师合并完才 yield 一次**,这是它和 `updates` 的本质区别。
          
          ### 顺手看到的两个副作用
          
          跑 `--mode wrong` 时,`append` 的执行顺序就是**完成时间顺序**:带 sleep 时 c 只睡 1 秒,菜单变成 `['鱼香肉丝', '番茄炒蛋', '宫保鸡丁']`;加 `--no-sleep` 又变回 a/b/c。而正确写法两种情况下都稳定是声明顺序 `['番茄炒蛋', '宫保鸡丁', '鱼香肉丝']`。**就地修改让结果依赖时序,返回增量不会。**
          
          `--no-sleep` 跑错误写法还能看到更难查的一幕:
          
          ```
          👀 chef_a 看到 menu(0) = []
          👀 chef_b 看到 menu(1) = ['番茄炒蛋']      ← b 看见了 a 写的东西
          👀 chef_c 看到 menu(2) = ['番茄炒蛋', '宫保鸡丁']
          ```
          
          按 LangGraph 的语义,并行节点应该都看到同一份起始快照(正确写法里三个都是 `menu(0)=[]`)。这里之所以互相可见,正是因为 `append` 改的是共享对象。**并行节点之间出现了本不该存在的可见性,而且这种可见性随时序变化**——比数量翻倍更难排查。
          
          ## 七、检查清单
          
          写并行图时按这几条自查:
          
          - [ ] 会被多个并行节点写的字段,加了 `Annotated[..., reducer]` 吗?(不加会 `InvalidUpdateError`)
          - [ ] 节点里有没有 `state[...].append(...)` / `.update(...)` / `+=` 这类就地修改?有就是 bug,并行节点共享同一个对象。
          - [ ] 有没有 `return state`?带 reducer 的字段一旦这么写就翻倍。全部改成返回只含自己输出 key 的 dict。
          - [ ] 汇聚节点单独看一遍:它读了带 reducer 的字段,返回值里绝不能再出现那个字段。
          - [ ] 有没有在初始化节点里给带 reducer 的 list 字段赋 `[]`?多余,通道本来就是空的。
          
          ## 八、和本项目 `_004_` 的关系
          
          `_004_langgraph_more_nodes/agent_state.py` 里的 `AgentState` **目前所有字段都没有 reducer**,节点也都是串行的,所以现在的 `def x_node(state) -> AgentState: ...; return state` 写法是安全的——覆盖语义下 `return state` 交回原值等于没改。
          
          但如果以后要并行(比如六类实体的匹配同时跑,或者多路 Cypher 并发执行),给某个字段加上 reducer 的那一刻,**这个字段上所有 `return state` 的节点都会立刻开始翻倍**,而且不报错,只是结果变多——比报错难查。届时的改法就是本文第五节:那条链上的节点全部改成返回增量 dict。
          
        • 📄 05_langgraph_paramlizm_demo2.py 9.5KB
          """
          并行 + reducer 追踪版:把 menu 通道的每一次变化打出来。
          
          04_langgraph_paramlizm_demo.py 是"错误写法"的现场,本文件是它的显微镜:
          把 operator.add 换成一个会打印的 traced_add,就能看见 menu 每一步
          从什么变成什么,从而搞清楚 3 个菜为什么会变成 24 个。
          
          用法:
              python -X utf8 05_langgraph_paramlizm_demo2.py            # 错误/正确 两种写法对比跑
              python -X utf8 05_langgraph_paramlizm_demo2.py --mode wrong
              python -X utf8 05_langgraph_paramlizm_demo2.py --mode right
              python -X utf8 05_langgraph_paramlizm_demo2.py --stream    # 再用 stream 看增量/快照
              python -X utf8 05_langgraph_paramlizm_demo2.py --no-sleep  # 去掉 sleep,跑得快点
          
          配套文档:04_并行与reducer说明.md
          """
          
          import argparse
          import time
          from operator import add
          from typing import Annotated, List, TypedDict
          
          from langgraph.graph import END, START, StateGraph
          
          # 是否真的 sleep(--no-sleep 关掉)
          SLEEP = True
          # reducer 被调用的次数,用来给日志编号
          _reduce_calls = 0
          
          
          def cook(seconds: float) -> None:
              """模拟做菜耗时。"""
              if SLEEP:
                  time.sleep(seconds)
          
          
          def traced_add(old: List[str], new: List[str]) -> List[str]:
              """带日志的 reducer。
          
              reducer 的签名永远是 (通道现在的值, 节点返回的值) -> 通道的新值。
              这里除了照常拼接,还把两边的内容和长度打出来 —— 这就是"追踪 menu"的关键。
              注意 reducer 是**每个节点的更新来一次**,不是一次收到所有并行结果。
              """
              global _reduce_calls
              _reduce_calls += 1
              result = add(old, new)
              print(
                  f"   🔧 [reducer #{_reduce_calls}] "
                  f"old({len(old)})={old}  +  new({len(new)})={new}  ->  长度 {len(result)}"
              )
              return result
          
          
          class MyState(TypedDict):
              menu: Annotated[List[str], traced_add]  # 有 reducer:节点返回值是"增量",会被追加
              all_ready: bool  # 无 reducer:节点返回值是"新值",直接覆盖
          
          
          def peek(node: str, state: MyState) -> None:
              """打印节点看到的 menu 快照,以及那个 list 对象的 id。
          
              并行节点打出来的 id 是同一个 —— 这说明 state 里的 list 是共享对象,
              所以 append 会直接改到通道里的数据上,绕过 reducer。
              """
              menu = state.get("menu", [])
              print(f"   👀 {node} 看到 menu({len(menu)}) = {menu}   id={id(menu)}")
          
          
          # ======================================================================
          # 写法一:错误写法(04 里的那份)—— append 改共享 list + return state
          # ======================================================================
          def wrong_start(state: MyState):
              print("👨‍🍳 [错误写法] 厨房准备开始!")
              peek("start", state)
              state["menu"] = []  # 多余:带 reducer 的通道本来就是 []
              return state
          
          
          def wrong_chef_a(state: MyState):
              print("👩‍🍳 A 厨师在做:番茄炒蛋...")
              peek("chef_a", state)
              cook(2)
              state["menu"].append("番茄炒蛋")  # ① 就地改了三个节点共享的那个 list
              return state  # ② 又把改完的整份当增量交回去
          
          
          def wrong_chef_b(state: MyState):
              print("👨‍🍳 B 厨师在做:宫保鸡丁...")
              peek("chef_b", state)
              cook(3)
              state["menu"].append("宫保鸡丁")
              return state
          
          
          def wrong_chef_c(state: MyState):
              print("👩‍🍳 C 厨师在做:鱼香肉丝...")
              peek("chef_c", state)
              cook(1)
              state["menu"].append("鱼香肉丝")
              return state
          
          
          def wrong_serve(state: MyState):
              print("\n🍽️ [错误写法] 上菜:")
              peek("serve", state)
              for dish in state["menu"]:
                  print("   -", dish)
              state["all_ready"] = True
              return state  # 汇聚节点的 return state:把读到的整份 menu 再追加一遍
          
          
          # ======================================================================
          # 写法二:正确写法 —— 只返回自己新增的那一份,不碰 state 里的 list
          # ======================================================================
          def right_start(state: MyState):
              print("👨‍🍳 [正确写法] 厨房准备开始!")
              peek("start", state)
              return {}  # 什么都不写就返回空 dict
          
          
          def right_chef_a(state: MyState):
              print("👩‍🍳 A 厨师在做:番茄炒蛋...")
              peek("chef_a", state)
              cook(2)
              return {"menu": ["番茄炒蛋"]}  # 只交增量,合并交给 reducer
          
          
          def right_chef_b(state: MyState):
              print("👨‍🍳 B 厨师在做:宫保鸡丁...")
              peek("chef_b", state)
              cook(3)
              return {"menu": ["宫保鸡丁"]}
          
          
          def right_chef_c(state: MyState):
              print("👩‍🍳 C 厨师在做:鱼香肉丝...")
              peek("chef_c", state)
              cook(1)
              return {"menu": ["鱼香肉丝"]}
          
          
          def right_serve(state: MyState):
              print("\n🍽️ [正确写法] 上菜:")
              peek("serve", state)
              for dish in state["menu"]:
                  print("   -", dish)
              return {"all_ready": True}  # 绝不带 menu,否则整份再追加一遍
          
          
          # --- 构建 LangGraph(两种写法共用同一张图,只换节点函数)---
          NODES = {
              "wrong": (wrong_start, wrong_chef_a, wrong_chef_b, wrong_chef_c, wrong_serve),
              "right": (right_start, right_chef_a, right_chef_b, right_chef_c, right_serve),
          }
          
          
          def build_graph(mode: str):
              start, a, b, c, serve = NODES[mode]
          
              graph_builder = StateGraph(MyState)
              graph_builder.add_node("start", start)
              graph_builder.add_node("chef_a", a)
              graph_builder.add_node("chef_b", b)
              graph_builder.add_node("chef_c", c)
              graph_builder.add_node("serve", serve)
          
              # 启动 → 并行三个厨师
              graph_builder.add_edge(START, "start")
              graph_builder.add_edge("start", "chef_a")
              graph_builder.add_edge("start", "chef_b")
              graph_builder.add_edge("start", "chef_c")
          
              # 三个厨师汇总到 serve(fan-in)
              graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
              graph_builder.add_edge("serve", END)
          
              return graph_builder.compile()
          
          
          # --- 两种查看方式 ---
          def run_invoke(mode: str) -> List[str]:
              """普通 invoke:靠 reducer 日志看 menu 的每一次合并。"""
              global _reduce_calls
              _reduce_calls = 0
          
              title = "错误写法:append + return state" if mode == "wrong" else "正确写法:只返回增量"
              print("\n" + "=" * 70)
              print(f"  {title}   (mode={mode})")
              print("=" * 70)
          
              result = build_graph(mode).invoke({})
              menu = result["menu"]
              print(f"\n📊 reducer 共被调用 {_reduce_calls} 次,最终 menu 长度 = {len(menu)}(期望 3)")
              print(f"   最终结果: {result}")
              return menu
          
          
          def run_stream(mode: str) -> None:
              """stream 方式:把"节点交了什么"和"通道变成了什么"分开看。
          
              stream_mode="updates"(默认):每个节点跑完 yield 一次 {节点名: 该节点的返回值}。
                  看到的是**增量**,也就是交给 reducer 的那个 new。
                  错误写法在这里最扎眼 —— chef_a 交回来的不是 1 个菜而是一整份。
              stream_mode="values":通道值变化后 yield 一次完整快照。
                  看到的是**合并后的结果**,也就是 reducer 返回的 old。
                  并行的三个厨师合并完才给一份,所以快照数比节点数少。
              """
              global _reduce_calls
          
              print("\n" + "=" * 70)
              print(f"  stream_mode='updates':每个节点交回的增量 (mode={mode})")
              print("=" * 70)
              _reduce_calls = 0
              for i, chunk in enumerate(build_graph(mode).stream({}), start=1):
                  for node_name, update in chunk.items():
                      menu = update.get("menu") if isinstance(update, dict) else None
                      n = len(menu) if isinstance(menu, list) else "-"
                      print(f"\n── 更新 {i} ── {node_name} 交回 menu({n}) = {menu}")
          
              print("\n" + "=" * 70)
              print(f"  stream_mode='values':合并后的通道快照 (mode={mode})")
              print("=" * 70)
              _reduce_calls = 0
              for i, snap in enumerate(build_graph(mode).stream({}, stream_mode="values")):
                  menu = snap.get("menu", [])
                  print(f"\n── 快照 {i} ── menu({len(menu)}) = {menu}  all_ready={snap.get('all_ready')}")
          
          
          # --- 运行 ---
          def main() -> None:
              global SLEEP
          
              ap = argparse.ArgumentParser(description="LangGraph 并行 + reducer 追踪 demo")
              ap.add_argument(
                  "--mode",
                  choices=["wrong", "right", "both"],
                  default="both",
                  help="跑哪种写法,默认两种都跑做对比",
              )
              ap.add_argument("--stream", action="store_true", help="额外用 stream 看增量和快照")
              ap.add_argument("--no-sleep", action="store_true", help="去掉模拟耗时,跑得快点")
              args = ap.parse_args()
          
              SLEEP = not args.no_sleep
              modes = ["wrong", "right"] if args.mode == "both" else [args.mode]
          
              menus = {m: run_invoke(m) for m in modes}
          
              if args.stream:
                  for m in modes:
                      run_stream(m)
          
              if args.mode == "both":
                  print("\n" + "=" * 70)
                  print("  对比")
                  print("=" * 70)
                  print(f"  错误写法 menu 长度 = {len(menus['wrong'])}   (3 个菜被写了 8 遍)")
                  print(f"  正确写法 menu 长度 = {len(menus['right'])}   ✅")
                  print("\n  两级翻倍:")
                  print("    3 → 12  三次 append 改共享 list,又被三次 return state 各追加 3 条")
                  print("    12 → 24 serve 的 return state 把读到的 12 条整份再追加一遍")
                  print("\n  正确写法的 menu 顺序是 a/b/c 的声明顺序,不是完成时间顺序")
                  print("  (c 只睡 1 秒却排最后)—— add 按 LangGraph 处理更新的顺序拼接。")
          
          
          if __name__ == "__main__":
              main()
          
        • 📄 06_langgraph_paramlizm_demo3.py 2.1KB
          import time
          from typing import TypedDict, List, Annotated
          
          from langgraph.graph import StateGraph, START, END
          
          def my_merge(a, b):
              # 这里定义合并逻辑
              return b
          
          # 定义状态结构
          class MyState(TypedDict):
              menu: Annotated[List[str], my_merge]
              all_ready: bool
          
          
          # --- 节点定义 ---
          def start_cooking(state: MyState):
              print("👨‍🍳 厨房准备开始!")
              # 初始化共享菜单列表
              state["menu"] = []
              return state
          
          
          def chef_a(state: MyState):
              print("👩‍🍳 A 厨师在做:番茄炒蛋...")
              time.sleep(2)
              # 向共享 list 添加菜品
              state["menu"].append("番茄炒蛋")
              return state
          
          
          def chef_b(state: MyState):
              print("👨‍🍳 B 厨师在做:宫保鸡丁...")
              time.sleep(3)
              state["menu"].append("宫保鸡丁")
              return state
          
          
          def chef_c(state: MyState):
              print("👩‍🍳 C 厨师在做:鱼香肉丝...")
              time.sleep(1)
              state["menu"].append("鱼香肉丝")
              return state
          
          
          def serve_dish(state: MyState):
              print("\n🍽️ 所有菜都准备好了!菜单如下:")
              for dish in state["menu"]:
                  print(" -", dish)
              state["all_ready"] = True
              return state
          
          
          # --- 构建 LangGraph ---
          def build_graph():
              graph_builder = StateGraph(MyState)
              graph_builder.add_node("start", start_cooking)
              graph_builder.add_node("chef_a", chef_a)
              graph_builder.add_node("chef_b", chef_b)
              graph_builder.add_node("chef_c", chef_c)
              graph_builder.add_node("serve", serve_dish)
          
              # 启动 → 并行三个厨师
              graph_builder.add_edge(START, "start")
              graph_builder.add_edge("start", "chef_a")
              graph_builder.add_edge("start", "chef_b")
              graph_builder.add_edge("start", "chef_c")
          
              # 三个厨师汇总到 serve
              graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
          
              graph_builder.add_edge("serve", END)
          
              return graph_builder.compile()
          
          
          # --- 运行 ---
          if __name__ == "__main__":
              graph = build_graph()
              result = graph.invoke({})
              print("\n最终结果:", result)
          
        • 📄 06_自定义reducer说明.md 10.2KB
          # 自定义 reducer:`return b` 为什么是个陷阱
          
          配套代码:
          
          - `06_langgraph_paramlizm_demo3.py` —— 本文分析的对象,跑出来"看着对了"的那份
          - `04_并行与reducer说明.md` —— 先读这篇,reducer 是什么、`add` 版为什么翻倍到 24
          - `05_langgraph_paramlizm_demo2.py` —— 追踪工具,本文的日志都是同样手法打出来的
          
          本文所有输出都在 langgraph 1.2.1 上实测过。
          
          ## 一句话结论
          
          `06` 把 reducer 从 `add` 换成 `my_merge(a, b): return b`,输出从 24 条变回 3 条 —— **但这不是修好了 bug,是换了个 reducer 把症状盖住了。** `my_merge` 在这份代码里是个空操作,那 3 个菜完全是 `append` 副作用的产物。代价是:顺序变得不确定、节点里埋了竞态、而且一旦有人把节点改成规范写法就立刻静默丢数据。
          
          ## 现象:它确实输出了 3 条
          
          ```python
          def my_merge(a, b):
              # 这里定义合并逻辑
              return b
          
          class MyState(TypedDict):
              menu: Annotated[List[str], my_merge]
              all_ready: bool
          ```
          
          节点还是 `04` 那份错误写法(`append` 改列表 + `return state`),实跑:
          
          ```
          🍽️ 所有菜都准备好了!菜单如下:
           - 鱼香肉丝
           - 番茄炒蛋
           - 宫保鸡丁
          
          最终结果: {'menu': ['鱼香肉丝', '番茄炒蛋', '宫保鸡丁'], 'all_ready': True}
          ```
          
          数量对了。但**顺序不对** —— 鱼香肉丝排在最前面,而它是 `chef_c` 做的、声明在最后。这是第一个信号,说明结果不是按合并规则算出来的。
          
          ## 一、`my_merge` 是个空操作
          
          reducer 的契约是 `(通道现在的值, 节点返回的值) -> 通道的新值`。`return b` 意思是"丢掉 old,只要 new",也就是**覆盖** —— 语义上等价于根本不写 reducer(无 reducer 字段的默认行为就是覆盖)。
          
          但这份代码里连覆盖都没发生。给 `my_merge` 加上日志,把两个参数的 `id()` 也打出来:
          
          ```python
          def my_merge(a, b):
              print(f"[my_merge] old={a} (id={id(a)})  new={b} (id={id(b)})")
              return b
          ```
          
          实测:
          
          ```
          [my_merge] old=[] (id=1259944948544)  new=[] (id=1259940762688)
          a  id(menu)= 1259940762688
          b  id(menu)= 1259940762688
          c  id(menu)= 1259940762688
          [my_merge] old=['鱼香肉丝','番茄炒蛋','宫保鸡丁'] (id=1259940762688)
                     new=['鱼香肉丝','番茄炒蛋','宫保鸡丁'] (id=1259940762688)
          [my_merge] old=[同上] (id=1259940762688)  new=[同上] (id=1259940762688)
          [my_merge] old=[同上] (id=1259940762688)  new=[同上] (id=1259940762688)
          [my_merge] old=[同上] (id=1259940762688)  new=[同上] (id=1259940762688)
          ```
          
          **`old` 和 `new` 的 id 完全相同 —— 它们是同一个对象。** 原因是节点 `append` 改的就是通道里那个 list,然后 `return state` 又把这个同一个 list 原样交回来。于是 `a is b` 成立,`return b` 返回的就是通道自己已经有的东西。
          
          推论:`return b` 和 `return a` 应该完全等效。实测确认了:
          
          | reducer | 结果 |
          |---|---|
          | `lambda a, b: b` | 3 条 `['鱼香肉丝','番茄炒蛋','宫保鸡丁']` |
          | `lambda a, b: a` | 3 条 `['鱼香肉丝','番茄炒蛋','宫保鸡丁']` |
          
          `return a` 是连"新值"都不看的 reducer,居然也能得到 3 个菜。**这就证明了那 3 个菜跟 reducer 无关,纯粹是 `append` 的副作用。**
          
          ## 二、和 `04` 的关系:盖住症状,不是修复
          
          `04` 的错误写法犯了两件事:
          
          1. `state["menu"].append(...)` —— 就地改了三个并行节点共享的那个 list
          2. `return state` —— 又把改完的整份列表当增量交回去
          
          `add` 作为 reducer 时,错误 ② 导致累加,逐级翻倍到 24。换成 `my_merge` 后,**只是让错误 ② 不再造成翻倍**(覆盖不累加),错误 ① 一个字都没动。
          
          数字上确实从 24 变成 3,但 3 这个正确结果是错误 ① 凑出来的,不是任何一条合并规则算出来的。
          
          ## 三、四×二对照表
          
          把 reducer 和节点写法两个维度交叉跑一遍(实测,sleep 缩短为 0.1~0.3 秒):
          
          | reducer | 节点写法 | 结果 | 评价 |
          |---|---|---|---|
          | `add` | append + return state | 12 条 | `04` 的 bug,翻倍 |
          | `add` | 只返回增量 | **3 条 `['番茄炒蛋','宫保鸡丁','鱼香肉丝']`** | ✅ 唯一正确 |
          | `my_merge`(`return b`) | append + return state | 3 条 `['鱼香肉丝',...]` | `06` —— 巧合,顺序不定 |
          | `my_merge`(`return b`) | 只返回增量 | 1 条 `['鱼香肉丝']` | ❌ 静默丢数据 |
          | `return a` | append + return state | 3 条 `['鱼香肉丝',...]` | reducer 完全没参与 |
          | `return a` | 只返回增量 | 0 条 `[]` | ❌ 全丢 |
          
          (上表 `add` + append 那行是 12 而非 24,因为测试里的 `serve` 用的是规范写法 `return {"all_ready": True}`,少了第二级翻倍。`04` 的 `serve` 也 `return state`,所以是 24。)
          
          只有第二行是对的:**合并语义用 `add`,节点交增量。** 其他五行要么数量错,要么是靠副作用凑对的。
          
          ## 四、三个实际后果
          
          ### 1. 顺序变成不确定的
          
          `06` 的输出是 `['鱼香肉丝', '番茄炒蛋', '宫保鸡丁']`,因为 `chef_c` 只 `sleep(1)` 最先跑完,它的 `append` 就落在最前面。**菜品顺序由线程完成的先后决定**,改一下 sleep 时长顺序就变。
          
          规范写法不受这个影响 —— `add` 按 LangGraph 处理节点更新的顺序拼接,与谁先跑完无关,稳定是声明顺序 `['番茄炒蛋','宫保鸡丁','鱼香肉丝']`。`05_langgraph_paramlizm_demo2.py` 加 `--no-sleep` 前后对比就能看到这个差别:错误写法的顺序会变,规范写法不会。
          
          ### 2. 节点里埋了竞态
          
          三个线程同时改一个 list,现在侥幸没丢数据,只是因为 CPython 的 `list.append` 恰好是原子操作。节点里只要换成下面任何一种写法,就是标准的 read-modify-write 竞态:
          
          ```python
          state["menu"] = state["menu"] + ["番茄炒蛋"]   # 读 → 建新 list → 写回
          state["menu"] += ["番茄炒蛋"]
          current = state["menu"]; ...; current.append(x)  # 中间隔了别的操作
          ```
          
          这类 bug 不报错,只是偶尔少一个菜,且随机器负载变化。**依赖 `append` 的原子性来保证正确性,是不该写进教学代码的假设。**
          
          ### 3. 换成规范写法立刻丢数据 —— 最危险的一点
          
          `my_merge` 和"节点交增量"这个正确习惯是**互斥**的。同一个 `my_merge`,节点改成规范写法:
          
          ```
          [my_merge] old=[]            new=['番茄炒蛋']  -> ['番茄炒蛋']
          [my_merge] old=['番茄炒蛋']   new=['宫保鸡丁']  -> ['宫保鸡丁']    ← 番茄炒蛋没了
          [my_merge] old=['宫保鸡丁']   new=['鱼香肉丝']  -> ['鱼香肉丝']
          
          结果: {'menu': ['鱼香肉丝']}
          ```
          
          这次 `old` 和 `new` 是不同对象了,覆盖语义真正生效,于是每个厨师都把前一个的菜盖掉,只剩最后一个。**不报错,只是少了两个菜。**
          
          这就是它最坏的地方:把一个坏习惯(改共享对象)变成了正确性的**前提**。后来的人按规范改代码,反而把功能改坏了 —— 而且没有任何报错提示他。
          
          ## 五、规范写法
          
          并行追加就用 `add`,节点交增量:
          
          ```python
          from operator import add
          
          class MyState(TypedDict):
              menu: Annotated[List[str], add]      # 合并语义:拼接
              all_ready: bool                      # 无 reducer:覆盖
          
          def chef_a(state: MyState):
              return {"menu": ["番茄炒蛋"]}         # 不 append,不 return state
          ```
          
          ## 六、自定义 reducer 什么时候是对的
          
          自定义 reducer 完全正当,只要它**真的在写合并逻辑**。几个实测可用的例子:
          
          ```python
          def merge_unique(old: List[str], new: List[str]) -> List[str]:
              """拼接并去重,保留首次出现的顺序。"""
              seen = set(old)
              return old + [x for x in new if not (x in seen or seen.add(x))]
          
          def keep_max(old: int, new: int) -> int:
              """并行打分,取最高的那个。"""
              return max(old, new)
          
          def merge_dict(old: dict, new: dict) -> dict:
              """浅合并,后来的覆盖同名 key。"""
              return {**old, **new}
          ```
          
          三个节点各交回一份,实测:
          
          ```python
          def a(s): return {"menu": ["番茄炒蛋", "米饭"], "best": 3, "tags": {"a": 1}}
          def b(s): return {"menu": ["宫保鸡丁", "米饭"], "best": 7, "tags": {"b": 2}}
          def c(s): return {"menu": ["米饭"],            "best": 5, "tags": {"c": 3}}
          
          merge_unique -> ['番茄炒蛋', '米饭', '宫保鸡丁']    # 三份里的"米饭"合成一个
          keep_max     -> 7
          merge_dict   -> {'a': 1, 'b': 2, 'c': 3}
          ```
          
          顺带一个好消息:**不需要给带 reducer 的字段准备初始值。** LangGraph 会按类型注解推出一个空值当通道初值,`keep_max` 第一次被调用时 `old` 就是 `0`(`int` → `0`,`str` → `''`,`List` → `[]`):
          
          ```
          [keep_max] old=0 (type=int)  new=3
          [keep_max] old=3             new=7
          结果: {'best': 7}
          ```
          
          所以 `invoke({})` 就够了,也不需要在起始节点里写 `state["menu"] = []`。
          
          ## 七、判断标准
          
          一条就够用:
          
          > **reducer 里如果没同时用到 `old` 和 `new` 两个参数,就该怀疑它写错了。**
          
          - `return b` —— 没用 `old`,那是覆盖,不是合并。这种情况下 reducer 可以直接删掉,效果一样(而且删掉之后并行写同一字段会正常报 `InvalidUpdateError`,比静默丢数据好)
          - `return a` —— 没用 `new`,节点的输出被整个扔掉
          - `return old + new` / `max(old, new)` / `{**old, **new}` —— 两个都用了,这才是合并
          
          配套的自查(详见 `04_并行与reducer说明.md` 第七节):节点里不出现 `state[...].append(...)`,不 `return state`,只返回自己输出 key 组成的 dict。
          
          ## 八、三份 demo 的定位
          
          | 文件 | reducer | 节点写法 | 结果 | 用途 |
          |---|---|---|---|---|
          | `04_langgraph_paramlizm_demo.py` | `add` | append + return state | 24 条 | 反例:翻倍 |
          | `05_langgraph_paramlizm_demo2.py` | `traced_add` | 两种都有,`--mode` 切换 | 24 / 3 | 追踪工具 + 正例 |
          | `06_langgraph_paramlizm_demo3.py` | `my_merge` | append + return state | 3 条 | 反例:靠副作用凑对 |
          
          `06` 比 `04` 更值得警惕:`04` 数字明显不对,一眼能看出有问题;`06` 数字是对的,藏着顺序不定、竞态、以及"改成规范写法就丢数据"三个隐患。**测试通过不等于写法正确。**
          
      • 📄 01_langgraph_config_demo.py 1.7KB
        from langgraph.graph import StateGraph, START, END
        from langgraph.types import RunnableConfig
        
        
        # 定义一个简单的状态
        class State(dict):
            input: str
            step1: str
            step2: str
            result: str
        
        # 定义节点函数 :如果要使用配置的话,节点的入参是state 和 RunnableConfig
        def step1(state: State, config: RunnableConfig):
            prefix = config.get("configurable", {}).get("prefix", "")
            nick_name = config.get("configurable", {}).get("nick_name", "")
            text = f"{prefix}{state['input']} 我女朋友说我是:{nick_name}"
            state["step1"] = text
            print(f"[Step1] 使用 prefix = {prefix}")
            return state
        
        
        def step2(state: State, config: RunnableConfig):
            suffix = config.get("configurable", {}).get("suffix", "")
            result = f"{state['step1']}{suffix}"
            print(f"[Step2] 使用 suffix = {suffix}")
            state["step2"] = result
            state["result"] = result
            return state
        
        
        # 构建有向图
        def build_graph():
            graph_builder = StateGraph(State)
            graph_builder.add_node("step1", step1)
            graph_builder.add_node("step2", step2)
            graph_builder.add_edge(START, "step1")
            graph_builder.add_edge("step1", "step2")
            graph_builder.add_edge("step2", END)
        
            # 编译图
            graph = graph_builder.compile()
            return graph
        
        
        # 构建图
        graph = build_graph()
        
        # 运行时传入config
        config = RunnableConfig(
            configurable={
                "prefix": "[前缀] ",
                "suffix": " [后缀]",
                "nick_name":"渣男"
            }
        )
        
        #调用invoke方法时候,注意,要传入state 和 config
        result = graph.invoke({"input": "你好,LangGraph!"}, config=config)
        
        print("最终输出:", result)
        
      • 📄 02_langgraph_memory_demo.py 1.7KB
        from langgraph.checkpoint.memory import InMemorySaver
        from langgraph.graph import StateGraph, START, END
        from langgraph.types import RunnableConfig
        
         # 记忆 是通过编译时候添加InMemorySaver ,invoke时候添加thread_id来个例记忆 ,就具有了记忆的能力
        # 定义状态结构
        class State(dict):
            total: int  # 当前累加总和
            new_value: int  # 新输入的数字
        
        
        # 定义节点:执行累加逻辑
        def add_node(state: State, config: RunnableConfig):
            # 取出旧的总和(如果没有则为0)
            old_total = state.get("total", 0)
            print(old_total)
            # 获取新输入的数字
            new_value = state["new_value"]
            # 计算新的总和
            new_total = old_total + new_value
            state["total"] = new_total
            return state
        
        
        # 构建图
        def build_graph():
            graph_builder = StateGraph(State)
            graph_builder.add_node("add", add_node)
            graph_builder.add_edge(START, "add")
            graph_builder.add_edge("add", END)
        
            # 编译成可运行的应用
            memory = InMemorySaver()
            graph = graph_builder.compile(memory)
            return graph
        
        
        graph = build_graph()
        
        # === 第一次调用 ===
        config1 = RunnableConfig(configurable={
            "thread_id": "12345"
        })
        result1 = graph.invoke({"new_value": 5}, config=config1)
        print("结果1:", result1)
        
        # === 第二次调用(同一个线程,累加)===
        result2 = graph.invoke({"new_value": 7}, config=config1)
        print("结果2:", result2)
        
        # === 第三次调用(不同线程,全新开始)===
        config2 = RunnableConfig(configurable={
            "thread_id": "12345"
        })
        result3 = graph.invoke({"new_value": 10}, config=config2)
        print("结果3:", result3)
        
      • 📄 03_langgraph_human_approval_demo.py 2.4KB
        from langgraph.graph import StateGraph, START, END
        from langgraph.checkpoint.memory import InMemorySaver
        from langgraph.types import RunnableConfig, interrupt, Command
        
        
        # 定义状态结构
        class State(dict):
            task: str
            approval: str
        
        
        # 自动步骤:生成任务
        def create_task(state: State):
            task = "审批预算申请(金额:5000元)"
            print(f"系统创建任务:{task}")
            state["task"] = task
            return state
        
        
        # 人工步骤:等待人工输入
        def human_approval(state: State):
            print("等待人工审批中...")
            # 触发人工中断(这里 LangGraph 会暂停执行,直到被 resume)
            approval = interrupt("请输入审批结果(批准 / 不批准):")
            state["approval"] = approval
            return state
        
        
        # 后续步骤:根据人工结果继续
        def finalize(state: State):
            if state["approval"] == "批准":
                print(f"✅ 审批通过:{state['task']}")
            else:
                print(f"❌ 审批拒绝:{state['task']}")
            return state
        
        
        # === 构建图 ===
        def build_graph():
            grpah_builder = StateGraph(State)
            grpah_builder.add_node("create_task", create_task)
            grpah_builder.add_node("human_approval", human_approval)
            grpah_builder.add_node("finalize", finalize)
        
            grpah_builder.add_edge(START, "create_task")
            grpah_builder.add_edge("create_task", "human_approval")
            grpah_builder.add_edge("human_approval", "finalize")
            grpah_builder.add_edge("finalize", END)
        
            memory = InMemorySaver()
            graph = grpah_builder.compile(checkpointer=memory)
            return graph
        
        
        # === 运行示例 ===
        graph = build_graph()
        
        # 第一次运行 —— 执行到人工中断处
        config = RunnableConfig(configurable={"thread_id": "12345"})
        print("=== 第一次运行 ===")
        state = graph.invoke({}, config=config)
        print("第一次运行状态:", state)
        
        # # 模拟人工输入并恢复执行
        # print("\n=== 第二次运行(人工输入后继续) ===")
        # user_decision = input("是否批准?")
        # state = graph.invoke(Command(resume=user_decision), config=config)
        # print("第二次运行状态:", state)
        
        # 模拟人工输入并恢复执行
        config1 = RunnableConfig(configurable={"thread_id": "678910"})
        print("\n=== 第二次运行(人工输入后继续) ===")
        user_decision = input("是否批准?")
        state = graph.invoke(Command(resume=user_decision), config=config)
        print("第二次运行状态:", state)
        
      • 📄 04_langgraph_bingxing_demo01.py 2.1KB
        import sys
        import time
        from typing import TypedDict
        
        from langgraph.graph import StateGraph, START, END
        
        
        # 定义状态结构
        class MyState(TypedDict):
            make_vegetables: bool
            rice_heated: bool
            dish_ready: bool
        
        
        # --- 节点定义 ---
        def start_cooking(state: MyState):
            print("👨‍🍳 开始做饭!")
            return state
        
        
        def make_vegetables(state: MyState):
            print("🔪 正在炒菜...")
            time.sleep(3)
            print("🔪 炒完菜了...")
            # 模拟完成切菜
            state["make_vegetables"] = True
            return state
        
        
        def heat_rice(state: MyState):
            print("🍚 微波炉正在加热米饭...")
            time.sleep(2)
            print("🍚 米饭已经加热完成!")
        
            # 模拟加热完成
            state["rice_heated"] = True
            return state
        
        
        def serve_dish(state: MyState):
            print("🍽️ 菜和饭都准备好了,上菜!")
            state["dish_ready"] = True
            return state
        
        
        # --- 构建 LangGraph ---
        def build_graph():
            graph_builder = StateGraph(MyState)
            # print(make_vegetables.__name__)
            # sys.exit(-1)
            graph_builder.add_node(start_cooking.__name__, start_cooking)
            graph_builder.add_node(make_vegetables.__name__, make_vegetables)
            graph_builder.add_node(heat_rice.__name__, heat_rice)
            graph_builder.add_node(serve_dish.__name__, serve_dish)
        
            # 顺序 & 并行逻辑
            graph_builder.add_edge(START, start_cooking.__name__)
            graph_builder.add_edge(start_cooking.__name__, make_vegetables.__name__)
            graph_builder.add_edge(start_cooking.__name__, heat_rice.__name__)
        
            # def add_edge(self, start_key: str | list[str], end_key: str) -> Self:
            #不能这么写
            # graph_builder.add_edge(start_cooking.__name__, [heat_rice.__name__,make_vegetables.__name__])
            # 并行结果都汇总到 serve
            graph_builder.add_edge([make_vegetables.__name__, heat_rice.__name__], serve_dish.__name__)
        
            graph_builder.add_edge(serve_dish.__name__, END)
        
            graph = graph_builder.compile()
            return graph
        
        
        # --- 运行 ---
        if __name__ == "__main__":
            graph = build_graph()
            result = graph.invoke({})
            print("\n最终结果:", result)
        
      • 📄 04_langgraph_paramlizm_demo.py 2.2KB
        import time
        from operator import add
        from typing import TypedDict, List, Annotated
        
        from langgraph.graph import StateGraph, START, END
        
        
        # 定义状态结构
        class MyState(TypedDict):
            menu: Annotated[List[str], add]  # ✅ 支持并行追加
            all_ready: bool
        
        
        # --- 节点定义 ---
        def start_cooking(state: MyState):
            print("👨‍🍳 厨房准备开始!")
            # 初始化共享菜单列表
            state["menu"] = []
            return state
        
        
        def chef_a(state: MyState):
            print("👩‍🍳 A 厨师在做:番茄炒蛋...")
            time.sleep(2)
            # 向共享 list 添加菜品
            # state["menu"].append("番茄炒蛋")
            return {"menu": ["番茄炒蛋"]}
            # return state
        
        
        def chef_b(state: MyState):
            print("👨‍🍳 B 厨师在做:宫保鸡丁...")
            time.sleep(3)
            # state["menu"].append("宫保鸡丁")
            # return state
            return {"menu": ["宫保鸡丁"]}
        
        
        def chef_c(state: MyState):
            print("👩‍🍳 C 厨师在做:鱼香肉丝...")
            time.sleep(1)
            # state["menu"].append("鱼香肉丝")
            # return state
            return
        
        
        def serve_dish(state: MyState):
            print("\n🍽️ 所有菜都准备好了!菜单如下:")
            for dish in state["menu"]:
                print(" -", dish)
            state["all_ready"] = True
            return state
        
        
        # --- 构建 LangGraph ---
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("start", start_cooking)
            graph_builder.add_node("chef_a", chef_a)
            graph_builder.add_node("chef_b", chef_b)
            graph_builder.add_node("chef_c", chef_c)
            graph_builder.add_node("serve", serve_dish)
        
            # 启动 → 并行三个厨师
            graph_builder.add_edge(START, "start")
            graph_builder.add_edge("start", "chef_a")
            graph_builder.add_edge("start", "chef_b")
            graph_builder.add_edge("start", "chef_c")
        
            # 三个厨师汇总到 serve
            graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
        
            graph_builder.add_edge("serve", END)
        
            return graph_builder.compile()
        
        
        # --- 运行 ---
        if __name__ == "__main__":
            graph = build_graph()
            result = graph.invoke({})
            print("\n最终结果:", result)
        
      • 📄 05_langgraph_bingxing_list_demo.py 2.2KB
        import operator
        import time
        from typing import TypedDict, List, Annotated
        
        from langgraph.graph import StateGraph, START, END
        
        def my_merge(a, b):
            # 这里定义合并逻辑
            return b
        # 定义状态结构
        class MyState(TypedDict):
            #operator.add 这个合并起,就是把原来的内容和新内容进行一个合并
            menu: Annotated[list, my_merge]
            all_ready: bool
        
        
        # --- 节点定义 ---
        def start_cooking(state: MyState):
            print("👨‍🍳 厨房准备开始!")
            # 初始化共享菜单列表
            state["menu"] = []
            return state
        
        
        def chef_a(state: MyState):
            print("👩‍🍳 A 厨师在做:番茄炒蛋...")
            time.sleep(2)
            # 向共享 list 添加菜品
            state["menu"].append("番茄炒蛋")
            return state
        
        
        def chef_b(state: MyState):
            print("👨‍🍳 B 厨师在做:宫保鸡丁...")
            time.sleep(3)
            state["menu"].append("宫保鸡丁")
            return state
        
        
        def chef_c(state: MyState):
            print("👩‍🍳 C 厨师在做:鱼香肉丝...")
            time.sleep(1)
            state["menu"].append("鱼香肉丝")
            return state
        
        
        def serve_dish(state: MyState):
            print("\n🍽️ 所有菜都准备好了!菜单如下:")
            for dish in state["menu"]:
                print(" -", dish)
            state["all_ready"] = True
            return state
        
        
        # --- 构建 LangGraph ---
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("start", start_cooking)
            graph_builder.add_node("chef_a", chef_a)
            graph_builder.add_node("chef_b", chef_b)
            graph_builder.add_node("chef_c", chef_c)
            graph_builder.add_node("serve", serve_dish)
        
            # 启动 → 并行三个厨师
            graph_builder.add_edge(START, "start")
            graph_builder.add_edge("start", "chef_a")
            graph_builder.add_edge("start", "chef_b")
            graph_builder.add_edge("start", "chef_c")
        
            # 三个厨师汇总到 serve
            graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
        
            graph_builder.add_edge("serve", END)
        
            return graph_builder.compile()
        
        
        # --- 运行 ---
        if __name__ == "__main__":
            graph = build_graph()
            result = graph.invoke({})
            print("\n最终结果:", result)
        
      • 📄 05_langgraph_bingxing_list_demo2.py 2.6KB
        import operator
        import time
        from typing import TypedDict, List, Annotated
        
        from langgraph.graph import StateGraph, START, END
        
        # def my_merge(a, b):
        #     # 这里定义合并逻辑
        #     return b
        # 定义状态结构
        class MyState(TypedDict):
            #operator.add 这个合并起,就是把原来的内容和新内容进行一个合并
            menu: Annotated[list, operator.add]
            all_ready: bool
        
        
        # --- 节点定义 ---
        def start_cooking(state: MyState):
            print("👨‍🍳 厨房准备开始!")
            # 带 reducer 的通道初值本来就是 [],不需要初始化
            # state["menu"] = []
            # return state
            return {}
        
        
        
        def chef_a(state: MyState):
            print("👩‍🍳 A 厨师在做:番茄炒蛋...")
            time.sleep(2)
            # 向共享 list 添加菜品
            # state["menu"].append("番茄炒蛋")
            # return state
            return {"menu": ["番茄炒蛋"]}
        
        
        def chef_b(state: MyState):
            print("👨‍🍳 B 厨师在做:宫保鸡丁...")
            time.sleep(3)
            # state["menu"].append("宫保鸡丁")
            # return state
            return {"menu": ["宫保鸡丁"]}
        
        
        def chef_c(state: MyState):
            print("👩‍🍳 C 厨师在做:鱼香肉丝...")
            time.sleep(1)
            # state["menu"].append("鱼香肉丝")
            # return state
            return {"menu": ["鱼香肉丝"]}
        
        
        def serve_dish(state: MyState):
            print("\n🍽️ 所有菜都准备好了!菜单如下:")
            for dish in state["menu"]:
                print(" -", dish)
            # menu 只读不返回:此刻它已经是合并好的 3 条,
            # 一旦 return state,operator.add 会把这 3 条再追加一遍变 6 条。
            # state["all_ready"] = True
            # return state
            return {"all_ready": True}
        
        
        # --- 构建 LangGraph ---
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("start", start_cooking)
            graph_builder.add_node("chef_a", chef_a)
            graph_builder.add_node("chef_b", chef_b)
            graph_builder.add_node("chef_c", chef_c)
            graph_builder.add_node("serve", serve_dish)
        
            # 启动 → 并行三个厨师
            graph_builder.add_edge(START, "start")
            graph_builder.add_edge("start", "chef_a")
            graph_builder.add_edge("start", "chef_b")
            graph_builder.add_edge("start", "chef_c")
        
            # 三个厨师汇总到 serve
            graph_builder.add_edge(["chef_a", "chef_b", "chef_c"], "serve")
        
            graph_builder.add_edge("serve", END)
        
            return graph_builder.compile()
        
        
        # --- 运行 ---
        if __name__ == "__main__":
            graph = build_graph()
            result = graph.invoke({})
            print("\n最终结果:", result)
        
      • 📄 06_langgraph_subgraph.py 1.5KB
        from langgraph.graph import StateGraph, START, END
        from typing import TypedDict
        
        
        # ---------- 子图 ----------
        class SubState(TypedDict):
            msg: str
        
        
        def say_hello(state: SubState):
            print("👋 子图:你好,我是子流程!")
            print(state)
            return state
        
        
        def say_bye(state: SubState):
            print("👋 子图:再见!")
            return state
        
        
        def build_subgraph():
            graph_builder = StateGraph(SubState)
            graph_builder.add_node("hello", say_hello)
            graph_builder.add_node("bye", say_bye)
            graph_builder.add_edge(START, "hello")
            graph_builder.add_edge("hello", "bye")
            graph_builder.add_edge("bye", END)
            return graph_builder.compile()
        
        
        # ---------- 主图 ----------
        class MainState(TypedDict):
            done: bool
        
        
        def start_main(state: MainState):
            print("🚀 主图开始运行!")
            return state
        
        
        def finish_main(state: MainState):
            print("✅ 主图:全部完成!")
            state["done"] = True
            return state
        
        
        def build_main_graph():
            subgraph = build_subgraph()  # ✅ 子图
        
            g = StateGraph(MainState)
            g.add_node("start", start_main)
            g.add_node("sub", subgraph)  # 👈 把子图当作一个节点
            g.add_node("finish", finish_main)
        
            g.add_edge(START, "start")
            g.add_edge("start", "sub")
            g.add_edge("sub", "finish")
            g.add_edge("finish", END)
            return g.compile()
        
        
        # ---------- 运行 ----------
        if __name__ == "__main__":
            graph = build_main_graph()
            result = graph.invoke({})
            print("结果:", result)
        
      • 📄 07_langgraph_subgraph_param.py 2.2KB
        from langgraph.graph import StateGraph, START, END
        from typing import TypedDict
        
        
        # ---------- 子图 ----------
        class SubState(TypedDict):
            name: str
        
        
        def say_hello(state: SubState):
            name = state["name"]
            print(f"input_name:{name}")
            print("子图开始")
            print(f"👋 子图:你好,{name},我是子流程!")
            state["name"]="小帅&小美"
            return state
        
        
        def say_bye(state: SubState):
            name = state["name"]
            print("子图结束")
            print(f"👋 子图:{name},再见!")
            return state
        
        
        def build_subgraph():
            g = StateGraph(SubState)
            g.add_node("hello", say_hello)
            g.add_node("bye", say_bye)
            g.add_edge(START, "hello")
            g.add_edge("hello", "bye")
            g.add_edge("bye", END)
            return g.compile()
        
        
        # ---------- 主图 ----------
        class MainState(TypedDict):
            input: str
            done: bool
            sub_output: dict  # 用于保存子图返回结果
        
        
        def start_main(state: MainState):
            print("🚀 主图开始运行!")
            return state
        
        
        # ✅ 子图包装节点(负责调用子图并传递/接收数据)
        def run_subgraph(state: MainState):
            subgraph = build_subgraph()
        
            # 从主图状态中构造子图输入
            # sub_state = {"name": "小帅"}
            input=state["input"]
            sub_state = {"name": input}
            # 调用子图,返回子图的状态
            result = subgraph.invoke(sub_state)
        
            # 打印和合并结果回主图
            print("✅ 主图:子图输出 =", result)
            state["sub_output"] = result
            print("✅ 主图:合并后的结果 =", state)
            return state
        
        
        def finish_main(state: MainState):
            print("✅ 主图:全部完成!")
            state["done"] = True
            return state
        
        
        def build_main_graph():
            g = StateGraph(MainState)
            g.add_node("start", start_main)
            g.add_node("sub", run_subgraph)
            g.add_node("finish", finish_main)
        
            g.add_edge(START, "start")
            g.add_edge("start", "sub")
            g.add_edge("sub", "finish")
            g.add_edge("finish", END)
            return g.compile()
        
        
        # ---------- 运行 ----------
        if __name__ == "__main__":
            graph = build_main_graph()
            result = graph.invoke({"input":"主图来了"})
            print("结果:", result)
        
      • 📄 08_langgraph_stream_demo.py 1.3KB
        from langgraph.graph import StateGraph, START, END
        from langchain_openai import ChatOpenAI
        from typing import TypedDict
        
        
        # ===== 1. 定义状态 =====
        class MyState(TypedDict):
            question: str
            thought: str
            answer: str
        
        
        # ===== 3. 定义节点 =====
        def think_node(state: MyState):
            """模拟思考过程"""
            print("🤔 思考中...")
            state["thought"] = "是怎么染色的呢?"
            return state
        
        
        def answer_node(state: MyState):
            """生成最终答案"""
            print("\n✅ 回答完成!")
            state["answer"] = "是用彩笔染色的。"
            return state
        
        
        # ===== 4. 构建图 =====
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("思考节点", think_node)
            graph_builder.add_node("回答节点", answer_node)
        
            graph_builder.add_edge(START, "思考节点")
            graph_builder.add_edge("思考节点", "回答节点")
            graph_builder.add_edge("回答节点", END)
        
            return graph_builder.compile()
        
        
        # ===== 5. 测试运行 =====
        if __name__ == "__main__":
            graph = build_graph()
            for chunk in graph.stream({"question": "为什么天空是蓝色的?"}):
                print(chunk)
        
            print("======================")
            print("非流式输出")
            result = graph.invoke({"question": "为什么天空是蓝色的?"})
            print(result)
        
      • 📄 09_langgraph_ainvoke_demo1.py 1.3KB
        import asyncio
        
        from langgraph.graph import StateGraph, START, END
        from langchain_openai import ChatOpenAI
        from typing import TypedDict
        
        
        # ===== 1. 定义状态 =====
        class MyState(TypedDict):
            question: str
            thought: str
            answer: str
        
        
        # ===== 3. 定义节点 =====
        def think_node(state: MyState):
            """模拟思考过程"""
            print("🤔 思考中...")
            state["thought"] = "是怎么染色的呢?"
            return state
        
        
        def answer_node(state: MyState):
            """生成最终答案"""
            print("\n✅ 回答完成!")
            state["answer"] = "是用彩笔染色的。"
            return state
        
        
        # ===== 4. 构建图 =====
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("思考节点", think_node)
            graph_builder.add_node("回答节点", answer_node)
        
            graph_builder.add_edge(START, "思考节点")
            graph_builder.add_edge("思考节点", "回答节点")
            graph_builder.add_edge("回答节点", END)
        
            return graph_builder.compile()
        
        
        async def main(state: MyState):
            """主函数"""
            graph = build_graph()
            result = await graph.ainvoke(state)
            return result
        
        
        # ===== 5. 测试运行 =====
        if __name__ == "__main__":
            result = asyncio.run(main({"question": "为什么天空是蓝色的?"}))
            print(result)
        
      • 📄 10_langgraph_ainvoke_demo02.py 1.6KB
        import asyncio
        
        from langgraph.graph import StateGraph, START, END
        from langchain_openai import ChatOpenAI
        from typing import TypedDict
        
        
        # ===== 1. 定义状态 =====
        class MyState(TypedDict):
            question: str
            thought: str
            answer: str
        
        
        # ===== 3. 定义节点 =====
        def think_node(state: MyState):
            """模拟思考过程"""
            print("🤔 思考中...")
            state["thought"] = "是怎么染色的呢?"
            return state
        
        
        def answer_node(state: MyState):
            """生成最终答案"""
            print("\n✅ 回答完成!")
            state["answer"] = "是用彩笔染色的。"
            return state
        
        
        # ===== 4. 构建图 =====
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("思考节点", think_node)
            graph_builder.add_node("回答节点", answer_node)
        
            graph_builder.add_edge(START, "思考节点")
            graph_builder.add_edge("思考节点", "回答节点")
            graph_builder.add_edge("回答节点", END)
        
            return graph_builder.compile()
        
        
        async def main(state: MyState):
            """主函数"""
            graph = build_graph()
            result = await graph.ainvoke(state)
            return result
        
        # 任务2:快任务
        async def fastprint():
            print("\n/n")
            print("我是 12345")
        
        async def run():
            #后台跑langgraph
            task1 = asyncio.create_task(main({"question": "为什么天空是蓝色的?"}))
            #后台跑fastprint
            task2 = asyncio.create_task(fastprint())
            await task1
            await task2
        # ===== 5. 测试运行 =====
        if __name__ == "__main__":
            asyncio.run(run())
        
      • 📄 11_langgraph_astream_demo01.py 1.4KB
        import asyncio
        
        from langgraph.graph import StateGraph, START, END
        from langchain_openai import ChatOpenAI
        from typing import TypedDict
        
        
        # ===== 1. 定义状态 =====
        class MyState(TypedDict):
            question: str
            thought: str
            answer: str
        
        
        # ===== 3. 定义节点 =====
        def think_node(state: MyState):
            """模拟思考过程"""
            print("🤔 思考中...")
            state["thought"] = "是怎么染色的呢?"
            return state
        
        
        def answer_node(state: MyState):
            """生成最终答案"""
            print("\n✅ 回答完成!")
            state["answer"] = "是用彩笔染色的。"
            return state
        
        
        # ===== 4. 构建图 =====
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("思考节点", think_node)
            graph_builder.add_node("回答节点", answer_node)
        
            graph_builder.add_edge(START, "思考节点")
            graph_builder.add_edge("思考节点", "回答节点")
            graph_builder.add_edge("回答节点", END)
        
            return graph_builder.compile()
        
        
        async def main(state: MyState):
            """主函数"""
            graph = build_graph()
            result = None
            async for event in graph.astream(state):
                print(event)
                result = event
            return result
        
        
        # ===== 5. 测试运行 =====
        if __name__ == "__main__":
            result = asyncio.run(main({"question": "为什么天空是蓝色的?"}))
            print("最终回答:")
            print(result)
        
      • 📄 12_langgraph_astream_demo02.py 1.8KB
        import asyncio
        
        from langgraph.graph import StateGraph, START, END
        from langchain_openai import ChatOpenAI
        from typing import TypedDict
        
        
        # ===== 1. 定义状态 =====
        class MyState(TypedDict):
            question: str
            thought: str
            answer: str
        
        
        # ===== 3. 定义节点 =====
        def think_node(state: MyState):
            """模拟思考过程"""
            print("🤔 思考中...")
            state["thought"] = "是怎么染色的呢?"
            return state
        
        
        def answer_node(state: MyState):
            """生成最终答案"""
            print("\n✅ 回答完成!")
            state["answer"] = "是用彩笔染色的。"
            return state
        
        
        # ===== 4. 构建图 =====
        def build_graph():
            graph_builder = StateGraph(MyState)
            graph_builder.add_node("思考节点", think_node)
            graph_builder.add_node("回答节点", answer_node)
        
            graph_builder.add_edge(START, "思考节点")
            graph_builder.add_edge("思考节点", "回答节点")
            graph_builder.add_edge("回答节点", END)
        
            return graph_builder.compile()
        
        
        async def main(state: MyState):
            """主函数"""
            graph = build_graph()
            result = None
            async for event in graph.astream(state):
                print(event)
                result = event
            return result
        
        # 任务2:快任务
        async def fastprint():
            print("\n/n")
            print("我是 12345")
        
        async def run():
            #后台跑langgraph
            task1 = asyncio.create_task(main({"question": "为什么天空是蓝色的?"}))
            #后台跑fastprint
            task2 = asyncio.create_task(fastprint())
            await task1
            await task2
        
        
        # ===== 5. 测试运行 =====
        if __name__ == "__main__":
            # result = asyncio.run(main({"question": "为什么天空是蓝色的?"}))
            # print("最终回答:")
            # print(result)
            asyncio.run(run())
        
    • 📄 002_BeautifulSoup_demo.py 947B
      from bs4 import BeautifulSoup
      
      # 假设有一段 HTML 内容 ,通过requests.get  requests.post请求回来的html内容
      html = """
      <html>
        <head><title>中药方剂示例</title></head>
        <body>
          <h1>方剂列表</h1>
          <div class="formula">
            <h2>葛根汤</h2>
            <p>功效:解肌发表,升阳止泻。</p>
          </div>
          <div class="formula">
            <h2>麻黄汤</h2>
            <p>功效:发汗解表,宣肺平喘。</p>
          </div>
        </body>
      </html>
      """
      
      # 使用 BeautifulSoup 解析 HTML
      soup = BeautifulSoup(html, 'html.parser')
      
      # # 提取标题
      # title = soup.title.string
      # print("网页标题:", title)
      
      # # 提取所有方剂名称
      # names = soup.find_all('h2')
      # print("\n方剂名称:")
      # for tag in names:
      #     print("-", tag.get_text())
      #
      # 提取所有功效描述
      effects = soup.find_all('p')
      print("\n功效描述:")
      for tag in effects:
          print("-", tag.get_text())
      
    • 📄 01_requests_demo.py 145B
      import requests
      from bs4 import BeautifulSoup
      
      url = 'http://www.baidu.com'
      response = requests.get(url)
      html = response.text
      
      print(html)
      
    • 📄 03_faiss_demo.py 1.5KB
      # encoding: utf-8
      import sys
      
      import faiss
      import numpy as np
      from sentence_transformers import SentenceTransformer
      
      # 1. 构造样本文本(假设是中医症状)
      texts = [
          "腹泻",
          "夜间盗汗",
          "手足心热",
          "腰膝酸软",
          "头晕耳鸣",
          "胃脘不适",
          "咳嗽有痰",
          "气短乏力",
          "心烦易怒",
          "失眠多梦"
      ]
      
      # 2. 加载一个中文向量模型(可换为 bge 或其他模型)
      model = SentenceTransformer(r'D:\bge-large-zh-v1.5')
      
      # 3. 向量化处理 AISS 只认 numpy 数组 所以要 convert_to_numpy=True
      embeddings = model.encode(texts, convert_to_numpy=True)
      # print(embeddings.shape)  #(10, 1024)  一共10条症状,每个embedding后的维度是1024
      # sys.exit(-1)
      # 4. 构建 Faiss 索引(L2 距离)
      dimension = embeddings.shape[1] #生成的向量的维度
      index = faiss.IndexFlatL2(dimension)   # 也可以用 IndexFlatIP 做余弦相似度
      index.add(embeddings)                  # 添加向量到索引中
      
      # 5. 输入查询语句
      query = "拉肚子"
      
      # 6. 向量化查询文本
      query_vec = model.encode([query], convert_to_numpy=True)
      
      # 7. Faiss 查询最相近的5条
      top_k = 5
      #返回的是 欧式距离 和索引id
      distances, indices = index.search(query_vec, top_k)
      
      # 8. 打印结果
      print("🔍 查询文本:", query)
      print("\n📋 最相似的结果:")
      for i, idx in enumerate(indices[0]):
          print(f"{i+1}. {texts[idx]} (欧式距离: {distances[0][i]:.4f})")
      
    • 📄 04_langgraph_demo.py 5.4KB
      from typing import TypedDict, Literal
      from langchain_openai import ChatOpenAI
      from langgraph.constants import START
      from langgraph.graph import StateGraph, END
      from langchain_core.messages import SystemMessage, HumanMessage
      from langchain_core.runnables import Runnable
      
      from common.llm import my_llm
      
      llm = my_llm
      # ========== 2) 定义状态 ==========
      class AgentState(dict):
          # 输入
          input: str
          # 是否是翻译
          is_translate: bool
          # 要翻译的句子
          translate_sentence:str
          # 翻译结果
          translate_result: str
          # QA结果
          qa_result: str
          # 输出
          output: str
      
      
      #意图识别节点
      def identify_intent_node(state: AgentState) -> AgentState:
          """判断用户输入是翻译文章还是普通回答"""
          input = state['input']
          messages = [
              SystemMessage(content=(
                  "你是一个意图分类助手,用来判断用户输入是'翻译任务'还是'普通回答任务'。\n"
                  "翻译任务的特征:用户明确要求翻译文章、句子、单词,或要求将中文翻译成英文/英文翻译成中文。\n"
                  "普通回答任务的特征:用户是直接提问、咨询信息、讨论话题,而不是要求翻译。\n"
                  "请你只回答:翻译 或 普通回答,不要补充其他内容。"
              )),
              HumanMessage(content=input)
          ]
      
          response = llm.invoke(messages).content.strip()
      
          if "翻译" in response:
              state['is_translate'] = True
          else:
              state['is_translate'] = False
      
          return state
      
      
      def extract_translate_sentence_node(state: AgentState) -> AgentState:
          """提取翻译句子"""
          input = state['input']
          prompot = (
              "你是翻译助手。请先自动识别语言,然后提取待翻译的句子。"
              "请将待翻译的句子提取出来,并输出。"
              "请不要输出其他内容。\n"
              f"待翻译内容:{input}"
          )
          result = llm.invoke([HumanMessage(content=prompot)]).content
          state['translate_sentence'] = result
          return state
      
      def translate_node(state: AgentState) -> AgentState:
          """翻译节点"""
          translate_sentence = state["translate_sentence"]
          prompt = (
              "你是翻译助手。请先自动识别语言,然后在中文与英文之间互译。"
              "只输出译文本身,不要额外解释。\n"
              f"待翻译内容:{translate_sentence}"
          )
          result = llm.invoke([HumanMessage(content=prompt)]).content
          state['translate_result'] = result
          state['output'] = result
          # 按你的状态定义回写
          return state
      
      def qa_node(state: AgentState) -> AgentState:
          """QA节点"""
          qa_sentence = state["input"]
          prompt = (
              "你是一个问答助手,请回答问题。"
              "请使用中文回答。\n"
              f"问题:{qa_sentence}"
          )
          result = llm.invoke([HumanMessage(content=prompt)]).content
          state['qa_result'] = result
          state['output'] = result
          # 按你的状态定义回写
          return state
      
      
      # 构建langgraph
      def build_langgraph():
          #langgraph的图就叫一个状态图 StateGraph  graph_builder 图的构造者
          graph_builder = StateGraph(AgentState)
          # 添加节点
          #两个参数: 节点名称,节点函数
          graph_builder.add_node("意图识别节点", identify_intent_node)
          graph_builder.add_node("抽取翻译内容节点", extract_translate_sentence_node)
          graph_builder.add_node("翻译节点", translate_node)
          graph_builder.add_node("问答节点", qa_node)
          # 添加边
          graph_builder.add_edge(START, "意图识别节点")
          #路由函数
          def intent_detector_agent_control(state: AgentState):
              # 根据问题是否为中医问题,决定下一步的代理节点
              if state['is_translate']:
                  return "抽取翻译内容节点"
              else:
                  return "问答节点"
          #根据add_conditional_edges 条件边+ 路由函数:intent_detector_agent_control
          graph_builder.add_conditional_edges("意图识别节点", intent_detector_agent_control)
          graph_builder.add_edge("抽取翻译内容节点", "翻译节点")
          graph_builder.add_edge("翻译节点", END)
          graph_builder.add_edge("问答节点", END)
          #通过compile 编译 才能从一张草图变成真正可用的图
          graph = graph_builder.compile()
          return graph
      
      
      
      # if __name__ == '__main__':
      #     # state = {'input': '翻译这句话:hello world'}
      #     # state=identify_intent_node(state)
      #     # print(state)
      #
      #     # state = {'input': 'hello world'}
      #     # state = identify_intent_node(state)
      #     # print(state)
      #     # state={'input': '翻译这句话:hello world'}
      #     # state=extract_translate_sentence_node(state)
      #     # print(state)
      #
      #     state={'translate_sentence': 'hello world'}
      #     #翻译节点
      #     state=translate_node(state)
      #     print(state)
      #     print("================")
      #     state={'input': '我与城北徐公孰美?'}
      #     #直接问答节点
      #     state=qa_node(state)
      #     print(state)
      
      
      if __name__ == "__main__":
          graph = build_langgraph()
          # 测试翻译任务
          # state = graph.invoke({"input": "请将这句话翻译成英文:你好,世界!"})
          # print(state)
          print("=============")
          # # 测试普通问答任务
          state = graph.invoke({"input": "解释下什么是大模型"})
          print(state)
      
    • 📄 fastapi_upload_file_client.py 517B
      # 创建请求客户端
      import requests
      
      # 单文件上传
      file_path = r"D:\heima_creazy_05\04_code\chinese_medical_project_all_project_for_heima_crazy_05\_000_demo\fastapi_demo\fastapi_client.py"
      # 发送单文件上传请求
      url = "http://127.0.0.1:8000/uploadfile/"
      with open(file_path, "rb") as f:
          files = {"file": (file_path, f)}
          response = requests.post(url, files=files)
      
      print("单文件上传响应:")
      print(f"状态码: {response.status_code}")
      print(f"响应内容: {response.json()}")
      
  • 📁 _001__clawler/
    • 📄 01_herb_list.py 3.4KB
      """爬取中药大全的中药列表:中药名、详情页链接、所属分类,保存到 csv。
      
      页面结构(2026-07 实测):
          div.p_content 下按 h2 分成 22 个药性分类(解表药、清热药 ...),
          每个 h2 之后跟着若干 <a href="/wiki/xxx">中药名</a>。
          共 1480 条,其中 8 条是同一味药挂在两个分类下。
      
      用法(在项目根目录执行):
          python -m _001__clawler.01_herb_list
      """
      
      import csv
      import time
      
      import requests
      from bs4 import BeautifulSoup
      
      from common.path_utils import get_file_path
      
      BASE_URL = "https://zhongyibaike.com"
      LIST_URL = "https://zhongyibaike.com/wiki/%E4%B8%AD%E8%8D%AF%E5%A4%A7%E5%85%A8"
      OUTPUT_CSV = get_file_path("_001__clawler/herb_list.csv")
      
      HEADERS = {
          "User-Agent": (
              "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
          )
      }
      
      
      def fetch_html(url, retries=3, timeout=30):
          """下载页面 html,失败时重试。"""
          for attempt in range(1, retries + 1):
              try:
                  response = requests.get(url, headers=HEADERS, timeout=timeout)
                  response.raise_for_status()
                  # 站点返回 utf-8,显式指定避免 requests 猜错编码导致中文乱码
                  response.encoding = "utf-8"
                  return response.text
              except requests.RequestException as exc:
                  print(f"第 {attempt}/{retries} 次请求失败: {exc}")
                  if attempt == retries:
                      raise
                  time.sleep(2 * attempt)
      
      
      def parse_herb_list(html):
          """从列表页 html 中解析出 [{分类, 中药名, 链接}, ...],并按链接去重。
      
          用 descendants 顺序遍历而不是 find_all('a'),这样才能知道每个链接
          落在哪个 h2 分类下面。
          """
          soup = BeautifulSoup(html, "html.parser")
          content = soup.find("div", class_="p_content")
          if content is None:
              raise RuntimeError("找不到 div.p_content,页面结构可能已经改版")
      
          herbs = []
          seen = set()
          category = None
          for element in content.descendants:
              name = getattr(element, "name", None)
              if name == "h2":
                  category = element.get_text(strip=True)
              elif name == "a" and element.get("href", "").startswith("/wiki/"):
                  herb_name = element.get_text(strip=True)
                  href = element["href"]
                  if not herb_name or href in seen:
                      continue
                  seen.add(href)
                  herbs.append(
                      {
                          "分类": category or "",
                          "中药名": herb_name,
                          "链接": BASE_URL + href,
                      }
                  )
          return herbs
      
      
      def save_to_csv(herbs, output_path):
          """写 csv。utf-8-sig 让 Excel 打开中文不乱码。"""
          with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
              writer = csv.DictWriter(f, fieldnames=["分类", "中药名", "链接"])
              writer.writeheader()
              writer.writerows(herbs)
      
      
      def main():
          print(f"正在抓取列表页: {LIST_URL}")
          html = fetch_html(LIST_URL)
      
          herbs = parse_herb_list(html)
          categories = {herb["分类"] for herb in herbs}
          print(f"解析到 {len(herbs)} 味中药,{len(categories)} 个分类")
      
          save_to_csv(herbs, OUTPUT_CSV)
          print(f"已保存到: {OUTPUT_CSV}")
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 02_herb_detail.py 8.0KB
      """遍历 herb_list.csv,爬取每味中药的详情,保存到 中药/中药名.txt。
      
      支持断点续爬:已经存在对应 txt 的中药直接跳过,中断后重跑即可继续。
      
      页面结构(2026-07 实测):
          div.p_content 下有 3~4 个 div.card-panel,每个 panel 一个 h2 大节:
              名称 / xx的种植和炮制 / xx的效果 / xx的药方(部分药材没有药方)
          "种植和炮制" 和 "效果" 两节内部再用 h3 分小节(来源/性味/炮制/性状/
          功效/经脉/主治/用法用量/注意禁忌),h3 集合每味药都不一样,所以按
          文档顺序动态解析,不写死字段名。
      
      用法(在项目根目录执行):
          python -m _001__clawler.02_herb_detail                    # 跑全部,5 线程
          python -m _001__clawler.02_herb_detail --limit 5          # 只跑 5 条看效果
          python -m _001__clawler.02_herb_detail --workers 10       # 提到 10 线程
          python -m _001__clawler.02_herb_detail --workers 1        # 退回单线程
      """
      
      import argparse
      import csv
      import os
      import random
      import threading
      import time
      from concurrent.futures import ThreadPoolExecutor, as_completed
      
      import requests
      from bs4 import BeautifulSoup
      
      from common.path_utils import get_file_path
      
      INPUT_CSV = get_file_path("_001__clawler/herb_list.csv")
      OUTPUT_DIR = get_file_path("_001__clawler/中药")
      
      HEADERS = {
          "User-Agent": (
              "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
          )
      }
      
      # requests.Session 不是线程安全的,每个线程各用一个(连接复用也还在)
      _local = threading.local()
      # print 在多线程下会串行,加锁保证一条日志不被撕开
      _print_lock = threading.Lock()
      
      
      def log(message):
          with _print_lock:
              print(message, flush=True)
      
      
      def get_session():
          session = getattr(_local, "session", None)
          if session is None:
              session = requests.Session()
              session.headers.update(HEADERS)
              _local.session = session
          return session
      
      
      def fetch_html(url, retries=3, timeout=30):
          """下载页面 html,失败时退避重试。"""
          for attempt in range(1, retries + 1):
              try:
                  response = get_session().get(url, timeout=timeout)
                  response.raise_for_status()
                  response.encoding = "utf-8"
                  return response.text
              except requests.RequestException as exc:
                  log(f"    第 {attempt}/{retries} 次请求失败: {exc}")
                  if attempt == retries:
                      return None
                  time.sleep(2 * attempt)
      
      
      def parse_detail(html):
          """解析详情页,返回 [(标题, [段落, ...]), ...],标题层级用 h2/h3 区分。
      
          跳过两类噪音:
            1. 图片 figure,其文字内容是无意义的 "1 #"
            2. ol.p_alternative_list,别名已经在上一个 <p> 里以顿号形式出现过
          """
          soup = BeautifulSoup(html, "html.parser")
          content = soup.find("div", class_="p_content")
          if content is None:
              return None
      
          sections = []
          for panel in content.find_all("div", class_="card-panel"):
              # 药方那一节的正文在 ul > li 里,不是 p,所以 li 也要收
              for element in panel.find_all(["h2", "h3", "p", "li"]):
                  if element.name == "p":
                      # 图片块和别名 ol 都挂在 <p> 里面,先剔掉
                      if element.find("div", class_="p_image_center") or element.find("ol"):
                          continue
                  elif element.name == "li":
                      # 别名列表 ol.p_alternative_list 的 li 是重复内容,跳过
                      if element.find_parent("ol", class_="p_alternative_list"):
                          continue
                  text = element.get_text(" ", strip=True)
                  if not text:
                      continue
                  if element.name in ("h2", "h3"):
                      # h2 后面多了空格的情况(如 "毛车藤  的效果")顺手清掉
                      sections.append((element.name, " ".join(text.split()), []))
                  elif sections:
                      sections[-1][2].append(" ".join(text.split()))
          return sections
      
      
      def format_detail(name, url, category, sections):
          """把解析结果拼成可读的 txt 文本。"""
          lines = [f"中药名:{name}", f"分类:{category}", f"链接:{url}", ""]
          for level, title, paragraphs in sections:
              lines.append(("## " if level == "h2" else "### ") + title)
              lines.extend(paragraphs)
              lines.append("")
          return "\n".join(lines).rstrip() + "\n"
      
      
      def crawl_one(herb, min_delay, max_delay):
          """抓一味药并落盘。返回 True 成功 / False 失败。由线程池并发调用。"""
          name, url = herb["中药名"], herb["链接"]
          output_path = os.path.join(OUTPUT_DIR, f"{name}.txt")
      
          html = fetch_html(url)
          if html is None:
              return False
      
          sections = parse_detail(html)
          if not sections:
              log(f"    解析失败,跳过 {name}(页面结构可能不同)")
              return False
      
          text = format_detail(name, url, herb["分类"], sections)
          # 先写临时文件再改名,避免中断时留下半个文件被误判成已完成。
          # 带线程名后缀,防止两个线程刚好撞到同名临时文件。
          temp_path = f"{output_path}.{threading.get_ident()}.part"
          with open(temp_path, "w", encoding="utf-8") as f:
              f.write(text)
          os.replace(temp_path, output_path)
      
          # 每个线程自己睡,等效于把整体 QPS 压到 workers / 平均延时
          time.sleep(random.uniform(min_delay, max_delay))
          return True
      
      
      def crawl(limit=0, workers=5, min_delay=1.0, max_delay=2.0):
          os.makedirs(OUTPUT_DIR, exist_ok=True)
          with open(INPUT_CSV, encoding="utf-8-sig") as f:
              herbs = list(csv.DictReader(f))
      
          # 断点续爬:先把已抓过的过滤掉,剩下的才交给线程池
          todo = [
              herb
              for herb in herbs
              if not os.path.exists(os.path.join(OUTPUT_DIR, f"{herb['中药名']}.txt"))
          ]
          skipped = len(herbs) - len(todo)
          if limit:
              todo = todo[:limit]
      
          print(f"共 {len(herbs)} 条,已存在 {skipped} 条,本次待抓 {len(todo)} 条")
          print(f"并发线程数: {workers}")
      
          done = failed = 0
          if todo:
              with ThreadPoolExecutor(max_workers=workers) as pool:
                  futures = {
                      pool.submit(crawl_one, herb, min_delay, max_delay): herb
                      for herb in todo
                  }
                  for finished, future in enumerate(as_completed(futures), start=1):
                      herb = futures[future]
                      try:
                          ok = future.result()
                      except Exception as exc:  # 单条异常不该拖垮整轮
                          log(f"    {herb['中药名']} 异常: {exc}")
                          ok = False
                      if ok:
                          done += 1
                      else:
                          failed += 1
                      log(f"[{finished}/{len(todo)}] {herb['中药名']} {'✓' if ok else '✗'}")
      
          print(f"\n完成 {done} 条,跳过(已存在){skipped} 条,失败 {failed} 条")
          print(f"输出目录: {OUTPUT_DIR}")
      
      
      def main():
          parser = argparse.ArgumentParser(description="爬取中药详情")
          parser.add_argument(
              "--limit",
              type=int,
              default=0,
              help="本次最多新抓多少条,0 表示不限制(默认 0)",
          )
          parser.add_argument(
              "--workers",
              type=int,
              default=5,
              help="并发线程数(默认 5,设 1 就是单线程)",
          )
          parser.add_argument(
              "--min-delay",
              type=float,
              default=1.0,
              help="每个线程每条之间的最小延时秒数(默认 1.0)",
          )
          parser.add_argument(
              "--max-delay",
              type=float,
              default=2.0,
              help="每个线程每条之间的最大延时秒数(默认 2.0)",
          )
          args = parser.parse_args()
          if args.workers < 1:
              parser.error("--workers 至少为 1")
          crawl(
              limit=args.limit,
              workers=args.workers,
              min_delay=args.min_delay,
              max_delay=args.max_delay,
          )
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 03_formula_list.py 3.5KB
      """爬取中医方剂大全的方剂列表:方剂名、详情页链接、所属分类,保存到 csv。
      
      页面结构(2026-07 实测):和中药大全一致——div.p_content 下按 h2 分成
      20 个分类(解表剂、泻下剂、和解剂 ...),每个 h2 之后跟着若干
      <a href="/wiki/xxx">方剂名</a>。共 721 条,其中 18 条是同一方剂
      挂在两个分类下。
      
      用法(在项目根目录执行):
          python -m _001__clawler.03_formula_list
      """
      
      import csv
      import time
      
      import requests
      from bs4 import BeautifulSoup
      
      from common.path_utils import get_file_path
      
      BASE_URL = "https://zhongyibaike.com"
      LIST_URL = "https://zhongyibaike.com/wiki/%E4%B8%AD%E5%8C%BB%E6%96%B9%E5%89%82"
      OUTPUT_CSV = get_file_path("_001__clawler/formula_list.csv")
      
      HEADERS = {
          "User-Agent": (
              "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
          )
      }
      
      
      def fetch_html(url, retries=3, timeout=30):
          """下载页面 html,失败时重试。"""
          for attempt in range(1, retries + 1):
              try:
                  response = requests.get(url, headers=HEADERS, timeout=timeout)
                  response.raise_for_status()
                  # 站点返回 utf-8,显式指定避免 requests 猜错编码导致中文乱码
                  response.encoding = "utf-8"
                  return response.text
              except requests.RequestException as exc:
                  print(f"第 {attempt}/{retries} 次请求失败: {exc}")
                  if attempt == retries:
                      raise
                  time.sleep(2 * attempt)
      
      
      def parse_formula_list(html):
          """从列表页 html 中解析出 [{分类, 方剂名, 链接}, ...],并按链接去重。
      
          用 descendants 顺序遍历而不是 find_all('a'),这样才能知道每个链接
          落在哪个 h2 分类下面。
          """
          soup = BeautifulSoup(html, "html.parser")
          content = soup.find("div", class_="p_content")
          if content is None:
              raise RuntimeError("找不到 div.p_content,页面结构可能已经改版")
      
          formulas = []
          seen = set()
          category = None
          for element in content.descendants:
              name = getattr(element, "name", None)
              if name == "h2":
                  category = element.get_text(strip=True)
              elif name == "a" and element.get("href", "").startswith("/wiki/"):
                  formula_name = element.get_text(strip=True)
                  href = element["href"]
                  if not formula_name or href in seen:
                      continue
                  seen.add(href)
                  formulas.append(
                      {
                          "分类": category or "",
                          "方剂名": formula_name,
                          "链接": BASE_URL + href,
                      }
                  )
          return formulas
      
      
      def save_to_csv(formulas, output_path):
          """写 csv。utf-8-sig 让 Excel 打开中文不乱码。"""
          with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
              writer = csv.DictWriter(f, fieldnames=["分类", "方剂名", "链接"])
              writer.writeheader()
              writer.writerows(formulas)
      
      
      def main():
          print(f"正在抓取列表页: {LIST_URL}")
          html = fetch_html(LIST_URL)
          #分类 :方剂  链接
          formulas = parse_formula_list(html)
          categories = {formula["分类"] for formula in formulas}
          print(f"解析到 {len(formulas)} 个方剂,{len(categories)} 个分类")
      
          save_to_csv(formulas, OUTPUT_CSV)
          print(f"已保存到: {OUTPUT_CSV}")
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 04_formula_detail.py 8.0KB
      """遍历 formula_list.csv,爬取每个方剂的详情,保存到 方剂/方剂名.txt。
      
      支持断点续爬:已经存在对应 txt 的方剂直接跳过,中断后重跑即可继续。
      
      页面结构(2026-07 实测):
          div.p_content 下有 2 个 div.card-panel,每个 panel 一个 h2 大节:
              xx的药方 / xx的功效
          内部再用 h3 分小节(出处/分类/组成/解释 · 功用/主治/用法/注意),
          h3 集合每个方剂都不一样,所以按文档顺序动态解析,不写死字段名。
          注意"注意"一节的正文可能在 ul > li 里,不是 p。
      
      用法(在项目根目录执行):
          python -m _001__clawler.04_formula_detail                 # 跑全部,5 线程
          python -m _001__clawler.04_formula_detail --limit 5       # 只跑 5 条看效果
          python -m _001__clawler.04_formula_detail --workers 10    # 提到 10 线程
          python -m _001__clawler.04_formula_detail --workers 1     # 退回单线程
      """
      
      import argparse
      import csv
      import os
      import random
      import threading
      import time
      from concurrent.futures import ThreadPoolExecutor, as_completed
      
      import requests
      from bs4 import BeautifulSoup
      
      from common.path_utils import get_file_path
      
      INPUT_CSV = get_file_path("_001__clawler/formula_list.csv")
      OUTPUT_DIR = get_file_path("_001__clawler/方剂")
      
      HEADERS = {
          "User-Agent": (
              "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
          )
      }
      
      # requests.Session 不是线程安全的,每个线程各用一个(连接复用也还在)
      _local = threading.local()
      # print 在多线程下会串行,加锁保证一条日志不被撕开
      _print_lock = threading.Lock()
      
      
      def log(message):
          with _print_lock:
              print(message, flush=True)
      
      
      def get_session():
          session = getattr(_local, "session", None)
          if session is None:
              session = requests.Session()
              session.headers.update(HEADERS)
              _local.session = session
          return session
      
      
      def fetch_html(url, retries=3, timeout=30):
          """下载页面 html,失败时退避重试。"""
          for attempt in range(1, retries + 1):
              try:
                  response = get_session().get(url, timeout=timeout)
                  response.raise_for_status()
                  response.encoding = "utf-8"
                  return response.text
              except requests.RequestException as exc:
                  log(f"    第 {attempt}/{retries} 次请求失败: {exc}")
                  if attempt == retries:
                      return None
                  time.sleep(2 * attempt)
      
      
      def parse_detail(html):
          """解析详情页,返回 [(标题层级, 标题, [段落, ...]), ...]。
      
          和中药详情用同一套逻辑:按文档顺序走 h2/h3/p/li,遇到标题就开一节。
          图片块和别名 ol 在方剂页暂时没出现,但保留判断,页面改版时不至于混进噪音。
          """
          soup = BeautifulSoup(html, "html.parser")
          content = soup.find("div", class_="p_content")
          if content is None:
              return None
      
          sections = []
          for panel in content.find_all("div", class_="card-panel"):
              # "注意"等小节的正文在 ul > li 里,不是 p,所以 li 也要收
              for element in panel.find_all(["h2", "h3", "p", "li"]):
                  if element.name == "p":
                      # 图片块和别名 ol 都挂在 <p> 里面,先剔掉
                      if element.find("div", class_="p_image_center") or element.find("ol"):
                          continue
                  elif element.name == "li":
                      # 别名列表 ol.p_alternative_list 的 li 是重复内容,跳过
                      if element.find_parent("ol", class_="p_alternative_list"):
                          continue
                  text = element.get_text(" ", strip=True)
                  if not text:
                      continue
                  if element.name in ("h2", "h3"):
                      # 标题里多余的空格顺手清掉
                      sections.append((element.name, " ".join(text.split()), []))
                  elif sections:
                      sections[-1][2].append(" ".join(text.split()))
          return sections
      
      
      def format_detail(name, url, category, sections):
          """把解析结果拼成可读的 txt 文本。"""
          lines = [f"方剂名:{name}", f"分类:{category}", f"链接:{url}", ""]
          for level, title, paragraphs in sections:
              lines.append(("## " if level == "h2" else "### ") + title)
              lines.extend(paragraphs)
              lines.append("")
          return "\n".join(lines).rstrip() + "\n"
      
      
      def crawl_one(formula, min_delay, max_delay):
          """抓一个方剂并落盘。返回 True 成功 / False 失败。由线程池并发调用。"""
          name, url = formula["方剂名"], formula["链接"]
          output_path = os.path.join(OUTPUT_DIR, f"{name}.txt")
      
          html = fetch_html(url)
          if html is None:
              return False
      
          sections = parse_detail(html)
          if not sections:
              log(f"    解析失败,跳过 {name}(页面结构可能不同)")
              return False
      
          text = format_detail(name, url, formula["分类"], sections)
          # 先写临时文件再改名,避免中断时留下半个文件被误判成已完成。
          # 带线程名后缀,防止两个线程刚好撞到同名临时文件。
          temp_path = f"{output_path}.{threading.get_ident()}.part"
          with open(temp_path, "w", encoding="utf-8") as f:
              f.write(text)
          os.replace(temp_path, output_path)
      
          # 每个线程自己睡,等效于把整体 QPS 压到 workers / 平均延时
          time.sleep(random.uniform(min_delay, max_delay))
          return True
      
      
      def crawl(limit=0, workers=5, min_delay=1.0, max_delay=2.0):
          os.makedirs(OUTPUT_DIR, exist_ok=True)
          with open(INPUT_CSV, encoding="utf-8-sig") as f:
              formulas = list(csv.DictReader(f))
      
          # 断点续爬:先把已抓过的过滤掉,剩下的才交给线程池
          todo = [
              formula
              for formula in formulas
              if not os.path.exists(os.path.join(OUTPUT_DIR, f"{formula['方剂名']}.txt"))
          ]
          skipped = len(formulas) - len(todo)
          if limit:
              todo = todo[:limit]
      
          print(f"共 {len(formulas)} 条,已存在 {skipped} 条,本次待抓 {len(todo)} 条")
          print(f"并发线程数: {workers}")
      
          done = failed = 0
          if todo:
              with ThreadPoolExecutor(max_workers=workers) as pool:
                  futures = {
                      pool.submit(crawl_one, formula, min_delay, max_delay): formula
                      for formula in todo
                  }
                  for finished, future in enumerate(as_completed(futures), start=1):
                      formula = futures[future]
                      try:
                          ok = future.result()
                      except Exception as exc:  # 单条异常不该拖垮整轮
                          log(f"    {formula['方剂名']} 异常: {exc}")
                          ok = False
                      if ok:
                          done += 1
                      else:
                          failed += 1
                      log(f"[{finished}/{len(todo)}] {formula['方剂名']} {'✓' if ok else '✗'}")
      
          print(f"\n完成 {done} 条,跳过(已存在){skipped} 条,失败 {failed} 条")
          print(f"输出目录: {OUTPUT_DIR}")
      
      
      def main():
          parser = argparse.ArgumentParser(description="爬取方剂详情")
          parser.add_argument(
              "--limit",
              type=int,
              default=0,
              help="本次最多新抓多少条,0 表示不限制(默认 0)",
          )
          parser.add_argument(
              "--workers",
              type=int,
              default=5,
              help="并发线程数(默认 5,设 1 就是单线程)",
          )
          parser.add_argument(
              "--min-delay",
              type=float,
              default=1.0,
              help="每个线程每条之间的最小延时秒数(默认 1.0)",
          )
          parser.add_argument(
              "--max-delay",
              type=float,
              default=2.0,
              help="每个线程每条之间的最大延时秒数(默认 2.0)",
          )
          args = parser.parse_args()
          if args.workers < 1:
              parser.error("--workers 至少为 1")
          crawl(
              limit=args.limit,
              workers=args.workers,
              min_delay=args.min_delay,
              max_delay=args.max_delay,
          )
      
      
      if __name__ == "__main__":
          main()
      
    • ⚙️ formula_list.csv 47.0KB
    • ⚙️ herb_list.csv 89.3KB
    • ⚙️ 需求1 982B
  • 📁 _002__extract_information/
    • 📄 01_extract_herb_graph.py 9.7KB
      """遍历 _001__clawler/中药/ 下的中药详情 txt,调用 LLM 抽取实体和关系,一药一个 json。
      
      支持断点续抽:已经存在对应 json 的中药直接跳过,中断后重跑即可继续。
      
      输出:
          _002__extract_information/中药图谱/<中药名>.json   成功(合法的空结果也算成功)
          _002__extract_information/中药图谱/_failed.log     失败清单,每行「中药名<TAB>原因」
          _002__extract_information/alpaca_herb_dataset.jsonl  顺便追加的 Alpaca 微调样本
      
      数据集是抽取时顺手追加的,所以只覆盖「本次抽的」那些药材。已经抽过的药材会被
      断点续抽跳过,不会再写进数据集 —— 要补齐全量或去重,跑 02_build_alpaca_dataset.py
      从 中药图谱/*.json 重建。
      
      单条 LLM 调用实测 20 秒~4 分钟(抽出来的实体关系多,输出 token 就多),所以默认 10 线程并发。
      1472 条串行大约 90 小时,10 线程约 9~15 小时。想快就把 --workers 提上去,注意接口限流。
      
      用法(在项目根目录执行):
          python -X utf8 -m _002__extract_information.01_extract_herb_graph --limit 5    # 先抽 5 条看效果
          python -X utf8 -m _002__extract_information.01_extract_herb_graph              # 全量,10 线程
          python -X utf8 -m _002__extract_information.01_extract_herb_graph --workers 20
          python -X utf8 -m _002__extract_information.01_extract_herb_graph --retry-failed  # 只重跑失败的
      """
      
      import argparse
      import json
      import os
      import threading
      import time
      
      from concurrent.futures import ThreadPoolExecutor, as_completed
      
      from tqdm import tqdm
      
      from common.path_utils import get_file_path
      from _002__extract_information.__000__extract_graph_data_utils import (
          build_alpaca_record,
          extract_tcm_knowledge,
      )
      
      INPUT_DIR = get_file_path("_001__clawler/中药")
      OUTPUT_DIR = get_file_path("_002__extract_information/中药图谱")
      FAILED_LOG = os.path.join(OUTPUT_DIR, "_failed.log")
      # 微调数据集用 jsonl 追加:一行一条,多线程各写一行不会互相截断,
      # 中途崩了也只会丢最后一行,不会把前面写好的记录带坏。
      ALPACA_JSONL = get_file_path("_002__extract_information/alpaca_herb_dataset.jsonl")
      
      # 失败清单是多线程追加写的,加锁避免两行日志挤在一起
      _failed_lock = threading.Lock()
      # 数据集同理,一次只让一个线程追加
      _alpaca_lock = threading.Lock()
      
      
      def read_failed_names():
          """读失败清单里的中药名,用于 --retry-failed。"""
          if not os.path.exists(FAILED_LOG):
              return set()
          names = set()
          with open(FAILED_LOG, encoding="utf-8") as f:
              for line in f:
                  line = line.strip()
                  if line:
                      names.add(line.split("\t")[0])
          return names
      
      
      def record_failure(name, reason):
          """把失败原因追加到清单。写成一行,方便之后 --retry-failed 挑出来重跑。"""
          reason = " ".join(str(reason).split())[:200]
          with _failed_lock:
              with open(FAILED_LOG, "a", encoding="utf-8") as f:
                  f.write(f"{name}\t{reason}\n")
      
      
      def append_alpaca(text, entities, relations):
          """把这一条抽取结果追加成一行 Alpaca 样本。
      
          只追加、不去重 —— 断点续抽保证同一味药一轮里只会抽一次。真要补历史或
          去重,跑 02_build_alpaca_dataset.py 从图谱 json 重建。
          """
          record = build_alpaca_record(text, entities, relations)
          line = json.dumps(record, ensure_ascii=False)
          with _alpaca_lock:
              with open(ALPACA_JSONL, "a", encoding="utf-8") as f:
                  f.write(line + "\n")
      
      
      def extract_one(name, retries=2):
          """抽一味药并落盘。返回 (name, ok, note, 实体数, 关系数)。由线程池并发调用。
      
          LLM 偶发超时/限流/返回非法 json,失败退避重试;仍然失败就记进清单,
          不写 json —— 这样下次跑还会被当成待抽项,断点续抽才是对的。
          """
          input_path = os.path.join(INPUT_DIR, f"{name}.txt")
          output_path = os.path.join(OUTPUT_DIR, f"{name}.json")
      
          try:
              with open(input_path, encoding="utf-8") as f:
                  text = f.read()
          except OSError as exc:
              record_failure(name, f"读取失败: {exc}")
              return name, False, "读取失败", 0, 0
      
          if not text.strip():
              record_failure(name, "源文件为空")
              return name, False, "源文件为空", 0, 0
      
          last_exc = None
          for attempt in range(1, retries + 1):
              try:
                  result = extract_tcm_knowledge(text)
                  break
              except Exception as exc:  # 网络、限流、json 解析失败都在这
                  last_exc = exc
                  if attempt < retries:
                      time.sleep(5 * attempt)
          else:
              record_failure(name, f"抽取失败: {last_exc}")
              return name, False, "抽取失败", 0, 0
      
          # parser 是 JsonOutputParser,正常返回 dict;模型偶尔会吐出 list 或别的形状
          if not isinstance(result, dict):
              record_failure(name, f"返回结构异常: {type(result).__name__}")
              return name, False, "结构异常", 0, 0
      
          entities = result.get("entities") or []
          relations = result.get("relations") or []
          # 单实体、无关系的文本,prompt 要求返回空结构,属于正常结果,照样落盘,
          # 否则每次重跑都会重复调用这些药材。
          payload = {
              "name": name,
              "source_file": os.path.relpath(input_path, get_file_path("")),
              "entities": entities,
              "relations": relations,
          }
      
          # 先写临时文件再改名,中断时不会留下半个 json 被误判成已完成
          temp_path = f"{output_path}.{threading.get_ident()}.part"
          with open(temp_path, "w", encoding="utf-8") as f:
              json.dump(payload, f, ensure_ascii=False, indent=2)
          os.replace(temp_path, output_path)
      
          # 图谱 json 落盘之后再追加数据集:反过来的话,万一追加完崩了、json 还没写成,
          # 续抽会把这味药重抽一遍,数据集里就多出一条重复样本。
          append_alpaca(text, entities, relations)
      
          note = f"{len(entities)}实体/{len(relations)}关系"
          return name, True, note, len(entities), len(relations)
      
      
      def build_todo(limit=0, retry_failed=False):
          """列出本次要抽的中药名。断点续抽的核心:已有 json 的直接排除。"""
          names = sorted(
              os.path.splitext(f)[0]
              for f in os.listdir(INPUT_DIR)
              if f.endswith(".txt")
          )
          done = {
              os.path.splitext(f)[0]
              for f in os.listdir(OUTPUT_DIR)
              if f.endswith(".json")
          }
          todo = [n for n in names if n not in done]
      
          if retry_failed:
              # 失败的本来就没写 json,已经在 todo 里;这里是缩小范围只跑它们
              failed = read_failed_names()
              todo = [n for n in todo if n in failed]
      
          if limit:
              todo = todo[:limit]
          return names, done, todo
      
      
      def run(limit=0, workers=10, retry_failed=False):
          os.makedirs(OUTPUT_DIR, exist_ok=True)
          names, done, todo = build_todo(limit=limit, retry_failed=retry_failed)
      
          print(f"源目录共 {len(names)} 味中药,已抽取 {len(done)} 味,本次待抽 {len(todo)} 味")
          print(f"并发线程数: {workers}")
          if not todo:
              print("没有需要抽取的内容。")
              return
      
          ok_count = fail_count = 0
          entity_total = relation_total = 0
      
          with ThreadPoolExecutor(max_workers=workers) as pool:
              futures = {pool.submit(extract_one, name): name for name in todo}
              # tqdm 显示进度;每条结果用 write() 打,避免把进度条撕开
              with tqdm(total=len(todo), desc="抽取中", unit="味", ncols=90) as bar:
                  for future in as_completed(futures):
                      name = futures[future]
                      try:
                          _, ok, note, n_entity, n_relation = future.result()
                      except Exception as exc:  # 单条异常不该拖垮整轮
                          record_failure(name, f"未捕获异常: {exc}")
                          ok, note, n_entity, n_relation = False, "异常", 0, 0
                      if ok:
                          ok_count += 1
                          entity_total += n_entity
                          relation_total += n_relation
                      else:
                          fail_count += 1
                      bar.write(f"  {'✓' if ok else '✗'} {name} {note}")
                      bar.update(1)
                      bar.set_postfix(成功=ok_count, 失败=fail_count)
      
          print(f"\n完成 {ok_count} 味,失败 {fail_count} 味")
          print(f"共抽出 {entity_total} 个实体、{relation_total} 条关系")
          print(f"输出目录: {OUTPUT_DIR}")
          print(f"微调数据集(追加 {ok_count} 条): {ALPACA_JSONL}")
          if fail_count:
              print(f"失败清单: {FAILED_LOG}(可用 --retry-failed 只重跑这些)")
      
      
      def main():
          parser = argparse.ArgumentParser(description="抽取中药详情中的实体和关系")
          parser.add_argument(
              "--limit",
              type=int,
              default=0,
              help="本次最多抽多少味,0 表示不限制(默认 0)",
          )
          parser.add_argument(
              "--workers",
              type=int,
              default=40,
              help="并发线程数(默认 10,设 1 就是单线程)",
          )
          parser.add_argument(
              "--retry-failed",
              action="store_true",
              help="只重跑 _failed.log 里记过的中药",
          )
          args = parser.parse_args()
          if args.workers < 1:
              parser.error("--workers 至少为 1")
          run(limit=args.limit, workers=args.workers, retry_failed=args.retry_failed)
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 02_build_alpaca_dataset.py 5.3KB
      """从 中药图谱/*.json 重建 Alpaca 格式的微调数据集,不再调用 LLM。
      
      01_extract_herb_graph.py 抽取时会顺手往 alpaca_herb_dataset.jsonl 追加样本,但那只
      覆盖「那一次抽的」药材:断点续抽跳过的、以及加这个功能之前就抽好的,都不在里面。
      这个脚本直接读已经落盘的图谱 json 重新生成全量数据集,所以是幂等的 —— 跑几次结果
      都一样,也不花钱。
      
      input 取源 txt 原文(图谱 json 里的 source_file 指回去),output 取 entities+relations
      的 json 串,instruction 固定。
      
      输出:
          _002__extract_information/alpaca_herb_dataset.jsonl   一行一条(默认)
          _002__extract_information/alpaca_herb_dataset.json    标准 json 数组(--json)
      
      用法(在项目根目录执行):
          python -X utf8 -m _002__extract_information.02_build_alpaca_dataset
          python -X utf8 -m _002__extract_information.02_build_alpaca_dataset --json
          python -X utf8 -m _002__extract_information.02_build_alpaca_dataset --skip-empty
      """
      
      import argparse
      import json
      import os
      
      from tqdm import tqdm
      
      from common.path_utils import get_file_path
      from _002__extract_information.__000__extract_graph_data_utils import (
          build_alpaca_record,
      )
      
      GRAPH_DIR = get_file_path("_002__extract_information/中药图谱")
      HERB_DIR = get_file_path("_001__clawler/中药")
      OUT_JSONL = get_file_path("_002__extract_information/alpaca_herb_dataset.jsonl")
      OUT_JSON = get_file_path("_002__extract_information/alpaca_herb_dataset.json")
      
      
      def load_source_text(graph, graph_path):
          """取这条图谱对应的中药详情原文,作为 Alpaca 的 input。
      
          优先用 json 里记的 source_file(相对仓库根),它不在或者文件没了就按
          「中药名.txt」回退到 中药/ 目录 —— 早期抽的 json 可能没这个字段。
          """
          rel = graph.get("source_file")
          if rel:
              # json 里存的是 Windows 风格的相对路径,normpath 一下跨平台都能用
              path = get_file_path(os.path.normpath(rel))
              if os.path.exists(path):
                  return open(path, encoding="utf-8").read()
      
          name = graph.get("name") or os.path.splitext(os.path.basename(graph_path))[0]
          path = os.path.join(HERB_DIR, f"{name}.txt")
          if os.path.exists(path):
              return open(path, encoding="utf-8").read()
          return None
      
      
      def build(skip_empty=False, as_json=False):
          if not os.path.isdir(GRAPH_DIR):
              print(f"图谱目录不存在: {GRAPH_DIR}")
              print("先跑 01_extract_herb_graph.py 抽取实体和关系。")
              return
      
          graph_files = sorted(
              os.path.join(GRAPH_DIR, f)
              for f in os.listdir(GRAPH_DIR)
              if f.endswith(".json")
          )
          if not graph_files:
              print(f"{GRAPH_DIR} 下没有图谱 json,先跑 01_extract_herb_graph.py。")
              return
      
          records = []
          empty = missing_text = broken = 0
      
          for path in tqdm(graph_files, desc="构建数据集", unit="条", ncols=90):
              try:
                  with open(path, encoding="utf-8") as f:
                      graph = json.load(f)
              except (OSError, json.JSONDecodeError):
                  broken += 1
                  continue
      
              entities = graph.get("entities") or []
              relations = graph.get("relations") or []
              # 空结果是合法抽取(prompt 明确要求单实体无关系时返回空),留着能教模型
              # 「这种文本不该硬凑关系」;真要练抽取能力可以 --skip-empty 去掉。
              if skip_empty and not entities and not relations:
                  empty += 1
                  continue
              if not entities and not relations:
                  empty += 1
      
              text = load_source_text(graph, path)
              if text is None or not text.strip():
                  missing_text += 1
                  continue
      
              records.append(build_alpaca_record(text, entities, relations))
      
          if as_json:
              out_path = OUT_JSON
              payload = json.dumps(records, ensure_ascii=False, indent=2)
          else:
              out_path = OUT_JSONL
              payload = "\n".join(json.dumps(r, ensure_ascii=False) for r in records)
              payload += "\n" if records else ""
      
          # 整体重写,不是追加:这个脚本的语义是「按当前图谱重建」,幂等
          temp_path = f"{out_path}.part"
          with open(temp_path, "w", encoding="utf-8") as f:
              f.write(payload)
          os.replace(temp_path, out_path)
      
          print(f"\n图谱 json {len(graph_files)} 个 → 样本 {len(records)} 条")
          if empty:
              print(f"  其中空抽取结果 {empty} 条{'(已跳过)' if skip_empty else '(已保留)'}")
          if missing_text:
              print(f"  找不到源 txt 跳过 {missing_text} 条")
          if broken:
              print(f"  json 读取失败跳过 {broken} 条")
          print(f"输出: {out_path}")
      
      
      def main():
          parser = argparse.ArgumentParser(
              description="从已抽取的图谱 json 重建 Alpaca 微调数据集"
          )
          parser.add_argument(
              "--json",
              action="store_true",
              dest="as_json",
              help="输出标准 json 数组,默认输出 jsonl(一行一条)",
          )
          parser.add_argument(
              "--skip-empty",
              action="store_true",
              help="丢掉 entities/relations 都为空的样本(默认保留)",
          )
          args = parser.parse_args()
          build(skip_empty=args.skip_empty, as_json=args.as_json)
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 03_extract_formula_graph.py 9.6KB
      """遍历 _001__clawler/方剂/ 下的方剂详情 txt,调用 LLM 抽取实体和关系,一方一个 json。
      
      和 01_extract_herb_graph.py 是一对镜像脚本,逻辑一致,只换输入输出目录 —— 改一边
      的行为时记得看另一边要不要同步。prompt 不用换:__000__ 里那套已经带了方剂的
      FormulaAttributes 和 HAS_INGREDIENT / FROM_SOURCE,实测方剂文本能正确抽出组成药材
      和出处。
      
      支持断点续抽:已经存在对应 json 的方剂直接跳过,中断后重跑即可继续。
      
      输出:
          _002__extract_information/方剂图谱/<方剂名>.json   成功(合法的空结果也算成功)
          _002__extract_information/方剂图谱/_failed.log     失败清单,每行「方剂名<TAB>原因」
          _002__extract_information/alpaca_formula_dataset.jsonl  顺便追加的 Alpaca 微调样本
      
      数据集是抽取时顺手追加的,只覆盖「本次抽的」那些方剂。已经抽过的会被断点续抽跳过,
      不会再写进数据集 —— 要补齐全量或去重,跑 04_build_formula_alpaca_dataset.py 从
      方剂图谱/*.json 重建。
      
      方剂文本比中药短得多(均值 377 字 vs 中药 1537),单条实测 1~2 分钟,
      703 条 10 线程大约 2~3 小时。
      
      用法(在项目根目录执行):
          python -X utf8 -m _002__extract_information.03_extract_formula_graph --limit 5   # 先抽 5 条看效果
          python -X utf8 -m _002__extract_information.03_extract_formula_graph             # 全量,10 线程
          python -X utf8 -m _002__extract_information.03_extract_formula_graph --workers 20
          python -X utf8 -m _002__extract_information.03_extract_formula_graph --retry-failed
      """
      
      import argparse
      import json
      import os
      import threading
      import time
      
      from concurrent.futures import ThreadPoolExecutor, as_completed
      
      from tqdm import tqdm
      
      from common.path_utils import get_file_path
      from _002__extract_information.__000__extract_graph_data_utils import (
          build_alpaca_record,
          extract_tcm_knowledge,
      )
      
      INPUT_DIR = get_file_path("_001__clawler/方剂")
      OUTPUT_DIR = get_file_path("_002__extract_information/方剂图谱")
      FAILED_LOG = os.path.join(OUTPUT_DIR, "_failed.log")
      # 微调数据集用 jsonl 追加:一行一条,多线程各写一行不会互相截断,
      # 中途崩了也只会丢最后一行,不会把前面写好的记录带坏。
      ALPACA_JSONL = get_file_path("_002__extract_information/alpaca_formula_dataset.jsonl")
      
      # 失败清单是多线程追加写的,加锁避免两行日志挤在一起
      _failed_lock = threading.Lock()
      # 数据集同理,一次只让一个线程追加
      _alpaca_lock = threading.Lock()
      
      
      def read_failed_names():
          """读失败清单里的方剂名,用于 --retry-failed。"""
          if not os.path.exists(FAILED_LOG):
              return set()
          names = set()
          with open(FAILED_LOG, encoding="utf-8") as f:
              for line in f:
                  line = line.strip()
                  if line:
                      names.add(line.split("\t")[0])
          return names
      
      
      def record_failure(name, reason):
          """把失败原因追加到清单。写成一行,方便之后 --retry-failed 挑出来重跑。"""
          reason = " ".join(str(reason).split())[:200]
          with _failed_lock:
              with open(FAILED_LOG, "a", encoding="utf-8") as f:
                  f.write(f"{name}\t{reason}\n")
      
      
      def append_alpaca(text, entities, relations):
          """把这一条抽取结果追加成一行 Alpaca 样本。
      
          只追加、不去重 —— 断点续抽保证同一个方剂一轮里只会抽一次。真要补历史或
          去重,跑 04_build_formula_alpaca_dataset.py 从图谱 json 重建。
          """
          record = build_alpaca_record(text, entities, relations)
          line = json.dumps(record, ensure_ascii=False)
          with _alpaca_lock:
              with open(ALPACA_JSONL, "a", encoding="utf-8") as f:
                  f.write(line + "\n")
      
      
      def extract_one(name, retries=2):
          """抽一个方剂并落盘。返回 (name, ok, note, 实体数, 关系数)。由线程池并发调用。
      
          LLM 偶发超时/限流/返回非法 json,失败退避重试;仍然失败就记进清单,
          不写 json —— 这样下次跑还会被当成待抽项,断点续抽才是对的。
          """
          input_path = os.path.join(INPUT_DIR, f"{name}.txt")
          output_path = os.path.join(OUTPUT_DIR, f"{name}.json")
      
          try:
              with open(input_path, encoding="utf-8") as f:
                  text = f.read()
          except OSError as exc:
              record_failure(name, f"读取失败: {exc}")
              return name, False, "读取失败", 0, 0
      
          if not text.strip():
              record_failure(name, "源文件为空")
              return name, False, "源文件为空", 0, 0
      
          last_exc = None
          for attempt in range(1, retries + 1):
              try:
                  result = extract_tcm_knowledge(text)
                  break
              except Exception as exc:  # 网络、限流、json 解析失败都在这
                  last_exc = exc
                  if attempt < retries:
                      time.sleep(5 * attempt)
          else:
              record_failure(name, f"抽取失败: {last_exc}")
              return name, False, "抽取失败", 0, 0
      
          # parser 是 JsonOutputParser,正常返回 dict;模型偶尔会吐出 list 或别的形状
          if not isinstance(result, dict):
              record_failure(name, f"返回结构异常: {type(result).__name__}")
              return name, False, "结构异常", 0, 0
      
          entities = result.get("entities") or []
          relations = result.get("relations") or []
          # 空结果也落盘,否则每次重跑都会重复调用这些方剂
          payload = {
              "name": name,
              "source_file": os.path.relpath(input_path, get_file_path("")),
              "entities": entities,
              "relations": relations,
          }
      
          # 先写临时文件再改名,中断时不会留下半个 json 被误判成已完成
          temp_path = f"{output_path}.{threading.get_ident()}.part"
          with open(temp_path, "w", encoding="utf-8") as f:
              json.dump(payload, f, ensure_ascii=False, indent=2)
          os.replace(temp_path, output_path)
      
          # 图谱 json 落盘之后再追加数据集:反过来的话,万一追加完崩了、json 还没写成,
          # 续抽会把这个方剂重抽一遍,数据集里就多出一条重复样本。
          append_alpaca(text, entities, relations)
      
          note = f"{len(entities)}实体/{len(relations)}关系"
          return name, True, note, len(entities), len(relations)
      
      
      def build_todo(limit=0, retry_failed=False):
          """列出本次要抽的方剂名。断点续抽的核心:已有 json 的直接排除。"""
          names = sorted(
              os.path.splitext(f)[0]
              for f in os.listdir(INPUT_DIR)
              if f.endswith(".txt")
          )
          done = {
              os.path.splitext(f)[0]
              for f in os.listdir(OUTPUT_DIR)
              if f.endswith(".json")
          }
          todo = [n for n in names if n not in done]
      
          if retry_failed:
              # 失败的本来就没写 json,已经在 todo 里;这里是缩小范围只跑它们
              failed = read_failed_names()
              todo = [n for n in todo if n in failed]
      
          if limit:
              todo = todo[:limit]
          return names, done, todo
      
      
      def run(limit=0, workers=10, retry_failed=False):
          os.makedirs(OUTPUT_DIR, exist_ok=True)
          names, done, todo = build_todo(limit=limit, retry_failed=retry_failed)
      
          print(f"源目录共 {len(names)} 个方剂,已抽取 {len(done)} 个,本次待抽 {len(todo)} 个")
          print(f"并发线程数: {workers}")
          if not todo:
              print("没有需要抽取的内容。")
              return
      
          ok_count = fail_count = 0
          entity_total = relation_total = 0
      
          with ThreadPoolExecutor(max_workers=workers) as pool:
              futures = {pool.submit(extract_one, name): name for name in todo}
              # tqdm 显示进度;每条结果用 write() 打,避免把进度条撕开
              with tqdm(total=len(todo), desc="抽取中", unit="方", ncols=90) as bar:
                  for future in as_completed(futures):
                      name = futures[future]
                      try:
                          _, ok, note, n_entity, n_relation = future.result()
                      except Exception as exc:  # 单条异常不该拖垮整轮
                          record_failure(name, f"未捕获异常: {exc}")
                          ok, note, n_entity, n_relation = False, "异常", 0, 0
                      if ok:
                          ok_count += 1
                          entity_total += n_entity
                          relation_total += n_relation
                      else:
                          fail_count += 1
                      bar.write(f"  {'✓' if ok else '✗'} {name} {note}")
                      bar.update(1)
                      bar.set_postfix(成功=ok_count, 失败=fail_count)
      
          print(f"\n完成 {ok_count} 个,失败 {fail_count} 个")
          print(f"共抽出 {entity_total} 个实体、{relation_total} 条关系")
          print(f"输出目录: {OUTPUT_DIR}")
          print(f"微调数据集(追加 {ok_count} 条): {ALPACA_JSONL}")
          if fail_count:
              print(f"失败清单: {FAILED_LOG}(可用 --retry-failed 只重跑这些)")
      
      
      def main():
          parser = argparse.ArgumentParser(description="抽取方剂详情中的实体和关系")
          parser.add_argument(
              "--limit",
              type=int,
              default=0,
              help="本次最多抽多少个,0 表示不限制(默认 0)",
          )
          parser.add_argument(
              "--workers",
              type=int,
              default=40,
              help="并发线程数(默认 10,设 1 就是单线程)",
          )
          parser.add_argument(
              "--retry-failed",
              action="store_true",
              help="只重跑 _failed.log 里记过的方剂",
          )
          args = parser.parse_args()
          if args.workers < 1:
              parser.error("--workers 至少为 1")
          run(limit=args.limit, workers=args.workers, retry_failed=args.retry_failed)
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 04_build_formula_alpaca_dataset.py 5.4KB
      """从 方剂图谱/*.json 重建 Alpaca 格式的微调数据集,不再调用 LLM。
      
      和 02_build_alpaca_dataset.py 是一对镜像脚本(那个管中药,这个管方剂)。
      
      03_extract_formula_graph.py 抽取时会顺手往 alpaca_formula_dataset.jsonl 追加样本,但
      那只覆盖「那一次抽的」方剂:断点续抽跳过的不在里面。这个脚本直接读已经落盘的图谱
      json 重新生成全量数据集,所以是幂等的 —— 跑几次结果都一样,也不花钱。
      
      input 取源 txt 原文(图谱 json 里的 source_file 指回去),output 取 entities+relations
      的 json 串,instruction 固定。
      
      输出:
          _002__extract_information/alpaca_formula_dataset.jsonl   一行一条(默认)
          _002__extract_information/alpaca_formula_dataset.json    标准 json 数组(--json)
      
      想把中药和方剂两份语料合成一份训练集,直接 cat 两个 jsonl 即可 —— instruction 相同,
      格式完全一致。
      
      用法(在项目根目录执行):
          python -X utf8 -m _002__extract_information.04_build_formula_alpaca_dataset
          python -X utf8 -m _002__extract_information.04_build_formula_alpaca_dataset --json
          python -X utf8 -m _002__extract_information.04_build_formula_alpaca_dataset --skip-empty
      """
      
      import argparse
      import json
      import os
      
      from tqdm import tqdm
      
      from common.path_utils import get_file_path
      from _002__extract_information.__000__extract_graph_data_utils import (
          build_alpaca_record,
      )
      
      GRAPH_DIR = get_file_path("_002__extract_information/方剂图谱")
      FORMULA_DIR = get_file_path("_001__clawler/方剂")
      OUT_JSONL = get_file_path("_002__extract_information/alpaca_formula_dataset.jsonl")
      OUT_JSON = get_file_path("_002__extract_information/alpaca_formula_dataset.json")
      
      
      def load_source_text(graph, graph_path):
          """取这条图谱对应的方剂详情原文,作为 Alpaca 的 input。
      
          优先用 json 里记的 source_file(相对仓库根),它不在或者文件没了就按
          「方剂名.txt」回退到 方剂/ 目录。
          """
          rel = graph.get("source_file")
          if rel:
              # json 里存的是 Windows 风格的相对路径,normpath 一下跨平台都能用
              path = get_file_path(os.path.normpath(rel))
              if os.path.exists(path):
                  return open(path, encoding="utf-8").read()
      
          name = graph.get("name") or os.path.splitext(os.path.basename(graph_path))[0]
          path = os.path.join(FORMULA_DIR, f"{name}.txt")
          if os.path.exists(path):
              return open(path, encoding="utf-8").read()
          return None
      
      
      def build(skip_empty=False, as_json=False):
          if not os.path.isdir(GRAPH_DIR):
              print(f"图谱目录不存在: {GRAPH_DIR}")
              print("先跑 03_extract_formula_graph.py 抽取实体和关系。")
              return
      
          graph_files = sorted(
              os.path.join(GRAPH_DIR, f)
              for f in os.listdir(GRAPH_DIR)
              if f.endswith(".json")
          )
          if not graph_files:
              print(f"{GRAPH_DIR} 下没有图谱 json,先跑 03_extract_formula_graph.py。")
              return
      
          records = []
          empty = missing_text = broken = 0
      
          for path in tqdm(graph_files, desc="构建数据集", unit="条", ncols=90):
              try:
                  with open(path, encoding="utf-8") as f:
                      graph = json.load(f)
              except (OSError, json.JSONDecodeError):
                  broken += 1
                  continue
      
              entities = graph.get("entities") or []
              relations = graph.get("relations") or []
              # 空结果是合法抽取,留着能教模型「这种文本不该硬凑关系」;
              # 真要练抽取能力可以 --skip-empty 去掉。
              if skip_empty and not entities and not relations:
                  empty += 1
                  continue
              if not entities and not relations:
                  empty += 1
      
              text = load_source_text(graph, path)
              if text is None or not text.strip():
                  missing_text += 1
                  continue
      
              records.append(build_alpaca_record(text, entities, relations))
      
          if as_json:
              out_path = OUT_JSON
              payload = json.dumps(records, ensure_ascii=False, indent=2)
          else:
              out_path = OUT_JSONL
              payload = "\n".join(json.dumps(r, ensure_ascii=False) for r in records)
              payload += "\n" if records else ""
      
          # 整体重写,不是追加:这个脚本的语义是「按当前图谱重建」,幂等
          temp_path = f"{out_path}.part"
          with open(temp_path, "w", encoding="utf-8") as f:
              f.write(payload)
          os.replace(temp_path, out_path)
      
          print(f"\n图谱 json {len(graph_files)} 个 → 样本 {len(records)} 条")
          if empty:
              print(f"  其中空抽取结果 {empty} 条{'(已跳过)' if skip_empty else '(已保留)'}")
          if missing_text:
              print(f"  找不到源 txt 跳过 {missing_text} 条")
          if broken:
              print(f"  json 读取失败跳过 {broken} 条")
          print(f"输出: {out_path}")
      
      
      def main():
          parser = argparse.ArgumentParser(
              description="从已抽取的方剂图谱 json 重建 Alpaca 微调数据集"
          )
          parser.add_argument(
              "--json",
              action="store_true",
              dest="as_json",
              help="输出标准 json 数组,默认输出 jsonl(一行一条)",
          )
          parser.add_argument(
              "--skip-empty",
              action="store_true",
              help="丢掉 entities/relations 都为空的样本(默认保留)",
          )
          args = parser.parse_args()
          build(skip_empty=args.skip_empty, as_json=args.as_json)
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 __000__extract_graph_data_utils.py 8.6KB
      import json
      from typing import Literal, Optional, Union, List
      
      from langchain_core.output_parsers import JsonOutputParser
      from langchain_core.prompts import PromptTemplate
      from pydantic import BaseModel
      
      from common.llm import my_llm
      
      # ======================
      # 枚举定义
      # ======================
      EntityType = Literal["Symptom", "Disease", "Formula", "Herb", "Effect", "Source"]
      
      RelationType = Literal[
          "TREATS_DISEASE",
          "ALLEVIATES_SYMPTOM",
          "HAS_EFFECT",
          "HAS_INGREDIENT",
          "HAS_SYMPTOM",
          "FROM_SOURCE"
      ]
      
      
      # ======================
      # 属性定义
      # ======================
      class FormulaAttributes(BaseModel):
          alias: Optional[str] = None
          effect: Optional[str] = None
          indication: Optional[str] = None
          taboo: Optional[str] = None
          usage: Optional[str] = None
      
      
      class HerbAttributes(BaseModel):
          dosage: Optional[str] = None
          effect: Optional[str] = None
          indication: Optional[str] = None
          meridian: Optional[str] = None
          origin: Optional[str] = None
          place: Optional[str] = None
          processing: Optional[str] = None
          property_flavor: Optional[str] = None
          taboo: Optional[str] = None
          traits: Optional[str] = None
      
      
      # ======================
      # 实体与关系结构
      # ======================
      class Entity(BaseModel):
          name: str
          type: EntityType
          attributes: Optional[Union[FormulaAttributes, HerbAttributes]] = None
      
      
      class Relation(BaseModel):
          subject: str
          subject_type: EntityType
          relation: RelationType
          object: str
          object_type: EntityType
      
      #Pydantic限定了是他的输出格式
      class TCMKnowledgeGraph(BaseModel):
          entities: List[Entity]
          relations: List[Relation]
      
      
      # 初始化解析器
      parser = JsonOutputParser(pydantic_object=TCMKnowledgeGraph)
      
      # 定义 Prompt
      prompt = PromptTemplate(
          template=(
              "你是一个中医知识图谱抽取专家。请从以下文本中提取结构化知识:\n"
              "仅当文本中存在实体之间的明确关系时(如‘某方剂治疗某疾病’、‘某药材具有某功效’、‘方剂包含药材’等),才进行抽取。\n"
              "如果文本中仅描述单个实体的信息、未涉及其他实体或关系,请不要抽取,返回空结构:\n"
              "{{\"entities\": [], \"relations\": []}}\n\n"
      
              "【实体类型说明】\n"
              "- Symptom:症状,如咳嗽、腹痛等\n"
              "- Disease:疾病,如感冒、肺炎、肾虚等\n"
              "- Formula:方剂,如四君子汤、桂枝汤等\n"
              "- Herb:药材,如人参、黄芪、丁香等\n"
              "- Effect:功效,如补气、活血、祛湿、止痛等\n"
              "- Source:出处,如《本草纲目》《伤寒论》等\n\n"
      
              "【关系类型说明】\n"
              "- TREATS_DISEASE:方剂或药材治疗某种疾病\n"
              "- ALLEVIATES_SYMPTOM:方剂或药材缓解某种症状\n"
              "- HAS_EFFECT:方剂或药材具有某种功效\n"
              "- HAS_INGREDIENT:方剂包含某种药材\n"
              "- HAS_SYMPTOM:疾病包含某种症状\n"
              "- FROM_SOURCE:方剂出自某文献或出处\n\n"
      
              "若文本涉及方剂或药材,请补充对应的属性字段(如功效、性味、剂量等)。\n"
              "如果文本主要是讲方剂的,请不要抽取药材的属性字段。\n"
              "如果文本主要是讲药材的,请不要抽取方剂的属性字段。\n"
              "如果值为空null,则不必显示键的值。"
              "所有输出必须严格符合以下 JSON 格式:\n"
              "{format_instructions}\n\n"
              "输入文本:{text}"
          ),
          input_variables=["text"],
          partial_variables={"format_instructions": parser.get_format_instructions()}
      )
      
      
      # ======================
      # 主函数封装
      # ======================
      def extract_tcm_knowledge(text: str):
          # 构建抽取链
          chain = prompt | my_llm | parser
          return chain.invoke({"text": text})
      
      
      # ======================
      # Alpaca 微调数据集
      # ======================
      # instruction 固定不变,input 是中药详情原文,output 是抽取结果的 json 串。
      # 常量和构造函数放在这里而不是放在 01_/02_ 脚本里:数字开头的模块名不是合法
      # 标识符,两个脚本没法互相 import,共享的东西只能落在这个正常命名的模块。
      ALPACA_INSTRUCTION = "请从以下中医文本中抽取知识图谱结构,包括实体与关系。"
      
      
      def build_alpaca_record(text, entities, relations):
          """把一条抽取结果组装成 Alpaca 格式的样本。
      
          output 按 Alpaca 约定是字符串,不是嵌套对象,所以这里 dumps 成 json 串。
          ensure_ascii=False 让中文在数据集里保持可读;键顺序固定成
          entities 在前、relations 在后,重跑时同样的抽取结果能得到同样的字节。
          """
          return {
              "instruction": ALPACA_INSTRUCTION,
              "input": text,
              "output": json.dumps(
                  {"entities": entities, "relations": relations},
                  ensure_ascii=False,
              ),
          }
      
      
      if __name__ == '__main__':
          text="""
          中药名:一匹绸
      分类:化痰止咳平喘药
      链接:https://zhongyibaike.com/wiki/一匹绸
      
      ## 名称
      白面水鸡(《陆川本草》),白背丝绸(《生草药手册》),白底丝绸(《岭南草药志》),绸缎藤、银背藤(《广西药植名录》),一匹绸(《广西中草药》)。
      
      ## 一匹绸的种植和炮制
      
      ### 来源
      为旋花科植物白鹤藤的茎叶。秋季采收。
      【原形态】
      缠绕藤本。叶卵形至椭圆形,长5~11厘米,宽3~9厘米,先端短尖或钝,基部浑圆或有时微心形,上面秃净或近秃净,下面密被银色紧贴的柔毛。花序腋生或顶生,头状或伞状;花序柄长3.5~7厘米,密被银色柔毛;花柄短,长约5毫米或不及;苞片大,萼片5,矩圆状卵形或卵形,外面的长9~10毫米,内面的较小,外密被银色柔毛;花冠漏斗状,白色,长2.7~2.8厘米,外被银色偃毛,裂片矩圆形,长1.5厘米内外;雄蕊5;子房4室,花柱线形。果近球形,径约8厘米,胭脂色,为卵形凹陷的宿萼所包围。花期秋季。
      【生境分布】
      生态环境:生于疏林下或路边灌丛中、河边。
      资源分布:分布于广东、海南、广西等地。
      
      ### 性味
      ①《陆川本草》:苦辛,凉。
      ②《岭南草药志》:味微酸微苦,性凉。
      
      ### 性状
      干燥茎叶,茎扭曲,表面暗灰棕色,有纵沟纹,断面绿白色,有明显的维管束,淡棕色。叶稍卷曲,质脆易碎,表面暗棕色至紫色,背面浅灰绿色,紧贴丝光毛。触之柔软。产广东、广西等地。
      
      ## 一匹绸的效果
      
      ### 功效
      为旋花科植物白鹤藤的茎叶。用于热咳,痰喘,吐血,崩漏,白带,跌打损伤,风湿痛,疮毒。
      
      ### 主治
      化痰止咳,理血祛风。治热咳,痰喘,吐血,崩漏,白带,跌打损伤,风湿痛,疮毒。
      ①《本草求原》:叶敷烂脚,化腐疮。
      ②《岭南采药录》:蛊胀,和米与黄糖捣烂煎香食之;肿胀,和苍术煎汤熏蒸之。
      ③《陆川本草》:叶:治肺热咳嗽痰喘。
      ④《广西药植名录》:消痰,润肺,止咳,止血。治肺痨,乳痈,水火烫伤。
      ⑤《广西中草药》:理血,祛风,除湿。治跌打损伤,内外伤出血及妇女血崩,白带。
      ⑥《文山中草药》:舒筋活络,祛风除湿,止咳化痰,调经止血。治跌打损伤,风湿疼痛,急慢性支气管炎。
      
      ### 用法用量
      内服:煎汤,9~15克。
      外用:煎水熏洗或捣敷。
      
      ## 一匹绸的药方
      ①治急、慢性支气管炎:白鹤藤(干品)五钱至一两,水煎服。(广州部队《常用中草药手册》)
      ②治内伤吐血:一匹绸叶、 虎杖 、 旱莲草 、 龙芽草 各一两,水煎服。(《广西中草药》)
      ③治崩漏:㈠一匹绸叶、 走马胎叶 、 龙芽草 各一两,捣烂,水煎服。(《广西中草药》)㈡紫苞银背藤叶三至四片,研末,酒送服。(《云南中草药》)
      ④治跌打积瘀,经络不和:一匹绸一两,水煎冲酒服。(《广西中草药》)
      ⑤治白带:一匹绸一两,小榕树须五钱, 鸡冠花 一两,水煎服。(《广西中草药》)
      ⑥治疮毒、烂脚:白鹤藤、 蒲公英 、 银花藤 煎水洗之,有消炎、化腐、去毒之效。(《岭南草药志》)
          """
          result=extract_tcm_knowledge(text)
          print(result)
      
    • ⚙️ alpaca_herb_dataset.json 172.0KB
    • ⚙️ 需求 1.4KB
  • 📁 _003_create_neo4j_database/
    • 📄 01_import_graph_to_neo4j.py 12.4KB
      """把 _002_ 抽好的中药图谱 / 方剂图谱导入 Neo4j。
      
      读 _002__extract_information/{中药图谱,方剂图谱}/*.json 里的 entities 和 relations,
      在 Python 里拼 Cypher,走 common/neo4j_manager.py 的 Neo4jClient 写库。
      
      几个关键取舍:
      
      - **先在内存里去重,再写库。** 49226 条 entity 里只有 18870 个不同节点,50627 条
        relation 里只有 48346 条不同边 —— 同一个「咳嗽」在几百个文件里都出现。逐条 MERGE
        等于把同样的活干几百遍,所以先聚合成唯一集合再写。
      - **节点主键是 (label, name),不是 name。** 有 356 个名字同时挂在两种类型下
        (「热咳」既是 Disease 又是 Symptom),只按 name 去重会把两类实体粘成一个节点。
      - **label 和关系类型只能拼进语句,不能当参数**,Cypher 不支持参数化它们。所以这两个
        位置都先对白名单做校验再拼字符串,值一律走 $ 参数,不做字符串插值。
      - 属性以「实体自己那个文件」为准:一贯煎.json 里的「当归」只是配料,没有性味剂量,
        当归.json 里才是全的。同名属性冲突时后者覆盖前者,其余只补空缺。
      
      用法(在项目根目录执行):
          python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --limit 5
          python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --dry-run
          python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j
          python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --only formula
      """
      
      import argparse
      import json
      import os
      from collections import Counter
      
      from tqdm import tqdm
      
      from common.config import Config
      from common.neo4j_manager import Neo4jClient
      from common.path_utils import get_file_path
      
      # 白名单 = 唯一允许拼进 Cypher 的 label / 关系类型,和 _002_ 的 Pydantic schema 一致。
      # 抽取结果里出现过 TREATS、TREATS_DISE 这种模型笔误,不在名单里就丢掉并记账。
      ENTITY_TYPES = {"Symptom", "Disease", "Formula", "Herb", "Effect", "Source"}
      RELATION_TYPES = {
          "TREATS_DISEASE",
          "ALLEVIATES_SYMPTOM",
          "HAS_EFFECT",
          "HAS_INGREDIENT",
          "HAS_SYMPTOM",
          "FROM_SOURCE",
      }
      # FormulaAttributes + HerbAttributes 的全部字段。属性名同样会拼进 SET,所以也过白名单,
      # 免得模型多吐一个键就在库里长出一个野属性。
      ATTR_KEYS = {
          "alias",
          "dosage",
          "effect",
          "indication",
          "meridian",
          "origin",
          "place",
          "processing",
          "property_flavor",
          "taboo",
          "traits",
          "usage",
      }
      
      GRAPH_DIRS = {
          "herb": "_002__extract_information/中药图谱",
          "formula": "_002__extract_information/方剂图谱",
      }
      
      # 一个事务里塞多少条语句。太小则往返次数多,太大则事务占内存、失败回滚的代价高。
      BATCH_SIZE = 500
      
      
      # ======================
      # 读盘 + 聚合
      # ======================
      def iter_graph_files(only=None, limit=0):
          """按 (来源, 路径) 产出要导入的图谱 json 路径。"""
          paths = []
          for kind, rel_dir in GRAPH_DIRS.items():
              if only and kind != only:
                  continue
              directory = get_file_path(rel_dir)
              if not os.path.isdir(directory):
                  print(f"图谱目录不存在,跳过: {directory}")
                  continue
              names = sorted(f for f in os.listdir(directory) if f.endswith(".json"))
              if limit:
                  names = names[:limit]
              paths.extend((kind, os.path.join(directory, f)) for f in names)
          return paths
      
      
      def clean_attributes(raw):
          """过滤属性字典:只留白名单里的键,丢掉空值,值统一成字符串。"""
          if not isinstance(raw, dict):
              return {}
          out = {}
          for key, value in raw.items():
              if key not in ATTR_KEYS or value is None:
                  continue
              text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
              text = text.strip()
              if text:
                  out[key] = text
          return out
      
      
      def collect(paths, stats):
          """把所有图谱 json 聚合成「唯一节点」和「唯一关系」两个集合。
      
          nodes: {(label, name): {属性}}
          rels:  {(主label, 主name, 关系, 宾label, 宾name)}
      
          关系两端的节点也顺手补进 nodes —— 有 177 处 relation 的端点没在同文件的 entities
          里列出来,不补的话 MATCH 匹配不上,那条边就凭空丢了。
          """
          nodes = {}
          rels = set()
      
          for kind, path in tqdm(paths, desc="读取图谱", unit="个", ncols=90):
              try:
                  with open(path, encoding="utf-8") as f:
                      graph = json.load(f)
              except (OSError, json.JSONDecodeError):
                  stats["json 读取失败"] += 1
                  continue
      
              # 主体实体的属性最全(同名的配料条目往往只有名字),最后合并时优先它
              main_name = graph.get("name")
      
              for entity in graph.get("entities") or []:
                  label = entity.get("type")
                  name = (entity.get("name") or "").strip()
                  if label not in ENTITY_TYPES or not name:
                      stats["实体不合法丢弃"] += 1
                      continue
                  key = (label, name)
                  attrs = clean_attributes(entity.get("attributes"))
                  existing = nodes.setdefault(key, {})
                  if name == main_name:
                      # 自己那个文件里的定义最权威,直接盖掉别处带来的零碎属性
                      existing.update(attrs)
                  else:
                      # 只补空缺,不覆盖已有的
                      for k, v in attrs.items():
                          existing.setdefault(k, v)
      
              for relation in graph.get("relations") or []:
                  rel_type = relation.get("relation")
                  s_label, o_label = relation.get("subject_type"), relation.get("object_type")
                  s_name = (relation.get("subject") or "").strip()
                  o_name = (relation.get("object") or "").strip()
                  if rel_type not in RELATION_TYPES:
                      stats["关系类型不合法丢弃"] += 1
                      continue
                  if s_label not in ENTITY_TYPES or o_label not in ENTITY_TYPES:
                      stats["关系端点类型不合法丢弃"] += 1
                      continue
                  if not s_name or not o_name:
                      stats["关系端点为空丢弃"] += 1
                      continue
                  nodes.setdefault((s_label, s_name), {})
                  nodes.setdefault((o_label, o_name), {})
                  rels.add((s_label, s_name, rel_type, o_label, o_name))
      
          return nodes, rels
      
      
      # ======================
      # 拼 Cypher
      # ======================
      def build_constraint_cypher():
          """每类实体按 name 建唯一约束。
      
          顺带把 MERGE 需要的索引也建好了(约束自带索引),不然 18870 次 MERGE 都是全表扫。
          IF NOT EXISTS 让脚本可以重复跑。
          """
          return [
              (
                  f"CREATE CONSTRAINT {label.lower()}_name IF NOT EXISTS "
                  f"FOR (n:{label}) REQUIRE n.name IS UNIQUE",
                  None,
              )
              for label in sorted(ENTITY_TYPES)
          ]
      
      
      def build_node_cypher(nodes):
          """一个节点一条 MERGE。
      
          label 只能拼进语句(Cypher 不支持参数化 label),name 和属性全走 $ 参数。
          label 已经过了 ENTITY_TYPES 白名单,不存在注入面。
      
          属性用 SET n += $attrs 而不是 MERGE (n {name:..., effect:...}):属性写进 MERGE
          的话,属性一变就 MERGE 不上、变成新建一个节点。ON CREATE SET 也不合适 —— 重跑或
          补数据时已存在的节点就永远补不上属性了。
          """
          queries = []
          for (label, name), attrs in sorted(nodes.items()):
              if attrs:
                  query = f"MERGE (n:{label} {{name: $name}}) SET n += $attrs"
                  params = {"name": name, "attrs": attrs}
              else:
                  query = f"MERGE (n:{label} {{name: $name}})"
                  params = {"name": name}
              queries.append((query, params))
          return queries
      
      
      def build_relation_cypher(rels):
          """一条关系一条语句:两端 MATCH(节点已经在上一步 MERGE 好了)再 MERGE 边。
      
          关系类型和两端 label 都拼进语句,同样先过了白名单。MERGE 而不是 CREATE,
          保证重复跑不会长出平行边。
      
          两个 MATCH 中间夹一个 WITH,而不是写成 `MATCH (s), (o)`:后者是一条语句里两个不
          相连的模式,Neo4j 每条都会回一个 cartesian product 警告,5 万条刷得日志没法看。
          这里两端都是唯一约束上的等值查找,笛卡尔积本来就是 1×1,换个写法只是让驱动闭嘴。
          """
          queries = []
          for s_label, s_name, rel_type, o_label, o_name in sorted(rels):
              query = (
                  f"MATCH (s:{s_label} {{name: $s_name}}) "
                  f"WITH s MATCH (o:{o_label} {{name: $o_name}}) "
                  f"MERGE (s)-[:{rel_type}]->(o)"
              )
              queries.append((query, {"s_name": s_name, "o_name": o_name}))
          return queries
      
      
      # ======================
      # 写库
      # ======================
      def run_in_batches(client, queries, desc):
          """分批提交。
      
          run_multiple_cypher 整批是一个事务,5 万条一次性提交内存吃不消、失败也全回滚,
          所以切成 BATCH_SIZE 一批。批与批之间不是原子的,但所有语句都是 MERGE,
          中断后重跑是幂等的,无所谓。
          """
          total = len(queries)
          done = 0
          for start in range(0, total, BATCH_SIZE):
              batch = queries[start : start + BATCH_SIZE]
              client.run_multiple_cypher(batch)
              done += len(batch)
              print(f"  {desc}: {done}/{total}")
          return done
      
      
      def run(only=None, limit=0, dry_run=False, wipe=False):
          paths = iter_graph_files(only=only, limit=limit)
          if not paths:
              print("没有找到图谱 json,先跑 _002_ 的抽取脚本。")
              return
      
          stats = Counter()
          nodes, rels = collect(paths, stats)
      
          label_count = Counter(label for label, _ in nodes)
          rel_count = Counter(r[2] for r in rels)
      
          print(f"\n图谱文件 {len(paths)} 个")
          print(f"唯一节点 {len(nodes)} 个: {dict(label_count.most_common())}")
          print(f"唯一关系 {len(rels)} 条: {dict(rel_count.most_common())}")
          for key, value in stats.most_common():
              print(f"  {key}: {value}")
      
          node_queries = build_node_cypher(nodes)
          rel_queries = build_relation_cypher(rels)
      
          if dry_run:
              print("\n--dry-run,不写库。示例语句:")
              for query, params in node_queries[:2] + rel_queries[:2]:
                  print(f"  {query}\n    {params}")
              return
      
          conf = Config()
          client = Neo4jClient(conf.NEO4J_URI, conf.NEO4J_USER, conf.NEO4J_PASSWORD)
      
          if wipe:
              # 只删这个图谱的六类节点,不动库里别的东西。DETACH 连边一起删。
              print("\n清空旧数据...")
              for label in sorted(ENTITY_TYPES):
                  client.run_cypher(f"MATCH (n:{label}) DETACH DELETE n")
      
          print("\n建唯一约束...")
          client.run_multiple_cypher(build_constraint_cypher())
      
          print("写入节点...")
          run_in_batches(client, node_queries, "节点")
      
          print("写入关系...")
          run_in_batches(client, rel_queries, "关系")
      
          # 从库里回读一遍计数,别只信自己发了多少条语句
          print("\n库内实际计数:")
          for label in sorted(ENTITY_TYPES):
              rows = client.run_cypher(f"MATCH (n:{label}) RETURN count(n) AS c")
              print(f"  {label}: {rows[0]['c']}")
          for rel_type in sorted(RELATION_TYPES):
              rows = client.run_cypher(f"MATCH ()-[r:{rel_type}]->() RETURN count(r) AS c")
              print(f"  {rel_type}: {rows[0]['c']}")
      
      
      def main():
          parser = argparse.ArgumentParser(description="把 _002_ 抽取的图谱导入 Neo4j")
          parser.add_argument(
              "--only",
              choices=sorted(GRAPH_DIRS),
              help="只导入一边:herb=中药图谱,formula=方剂图谱,默认两边都导",
          )
          parser.add_argument(
              "--limit",
              type=int,
              default=0,
              help="每个目录只取前 N 个 json 试跑,默认 0 表示全量",
          )
          parser.add_argument(
              "--dry-run",
              action="store_true",
              help="只统计并打印示例 Cypher,不连库不写入",
          )
          parser.add_argument(
              "--wipe",
              action="store_true",
              help="导入前先 DETACH DELETE 这六类节点(会删掉已有图谱数据)",
          )
          args = parser.parse_args()
          run(only=args.only, limit=args.limit, dry_run=args.dry_run, wipe=args.wipe)
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 __003__faiss_embedding.py 1.6KB
      # from common.neo4j_manager import neo4j_client
      import pickle
      import faiss
      from common.embedding_model import embedding_model
      from common.neo4j_manager import neo4j_client
      
      
      def build_faiss_index(sentences, index_path="faiss.index", mapping_path="id2text.pkl"):
          """
          基于字符串列表构建 FAISS 索引并保存
          :param sentences: List[str] 输入的文本列表
          :param index_path: FAISS 索引保存路径
          :param mapping_path: id->原始文本映射保存路径
          """
          # 1. 加载预训练文本向量模型
      
          # 2. 生成向量
          embeddings = embedding_model.encode(sentences, convert_to_numpy=True, normalize_embeddings=True)
          # 3. 构建 FAISS 索引
          dim = embeddings.shape[1]
          index = faiss.IndexFlatL2(dim)  # L2距离,欧式距离
          index.add(embeddings)
      
          # 4. 保存索引, 也就是embedding文件 存的是啥,存的是id, vector(向量), faiss中不能存元数据
          faiss.write_index(index, index_path)
      
          # 5. 保存 id -> 原始文本 映射 , 因为faiss中不保存元数据,如果你不保存一个映射文件,就没法从id恢复到原文本了
          id2text = {i: s for i, s in enumerate(sentences)}
          with open(mapping_path, "wb") as f:
              pickle.dump(id2text, f)
      
          print(f"✅ 索引已保存到 {index_path}, 映射保存到 {mapping_path}")
      
      
      # 获取所有节点名称
      node_names = neo4j_client.get_all_node_names()
      
      # 将节点进行向量化
      build_faiss_index(node_names, index_path="nero4j_embedding_faiss.index",
                        mapping_path="nero4j_embedding_faiss_id2text.pkl")
      
    • 📄 __004__test_faiss_index.py 18.5KB
      """体检 __003__faiss_embedding.py 存下来的两个文件是否可用。
      
      要测的是「这套索引现在能不能拿来做实体标准化」,不是「文件能不能打开」,所以除了
      加载,还要真的查一次,并且和 Neo4j 里的实体对一遍。分七项:
      
        1. 两个文件都在、能加载,index 和 pkl 各自自洽
        2. 向量条数 == 映射条数,且 id 是 0..N-1 连续无缺(faiss 只认下标,映射缺一个
           id 就会在检索时 KeyError,或者更糟 —— 错位返回别人的名字)
        3. 索引维度 == 当前 embedding 模型维度(换过模型就必须重建,否则 search 直接报错)
        4. 自检索:拿索引里已有的实体名去查,top1 必须是它自己且距离≈0。这一项才真正证明
           向量和映射是对齐的 —— 前两项只能证明数量对得上
        5. 归一化一致性:建库时用了 normalize_embeddings=True,查询若不归一化,L2 距离
           就不在同一尺度上。这里两种都跑一遍做对比,明确该怎么查
        6. 语义检索:用不在库里的口语词(「拉肚子」)查,看回来的是不是相关实体
        7. 与 Neo4j 对账:映射里的名字是否还都在库里、库里的名字是否都进了索引 ——
           _003_ 重新导入过数据但没重建索引的话,这一项会亮
      
      用法(在项目根目录执行):
          python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index
          python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index --skip-neo4j
          python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index --query 拉肚子 失眠
      """
      
      import argparse
      import os
      import pickle
      import random
      
      import faiss
      import numpy as np
      
      from common.path_utils import get_file_path
      
      INDEX_PATH = get_file_path("_003_create_neo4j_database/nero4j_embedding_faiss.index")
      MAPPING_PATH = get_file_path(
          "_003_create_neo4j_database/nero4j_embedding_faiss_id2text.pkl"
      )
      
      # 默认的语义检索用例:故意用不在实体表里的口语说法,看能不能召回对应的中医术语
      DEFAULT_QUERIES = ["拉肚子", "睡不着觉", "头疼", "补气血的药", "咳嗽有痰"]
      
      # 每项检查的结果都塞进这里,最后统一汇总,中间失败不早退(除非没法继续)
      results = []
      
      
      def check(name, ok, detail=""):
          results.append((name, ok, detail))
          print(f"  [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
          return ok
      
      
      # ======================
      # 1. 加载
      # ======================
      def load_files():
          print("\n[1] 加载文件")
          for path in (INDEX_PATH, MAPPING_PATH):
              if not os.path.exists(path):
                  check(f"文件存在: {os.path.basename(path)}", False, "文件不存在")
                  print("\n先跑 __003__faiss_embedding.py 生成索引。")
                  return None, None
              size_mb = os.path.getsize(path) / 1024 / 1024
              check(f"文件存在: {os.path.basename(path)}", True, f"{size_mb:.1f} MB")
      
          try:
              index = faiss.read_index(INDEX_PATH)
          except Exception as exc:  # faiss 抛的是 RuntimeError,也可能是别的
              check("faiss.read_index 可加载", False, f"{type(exc).__name__}: {exc}")
              return None, None
          check(
              "faiss.read_index 可加载",
              True,
              f"{type(index).__name__}, ntotal={index.ntotal}, d={index.d}",
          )
      
          try:
              with open(MAPPING_PATH, "rb") as f:
                  id2text = pickle.load(f)
          except Exception as exc:
              check("pickle 可加载", False, f"{type(exc).__name__}: {exc}")
              return index, None
          check("pickle 可加载", True, f"{type(id2text).__name__}, {len(id2text)} 条")
      
          return index, id2text
      
      
      # ======================
      # 2. 数量与 id 连续性
      # ======================
      def check_alignment(index, id2text):
          print("\n[2] 向量与映射对齐")
          check(
              "向量数 == 映射数",
              index.ntotal == len(id2text),
              f"index.ntotal={index.ntotal}, 映射={len(id2text)}",
          )
      
          keys = set(id2text)
          expected = set(range(index.ntotal))
          missing = expected - keys
          extra = keys - expected
          # id 不是 0..N-1 的话,search 返回的下标就可能查不到名字,或者取到错位的名字
          check(
              "映射 id 覆盖 0..ntotal-1",
              not missing and not extra,
              f"缺 {len(missing)} 个, 多 {len(extra)} 个"
              if (missing or extra)
              else "连续无缺",
          )
      
          empty = [k for k, v in id2text.items() if not isinstance(v, str) or not v.strip()]
          check("映射值都是非空字符串", not empty, f"异常 {len(empty)} 条")
      
          # 实体名重复不算错(Disease 和 Symptom 同名会各出现一次),但值得报出来:
          # 重复意味着同一个名字占了两个向量,检索时会连着返回两条一样的结果
          names = list(id2text.values())
          dup = len(names) - len(set(names))
          check("映射值无重复", dup == 0, f"重复 {dup} 条" if dup else "全部唯一")
      
      
      # ======================
      # 3. 维度 & 4/5. 自检索
      # ======================
      def check_dimension(index, model):
          print("\n[3] 维度匹配")
          # get_sentence_embedding_dimension 在新版 sentence-transformers 已改名,
          # 优先用新名字,取不到再退回旧名字,兼容两个版本
          getter = getattr(model.model, "get_embedding_dimension", None) or getattr(
              model.model, "get_sentence_embedding_dimension"
          )
          dim = getter()
          ok = dim == index.d
          check(
              "模型维度 == 索引维度",
              ok,
              f"模型={dim}, 索引={index.d}" + ("" if ok else " → 换过模型,必须重建索引"),
          )
          return ok
      
      
      def check_self_retrieval(index, id2text, model, sample=20, seed=0):
          """拿索引里已有的名字去查,top1 必须是它自己。
      
          这是整个脚本最关键的一项:数量对得上只能说明两个文件规模一致,只有自检索命中
          才能证明第 i 个向量对应的确实是 id2text[i] 这个名字(没有错位)。
          """
          print(f"\n[4] 自检索(随机抽 {sample} 个已有实体,top1 应命中自己)")
          rng = random.Random(seed)
          ids = rng.sample(range(index.ntotal), min(sample, index.ntotal))
          texts = [id2text[i] for i in ids]
      
          # 建库时用的是 normalize_embeddings=True,查询保持一致
          vecs = model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
          distances, indices = index.search(np.asarray(vecs, dtype="float32"), 1)
      
          hit = sum(1 for want, got in zip(ids, indices[:, 0]) if want == got)
          max_dist = float(distances[:, 0].max())
          check("top1 全部命中自己", hit == len(ids), f"{hit}/{len(ids)}")
          # 同一段文本重新编码后向量应当一模一样,L2 距离接近 0;明显大于 0 说明
          # 建库和现在用的不是同一个模型(或同模型不同配置)
          check(
              "自检索距离 ≈ 0",
              max_dist < 1e-4,
              f"最大距离={max_dist:.2e}",
          )
      
          miss = [
              (id2text[w], id2text.get(int(g)))
              for w, g in zip(ids, indices[:, 0])
              if w != int(g)
          ]
          if miss:
              print("    未命中的样本(期望 → 实际):")
              for want, got in miss[:5]:
                  print(f"      {want} → {got}")
      
      
      def check_normalization(index, id2text, model, sample=10, seed=1):
          """确认向量是单位长度,以及 normalize_embeddings 这个开关到底有没有影响。
      
          建库时传了 normalize_embeddings=True,直觉上查询也必须传,否则距离尺度对不上。
          但 bge-large-zh-v1.5 的 SentenceTransformer 结构最后一层就是 Normalize,出来的
          向量本身已经是单位长度,这个开关对它是空操作 —— 所以两种查法结果完全一样。
          这里把两种都跑一遍并断言「结果一致」,如果哪天换了个不自带 Normalize 的模型,
          这一项会立刻失败,提醒必须显式归一化。
      
          单位长度还有个用处:此时 L2 距离和余弦相似度是单调等价的(d² = 2-2cos),
          所以 IndexFlatL2 的排序结果就等于按余弦相似度排,距离范围落在 [0, 2]。
          """
          print("\n[5] 归一化一致性(建库用了 normalize_embeddings=True)")
          rng = random.Random(seed)
          ids = rng.sample(range(index.ntotal), min(sample, index.ntotal))
          texts = [id2text[i] for i in ids]
          arr = np.asarray(ids)
      
          norm = model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
          raw = model.encode(texts, convert_to_numpy=True, normalize_embeddings=False)
          d_norm, i_norm = index.search(np.asarray(norm, dtype="float32"), 1)
          d_raw, i_raw = index.search(np.asarray(raw, dtype="float32"), 1)
      
          hit_norm = int((i_norm[:, 0] == arr).sum())
          hit_raw = int((i_raw[:, 0] == arr).sum())
          raw_norms = np.linalg.norm(np.asarray(raw), axis=1)
          print(
              f"    归一化查询: 命中 {hit_norm}/{len(ids)}, 平均距离 {d_norm[:, 0].mean():.4f}"
          )
          print(
              f"    未归一化查询: 命中 {hit_raw}/{len(ids)}, 平均距离 {d_raw[:, 0].mean():.4f}"
          )
          print(
              f"    不传归一化时的向量模长: {raw_norms.min():.4f} ~ {raw_norms.max():.4f}"
              "(≈1 说明模型自带 Normalize 层)"
          )
      
          check("归一化查询能命中自己", hit_norm == len(ids), f"{hit_norm}/{len(ids)}")
          # 模型自带 Normalize 时这一项必然通过;换成不自带的模型就会失败,
          # 那时候就必须在检索侧显式 normalize_embeddings=True
          check(
              "开关不影响结果(模型自带 Normalize)",
              hit_raw == hit_norm and np.allclose(raw_norms, 1.0, atol=1e-3),
              f"模长≈{raw_norms.mean():.4f}",
          )
      
      
      # ======================
      # 6. 语义检索
      # ======================
      def check_semantic(index, id2text, model, queries, top_k=5):
          """用不在实体表里的口语说法查,看能不能召回对应的中医术语。
      
          这一项没有硬性判据 —— 召回质量得人看。脚本只保证「能查、有结果、id 都能翻回
          名字」,把结果打出来供判断。这正是实体标准化(用户说「拉肚子」→ 图里的「腹泻」)
          在 _004_ 里要依赖的能力。
          """
          print(f"\n[6] 语义检索(top{top_k},查询词不在实体表里)")
          vecs = model.encode(queries, convert_to_numpy=True, normalize_embeddings=True)
          distances, indices = index.search(np.asarray(vecs, dtype="float32"), top_k)
      
          unmapped = 0
          for query, dist_row, idx_row in zip(queries, distances, indices):
              hits = []
              for dist, idx in zip(dist_row, idx_row):
                  name = id2text.get(int(idx))
                  if name is None:
                      unmapped += 1
                      name = f"<id {idx} 无映射>"
                  hits.append(f"{name}({dist:.3f})")
              print(f"    {query} → {' | '.join(hits)}")
      
          check("检索结果 id 都能翻回名字", unmapped == 0, f"{unmapped} 个 id 查不到映射")
      
      
      # ======================
      # 定点用例
      # ======================
      # 口语说法 → 期望能召回的图中实体(只要 topK 里出现任意一个就算通过)。
      # 这几条是实体标准化的典型场景:用户嘴里的词和图里的术语对不上,只能靠向量召回。
      # 断言写成「至少命中一个」而不是「top1 必须是某个词」—— 同义术语在图里往往有好几个
      # 粒度不同的写法(腹泻/泄泻/泻痢),谁排第一取决于模型,锁死 top1 会让测试很脆。
      STANDARDIZE_CASES = [
          ("拉肚子", ["腹泻", "泄泻", "泻痢"]),
          ("睡不着觉", ["失眠", "睡眠不好", "不能入睡"]),
          ("咳嗽有痰", ["咳嗽痰多", "咳嗽多痰", "咳痰"]),
      ]
      
      
      def check_cases(index, id2text, model, top_k=10, cases=STANDARDIZE_CASES):
          """定点用例:口语词能不能召回图里对应的实体名。
      
          比 [6] 严格 —— [6] 只看「有没有结果」,这里明确写死了期望值,回归时能发现
          换模型 / 重建索引导致的召回退化。
          """
          print(f"\n[7] 实体标准化定点用例(top{top_k} 内应出现期望实体)")
          queries = [q for q, _ in cases]
          vecs = model.encode(queries, convert_to_numpy=True, normalize_embeddings=True)
          distances, indices = index.search(np.asarray(vecs, dtype="float32"), top_k)
      
          for (query, expected), dist_row, idx_row in zip(cases, distances, indices):
              names = [id2text.get(int(i)) for i in idx_row]
              found = [e for e in expected if e in names]
              top1 = f"{names[0]}({dist_row[0]:.3f})"
              check(
                  f"「{query}」召回 {'/'.join(expected)}",
                  bool(found),
                  f"top1={top1}, 命中={found or '无'}",
              )
              # 把完整 topK 打出来,方便人工看召回质量而不只是看通过与否
              detail = " | ".join(f"{n}({d:.3f})" for n, d in zip(names, dist_row))
              print(f"      top{top_k}: {detail}")
      
      
      def show_case_detail(index, id2text, query, top_k=10, skip_neo4j=False):
          """把单个查询的相似实体连标签一起列出来。
      
          带上 Neo4j 里的标签才知道召回的是症状还是疾病还是功效 —— 做实体标准化时,
          「拉肚子」应该落到 Symptom/Disease,如果 top 几全是 Effect 就说明用错了方向。
          """
          print("\n" + "=" * 60)
          print(f"用例明细:「{query}」的相似实体(top{top_k})")
          print("=" * 60)
      
          vec = model_encode_one(query)
          distances, indices = index.search(vec, top_k)
      
          labels = {}
          if not skip_neo4j:
              try:
                  from common.neo4j_manager import neo4j_client
      
                  names = [id2text.get(int(i)) for i in indices[0]]
                  rows = neo4j_client.run_cypher(
                      "UNWIND $names AS nm MATCH (n {name: nm}) "
                      "RETURN nm AS name, collect(DISTINCT head(labels(n))) AS labels",
                      {"names": names},
                  )
                  labels = {r["name"]: r["labels"] for r in rows}
              except Exception as exc:
                  print(f"(标签查询失败,只显示名字: {type(exc).__name__})")
      
          print(f"{'排名':<5}{'实体名':<18}{'L2距离':<10}{'余弦相似度':<12}标签")
          print("-" * 60)
          for rank, (dist, idx) in enumerate(zip(distances[0], indices[0]), 1):
              name = id2text.get(int(idx), f"<id {idx}>")
              # 向量是单位长度,所以 d² = 2 - 2cos,可以反推余弦相似度
              cos = 1 - float(dist) / 2
              tag = "/".join(labels.get(name, [])) or "-"
              print(f"{rank:<5}{name:<18}{float(dist):<10.4f}{cos:<12.4f}{tag}")
      
      
      def model_encode_one(text):
          """单条编码,show_case_detail 用。模型是全局单例,这里直接取。"""
          from common.embedding_model import embedding_model
      
          vec = embedding_model.encode(
              [text], convert_to_numpy=True, normalize_embeddings=True
          )
          return np.asarray(vec, dtype="float32")
      
      
      # ======================
      # 7. 与 Neo4j 对账
      # ======================
      def check_against_neo4j(id2text):
          """索引是不是当前 Neo4j 的快照。
      
          _003_ 重新导入过数据、但没重跑 __003__faiss_embedding.py 的话,这一项会露出来:
          图里新增的实体检索不到,索引里的旧实体又对不上任何节点。
          """
          print("\n[8] 与 Neo4j 对账")
          try:
              from common.neo4j_manager import neo4j_client
      
              node_names = neo4j_client.get_all_node_names()
          except Exception as exc:
              check("Neo4j 可连接", False, f"{type(exc).__name__}: {exc}")
              return
      
          graph = set(node_names)
          indexed = set(id2text.values())
          missing = graph - indexed  # 图里有、索引里没有 → 检索不到
          stale = indexed - graph  # 索引里有、图里没了 → 召回了不存在的实体
      
          print(f"    Neo4j 去重实体名 {len(graph)} 个,索引 {len(indexed)} 个")
          check("图中实体都已入索引", not missing, f"缺 {len(missing)} 个")
          if missing:
              print(f"      例: {list(missing)[:5]}")
          check("索引中无失效实体", not stale, f"多 {len(stale)} 个")
          if stale:
              print(f"      例: {list(stale)[:5]}")
          if missing or stale:
              print("    → 图和索引不同步,重跑 __003__faiss_embedding.py")
      
      
      def main():
          parser = argparse.ArgumentParser(
              description="检查 nero4j_embedding_faiss.index / _id2text.pkl 是否可用"
          )
          parser.add_argument(
              "--query",
              nargs="*",
              default=DEFAULT_QUERIES,
              help="语义检索用的查询词,可给多个",
          )
          parser.add_argument("--top-k", type=int, default=5, help="语义检索返回条数")
          parser.add_argument(
              "--detail",
              default="拉肚子",
              help="末尾单独列出这个查询的相似实体(带 Neo4j 标签),默认「拉肚子」;传空串关闭",
          )
          parser.add_argument(
              "--sample", type=int, default=20, help="自检索抽样条数,默认 20"
          )
          parser.add_argument(
              "--skip-neo4j",
              action="store_true",
              help="跳过与 Neo4j 的对账(连不上库时用)",
          )
          args = parser.parse_args()
      
          print("=" * 60)
          print("FAISS 索引可用性检查")
          print("=" * 60)
      
          index, id2text = load_files()
          if index is None or id2text is None:
              print("\n文件都没加载起来,后面的检查没法做。")
              raise SystemExit(1)
      
          check_alignment(index, id2text)
      
          # 模型加载慢(几百 MB 权重 + 可能上 GPU),放在文件检查之后:
          # 文件本身有问题的话,没必要等模型
          print("\n加载 embedding 模型...")
          from common.embedding_model import embedding_model
      
          dim_ok = check_dimension(index, embedding_model)
          if dim_ok:
              check_self_retrieval(index, id2text, embedding_model, sample=args.sample)
              check_normalization(index, id2text, embedding_model)
              check_semantic(index, id2text, embedding_model, args.query, top_k=args.top_k)
              check_cases(index, id2text, embedding_model, top_k=args.top_k)
          else:
              print("  维度不匹配,跳过所有检索项 —— 先重建索引。")
      
          if not args.skip_neo4j:
              check_against_neo4j(id2text)
      
          # 明细放在汇总前面:这是给人看的部分,跑完最想看到的就是它
          if dim_ok and args.detail:
              show_case_detail(
                  index,
                  id2text,
                  args.detail,
                  top_k=args.top_k,
                  skip_neo4j=args.skip_neo4j,
              )
      
          print("\n" + "=" * 60)
          failed = [r for r in results if not r[1]]
          print(f"共 {len(results)} 项检查,失败 {len(failed)} 项")
          for name, _, detail in failed:
              print(f"  FAIL {name} — {detail}")
          if not failed:
              print("索引可用:向量与映射对齐,模型维度一致,检索正常。")
          print("=" * 60)
          raise SystemExit(1 if failed else 0)
      
      
      if __name__ == "__main__":
          main()
      
    • ⚙️ tcm_metadata.json 14.4KB
    • ⚙️ 需求 1.2KB
  • 📁 _004_langgraph_more_nodes/
    • 📁 nodes/
      • 📄 check_cypher_node.py 1.0KB
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.neo4j_manager import neo4j_client
        
        
        def check_cypher_node(state:AgentState):
            print("cypher检查节点执行开始了")
            cypher_query_list = state.get("cypher_query") or []
        
            # 空列表判 False。"空集合全部合法"逻辑上成立,但这个标志位是给路由用的:
            # 没有语句可执行时必须走兜底,判 True 会让下游拿着空列表白查一趟。
            if not cypher_query_list:
                state['is_all_validate_cypher'] = False
                print("完成检查cypher语句:False(没有语句)")
                return state
        
            state['is_all_validate_cypher'] = True
            for cypher_query in cypher_query_list:
                if not neo4j_client.validate_cypher(cypher_query):
                    state['is_all_validate_cypher'] = False
                    break
            print(f"完成检查cypher语句:{state['is_all_validate_cypher']}")
            return state
        
        
        if __name__ == '__main__':
            print(check_cypher_node({"cypher_query":["MATC1H (e:Employee) RETURN e.id, e.name, e.salary, e.deptno"]}))
        
      • 📄 check_cypher_node_bak.py 11.1KB
        """Cypher 合法性检查节点
        
        输入: state['cypher_query']: List[str]        上游生成的 cypher 语句
        输出: state['is_all_validate_cypher']: bool   是否全部合法
        
        为什么生成完还要再查一遍:`generate_neo4j_cypher_node` 里的过滤只看"是不是只读",
        拦不住"语法对、但标签/属性图里没有"这类语句 —— 它们能正常执行,只是恒返回空行,
        下游会当成"图里没这个知识",比报错更难查。所以这里做三层检查:
        
          1. 静态只读:复用生成节点的 FORBIDDEN_KEYWORDS,同一份白名单,避免两处漂移
          2. 对 schema 核账:标签、关系类型、`变量.属性` 都要在 tcm_metadata.json 里存在
          3. Neo4j EXPLAIN:只编译不执行,把语法错误在打库之前拦掉
        
        第 3 层要连库,连不上时只跳过这一层(前两层的结论照样有效),不让整个节点挂掉。
        """
        
        import json
        import re
        from typing import Dict, List, Set, Tuple
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from _004_langgraph_more_nodes.nodes.generate_neo4j_cypher_node import (
            FORBIDDEN_KEYWORDS,
        )
        from common.config import Config
        
        conf = Config()
        
        # 空的 cypher_query 判 False。空集合"全部合法"在逻辑上成立,但这个标志位是给路由用的:
        # 没有语句可执行时,必须走兜底分支而不是查库分支,判 True 会让下游拿着空列表白跑一趟。
        EMPTY_IS_VALID = False
        
        
        # ======================
        # schema:从 tcm_metadata.json 读出标签 / 关系 / 属性
        # ======================
        def _load_schema() -> Tuple[Set[str], Set[str], Set[str]]:
            """返回 (标签集合, 关系类型集合, 所有属性名集合)。
        
            属性不按标签分开:cypher 里 `n.name` 的 `n` 要回溯到它的标签才能精确校验,
            而 WITH / OPTIONAL MATCH 之后变量的标签可能已经丢了。这里只判"这个属性名在图里
            存在吗",宁可漏判也不误杀。
            """
            meta = json.loads(conf.TCM_METADATA)
            labels = {label["name"] for label in meta.get("labels", [])}
            rel_types = {rel["type"] for rel in meta.get("relationships", [])}
        
            props = set()
            for label in meta.get("labels", []):
                props.update(prop["name"] for prop in label.get("properties", []))
            for rel in meta.get("relationships", []):
                props.update(prop["name"] for prop in rel.get("properties", []))
            return labels, rel_types, props
        
        
        LABELS, REL_TYPES, PROP_KEYS = _load_schema()
        
        # `(n:Herb)`、`(f:Formula:Prescription)` 里的标签。冒号后面允许空格,多标签用 | 或 :
        LABEL_PATTERN = re.compile(r"\(\s*\w*\s*:\s*([A-Za-z_][\w|:\s]*?)\s*[){\s]")
        # `-[:HAS_EFFECT]->`、`-[r:HAS_EFFECT|HAS_INGREDIENT*1..2]-`
        REL_PATTERN = re.compile(r"\[\s*\w*\s*:\s*([A-Za-z_][\w|\s]*)")
        # `n.name`、`h.property_flavor`。前面不能是字母/数字,排开 `count(n).x` 这种不存在的写法
        PROP_PATTERN = re.compile(r"\b[A-Za-z_]\w*\.([A-Za-z_]\w+)")
        
        # cypher 内建函数和关键字后面也可能跟点号或被当成标签,先排掉常见的
        IGNORED_TOKENS = {"DISTINCT", "AS", "AND", "OR", "NOT", "IN", "NULL", "TRUE", "FALSE"}
        
        STRING_LITERAL = re.compile(r"'[^']*'|\"[^\"]*\"")
        
        
        def _strip_literals(cypher: str) -> str:
            """把字符串字面量换成空串再做正则匹配。
        
            实体名是数据不是代码,`WHERE s.name = '桂枝汤.加减'` 里的 `.加减` 不该被当成属性,
            `'Herb'` 这种字面量也不该被当成标签。
            """
            return STRING_LITERAL.sub("''", cypher)
        
        
        def _split_names(raw: str) -> List[str]:
            """`Formula:Prescription`、`HAS_EFFECT|HAS_INGREDIENT*1..2` 拆成名字列表"""
            names = re.split(r"[|:]", raw)
            # 关系里可能带 `*1..2` 变长路径,星号后面的部分不是名字
            return [name.split("*")[0].strip() for name in names if name.split("*")[0].strip()]
        
        
        # ======================
        # 第 1 层:静态只读
        # ======================
        def _check_readonly(cypher: str) -> List[str]:
            """返回问题列表,空列表表示这一层通过"""
            upper = _strip_literals(cypher).upper()
            problems = [
                f"包含写操作关键字 {keyword}"
                for keyword in FORBIDDEN_KEYWORDS
                if re.search(rf"\b{re.escape(keyword)}\b", upper)
            ]
            if "RETURN" not in upper:
                problems.append("没有 RETURN,查不出任何结果")
            if not upper.lstrip().startswith(("MATCH", "OPTIONAL MATCH", "WITH")):
                problems.append("不是以 MATCH / OPTIONAL MATCH / WITH 开头")
            return problems
        
        
        # ======================
        # 第 2 层:对 schema 核账
        # ======================
        def _check_schema(cypher: str) -> List[str]:
            """标签、关系类型、属性名都要在 tcm_metadata.json 里存在"""
            text = _strip_literals(cypher)
            problems = []
        
            for raw in LABEL_PATTERN.findall(text):
                for label in _split_names(raw):
                    if label.upper() in IGNORED_TOKENS:
                        continue
                    if label not in LABELS:
                        problems.append(f"标签不存在: {label}")
        
            for raw in REL_PATTERN.findall(text):
                for rel in _split_names(raw):
                    if rel not in REL_TYPES:
                        problems.append(f"关系类型不存在: {rel}")
        
            for prop in PROP_PATTERN.findall(text):
                if prop not in PROP_KEYS:
                    problems.append(f"属性不存在: .{prop}")
        
            # 同一个错误可能在一条语句里出现多次,去重保序
            return list(dict.fromkeys(problems))
        
        
        # ======================
        # 第 3 层:Neo4j EXPLAIN
        # ======================
        _explain_available = None
        
        
        def _check_explain(cypher: str) -> List[str]:
            """用 EXPLAIN 让 Neo4j 自己编译一遍,只查语法不执行。
        
            连不上库时把 _explain_available 置 False,后续语句直接跳过这一层,
            不用每条都重试一次连接。
            """
            global _explain_available
            if _explain_available is False:
                return []
        
            try:
                from common.neo4j_manager import neo4j_client
        
                neo4j_client.run_cypher(f"EXPLAIN {cypher}")
                _explain_available = True
                return []
            except Exception as exc:
                message = str(exc)
                # 区分"库连不上"和"这条语句本身有问题":连接类异常不该算语句不合法
                if _explain_available is None and (
                    "Unable to retrieve routing information" in message
                    or "ServiceUnavailable" in message
                    or "AuthError" in type(exc).__name__
                    or "Couldn't connect" in message
                ):
                    _explain_available = False
                    print(f"[warn] Neo4j 连不上,跳过 EXPLAIN 校验: {type(exc).__name__}")
                    return []
                _explain_available = True
                return [f"EXPLAIN 失败: {message.splitlines()[0][:120]}"]
        
        
        # ======================
        # 对外接口
        # ======================
        def validate_cypher(cypher: str, use_explain: bool = True) -> List[str]:
            """检查单条 cypher,返回问题列表。空列表表示合法。
        
            三层依次短路:静态检查没过就不必再对 schema 和连库了,问题已经确定。
            """
            cypher = (cypher or "").strip()
            if not cypher:
                return ["空语句"]
        
            problems = _check_readonly(cypher)
            if problems:
                return problems
        
            problems = _check_schema(cypher)
            if problems:
                return problems
        
            return _check_explain(cypher) if use_explain else []
        
        
        def check_cypher_node01(state: AgentState, use_explain: bool = True) -> AgentState:
            """检查 cypher_query 里所有语句是否合法,结果写回 is_all_validate_cypher"""
            cyphers = state.get("cypher_query") or []
        
            if not cyphers:
                state["is_all_validate_cypher"] = EMPTY_IS_VALID
                return state
        
            all_valid = True
            for cypher in cyphers:
                problems = validate_cypher(cypher, use_explain=use_explain)
                if problems:
                    all_valid = False
                    # 打日志:不合法时下游只看到一个 False,不记下来就无法定位是哪条、错在哪
                    print(f"[warn] 非法 cypher: {cypher[:80]}")
                    for problem in problems:
                        print(f"        - {problem}")
        
            state["is_all_validate_cypher"] = all_valid
            return state
        
        
        if __name__ == "__main__":
            print(f"schema: {len(LABELS)} 标签 / {len(REL_TYPES)} 关系 / {len(PROP_KEYS)} 属性")
        
            # (语句, 期望合法) —— 前四条来自 generate_neo4j_cypher_node 的真实输出
            cases: List[Tuple[str, bool]] = [
                (
                    "MATCH (h:Herb)-[:HAS_EFFECT]->(e:Effect) WHERE h.name IN ['人参'] "
                    "RETURN h.name AS herb, e.name AS effect LIMIT 20",
                    True,
                ),
                (
                    "MATCH (f:Formula)-[:HAS_INGREDIENT]->(h:Herb) WHERE f.name = '四君子汤' "
                    "RETURN h.name AS herb LIMIT 20",
                    True,
                ),
                (
                    "MATCH (s:Source)<-[:FROM_SOURCE]-(f:Formula) WHERE s.name IN ['《伤寒论》'] "
                    "RETURN f.name AS formula LIMIT 20",
                    True,
                ),
                (
                    "MATCH (h:Herb) WHERE h.name = '人参' "
                    "RETURN h.property_flavor AS flavor, h.meridian AS meridian LIMIT 20",
                    True,
                ),
                # 标签不在 schema 里:语法合法,执行恒空 —— 第 2 层要抓的就是这种
                (
                    "MATCH (m:Medicine)-[:HAS_EFFECT]->(e:Effect) RETURN m.name AS name LIMIT 20",
                    False,
                ),
                # 关系名编造
                (
                    "MATCH (h:Herb)-[:CURES]->(d:Disease) RETURN d.name AS disease LIMIT 20",
                    False,
                ),
                # 属性编造
                ("MATCH (h:Herb) RETURN h.price AS price LIMIT 20", False),
                # 写操作
                ("MATCH (h:Herb {name: '人参'}) SET h.name = '党参' RETURN h.name AS n", False),
                # 没有 RETURN
                ("MATCH (h:Herb) WHERE h.name = '人参'", False),
                # 语法错误:括号不闭合,前两层看不出来,靠 EXPLAIN
                ("MATCH (h:Herb RETURN h.name AS herb LIMIT 20", False),
                # 实体名里带点号和标签同名词,不该被误判
                (
                    "MATCH (h:Herb) WHERE h.name IN ['Herb', '桂枝.加减'] RETURN h.name AS n LIMIT 5",
                    True,
                ),
                ("", False),
            ]
        
            print("\n=== 单条校验 ===")
            wrong = 0
            for cypher, expected in cases:
                problems = validate_cypher(cypher)
                actual = not problems
                flag = "OK " if actual == expected else "MISS"
                if actual != expected:
                    wrong += 1
                print(f"  [{flag}] {'合法' if actual else '非法'} <- {cypher[:64] or '<空>'}")
                for problem in problems:
                    print(f"         {problem}")
            print(f"\n预期不符 {wrong} 条")
        
            print("\n=== 节点整体 ===")
            for cypher_query in [
                [cases[0][0], cases[1][0]],  # 全合法 -> True
                [cases[0][0], cases[4][0]],  # 混了一条非法 -> False
                [],  # 空列表 -> False
            ]:
                result = check_cypher_node01({"cypher_query": list(cypher_query)})
                print(f"  {len(cypher_query)} 条 -> {result['is_all_validate_cypher']}")
        
        
        #我们不用这个node了,逻辑有些复杂
        #逻辑:
        #1. 用正则进行cypher语句的检查如果没问题
        #2. 用neo4j的元数据进行cypher语句的检查如果没问题
        # 3.用Explain 语句进行检查
        
      • 📄 extract_entity_from_user_input_node.py 5.5KB
        """用户输入实体抽取节点
        
        输入: 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]}")
        
      • 📄 generate_neo4j_answer_node_bak.py 5.7KB
        """图谱答案生成节点
        
        输入: state['input']            用户原始提问
              state['cypher_query']     执行过的语句,用来告诉模型每份结果是问什么的
              state['cypher_results']   run_cypher_node 查回来的行
        输出: state['neo4j_answer']     根据查询结果组织的自然语言回答
              state['output']           同一份回答,整张图的统一出口
        
        把 list[dict] 交给模型转写成人话,而不是在 Python 里拼字符串:返回列名由生成的 cypher
        决定(herb / effect / formula ...),拼接逻辑得跟着 cypher 变,交给模型更省事。
        
        约束模型只用查询结果作答,是这个节点的重点。图谱问答的价值就在于答案可溯源,
        一旦允许模型补充自己的知识,就退化成了普通问答,还看不出哪句话是图里来的。
        """
        
        import json
        
        from langchain_core.messages import HumanMessage, SystemMessage
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.llm import my_llm
        
        llm = my_llm
        
        # 结果行数可能很多,全塞进 prompt 既慢又容易超长。每条语句最多给这么多行。
        MAX_ROWS_PER_QUERY = 30
        
        SYSTEM_PROMPT = (
            "你是一个中医知识问答助手。下面给你的是从中医知识图谱里查询出来的结果,"
            "请依据这些结果回答用户的提问。\n\n"
            "【要求】\n"
            "1. 只使用查询结果中的信息作答,不要补充你自己知道的中医知识,也不要推测。\n"
            "2. 查询结果为空时,直接说明图谱中没有查到相关信息,不要编造。\n"
            "3. 结果条目多时归类整理,用简洁的中文分点表述,不要罗列原始字段名。\n"
            "4. 不要提及 cypher、数据库、查询语句这些技术细节,直接回答内容。\n"
            "5. 涉及具体用药时,末尾提示需遵医嘱。"
        )
        
        EMPTY_ANSWER = "抱歉,图谱中没有查询到与您的问题相关的信息。"
        
        
        def _build_result_block(state: AgentState) -> str:
            """把每条语句和它的结果配对,拼成给模型看的文本"""
            cyphers = state.get("cypher_query") or []
            results = state.get("cypher_results") or []
        
            blocks = []
            for i, rows in enumerate(results):
                # cypher_results 和 cypher_query 下标对齐,但防一手长度不一致
                cypher = cyphers[i] if i < len(cyphers) else ""
                shown = rows[:MAX_ROWS_PER_QUERY]
                omitted = len(rows) - len(shown)
        
                lines = [f"【查询 {i + 1}】{cypher}"]
                if not rows:
                    lines.append("结果:无")
                else:
                    lines.append(f"结果(共 {len(rows)} 条):")
                    for row in shown:
                        lines.append(f"  {json.dumps(row, ensure_ascii=False)}")
                    if omitted > 0:
                        lines.append(f"  ...另有 {omitted} 条已省略")
                blocks.append("\n".join(lines))
        
            return "\n\n".join(blocks)
        
        
        def generate_neo4j_answer_node(state: AgentState) -> AgentState:
            print("图谱答案节点执行开始了")
            user_input = (state.get("input_semantic_trans") or "").strip()
            results = state.get("cypher_results") or []
        
            # 所有语句都没查回东西,不必调用大模型 —— 模型能说的也只是"没查到"
            if not user_input or not any(results):
                state["neo4j_answer"] = EMPTY_ANSWER
                state["output"] = EMPTY_ANSWER
                print("完成生成图谱答案: 无结果")
                return state
        
            messages = [
                SystemMessage(content=SYSTEM_PROMPT),
                HumanMessage(
                    content=(
                        f"用户提问:{user_input}\n\n"
                        f"图谱查询结果:\n{_build_result_block(state)}"
                    )
                ),
            ]
            answer = llm.invoke(messages).content.strip()
        
            state["neo4j_answer"] = answer
            state["output"] = answer
            history_messages = state.get("history_messages", [])
            history_messages.append({"role": "assistant", "content": answer})
            state["history_messages"] = history_messages
        
            print(f"完成生成图谱答案: {len(answer)} 字")
            return state
        
        
        if __name__ == "__main__":
            cases = [
                {
                    "input": "四君子汤由哪些药材组成?",
                    "cypher_query": [
                        "MATCH (f:Formula)-[:HAS_INGREDIENT]->(h:Herb) "
                        "WHERE f.name = '四君子汤' RETURN h.name AS herb LIMIT 20"
                    ],
                    "cypher_results": [
                        [{"herb": "甘草"}, {"herb": "人参"}, {"herb": "茯苓"}, {"herb": "白术"}]
                    ],
                },
                {
                    "input": "人参有什么功效?",
                    "cypher_query": [
                        "MATCH (h:Herb)-[:HAS_EFFECT]->(e:Effect) WHERE h.name IN ['人参'] "
                        "RETURN h.name AS herb, e.name AS effect LIMIT 20"
                    ],
                    "cypher_results": [
                        [
                            {"herb": "人参", "effect": "安神"},
                            {"herb": "人参", "effect": "益气温阳"},
                            {"herb": "人参", "effect": "补脾健肺"},
                            {"herb": "人参", "effect": "大补元气"},
                        ]
                    ],
                },
                # 查询结果全空:预期不调用大模型,直接给兜底话术
                {
                    "input": "阿司匹林是什么药材?",
                    "cypher_query": ["MATCH (h:Herb) WHERE h.name = '阿司匹林' RETURN h.name AS herb"],
                    "cypher_results": [[]],
                },
            ]
        
            for case in cases:
                result = generate_neo4j_answer_node(dict(case))
                print(f"\n提问: {case['input']}")
                print(f"  {result['neo4j_answer'][:200]}")
                print(f"  output 一致: {result['output'] == result['neo4j_answer']}")
        
      • 📄 generate_neo4j_cypher_node.py 9.9KB
        """Cypher 生成节点
        
        输入: state['input']                          用户原始提问
              state['matched_*']                      标准化后的实体名,拿来写 WHERE 条件
              Config().TCM_METADATA                   图谱的 schema(labels / relationships / triples)
        输出: state['cypher_query']: List[str]        生成的 cypher 语句列表
        
        为什么要喂元数据:不给 schema,模型会凭空造标签和关系名(`:Medicine`、`:CURES`),
        查出来永远是空。`tcm_metadata.json` 是 `_003_` 从库里导出的真实骨架,14 个三元组
        限定了哪些 (起点标签, 关系, 终点标签) 组合真实存在。
        
        为什么要喂 matched_* 而不是用户原词:用户说"拉肚子",图里存的是"腹泻",
        `WHERE n.name = '拉肚子'` 查不到东西。matched_* 已经是库里的标准名了,
        所以 prompt 里明确要求 WHERE 只能用这些名字的原文。
        """
        
        import json
        import re
        from typing import List
        
        from langchain_core.messages import HumanMessage, SystemMessage
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.config import Config
        from common.llm import my_llm
        
        conf = Config()
        llm = my_llm
        
        # 六类实体在 state 里的键 -> prompt 里展示用的中文标签
        MATCHED_FIELDS = {
            "matched_herbs": ("Herb", "药材"),
            "matched_formulas": ("Formula", "方剂"),
            "matched_diseases": ("Disease", "疾病"),
            "matched_symptoms": ("Symptom", "症状"),
            "matched_effects": ("Effect", "功效"),
            "matched_sources": ("Source", "出处"),
        }
        
        SYSTEM_PROMPT = (
            "你是一个 Neo4j Cypher 生成专家,负责把中医知识图谱上的自然语言提问翻译成 Cypher 查询。\n\n"
            "【图谱 schema】以下是数据库的真实骨架,只能使用这里出现的标签、关系类型和属性,"
            "不要发明任何新的标签或关系名:\n"
            "{metadata}\n\n"
            "【生成要求】\n"
            "1. 只使用 schema 中 triples 里真实存在的 (起点标签)-[关系]->(终点标签) 组合,方向不要写反。\n"
            "2. WHERE 条件里的实体名必须原样使用下面给出的『可用实体名』,一个字都不要改,"
            "也不要用提问里的原词(图里存的是标准名)。多个候选名用 IN [...] 覆盖。\n"
            "3. 只生成读查询:只能用 MATCH / OPTIONAL MATCH / WHERE / WITH / RETURN / ORDER BY / LIMIT / DISTINCT。"
            "严禁 CREATE、MERGE、SET、DELETE、REMOVE、DETACH、DROP、LOAD CSV、CALL。\n"
            "4. 每条语句必须有 RETURN,并且给返回列起别名(AS)。结果集可能很大,一律加 LIMIT(默认 20)。\n"
            "5. 一条语句只回答一个角度。提问涉及多个角度时拆成多条语句,最多 5 条。\n"
            "6. 如果可用实体名全为空,或提问无法用这个图谱回答,返回空数组 []。\n\n"
            "【输出格式】只输出一个 JSON 数组,每个元素是一条完整的 cypher 字符串,"
            "不要 markdown 代码块,不要任何解释文字。例如:\n"
            '["MATCH (h:Herb)-[:HAS_EFFECT]->(e:Effect) WHERE h.name IN [\'人参\'] '
            'RETURN h.name AS herb, e.name AS effect LIMIT 20"]'
        )
        
        # 只读白名单:出现这些写操作关键字就把语句丢掉。
        # 生成的 cypher 会直接打到生产库上,不能只靠 prompt 里那句"严禁"。
        FORBIDDEN_KEYWORDS = (
            "CREATE",
            "MERGE",
            "SET",
            "DELETE",
            "REMOVE",
            "DETACH",
            "DROP",
            "LOAD CSV",
            "CALL",
            "FOREACH",
            "IMPORT",
        )
        
        MAX_CYPHER = 5
        
        
        def _build_entity_block(state: AgentState) -> str:
            """把 matched_* 拼成给模型看的『可用实体名』清单"""
            lines = []
            for key, (label, zh) in MATCHED_FIELDS.items():
                names = state.get(key) or []
                if names:
                    listed = "、".join(f"'{n}'" for n in names)
                    lines.append(f"- {label}({zh}): {listed}")
            return "\n".join(lines) if lines else "(无,没有匹配到任何实体)"
        
        
        def _strip_code_fence(text: str) -> str:
            """去掉模型可能套上的 ```json ... ``` 外壳"""
            text = text.strip()
            if text.startswith("```"):
                text = re.sub(r"^```[a-zA-Z]*\s*", "", text)
                text = re.sub(r"\s*```$", "", text)
            return text.strip()
        
        
        def _parse_cypher_list(raw: str) -> List[str]:
            """把模型输出解析成 cypher 字符串列表。
        
            这里不用 JsonOutputParser:需要在解析失败时退回到"按行捞 MATCH 语句",
            而不是抛异常整条链断掉 —— 模型偶尔会在数组外面多写一句话。
            """
            text = _strip_code_fence(raw)
        
            try:
                data = json.loads(text)
            except json.JSONDecodeError:
                # 从整段文本里截出第一个 [...],模型在数组前后加了废话时救一把
                match = re.search(r"\[.*\]", text, re.DOTALL)
                try:
                    data = json.loads(match.group(0)) if match else None
                except json.JSONDecodeError:
                    data = None
        
            if isinstance(data, list):
                items = data
            elif isinstance(data, dict):
                # 模型有时包一层 {"cypher_query": [...]}
                items = next(
                    (v for v in data.values() if isinstance(v, list)), [data.get("cypher", "")]
                )
            else:
                # 彻底解析不了,按行捞出以 MATCH 开头的语句
                items = [
                    line.strip().rstrip(",").strip('"')
                    for line in text.splitlines()
                    if line.strip().upper().startswith(("MATCH", "OPTIONAL MATCH"))
                ]
        
            return [str(item).strip() for item in items if str(item).strip()]
        
        
        def _is_readonly(cypher: str) -> bool:
            """确认这条语句只读。
        
            按词边界匹配,避免把实体名里的字母误判 —— 比如属性名 `processing` 含有 "cess"
            不成问题,但 `MERGE` 这种词必须整词命中才算。
            """
            upper = cypher.upper()
            for keyword in FORBIDDEN_KEYWORDS:
                if re.search(rf"\b{re.escape(keyword)}\b", upper):
                    return False
            return "RETURN" in upper and upper.lstrip().startswith(("MATCH", "OPTIONAL MATCH", "WITH"))
        
        
        def generate_neo4j_cypher_node(state: AgentState) -> AgentState:
            """根据提问 + 匹配到的实体 + 图谱元数据生成 cypher,写回 cypher_query"""
            # 检查节点不通过时会再回到这里,所以这个节点一轮里可能执行多次,计数加在最前面
            retry_count = (state.get("cypher_retry_count") or 0) + 1
            state["cypher_retry_count"] = retry_count
            print(f"cypher生成节点执行开始了(第 {retry_count} 次生成)")
        
            # 读转写结果而不是 state['input']:指代词还原过的问题才生成得出正确的 cypher
            user_input = (state.get("input_semantic_trans") or "").strip()
            entity_block = _build_entity_block(state)
        
            # 一个实体都没匹配上时,生成的 WHERE 必然查不到东西,省一次调用
            has_entity = any(state.get(key) for key in MATCHED_FIELDS)
            if not user_input or not has_entity:
                state["cypher_query"] = []
                print("cypher生成节点:没有匹配到实体,不生成")
                return state
        
            # 重试时把上一轮不合法的语句回喂给模型。不带这段,模型看到的输入和上一次
            # 完全一样,大概率原样再生成一遍,重试就白花了。
            retry_block = ""
            previous = state.get("cypher_query") or []
            if retry_count > 1 and previous:
                listed = "\n".join(f"  {i}. {c}" for i, c in enumerate(previous, 1))
                retry_block = (
                    f"\n\n【注意】下面这些语句是你上一次生成的,它们没有通过 Neo4j 的语法/schema "
                    f"校验,请找出问题并重新生成,不要再输出同样的语句:\n{listed}"
                )
        
            messages = [
                SystemMessage(content=SYSTEM_PROMPT.format(metadata=conf.TCM_METADATA)),
                HumanMessage(
                    content=(
                        f"用户提问:{user_input}\n\n"
                        f"可用实体名(WHERE 条件只能用这些):\n{entity_block}"
                        f"{retry_block}"
                    )
                ),
            ]
            raw = llm.invoke(messages).content
        
            cyphers, seen = [], set()
            for cypher in _parse_cypher_list(raw):
                if not _is_readonly(cypher):
                    print(f"[warn] 丢弃非只读或格式异常的 cypher: {cypher[:80]}")
                    continue
                if cypher not in seen:
                    seen.add(cypher)
                    cyphers.append(cypher)
                if len(cyphers) >= MAX_CYPHER:
                    break
        
            state["cypher_query"] = cyphers
            print(f"cypher生成节点执行完成: {len(cyphers)} 条")
            return state
        
        
        if __name__ == "__main__":
            cases = [
                {
                    "input_semantic_trans": "人参有什么功效?",
                    "matched_herbs": ["人参"],
                },
                {
                    "input_semantic_trans": "四君子汤由哪些药材组成,出自哪里?",
                    "matched_formulas": ["四君子汤"],
                },
                {
                    "input_semantic_trans": "我拉肚子,有哪些方剂或药材可以缓解?",
                    "matched_symptoms": ["腹泻", "泄泻", "泻痢"],
                },
                {
                    "input_semantic_trans": "哪些药材能补气,又能治肾虚?",
                    "matched_effects": ["补气"],
                    "matched_diseases": ["肾虚"],
                },
                {
                    "input_semantic_trans": "《伤寒论》里记载了哪些方剂?",
                    "matched_sources": ["《伤寒论》", "《伤寒杂病论》"],
                },
                # 没有匹配到实体:预期空列表,不调用大模型
                {"input_semantic_trans": "今天天气怎么样"},
            ]
        
            for case in cases:
                result = generate_neo4j_cypher_node(dict(case))
                print(f"\n提问: {case['input_semantic_trans']}")
                if not result["cypher_query"]:
                    print("  <无 cypher>")
                for i, cypher in enumerate(result["cypher_query"], 1):
                    print(f"  {i}. {cypher}")
        
      • 📄 llm_direct_out_node.py 2.6KB
        """大模型直接回答节点
        
        输入: state['input_semantic_trans']  语义转写节点补全后的提问
        输出: state['direct_out']            大模型的回答
              state['output']                同一份回答,直接作为整张图的最终输出
        
        这是 is_zhongyi_intent 为 False 时走的兜底分支:提问跟中医无关,图谱里查不到东西,
        不必再走实体抽取和 cypher 那一串,交给大模型自由回答。
        
        两个键写同一份内容:direct_out 记录"这一轮是直接回答的",output 是图的统一出口,
        下游(_005_ 的接口)只读 output,不用关心答案是从图谱来的还是模型直接给的。
        """
        
        from langchain_core.messages import HumanMessage, SystemMessage
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.llm import my_llm
        
        llm = my_llm
        
        SYSTEM_PROMPT = (
            "你是一个乐于助人的中文助手。请直接、简洁地回答用户的问题。\n"
            "如果用户的问题涉及中医药,只做常识性说明,并提示具体用药需遵医嘱。\n"
            "不要编造事实,不知道就说不知道。"
        )
        
        
        def llm_direct_out_node(state: AgentState) -> AgentState:
            print("直接回答节点执行开始了")
            # 读转写结果而不是 state['input']:多轮里"那第二呢?"必须补全成完整问题才答得对
            user_input = (state.get("input_semantic_trans") or "").strip()
        
            # 空输入不必调用大模型,两个键仍然要写满,下游只读 output 不用判空
            if not user_input:
                state["direct_out"] = ""
                state["output"] = ""
                print("完成直接回答: 空输入")
                return state
        
            messages = [
                SystemMessage(content=SYSTEM_PROMPT),
                HumanMessage(content=user_input),
            ]
            model_answer = llm.invoke(messages).content.strip()
        
            # 存入 state
            state["direct_out"] = model_answer
            state["output"] = model_answer
        
            history_messages = state.get("history_messages", [])
            history_messages.append({"role": "assistant", "content": model_answer})
            state["history_messages"] = history_messages
        
            print(f"完成直接回答: {len(model_answer)} 字")
            return state
        
        
        if __name__ == "__main__":
            for text in [
                "今天上海天气怎么样?",
                "帮我写一段 Python 快排代码",
                "解释下什么是大模型",
                "",
            ]:
                result = llm_direct_out_node({"input_semantic_trans": text})
                print(f"\n提问: {text!r}")
                print(f"  direct_out: {result['direct_out'][:120]}")
                print(f"  output 与 direct_out 一致: {result['output'] == result['direct_out']}")
        
      • 📄 match_entity_from_neo4j_node.py 7.0KB
        """实体标准化节点:把用户嘴里的词对到 Neo4j 里的标准实体名
        
        输入: state['user_input_effects'] / _diseases / _symptoms / _formulas / _herbs / _sources
        输出: state['matched_effects']    / _diseases / _symptoms / _formulas / _herbs / _sources
        
        标准实体名存在 `_003_create_neo4j_database/` 的两个文件里(`__003__faiss_embedding.py`
        建的):index 存向量,pkl 存 id -> 实体名 的映射,faiss 本身存不了元数据。
        
        相似度换算:建库用的是 IndexFlatL2,向量是单位长度(bge-large-zh-v1.5 最后一层自带
        Normalize),此时 d² = 2 - 2cos,而 faiss 的 IndexFlatL2 返回的就是平方距离,取值
        落在 [0, 2]。所以 cos = 1 - dist / 2,阈值 70% 相似度等价于 dist <= 0.6。
        """
        
        import os
        import pickle
        from typing import Dict, List, Tuple
        
        import faiss
        import numpy as np
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.embedding_model import embedding_model
        from common.path_utils import get_file_path
        
        INDEX_PATH = get_file_path("_003_create_neo4j_database/nero4j_embedding_faiss.index")
        MAPPING_PATH = get_file_path(
            "_003_create_neo4j_database/nero4j_embedding_faiss_id2text.pkl"
        )
        
        # 需求:取 top5,相似度大于 70%
        TOP_K = 5
        SIMILARITY_THRESHOLD = 0.80
        
        # Neo4j 标签 -> (输入 state 键, 输出 state 键)
        TYPE_FIELDS = {
            "Effect": ("user_input_effects", "matched_effects"),
            "Disease": ("user_input_diseases", "matched_diseases"),
            "Symptom": ("user_input_symptoms", "matched_symptoms"),
            "Formula": ("user_input_formulas", "matched_formulas"),
            "Herb": ("user_input_herbs", "matched_herbs"),
            "Source": ("user_input_sources", "matched_sources"),
        }
        
        # 索引 75MB,pkl 也不小,按需加载一次后缓存,别在 import 时就吃掉这份内存
        _index = None
        _id2text = None
        
        
        def _load_faiss():
            """加载 index 和 id2text 映射,进程内只做一次"""
            global _index, _id2text
            if _index is not None:
                return _index, _id2text
        
            for path in (INDEX_PATH, MAPPING_PATH):
                if not os.path.exists(path):
                    raise FileNotFoundError(
                        f"faiss 文件不存在: {path}\n"
                        "先跑 python -X utf8 -m _003_create_neo4j_database.__003__faiss_embedding"
                    )
        
            _index = faiss.read_index(INDEX_PATH)
            with open(MAPPING_PATH, "rb") as f:
                _id2text = pickle.load(f)
            return _index, _id2text
        
        
        # 标签 -> 该标签下的实体名集合。索引里所有标签的实体混在一起(建库用的是不带
        # label 的 get_all_node_names()),不按标签过滤的话,"咳嗽"可能召回一个药材名,
        # 落到 matched_symptoms 里就会生成没意义的 cypher。
        _label_names = None
        
        
        def _load_label_names() -> Dict[str, set]:
            """从 Neo4j 读每个标签下的实体名,用来过滤召回结果。
        
            读不到就返回空 dict,此时退化成不按标签过滤 —— 只是召回可能串类型,
            不至于让整个节点挂掉。
            """
            global _label_names
            if _label_names is not None:
                return _label_names
        
            try:
                from common.neo4j_manager import neo4j_client
        
                _label_names = {
                    label: set(neo4j_client.get_all_node_names(label)) for label in TYPE_FIELDS
                }
            except Exception as exc:
                print(f"[warn] 读取 Neo4j 标签实体名失败,跳过按标签过滤: {exc}")
                _label_names = {}
            return _label_names
        
        
        def search_similar_entities(
            query: str, label: str = None, top_k: int = TOP_K, threshold: float = SIMILARITY_THRESHOLD
        ) -> List[Tuple[str, float]]:
            """在 faiss 里检索 query 的相似标准实体,返回 [(实体名, 相似度)],按相似度降序。
        
            label 非空时只保留属于该标签的实体。为了过滤掉别的标签后仍能凑够 top_k,
            检索时多要一些候选(top_k * 6),再截断到 top_k。
            """
            query = (query or "").strip()
            if not query:
                return []
        
            index, id2text = _load_faiss()
            allowed = _load_label_names().get(label) if label else None
            fetch_k = min(index.ntotal, top_k * 6 if allowed else top_k)
        
            # 建库时用了 normalize_embeddings=True,查询保持一致
            vec = embedding_model.encode(
                [query], convert_to_numpy=True, normalize_embeddings=True
            )
            distances, indices = index.search(np.asarray(vec, dtype="float32"), fetch_k)
        
            hits = []
            for dist, idx in zip(distances[0], indices[0]):
                if idx < 0:  # faiss 用 -1 填充不足的位置
                    continue
                name = id2text.get(int(idx))
                if name is None or (allowed is not None and name not in allowed):
                    continue
                # 单位向量下 IndexFlatL2 返回平方距离,cos = 1 - dist/2
                similarity = 1.0 - float(dist) / 2.0
                if similarity >= threshold:
                    hits.append((name, similarity))
                if len(hits) >= top_k:
                    break
            return hits
        
        
        def match_entity_from_neo4j_node(state: AgentState) -> AgentState:
            """把六类用户输入实体分别标准化成 Neo4j 里的实体名,写回 matched_*"""
            print("实体匹配节点执行开始了")
            for label, (in_key, out_key) in TYPE_FIELDS.items():
                matched, seen = [], set()
                for query in state.get(in_key) or []:
                    for name, _similarity in search_similar_entities(query, label=label):
                        # 多个输入词可能召回同一个实体("拉肚子"和"腹泻"),去重保序
                        if name not in seen:
                            seen.add(name)
                            matched.append(name)
                state[out_key] = matched
        
            total = sum(len(state[out_key]) for _in_key, out_key in TYPE_FIELDS.values())
            print(f"实体匹配节点执行完成: 共 {total} 个")
            return state
        
        
        if __name__ == "__main__":
            # 单个词的召回明细:看清相似度和阈值起没起作用
            print("\n=== 单词检索明细(top5,阈值 70%)===")
            for label, query in [
                ("Symptom", "拉肚子"),
                ("Symptom", "咳嗽"),
                ("Herb", "人参"),
                ("Formula", "四君子汤"),
                ("Effect", "补气"),
                ("Source", "《伤寒论》"),
                ("Disease", "肾虚"),
                ("Herb", "阿司匹林"),  # 图里没有,预期空
            ]:
                hits = search_similar_entities(query, label=label)
                shown = " | ".join(f"{n}({s:.1%})" for n, s in hits) or "<无命中>"
                print(f"  [{label}] {query} → {shown}")
        
            # 整个节点:接 _002_ 抽取节点的输出形状
            print("\n=== 节点整体 ===")
            state = {
                "user_input_herbs": ["人参", "黄芪"],
                "user_input_effects": ["补气"],
                "user_input_symptoms": ["拉肚子", "睡不着觉"],
                "user_input_diseases": ["肾虚"],
                "user_input_formulas": ["四君子汤"],
                "user_input_sources": ["《伤寒论》"],
            }
            result = match_entity_from_neo4j_node(state)
            print(result)
            # for _in_key, out_key in TYPE_FIELDS.values():
            #     print(f"  {out_key}: {result[out_key]}")
            #
            # # 空输入:六个键仍要写满
            # print("\n=== 空输入 ===")
            # print(match_entity_from_neo4j_node({}))
        
      • 📄 neo4j_answer_generate_node.py 1.8KB
        from langchain_core.messages import HumanMessage
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.llm import my_llm
        import json
        
        
        def neo4j_answer_generate_node(state: AgentState) -> AgentState:
            print("图谱答案节点执行开始了")
            # 读转写结果而不是 state["input"]:回答要针对补全后的完整问题,
            # 否则模型看到的是"那第二呢?",拿着查询结果也不知道在答什么
            user_input = state.get("input_semantic_trans") or ""
            cypher_results = state.get("cypher_results", [])
        
            # 把 cypher_results 转成字符串,方便喂给大模型
            cypher_results_str = json.dumps(cypher_results, ensure_ascii=False, indent=2)
        
            prompt = f"""
            你是一个中医知识图谱问答助手。
            用户提出了问题:{user_input}
        
            我已经在 Neo4j 图数据库中执行了查询,查询结果如下:
            {cypher_results_str}
        
            请你根据这些查询结果,用简洁、清晰、自然的中文回答用户的问题。
            如果查询结果无法回答用户的问题,请如实告知用户没有找到相关答案。
            """
            # print(prompt)
            response = my_llm.invoke([HumanMessage(content=prompt)])
        
            # 保存结果
            state["neo4j_answer"] = response.content.strip()
            state["output"] = response.content.strip()
            print("完成进行neo4j输入大模型的回答")
        
            return state
        
        if __name__ == '__main__':
            result=neo4j_answer_generate_node(
                {
                    "input_semantic_trans": "请给我推荐一个Herb",
                    "cypher_results": [
                        [
                            {
                                "herb": "人参",
                                "effect": "解热镇痛"
                            }
                        ]
                    ]
                }
            )
        
            print(result)
        
      • 📄 run_cypher_node.py 2.5KB
        """执行 cypher 节点
        
        输入: state['cypher_query']: List[str]                 要执行的 cypher 语句
        输出: state['cypher_results']: List[List[Dict]]        每条语句一份结果
        
        用 neo4j_manager 的 run_cypher 逐条执行,不用 run_multiple_cypher:后者把整批放进一个
        写事务,任意一条报错整批回滚,这里是读查询,一条查不出来不该拖掉其他几条的结果。
        
        结果和 cypher_query 下标一一对应,即使某条报错也占一个空列表的位置 —— 下游要靠下标
        把结果对回是哪条语句问出来的。
        """
        
        from typing import Any, Dict, List
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.neo4j_manager import neo4j_client
        
        
        def run_cypher_node(state: AgentState) -> AgentState:
            print("cypher执行节点执行开始了")
            cypher_query_list = state.get("cypher_query") or []
            cypher_results: List[List[Dict[str, Any]]] = []
        
            for cypher_query in cypher_query_list:
                try:
                    rows = neo4j_client.run_cypher(cypher_query)
                except Exception as e:
                    # 查询本身出错不该让整个图挂掉,记下来给这条占位空结果
                    print(f"cypher 执行失败: {cypher_query[:80]}\n  {e}")
                    rows = []
                print(f"  {len(rows)} 行 <- {cypher_query[:70]}")
                cypher_results.append(rows)
        
            state["cypher_results"] = cypher_results
            print(f"完成执行cypher语句: {len(cypher_results)} 条")
            return state
        
        
        if __name__ == "__main__":
            state = run_cypher_node(
                {
                    "cypher_query": [
                        "MATCH (h:Herb)-[:HAS_EFFECT]->(e:Effect) WHERE h.name IN ['人参'] "
                        "RETURN h.name AS herb, e.name AS effect LIMIT 10",
                        "MATCH (f:Formula)-[:HAS_INGREDIENT]->(h:Herb) WHERE f.name = '四君子汤' "
                        "RETURN h.name AS herb LIMIT 10",
                        "MATCH (s:Source)<-[:FROM_SOURCE]-(f:Formula) WHERE s.name = '《伤寒论》' "
                        "RETURN f.name AS formula LIMIT 5",
                        # 图里没有的实体:语句合法,预期 0 行
                        "MATCH (h:Herb) WHERE h.name = '阿司匹林' RETURN h.name AS herb",
                        # 语法错误:预期打印失败并占一个空列表
                        "MATC1H (h:Herb) RETURN h.name",
                    ]
                }
            )
            for i, rows in enumerate(state["cypher_results"], 1):
                print(f"\n第 {i} 条 -> {len(rows)} 行")
                for row in rows[:5]:
                    print(f"  {row}")
        
        
            print("======================")
            print(state)
        
      • 📄 semantic_transcription_node.py 5.8KB
        from langchain_core.messages import HumanMessage
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.llm import my_llm
        from common.config import Config
        
        conf = Config()
        
        
        def semantic_transcription_node(state: AgentState) -> AgentState:
            """
            语义转写节点:
            根据历史对话内容(history_messages)和当前用户输入(input),
            生成一个更清晰、标准化的语义转写结果,用于下游意图识别或问题理解。
        
            这是图里唯一读 state["input"](用户原话)的节点,往后所有节点读的都是
            本节点写出的 input_semantic_trans。因此这个键必须在每条路径上都写非空值 ——
            留空会让下游全部收到空输入,整张图返回空回答。
            """
            # 获取用户输入和历史上下文
            print("语义转写节点执行开始了")
            user_input = (state.get("input") or "").strip()
            history_messages = state.get("history_messages", [])
            print(f"历史消息:{history_messages}")
        
            # 本节点是每轮的第一个节点,也就是唯一能识别"新一轮开始"的地方,
            # 所以每轮只属于这一轮的键在这里清掉。
            # 加了 checkpointer 之后 state 会按 thread_id 跨轮保留,不清的话:
            #   - cypher_retry_count 只增不减,第 4 轮时已经到上限 3,这一轮的 cypher
            #     只要有一条不合法就直接放弃重试,额度被前几轮吃光了;
            #   - neo4j_answer / direct_out 只在各自那条分支写,走另一条分支时留着上一轮的值,
            #     路径判断(_summarize 用 neo4j_answer 判断走没走图谱)会看错;
            #   - output 万一哪条路径没写,会把上一轮的答案返回给这一轮的问题 —— 清空后
            #     退化成空串,_005_ 那边会正常兜底成错误文案。
            # history_messages 和 input_semantic_trans 不能清:前者就是跨轮记忆本身,后者下面就写。
            state["cypher_retry_count"] = 0
            for stale_key in (
                "neo4j_answer",
                "direct_out",
                "output",
                "cypher_query",
                "cypher_results",
                "is_all_validate_cypher",
            ):
                state[stale_key] = None
        
            # 空输入:没什么可转写的,也不必调大模型。下游靠 input_semantic_trans 判空短路
            if not user_input:
                state["input_semantic_trans"] = ""
                state["history_messages"] = history_messages
                print("语义转写节点:空输入,跳过")
                return state
        
            # 更新历史
            history_messages.append({"role": "user", "content": user_input})
            state["history_messages"] = history_messages
            # 只保留最近 history_num 轮。切片放在 append 之后、末尾去掉一条,
            # 去掉的正是刚加进去的这轮 —— 当前输入单独放在提示词的【当前用户输入】里,
            # 混进【历史对话】会让模型把它当成上一轮的话
            history_messages = history_messages[-(conf.history_num + 1):-1]
        
            # 第一轮没有历史,没有指代词可还原,转写只会把原问题改写一遍(甚至改坏),
            # 白花一次调用。直接用原话
            if not history_messages:
                state["input_semantic_trans"] = user_input
                print(f"语义转写节点:无历史,直接用原输入:{user_input}")
                return state
        
            # 构建提示词
            prompt = f"""
        你是一名语言理解专家,负责将用户输入转写为清晰、简洁、语义明确的问题或意图表达。
        你需要基于以下上下文(最近6轮对话),分析用户真正想表达的意思。
        请不要编造不存在的信息,也不要直接回答问题,只需输出语义转写后的句子。
        
        【历史对话】
        {history_messages}
        
        【当前用户输入】
        {user_input}
        
        请输出:
        1. 一句自然语言,清楚表达用户当前真正的问题或意图;
        2. 不要包含解释性文字;
        3. 如果用户输入含糊不清,请基于上下文合理补全语义;
        4. 输出格式:直接输出转写结果,无需加前缀。
        """
        
            # 调用大模型
            response = my_llm.invoke([HumanMessage(content=prompt)])
            result = response.content.strip()
        
            # 模型偶尔会返回空串。下游全靠这个键,空了等于整张图收不到输入,所以退回原话
            if not result:
                result = user_input
                print("语义转写节点:模型返回空,退回原输入")
        
            # 保存语义转写结果
            state["input_semantic_trans"] = result
            print(f"完成生成语义转写:{result}")
            return state
        
        
        if __name__ == '__main__':
            # 每条覆盖一种情况:有历史要还原指代 / 中医多轮 / 无历史 / 空输入
            cases = [
                {
                    "input": "那第二呢?",
                    "history_messages": [
                        {"role": "user", "content": "世界第一高峰是什么?"},
                        {"role": "assistant", "content": "世界第一高峰是珠穆朗玛峰。"},
                    ],
                },
                {
                    "input": "它有什么副作用?",
                    "history_messages": [
                        {"role": "user", "content": "人参有什么功效?"},
                        {"role": "assistant", "content": "人参能大补元气、复脉固脱、补脾益肺。"},
                    ],
                },
                # 无历史:预期原样返回,不调用大模型
                {"input": "人参有什么功效?", "history_messages": []},
                # 空输入:预期空串,不调用大模型
                {"input": "", "history_messages": []},
            ]
        
            for case in cases:
                result_state = semantic_transcription_node(dict(case))
                print(f"\n原输入: {case['input']!r}")
                print(f"  转写后: {result_state['input_semantic_trans']!r}")
                print(f"  历史轮数: {len(result_state['history_messages'])}")
        
      • 📄 zhongyi_intent_node.py 2.5KB
        """中医意图识别节点
        
        输入: state['input_semantic_trans'] 语义转写节点补全后的提问
        输出: state['is_zhongyi_intent']    该提问是否与中医相关
        
        读转写结果而不是 state['input']:多轮对话里"那它有什么副作用?"单看一句判不出跟
        中医有关,补全成"人参有什么副作用?"才判得准。
        """
        
        from langchain_core.messages import HumanMessage, SystemMessage
        
        from _004_langgraph_more_nodes.agent_state import AgentState
        from common.llm import my_llm
        
        llm = my_llm
        
        SYSTEM_PROMPT = (
            "你是一个意图分类助手,判断用户输入是否与'中医'相关。\n"
            "与中医相关的特征:询问中药材(性味、功效、用法用量、归经、产地、炮制等)、"
            "方剂(组成、主治、宜忌、出处)、中医证候与病症(如咳嗽、失眠、脾虚、湿热)、"
            "中医理论(阴阳五行、经络、辨证论治)、针灸推拿、药膳食疗,"
            "或者描述身体不适并希望用中医方式调理。\n"
            "与中医无关的特征:闲聊、编程、数学、天气、新闻、纯西医的药品与检查,"
            "以及其他与中医药无关的话题。\n"
            "请只回答:是 或 否,不要输出任何其他内容。"
        )
        
        
        def zhongyi_intent_node(state: AgentState) -> AgentState:
            """判断用户输入是否和中医意图相关,结果写回 is_zhongyi_intent"""
            print("中医意图识别节点执行开始了")
            user_input = (state.get("input_semantic_trans") or "").strip()
        
            # 空输入不必调用大模型,直接判为无关
            if not user_input:
                state["is_zhongyi_intent"] = False
                return state
        
            messages = [
                SystemMessage(content=SYSTEM_PROMPT),
                HumanMessage(content=user_input),
            ]
            response = llm.invoke(messages).content.strip()
        
            # 模型可能返回"是的"、"否。"这类带尾巴的回答,所以取首字符判断
            state["is_zhongyi_intent"] = response.startswith("是")
            print(f"中医意图识别节点执行完成: {state['is_zhongyi_intent']}")
            return state
        
        
        if __name__ == "__main__":
            for text in [
                "麻黄的性味和功效是什么?",
                "小柴胡汤由哪些药材组成?",
                "我最近总是失眠,中医怎么调理?",
                "帮我写一段 Python 快排代码",
                "明天上海天气怎么样?",
                "",
            ]:
                result = zhongyi_intent_node({"input_semantic_trans": text})
                print(f"{text!r} -> {result['is_zhongyi_intent']}")
        
      • 📄 元数据裁剪方案.md 7.4KB
        # 大规模 schema 下的 Cypher 生成:元数据怎么裁
        
        配套 `generate_neo4j_cypher_node.py`。当前实现是把 `tcm_metadata.json` 整份塞进
        SystemPrompt,本文讨论 schema 变大之后该怎么改。**这是方案文档,不是已实现的功能。**
        
        ## 现状与临界点
        
        现在的 schema 规模:
        
        | 项 | 数量 |
        |---|---|
        | labels | 6(Disease / Effect / Formula / Herb / Source / Symptom)|
        | relationships | 6 |
        | triples(标签-关系-标签) | 14 |
        | 属性 | Herb 13 个、Formula 6 个,其余四个标签只有 `name` |
        
        `tcm_metadata.json` 约 14.8 KB,全量塞进 prompt 每次调用多花大概 5–8k token(中文 JSON,
        粗估)。这个量级下全量喂是**正确选择**:召回率 100%,没有裁剪逻辑要维护,也没有
        "该给的没给"这类难查的 bug。别过早优化。
        
        会出问题的信号,按出现顺序:
        
        1. **成本**:schema 到几十 KB 时,每次生成都在为 99% 用不上的 schema 付钱。
        2. **准确率下降**(比成本更早咬人)。这是关键——不是"塞不进上下文窗口",而是
           **候选变多之后模型选错**。200 个标签里有 `Herb` / `HerbalMedicine` / `Medicine`
           三个近义标签时,模型挑错的概率显著上升。裁剪的首要目的是**提准**,省钱是副产品。
        3. **上下文超限**:几百个标签、上千个属性时才会真正撞墙。
        
        ## 核心思路
        
        `matched_*` 已经告诉你这次查询涉及哪些标签了 —— 这是**免费的、确定性的**过滤信号,
        不需要再调一次大模型去猜。裁剪的主线就是:从 `matched_*` 命中的标签出发,
        在 schema 图上做有限步扩展,只把这部分喂给模型。
        
        下面五层按"该先上哪个"排序,不是全都要上。
        
        ---
        
        ## 第 1 层:从 matched_* 反查子图(首选,先做这个)
        
        **做法**:`matched_*` 非空的键对应哪些标签是已知的(`MATCH_FIELDS` 里就有映射),
        以这些标签为种子,在 triples 上做 1 跳(必要时 2 跳)BFS,只保留触达的
        labels / relationships / triples。
        
        举例,"人参有什么功效?"命中 `matched_herbs`,种子 `{Herb}`:
        
        - 1 跳拿到 `Herb HAS_EFFECT Effect`、`Herb ALLEVIATES_SYMPTOM Symptom`、
          `Herb TREATS_DISEASE Disease`、`Herb FROM_SOURCE Source`、`Herb HAS_INGREDIENT Herb`、
          `Herb TREATS_DISEASE Symptom`
        - 涉及标签收敛到 `{Herb, Effect, Symptom, Disease, Source}`,`Formula` 及其 6 个属性整块不用给
        
        **跳数怎么定**:1 跳覆盖"这个实体的直接属性和邻居",绝大多数提问是这类。需要 2 跳的
        是"桥接型"提问——"治感冒的方剂里都有哪些药材"要走
        `Disease <- Formula -> Herb`,种子是 `{Disease, Herb}`,1 跳各自扩展后并集里已经有
        `Formula` 了,所以这种情况**种子有两个标签时 1 跳往往就够**。真正需要 2 跳的是种子只有
        一个标签、但答案在两跳之外的提问。建议:**默认 1 跳;种子只有 1 个标签时放宽到 2 跳**。
        
        **为什么这条排第一**:纯确定性、零额外 LLM 调用、零额外延迟,而且复用了已有的
        `matched_*`。在当前 6 标签的图上就能立刻见效(Herb 类提问能省掉 Formula 那 6 个属性)。
        
        **风险**:`matched_*` 全空时没有种子。此时现有代码已经直接返回 `[]` 不调模型,所以
        这个洞不存在。但如果以后放宽成"无实体也生成",要记得回退到全量 schema。
        
        ---
        
        ## 第 2 层:属性按需给(省的量最大)
        
        属性是 schema 体积的主要来源——真实业务库里"20 个标签 × 每个 30 个属性"很常见,
        属性占的字符数会远超 labels 和 triples 之和。当前库里 Herb 一个标签就占了 13 个属性。
        
        **做法**,从省得多到省得少:
        
        - **只给第 1 层筛出来的标签的属性**(和第 1 层天然合并,无额外成本)
        - **属性名做向量召回**:把提问和属性名(带 description)一起 embed,只给 top-N。
          "人参是什么性味"只需要 `property_flavor`,不需要 `processing` / `place` / `traits`。
          这一步能用上现成的 `common/embedding_model.py`。
        - **建一个属性别名表**:`性味 -> property_flavor`、`用法用量 -> usage`、`归经 -> meridian`。
          中文提问到英文属性名的映射是固定的,查表比向量召回更准更快,值得手工维护。
          `tcm_metadata.json` 里每个属性都有空的 `description` 字段,这个表就该填在那儿。
        
        **注意**:`name` 属性永远要给——所有 WHERE 条件都靠它。
        
        ---
        
        ## 第 3 层:Few-shot 示例检索
        
        schema 大了之后,光给结构还不够,模型需要看到"这类问题在这个图上长什么样"。
        
        **做法**:攒一批 `(提问, 正确 cypher)` 对,embed 提问侧存进 faiss,生成时召回最相似的
        3–5 条塞进 prompt。
        
        这一层的收益常常**超过**裁 schema 本身:一条示例把标签、关系方向、属性名、LIMIT 习惯
        全都示范了一遍,比一堆结构描述更有效。当前节点里那句写死的输出格式示例,其实就是
        一条退化的 few-shot。
        
        **冷启动**:从人工写 20–30 条常见提问开始。之后把线上验证通过的 cypher(第 5 层的
        `is_all_validate_cypher` 为真、且实际有返回行的)自动回灌进这个库。
        
        ---
        
        ## 第 4 层:分层 schema / 领域切分
        
        标签上百之后,schema 本身要有结构。
        
        **做法**:给标签打领域标签(药材域 / 方剂域 / 诊断域 / 文献域),先用一次小模型调用
        或关键词规则判定提问属于哪个域,再只加载该域的 schema。也可以做成两级 prompt:
        第一级给"域清单 + 每域一句话说明"让模型选域,第二级给选中域的完整 schema。
        
        **代价**:多一次 LLM 往返,且选错域会直接查不到。所以这层要等到第 1–3 层都压不住时再上。
        
        ---
        
        ## 第 5 层:结果反馈(兜底,不是裁剪)
        
        裁剪必然带来"该给的没给",需要一个自愈回路。
        
        **做法**:`AgentState` 里已经预留了 `is_all_validate_cypher`。执行 cypher 后如果全部
        返回空行,说明可能裁错了 —— 这时候放宽到"上一轮跳数 +1"或全量 schema,重生成一次。
        最多重试一次,避免死循环。
        
        配合 `EXPLAIN`(不是 `PROFILE`,`EXPLAIN` 不真正执行)可以在打库之前就验证语法和
        标签/属性是否存在,把"用了不存在的属性"这类错误在执行前拦掉。
        
        ---
        
        ## 推荐落地顺序
        
        | 阶段 | 触发条件 | 做什么 |
        |---|---|---|
        | 现在 | schema < 20 KB | **什么都不做**,全量喂 |
        | 第一步 | 生成开始偶尔选错标签,或 schema > 30 KB | 第 1 层 + 第 2 层的"只给相关标签属性" |
        | 第二步 | 准确率仍不够 | 第 3 层 few-shot |
        | 第三步 | 标签上百 | 第 2 层的属性向量召回 + 第 4 层领域切分 |
        | 全程 | —— | 第 5 层空结果回退,配 `EXPLAIN` 预校验 |
        
        ## 两个容易踩的坑
        
        - **别裁掉关系方向信息**。triples 的价值就在方向。当前节点已经验证过这点:
          "《伤寒论》里有哪些方剂"必须生成 `(s:Source)<-[:FROM_SOURCE]-(f:Formula)`,
          因为 schema 里只有 `Formula FROM_SOURCE Source` 这一个方向。裁剪时保留了 label 却
          丢了对应 triple,模型就会把箭头写反,语法合法但恒返回空。
        - **裁剪逻辑要能被观测**。至少打日志记录"这次给了哪些标签",否则线上出现"某类提问
          总查不到"时,没法区分是裁错了还是模型生成错了。
        
    • 📄 agent_state.py 1.1KB
      from typing import Any, Dict, TypedDict, List
      
      
      class AgentState(TypedDict):
          # 输入
          input: str
          # 是否跟中医有关系
          is_zhongyi_intent: bool
          # 直接回答
          direct_out: str
          # 用户输入的实体抽取
          user_input_effects: List[str]
          user_input_diseases: List[str]
          user_input_symptoms: List[str]
          user_input_formulas: List[str]
          user_input_herbs: List[str]
          user_input_sources: List[str]
          # 匹配的实体
          matched_effects: List[str]
          matched_diseases: List[str]
          matched_symptoms: List[str]
          matched_formulas: List[str]
          matched_herbs: List[str]
          matched_sources: List[str]
          # cypher查询语句
          cypher_query: List[str]
          is_all_validate_cypher: bool
          # cypher 生成了几次(首次生成算 1)。检查不通过时回到生成节点重试,靠这个计数刹车
          cypher_retry_count: int
          # run_cypher 返回的是 list[dict],每条 cypher 一份结果
          cypher_results: List[List[Dict[str, Any]]]
          neo4j_answer: str
          # 输出
          output: str
      
          # 历史数据
          history_messages: List[dict]
          input_semantic_trans: str
      
    • 📄 langgraph_more_nodes.py 10.7KB
      """把 nodes/ 下的节点串成一张 LangGraph 图
      
          START → 语义转写节点 → 中医意图识别节点
                    ├─ 非中医 ─→ 直接回答节点 ─────────────────────────→ END
                    └─ 中医  ─→ 实体抽取节点 → 实体匹配节点 → cypher生成节点 ←──┐
                                    → cypher检查节点                          │
                                         ├─ 全部合法 ─→ cypher执行节点         │
                                         │                  → 图谱答案节点 → END│
                                         ├─ 不合法且未超 3 次 ─→ 重新生成 ──────┘
                                         └─ 不合法且已 3 次 ─→ 直接回答节点 → END
      
      语义转写排在最前面,是因为它后面每一个节点都依赖它的产出:多轮对话里"那第二呢?"
      这种话单看一句没有任何信息,意图识别会判成非中医、实体抽取一个词也抽不出来。转写节点
      结合 history_messages 把它补成"珠穆朗玛峰之后第二高的山峰是什么?",下游才有东西可用。
      
      所以 `input` 只有转写节点自己读(它的输入就是原话),其余节点一律读
      `input_semantic_trans`。两者的区别是"用户打了什么"和"用户想问什么"。
      
      两个条件边,两处兜底,一个环:
      
      - 意图识别之后:跟中医无关的提问查图谱只会白跑一圈,直接交给大模型。
      - cypher 检查之后:不合法先回到生成节点重写,最多生成 3 次(首次 + 2 次重试);
        还是不行才退回大模型。计数在 `cypher_retry_count`,生成节点里自增 —— 路由函数
        只读 state,没法自己记账,所以计数必须由节点写。
      - `直接回答节点` 被两条兜底路径共用(LangGraph 允许多个前驱指向同一节点)。
      
      重试时会把上一轮不合法的语句回喂给模型(见生成节点里的 `retry_block`),否则模型
      看到的输入和上次一模一样,大概率原样再生成一遍。
      
      还有一处兜底没有做成条件边:cypher 生成为空(一个实体都没匹配上)。这种情况
      `cypher_query` 是 `[]`,而 `check_cypher_node` 对空列表判 False,于是会走重试
      那条边 —— 但生成节点在没有实体时直接返回不调大模型,所以这两次重试是空转、不花钱,
      最后由计数刹住落到兜底。省下了为空列表单独写一条分支。
      
      节点名用中文,和 `_000_demo/04_langgraph_demo.py` 保持一致。
      """
      from langgraph.checkpoint.memory import InMemorySaver
      from langgraph.constants import START
      from langgraph.graph import END, StateGraph
      
      from _004_langgraph_more_nodes.agent_state import AgentState
      from _004_langgraph_more_nodes.nodes.check_cypher_node import check_cypher_node
      from _004_langgraph_more_nodes.nodes.extract_entity_from_user_input_node import (
          extract_entity_from_user_input_node,
      )
      from _004_langgraph_more_nodes.nodes.generate_neo4j_cypher_node import (
          generate_neo4j_cypher_node,
      )
      from _004_langgraph_more_nodes.nodes.llm_direct_out_node import llm_direct_out_node
      from _004_langgraph_more_nodes.nodes.match_entity_from_neo4j_node import (
          match_entity_from_neo4j_node,
      )
      from _004_langgraph_more_nodes.nodes.neo4j_answer_generate_node import (
          neo4j_answer_generate_node,
      )
      from _004_langgraph_more_nodes.nodes.run_cypher_node import run_cypher_node
      from _004_langgraph_more_nodes.nodes.semantic_transcription_node import (
          semantic_transcription_node,
      )
      from _004_langgraph_more_nodes.nodes.zhongyi_intent_node import zhongyi_intent_node
      
      # 节点名集中在这里,路由函数和加边都引用它,避免字符串写错了却要到运行时才报错
      SEMANTIC_TRANS = "语义转写节点"
      INTENT = "中医意图识别节点"
      EXTRACT_ENTITY = "实体抽取节点"
      MATCH_ENTITY = "实体匹配节点"
      GENERATE_CYPHER = "cypher生成节点"
      CHECK_CYPHER = "cypher检查节点"
      RUN_CYPHER = "cypher执行节点"
      NEO4J_ANSWER = "图谱答案节点"
      DIRECT_OUT = "直接回答节点"
      
      # cypher 最多生成 3 次:首次 + 2 次重试。超了就别再烧钱了,退回大模型直接回答。
      MAX_CYPHER_RETRY = 3
      
      
      # ======================
      # 路由函数
      # ======================
      def zhongyi_intent_control(state: AgentState) -> str:
          """意图分流:和中医相关走图谱,否则直接回答"""
          if state.get("is_zhongyi_intent"):
              return EXTRACT_ENTITY
          return DIRECT_OUT
      
      
      def check_cypher_control(state: AgentState) -> str:
          """cypher 分流,三条出路:
      
          - 全部合法       -> 去查库
          - 不合法且还有额度 -> 回到生成节点重写(这是图里唯一的环)
          - 不合法且额度用尽 -> 退回大模型直接回答
      
          环的刹车靠 cypher_retry_count,它在生成节点里自增。只看 is_all_validate_cypher
          的话,模型一直生成不出合法语句就会无限打转。
          """
          if state.get("is_all_validate_cypher"):
              return RUN_CYPHER
      
          retry_count = state.get("cypher_retry_count") or 0
          if retry_count < MAX_CYPHER_RETRY:
              print(f"cypher 不合法,重新生成(已生成 {retry_count} 次,上限 {MAX_CYPHER_RETRY} 次)")
              return GENERATE_CYPHER
      
          print(f"cypher 已生成 {retry_count} 次仍不合法,转由大模型直接回答")
          return DIRECT_OUT
      
      
      # ======================
      # 建图
      # ======================
      def build_langgraph():
          """把节点和边装好并编译,返回可以 invoke 的图"""
          graph_builder = StateGraph(AgentState)
      
          # 加节点:名字 -> 函数。函数签名都是 (state) -> state,直接传进来即可
          graph_builder.add_node(SEMANTIC_TRANS, semantic_transcription_node)
          graph_builder.add_node(INTENT, zhongyi_intent_node)
          graph_builder.add_node(EXTRACT_ENTITY, extract_entity_from_user_input_node)
          graph_builder.add_node(MATCH_ENTITY, match_entity_from_neo4j_node)
          graph_builder.add_node(GENERATE_CYPHER, generate_neo4j_cypher_node)
          graph_builder.add_node(CHECK_CYPHER, check_cypher_node)
          graph_builder.add_node(RUN_CYPHER, run_cypher_node)
          graph_builder.add_node(NEO4J_ANSWER, neo4j_answer_generate_node)
          graph_builder.add_node(DIRECT_OUT, llm_direct_out_node)
      
          # 入口:先转写,再判意图。顺序不能反 —— 意图识别读的是转写结果
          graph_builder.add_edge(START, SEMANTIC_TRANS)
          graph_builder.add_edge(SEMANTIC_TRANS, INTENT)
      
          # 条件边 1:意图识别之后分两路。
          # 第三个参数(路由表)必须给:不给的话 LangGraph 不知道这条边可能通向哪些节点,
          # 画出来的图会只剩 START -> 意图识别 -> END,后面的边全看不到。
          graph_builder.add_conditional_edges(
              INTENT,
              zhongyi_intent_control,
              {EXTRACT_ENTITY: EXTRACT_ENTITY, DIRECT_OUT: DIRECT_OUT},
          )
      
          # 中医这一路的固定顺序
          graph_builder.add_edge(EXTRACT_ENTITY, MATCH_ENTITY)
          graph_builder.add_edge(MATCH_ENTITY, GENERATE_CYPHER)
          graph_builder.add_edge(GENERATE_CYPHER, CHECK_CYPHER)
      
          # 条件边 2:cypher 合法去查库,不合法回生成节点重试,重试用尽走兜底。
          # 路由表里的 GENERATE_CYPHER 就是那条回边,图里唯一的环。
          graph_builder.add_conditional_edges(
              CHECK_CYPHER,
              check_cypher_control,
              {
                  RUN_CYPHER: RUN_CYPHER,
                  GENERATE_CYPHER: GENERATE_CYPHER,
                  DIRECT_OUT: DIRECT_OUT,
              },
          )
      
          graph_builder.add_edge(RUN_CYPHER, NEO4J_ANSWER)
      
          # 三个出口
          graph_builder.add_edge(NEO4J_ANSWER, END)
          graph_builder.add_edge(DIRECT_OUT, END)
          # 添加记忆
          memory = InMemorySaver()
          # compile 之后才是可执行的图
          return graph_builder.compile(memory)
      
      
      # 模块级单例:_005_ 的接口层导入这个,不用每个请求重建一次图
      graph = build_langgraph()
      
      
      def ask(question: str) -> dict:
          """问一句,返回跑完的完整 state"""
          return graph.invoke({"input": question})
      
      
      # ======================
      # 测试用例
      # ======================
      # (提问, 期望走图谱那一路)。每条覆盖一种路径或一类实体:
      TEST_CASES = [
          # 图谱路:单个药材 + 属性
          ("人参有什么功效?", True),
          # 图谱路:方剂 → 组成药材,全图最主要的关系
          ("四君子汤由哪些药材组成?", True),
          # 图谱路:出处反查,考的是关系方向没写反
          ("《伤寒论》里记载了哪些方剂?", True),
          # 图谱路:口语症状词,考实体标准化(拉肚子 -> 腹泻)
          ("我最近总是拉肚子,有什么方剂可以调理?", True),
          # 兜底路:非中医提问,意图识别就该分流走
          ("帮我写一段 Python 快排代码", False),
          # 兜底路:像中医但图里没有的实体,匹配不到 -> cypher 为空 -> 落到兜底
          ("阿司匹林的功效是什么?", False),
      ]
      
      
      def _summarize(state: dict) -> str:
          """一行讲清这一轮走了哪条路"""
          if state.get("neo4j_answer"):
              rows = sum(len(r) for r in state.get("cypher_results") or [])
              return f"图谱路({len(state.get('cypher_query') or [])} 条 cypher / {rows} 行)"
          return "兜底路(大模型直接回答)"
      
      
      if __name__ == "__main__":
          # 图结构:不调用大模型也能看,改完边先跑这段确认接线对不对
          # print("=== 图结构 ===")
          # print(graph.get_graph().draw_ascii())
      
          print("\n=== 端到端测试 ===")
          wrong = 0
          for question, expect_graph_path in TEST_CASES:
              print(f"\n{'-' * 70}\n提问: {question}")
              state = ask(question)
      
              went_graph_path = bool(state.get("neo4j_answer"))
              flag = "OK " if went_graph_path == expect_graph_path else "MISS"
              if went_graph_path != expect_graph_path:
                  wrong += 1
      
              print(f"[{flag}] {_summarize(state)}")
              print(f"  意图: {state.get('is_zhongyi_intent')}")
              if state.get("is_zhongyi_intent"):
                  matched = {
                      key.replace("matched_", ""): value
                      for key, value in state.items()
                      if key.startswith("matched_") and value
                  }
                  print(f"  匹配实体: {matched or '<无>'}")
                  print(f"  cypher 合法: {state.get('is_all_validate_cypher')}")
                  for i, cypher in enumerate(state.get("cypher_query") or [], 1):
                      print(f"    {i}. {cypher[:100]}")
              print(f"  回答: {(state.get('output') or '')[:180]}")
      
          print(f"\n{'=' * 70}\n{len(TEST_CASES)} 条用例,路径与预期不符 {wrong} 条")
      
    • ⚙️ 需求1 4.6KB
  • 📁 _005_fastapi/
    • 📄 API_INTERFACE.md 16.1KB
      # 中医知识图谱问答系统 - API接口文档
      
      ## 📖 项目简介
      
      基于FastAPI和LangGraph的中医知识图谱智能问答系统,提供RESTful API接口服务。
      
      **技术栈:**
      - FastAPI - 高性能Web框架
      - LangGraph - 工作流编排
      - Neo4j - 图数据库
      - FAISS - 向量相似度搜索
      - LLM - 大语言模型
      
      ---
      
      ## 🚀 快速开始
      
      ### 1. 启动服务
      
      ```bash
      cd __005_fastapi
      python main.py
      ```
      
      或使用启动脚本:
      ```bash
      # Windows
      start_server.bat
      ```
      
      ### 2. 访问地址
      
      - **API基础地址**: `http://localhost:8000`
      - **交互式文档**: `http://localhost:8000/docs` (Swagger UI)
      - **ReDoc文档**: `http://localhost:8000/redoc`
      
      ---
      
      ## 📡 API接口列表
      
      ### 1. 健康检查接口
      
      **接口地址**: `GET /`
      
      **功能描述**: 检查服务是否正常运行
      
      **请求参数**: 无
      
      **响应示例**:
      ```json
      {
        "status": "ok",
        "message": "中医知识图谱问答系统服务运行正常"
      }
      ```
      
      **状态码**:
      - `200` - 服务正常
      
      ---
      
      ### 2. 中医问答接口(核心接口)
      
      **接口地址**: `POST /process`
      
      **功能描述**: 接收用户问题,返回中医知识图谱的智能回答
      
      **Content-Type**: `application/json`
      
      #### 请求参数
      
      | 参数名 | 类型 | 必填 | 说明 |
      |--------|------|------|------|
      | input | string | 是 | 用户输入的问题 |
      | user_id | string | 是 | 用户唯一标识,用于隔离不同用户的对话历史/记忆。同一用户需在多次请求中传入相同的user_id才能延续上下文,不同用户请使用不同的user_id |
      
      #### 请求示例
      
      ```json
      {
        "input": "人参有什么功效?",
        "user_id": "user-123"
      }
      ```
      
      #### 响应参数
      
      | 参数名 | 类型 | 说明 |
      |--------|------|------|
      | input | string | 用户原始输入 |
      | output | string | 系统生成的回答 |
      
      #### 响应示例
      
      **成功响应**:
      ```json
      {
        "input": "人参有什么功效?",
        "output": "人参具有大补元气、复脉固脱、补脾益肺、生津养血、安神益智等功效。适用于体虚欲脱、肢冷脉微、脾虚食少、肺虚喘咳等症状。"
      }
      ```
      
      **错误响应**:
      ```json
      {
        "input": "",
        "output": "系统出错了,请重试!"
      }
      ```
      
      #### 状态码
      
      | 状态码 | 说明 |
      |--------|------|
      | 200 | 请求成功 |
      | 500 | 服务器内部错误 |
      
      #### cURL示例
      
      ```bash
      curl -X POST "http://localhost:8000/process" \
        -H "Content-Type: application/json" \
        -d '{"input": "感冒了吃什么药?", "user_id": "user-123"}'
      ```
      
      #### Python示例
      
      ```python
      import requests
      
      url = "http://localhost:8000/process"
      data = {"input": "人参有什么功效?", "user_id": "user-123"}
      
      response = requests.post(url, json=data)
      result = response.json()
      
      print(f"问题: {result['input']}")
      print(f"回答: {result['output']}")
      ```
      
      #### JavaScript示例
      
      ```javascript
      fetch('http://localhost:8000/process', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          input: '人参有什么功效?',
          user_id: 'user-123'
        })
      })
      .then(response => response.json())
      .then(data => {
        console.log('问题:', data.input);
        console.log('回答:', data.output);
      })
      .catch(error => {
        console.error('Error:', error);
      });
      ```
      
      ---
      
      ### 3. 中医问答接口(流式版)
      
      **接口地址**: `POST /process_stream`
      
      **功能描述**: 与 `/process` 接受同样的请求参数(`input`、`user_id`),但以换行分隔的JSON消息流
      (`text/plain`,非严格SSE)返回结果。LangGraph每个节点执行完毕,立即产出一条该节点的状态增量作为
      "思考过程";图执行结束后再产出一条最终答案消息。
      
      #### 请求参数
      
      同 `/process`:`input`(必填)、`user_id`(必填)。
      
      #### 响应格式(每行一个JSON对象)
      
      | 消息type | 字段 | 说明 |
      |---------|------|------|
      | thinking | node, data | 某个节点刚执行完毕,`data`为该节点这一步产生的状态增量(思考过程中间结果) |
      | final | input, output | 图执行成功结束,`output`为最终回答 |
      | error | input, output | 图执行异常,`output`固定为"系统出错了,请重试!" |
      
      #### 响应示例
      
      ```
      {"type": "thinking", "node": "语义转写节点", "data": {"input_semantic_trans": "人参有什么功效?"}}
      {"type": "thinking", "node": "意图识别节点", "data": {"is_zhongyi_intent": true}}
      {"type": "thinking", "node": "实体抽取节点", "data": {"user_input_herbs": ["人参"]}}
      ...
      {"type": "final", "input": "人参有什么功效?", "output": "人参具有大补元气、复脉固脱..."}
      ```
      
      #### Python示例
      
      ```python
      import json
      import requests
      
      resp = requests.post(
          "http://localhost:8000/process_stream",
          json={"input": "人参有什么功效?", "user_id": "user-123"},
          stream=True,
          timeout=120,
      )
      for line in resp.iter_lines(decode_unicode=True):
          if not line:
              continue
          message = json.loads(line)
          if message["type"] == "thinking":
              print("思考中:", message["node"], message["data"])
          else:
              print("最终回答:", message["output"])
      ```
      
      ---
      
      ### 4. 静态资源访问
      
      **接口地址**: `GET /picture/{filename}`
      
      **功能描述**: 访问静态图片资源
      
      **路径参数**:
      - `filename`: 图片文件名
      
      **示例**:
      ```
      http://localhost:8000/picture/graph.jpg
      ```
      
      **配置说明**:
      静态文件目录通过 `app.mount("/picture", StaticFiles(directory=...))` 挂载
      
      ---
      
      ## 🔄 工作流程
      
      当用户通过 `/process` 接口提交问题时,系统会执行以下流程:
      
      ```
      用户输入
        ↓
      意图识别(判断是否中医相关)
        ↓
      ├─ 非中医问题 → 直接LLM回答
      └─ 中医问题
           ↓
        实体抽取(症状、疾病、方剂、药材等)
           ↓
        FAISS向量匹配(标准化实体名称)
           ↓
        生成Cypher查询语句
           ↓
        验证Cypher语法
           ↓
        执行Neo4j图数据库查询
           ↓
        生成自然语言回答
           ↓
        返回结果
      ```
      
      ---
      
      ## 💡 使用示例
      
      ### 示例1: 中药查询
      
      **请求**:
      ```json
      {
        "input": "人参的性味归经是什么?"
      }
      ```
      
      **响应**:
      ```json
      {
        "input": "人参的性味归经是什么?",
        "output": "人参性温,味甘、微苦,归脾、肺、心经。"
      }
      ```
      
      ### 示例2: 方剂查询
      
      **请求**:
      ```json
      {
        "input": "桂枝汤的组成和功效是什么?"
      }
      ```
      
      **响应**:
      ```json
      {
        "input": "桂枝汤的组成和功效是什么?",
        "output": "桂枝汤由桂枝、芍药、甘草、生姜、大枣组成。具有解肌发表、调和营卫的功效,主治外感风寒表虚证。"
      }
      ```
      
      ### 示例3: 症状咨询
      
      **请求**:
      ```json
      {
        "input": "我感冒了,有点咳嗽和发热,应该吃什么药?"
      }
      ```
      
      **响应**:
      ```json
      {
        "input": "我感冒了,有点咳嗽和发热,应该吃什么药?",
        "output": "根据您的症状(感冒、咳嗽、发热),建议可以考虑以下方剂:银翘散、桑菊饮等。但请注意,具体用药需要结合您的体质和病情,建议咨询专业中医师。"
      }
      ```
      
      ### 示例4: 非中医问题
      
      **请求**:
      ```json
      {
        "input": "今天天气怎么样?"
      }
      ```
      
      **响应**:
      ```json
      {
        "input": "今天天气怎么样?",
        "output": "抱歉,我无法提供天气信息。我是一个中医知识图谱问答系统,主要回答中医药相关的问题,如中药、方剂、症状、疾病等。如果您有中医方面的问题,欢迎提问!"
      }
      ```
      
      ---
      
      ## ⚙️ 配置说明
      
      ### 环境变量配置
      
      在 `common/.env` 文件中配置以下参数:
      
      ```env
      # 大模型配置
      MODEL_API_KEY=your_api_key
      MODEL_BASE_URL=https://api.example.com
      MODEL_NAME=gpt-3.5-turbo
      
      # Neo4j数据库配置
      NEO4J_URI=bolt://localhost:7687
      NEO4J_USER=neo4j
      NEO4J_PASSWORD=your_password
      
      # Embedding模型配置
      EMBEDDING_MODEL_PATH=./bge-large-zh-v1.5
      ```
      
      ### FAISS索引路径
      
      FAISS索引文件路径配置在以下文件中:
      
      - `__004__langgraph_more_nodes/nodes/match_entity_node.py`
      - `__003__create__information/__003__faiss_embedding.py`
      
      **注意**: 由于FAISS库在Windows中文路径下的兼容性问题,建议使用英文路径存储FAISS索引文件。
      
      ```python
      index_path = r"E:\ctm_index\nero4j_embedding_faiss.index"
      id2text_path = r"E:\ctm_index\nero4j_embedding_faiss_id2text.pkl"
      ```
      
      ---
      
      ## 🔧 开发指南
      
      ### 项目结构
      
      ```
      __005_fastapi/
      ├── main.py                 # FastAPI主程序
      ├── start_server.bat        # Windows启动脚本
      ├── test_api.py            # API测试脚本
      ├── quick_test.py          # 快速诊断脚本
      ├── README.md              # 使用说明
      └── API_INTERFACE.md       # API接口文档(本文件)
      
      __004__langgraph_more_nodes/
      ├── full_workflow.py       # LangGraph工作流
      ├── agent_state.py         # Agent状态定义
      └── nodes/                 # 各个处理节点
          ├── zhongyi_intent_node.py      # 意图识别
          ├── extract_entity_node.py      # 实体抽取
          ├── match_entity_node.py        # 实体匹配
          ├── generate_cypher_node.py     # Cypher生成
          ├── check_cypher_node.py        # Cypher验证
          ├── run_cypher_node.py          # Cypher执行
          └── generate_answer_node.py     # 答案生成
      ```
      
      ### 添加新接口
      
      在 `main.py` 中添加新的路由:
      
      ```python
      from fastapi import FastAPI
      from pydantic import BaseModel
      
      app = FastAPI()
      
      class QueryRequest(BaseModel):
          input: str
          max_retries: int = 3
      
      @app.post("/api/custom")
      async def custom_endpoint(request: QueryRequest):
          # 你的业务逻辑
          result = process_query(request.input)
          return {"result": result}
      ```
      
      ### 错误处理
      
      系统会自动捕获异常并返回友好的错误信息:
      
      ```python
      try:
          data = await request.json()
          input_text = data.get("input", "")
          output = await zhongyi_response(input_text)
          result = {
              "input": input_text,
              "output": output
          }
      except Exception as e:
          import traceback
          traceback.print_exc()
          result = {
              "input": "",
              "output": "系统出错了,请重试!"
          }
      ```
      
      ---
      
      ## 📊 性能优化
      
      ### 1. 超时设置
      
      对于长时间运行的查询,建议在客户端设置合理的超时时间:
      
      ```python
      # Python requests示例
      response = requests.post(url, json=data, timeout=120)  # 120秒超时
      ```
      
      ### 2. 并发处理
      
      FastAPI天然支持异步处理,可以同时处理多个请求。
      
      ### 3. 缓存策略
      
      对于频繁查询的问题,可以实现缓存机制:
      
      ```python
      from functools import lru_cache
      
      @lru_cache(maxsize=1000)
      def cached_zhongyi_response(input_text: str):
          return zhongyi_response(input_text)
      ```
      
      ---
      
      ## ❓ 常见问题
      
      ### Q1: 服务启动失败怎么办?
      
      **A**: 检查以下几点:
      1. 确认端口8000未被占用
      2. 检查 `common/.env` 配置文件是否正确
      3. 确认所有依赖已安装:`pip install -r requirements.txt`
      4. 查看控制台错误信息
      
      ### Q2: 响应时间过长怎么办?
      
      **A**: 可能的原因:
      1. LLM模型响应慢 - 考虑使用更快的模型
      2. FAISS索引较大 - 优化索引结构
      3. Neo4j查询复杂 - 优化Cypher语句
      4. 首次加载需要初始化 - 后续请求会更快
      
      ### Q3: 如何修改端口号?
      
      **A**: 修改 `main.py` 最后一行:
      
      ```python
      uvicorn.run(app, host="0.0.0.0", port=8001)  # 改为8001端口
      ```
      
      ### Q4: 如何部署到生产环境?
      
      **A**: 推荐使用以下方式之一:
      
      **方式1: 使用Gunicorn + Uvicorn**
      ```bash
      pip install gunicorn
      gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
      ```
      
      **方式2: 使用Docker**
      ```dockerfile
      FROM python:3.10
      WORKDIR /app
      COPY . .
      RUN pip install -r requirements.txt
      CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
      ```
      
      **方式3: 使用Nginx反向代理**
      ```nginx
      server {
          listen 80;
          server_name your-domain.com;
          
          location / {
              proxy_pass http://127.0.0.1:8000;
              proxy_set_header Host $host;
              proxy_set_header X-Real-IP $remote_addr;
          }
      }
      ```
      
      ### Q5: 如何监控API使用情况?
      
      **A**: 可以添加日志记录:
      
      ```python
      import logging
      from datetime import datetime
      
      logging.basicConfig(filename='api.log', level=logging.INFO)
      
      @app.post("/process")
      async def process(request: Request):
          start_time = datetime.now()
          data = await request.json()
          
          # 记录请求
          logging.info(f"Request: {data.get('input')} at {start_time}")
          
          # ... 业务逻辑
          
          end_time = datetime.now()
          duration = (end_time - start_time).total_seconds()
          
          # 记录响应时间和结果
          logging.info(f"Response time: {duration}s")
          
          return JSONResponse(content=result)
      ```
      
      ---
      
      ## 🔐 安全建议
      
      ### 1. 添加API认证
      
      ```python
      from fastapi import Depends, HTTPException
      from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
      
      security = HTTPBearer()
      
      async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
          if credentials.credentials != "your-secret-token":
              raise HTTPException(status_code=401, detail="Invalid token")
          return credentials
      
      @app.post("/process")
      async def process(request: Request, credentials: dict = Depends(verify_token)):
          # 你的业务逻辑
          pass
      ```
      
      ### 2. 限制请求频率
      
      ```python
      from slowapi import SlowAPIMiddleware
      from slowapi.errors import RateLimitExceeded
      
      app.state.limiter = Limiter(default_rate_limit="10/minute")
      app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
      
      @app.post("/process")
      @limiter.limit("5/minute")
      async def process(request: Request):
          # 每分钟最多5次请求
          pass
      ```
      
      ### 3. 输入验证
      
      ```python
      from pydantic import BaseModel, Field
      
      class QueryRequest(BaseModel):
          input: str = Field(..., min_length=1, max_length=500, description="用户问题")
      ```
      
      ---
      
      ## 📝 更新日志
      
      ### v1.0.0 (2024-01-01)
      - ✅ 初始版本发布
      - ✅ 实现基本的中医问答接口
      - ✅ 集成LangGraph工作流
      - ✅ 支持Neo4j图数据库查询
      - ✅ 支持FAISS向量相似度搜索
      - ✅ 提供Swagger交互式文档
      
      ---
      
      ## 📞 技术支持
      
      如有问题或建议,请联系:
      - 📧 Email: [your-email@example.com]
      - 💬 Issues: [GitHub Issues链接]
      - 📖 文档: [项目Wiki链接]
      
      ---
      
      ## 📄 License
      
      本项目仅供学习和研究使用。
      
      ---
      
      ## 🌟 附录
      
      ### A. 完整的请求-响应流程图
      
      ```
      Client                          Server                         Backend
        |                               |                               |
        |-- POST /process ------------->|                               |
        |   {input: "人参功效"}          |                               |
        |                               |-- 意图识别 ------------------>| 
        |                               |                               |
        |                               |-- 实体抽取 ------------------>|
        |                               |                               |
        |                               |-- FAISS匹配 ---------------->|
        |                               |                               |
        |                               |-- 生成Cypher --------------->|
        |                               |                               |
        |                               |-- 执行Neo4j查询 ------------>|
        |                               |                               |
        |                               |-- LLM生成回答 -------------->|
        |                               |                               |
        |<-- 200 OK --------------------|                               |
        |   {input, output}             |                               |
        |                               |                               |
      ```
      
      ### B. 支持的实体类型
      
      | 实体类型 | 英文标识 | 示例 |
      |---------|---------|------|
      | 症状 | Symptom | 咳嗽、发热、失眠 |
      | 疾病 | Disease | 风寒感冒、脾虚泄泻 |
      | 方剂 | Formula | 桂枝汤、麻黄汤 |
      | 药材 | Herb | 人参、黄芪、当归 |
      | 功效 | Effect | 清热、补气、祛湿 |
      | 出处 | Source | 《伤寒论》《本草纲目》 |
      
      ### C. 常用测试问题
      
      ```
      1. 人参有什么功效?
      2. 桂枝汤的组成是什么?
      3. 感冒了吃什么药?
      4. 如何治疗失眠?
      5. 黄芪和人参有什么区别?
      6. 《伤寒论》中有哪些经典方剂?
      7. 咳嗽有哪些中药可以治疗?
      8. 补气的中药有哪些?
      ```
      
      ---
      
      **最后更新时间**: 2024-01-01
      
      **文档版本**: v1.0.0
      
    • 📄 API_QUICK_REFERENCE.md 3.0KB
      # API 快速参考手册
      
      ## 🚀 快速开始
      
      ### 启动服务
      ```bash
      cd __005_fastapi
      python main.py
      ```
      
      ### 访问地址
      - **API**: http://localhost:8000
      - **文档**: http://localhost:8000/docs
      
      ---
      
      ## 📡 核心接口
      
      ### POST /process - 中医问答接口
      
      **请求**:
      ```bash
      curl -X POST "http://localhost:8000/process" \
        -H "Content-Type: application/json" \
        -d '{"input": "人参有什么功效?"}'
      ```
      
      **响应**:
      ```json
      {
        "input": "人参有什么功效?",
        "output": "人参具有大补元气、复脉固脱、补脾益肺等功效..."
      }
      ```
      
      ---
      
      ## 💻 代码示例
      
      ### Python
      ```python
      import requests
      
      response = requests.post(
          "http://localhost:8000/process",
          json={"input": "人参有什么功效?"}
      )
      print(response.json()["output"])
      ```
      
      ### JavaScript
      ```javascript
      fetch('http://localhost:8000/process', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({input: '人参有什么功效?'})
      })
      .then(r => r.json())
      .then(data => console.log(data.output));
      ```
      
      ### Java
      ```java
      OkHttpClient client = new OkHttpClient();
      RequestBody body = RequestBody.create(
          "{\"input\":\"人参有什么功效?\"}",
          MediaType.parse("application/json")
      );
      Request request = new Request.Builder()
          .url("http://localhost:8000/process")
          .post(body)
          .build();
      Response response = client.newCall(request).execute();
      ```
      
      ---
      
      ## 📋 常用测试问题
      
      ```json
      {"input": "人参的性味归经是什么?"}
      {"input": "桂枝汤的组成和功效?"}
      {"input": "感冒咳嗽吃什么药?"}
      {"input": "如何治疗失眠?"}
      {"input": "补气的中药有哪些?"}
      ```
      
      ---
      
      ## ⚠️ 注意事项
      
      1. **超时设置**: 建议设置120秒超时
         ```python
         requests.post(url, json=data, timeout=120)
         ```
      
      2. **错误处理**: 始终检查响应状态码
         ```python
         if response.status_code == 200:
             result = response.json()
         else:
             print(f"Error: {response.status_code}")
         ```
      
      3. **FAISS路径**: 确保FAISS索引文件在英文路径下
         ```
         E:\ctm_index\nero4j_embedding_faiss.index
         ```
      
      ---
      
      ## 🔧 配置修改
      
      ### 修改端口
      编辑 `main.py`:
      ```python
      uvicorn.run(app, host="0.0.0.0", port=8001)  # 改为8001
      ```
      
      ### 修改FAISS路径
      编辑 `__004__langgraph_more_nodes/nodes/match_entity_node.py`:
      ```python
      index_path = r"你的路径\nero4j_embedding_faiss.index"
      ```
      
      ---
      
      ## ❓ 常见问题
      
      | 问题 | 解决方案 |
      |------|---------|
      | 连接被拒绝 | 确认服务已启动 |
      | 响应超时 | 增加timeout时间 |
      | FAISS加载失败 | 检查文件路径是否为英文 |
      | 端口被占用 | 修改端口号或关闭占用程序 |
      
      ---
      
      ## 📊 性能指标
      
      - **平均响应时间**: 5-30秒(取决于问题复杂度)
      - **并发支持**: 支持多请求并发处理
      - **推荐超时**: 120秒
      
      ---
      
      ## 📞 获取帮助
      
      - 完整文档: [API_INTERFACE.md](API_INTERFACE.md)
      - Swagger文档: http://localhost:8000/docs
      - 快速诊断: `python quick_test.py`
      
      ---
      
      **提示**: 更多详细信息请查看 [API_INTERFACE.md](API_INTERFACE.md)
      
    • 📄 main.py 14.1KB
      """中医知识图谱问答系统 —— FastAPI 接口层
      
      按 API_INTERFACE.md 实现三个接口:
      
          GET  /                健康检查
          POST /process         中医问答(核心接口)
          POST /process_stream  中医问答(流式,逐节点返回思考过程)
      
      这一层很薄:所有业务逻辑都在 `_004_langgraph_more_nodes.langgraph_more_nodes` 编译好的
      图里,接口只做三件事 —— 校验入参、把图跑起来、把 `state["output"]` 取出来返回。
      `output` 是整张图的统一出口(图谱路和兜底路都会写它),所以这里不用关心答案是查图谱
      得来的还是大模型直接给的。
      
      两个和"薄"有关的设计:
      
      - **图是懒加载的模块级单例**(`get_graph()`)。导入这张图会连带加载 76 MB 的 FAISS
        索引和 embedding 模型,几十秒量级,所以不在 import 时做,也不每个请求做一次。
        `lifespan` 在服务启动时提前把它加载好,这样首个请求不会额外背上初始化的时间;
        加载失败只打日志不阻止启动,健康检查照样能用来看服务活着。
      - **图是同步阻塞的**(LLM 调用 + Neo4j 查询,单次 5-30 秒),而接口是 async 的。
        直接 `graph.invoke()` 会把事件循环占死,一个请求就让整个服务失去并发能力,
        所以必须丢进 `run_in_threadpool`。
      
      运行(在仓库根目录,导入用的是绝对包名):
      
          python -X utf8 -m _005_fastapi.main
      """
      
      import asyncio
      import json
      import traceback
      from contextlib import asynccontextmanager
      
      import uvicorn
      from fastapi import FastAPI
      from fastapi.concurrency import run_in_threadpool
      from fastapi.responses import JSONResponse, StreamingResponse
      from langchain_core.runnables import RunnableConfig
      from pydantic import BaseModel, Field, field_validator
      
      # 出错时返回给用户的固定文案,和 API_INTERFACE.md 的错误响应示例保持一致
      ERROR_OUTPUT = "系统出错了,请重试!"
      
      # 流式接口里,这些键每个节点都会带回来,但对"思考过程"没有信息量,逐个节点重复推送
      # 只会把有用的内容挤掉,所以不进 thinking 消息:
      #   input                 每条消息都一样,最后的 final 里已经有了
      #   history_messages      跨轮记忆,会越来越长,且每轮都变
      #   cypher_retry_count    内部计数
      #   output/neo4j_answer/direct_out  最终答案,final 消息负责给
      STREAM_SKIP_KEYS = frozenset(
          {
              "input",
              "history_messages",
              "cypher_retry_count",
              "output",
              "neo4j_answer",
              "direct_out",
          }
      )
      
      # 队列结束标记。用一个独一无二的对象而不是 None,因为 None 本身可能是正常数据
      _STREAM_DONE = object()
      
      # 图的模块级缓存。测试可以直接把这个变量替换成假图,绕开 LLM 和 Neo4j
      _graph = None
      
      
      def get_graph():
          """懒加载并缓存编译好的 LangGraph。
      
          import 放在函数体内是故意的:模块顶层 import 会让 `import main` 就去加载
          FAISS 索引和 embedding 模型。
          """
          global _graph
          if _graph is None:
              from _004_langgraph_more_nodes.langgraph_more_nodes import graph
      
              _graph = graph
          return _graph
      
      
      @asynccontextmanager
      async def lifespan(app: FastAPI):
          """服务启动时预热图,省掉首个请求的初始化等待。
      
          加载失败只记日志、不抛异常:让服务照样起来,健康检查能回 200,
          /process 会在真正被调用时再报错,比整个服务起不来好排查。
          """
          try:
              await run_in_threadpool(get_graph)
              print("LangGraph 预加载完成")
          except Exception:
              traceback.print_exc()
              print("LangGraph 预加载失败,服务仍会启动,/process 将返回错误响应")
          yield
      
      
      app = FastAPI(
          title="中医知识图谱问答系统",
          description="基于 FastAPI + LangGraph + Neo4j + FAISS 的中医知识图谱智能问答接口",
          version="1.0.0",
          lifespan=lifespan,
      )
      
      
      # ======================
      # 请求 / 响应模型
      # ======================
      class ProcessRequest(BaseModel):
          """POST /process 的请求体。两个字段都是必填,见接口文档的请求参数表。"""
      
          input: str = Field(..., description="用户输入的问题")
          user_id: str = Field(..., description="用户唯一标识,用于隔离不同用户的会话")
      
          @field_validator("input", "user_id")
          @classmethod
          def not_blank(cls, value: str) -> str:
              """全空格的入参和空字符串一样没有意义,一并挡掉,并顺手去掉首尾空白"""
              value = value.strip()
              if not value:
                  raise ValueError("不能为空")
              return value
      
      
      class ProcessResponse(BaseModel):
          """POST /process 的响应体:原始输入 + 系统回答"""
      
          input: str = Field(..., description="用户原始输入")
          output: str = Field(..., description="系统生成的回答")
      
      
      class HealthResponse(BaseModel):
          """GET / 的响应体"""
      
          status: str = Field(..., description="服务状态,正常为 ok")
          message: str = Field(..., description="状态说明")
      
      
      # ======================
      # 流式接口的辅助函数
      # ======================
      def _json_safe(value):
          """把状态值转成能 json.dumps 的形状。
      
          状态里绝大多数是 str/list/bool,但 cypher_results 里是 Neo4j 驱动返回的行,
          真出现不认识的类型时不能让整条流断掉,退化成 str() 至少还能看。
          """
          if value is None or isinstance(value, (str, int, float, bool)):
              return value
          if isinstance(value, dict):
              return {str(k): _json_safe(v) for k, v in value.items()}
          if isinstance(value, (list, tuple)):
              return [_json_safe(v) for v in value]
          return str(value)
      
      
      def _state_delta(previous: dict, current: dict) -> dict:
          """算出这个节点相比上一个节点新写了什么。
      
          我们的节点都是"改完整个 state 再返回",所以 LangGraph 的 updates 模式给回来的是
          全量状态,不是增量 —— 直接推给前端每条消息都有二十多个键。这里跟上一步的状态做差,
          才是接口文档里 data 想要的"该节点这一步产生的状态增量"。
          """
          delta = {}
          for key, value in current.items():
              if key in STREAM_SKIP_KEYS:
                  continue
              # 语义转写节点会把上一轮的残留键清成 None,这种"清空"不算这一步的产出
              if value is None or value == [] or value == {}:
                  continue
              if previous.get(key) != value:
                  delta[key] = _json_safe(value)
          return delta
      
      
      # ======================
      # 接口 1:健康检查
      # ======================
      @app.get("/", response_model=HealthResponse, summary="健康检查")
      async def health_check() -> HealthResponse:
          """检查服务是否正常运行。无请求参数,永远返回 200。
      
          这里刻意不去探测 Neo4j / LLM / FAISS:健康检查要快且不花钱,
          它回答的是"HTTP 服务活着吗",而不是"所有依赖都好吗"。
          """
          return HealthResponse(status="ok", message="中医知识图谱问答系统服务运行正常")
      
      
      # ======================
      # 接口 2:中医问答(核心)
      # ======================
      @app.post("/process", response_model=ProcessResponse, summary="中医问答接口")
      async def process(request: ProcessRequest) -> ProcessResponse:
          """跑一遍 LangGraph,把最终回答返回给调用方。
      
      
          graph.invoke 是同步阻塞的(LLM + Neo4j,单次 5-30 秒),所以扔到线程池里,
          否则事件循环被占死,服务就退化成一次只能处理一个请求。
      
          任何异常都收敛成 500 + 固定文案:内部报错细节(cypher、堆栈)不应该漏给调用方,
          完整堆栈打在服务端日志里。
          """
          print(f"[/process] user_id={request.user_id} input={request.input!r}")
          try:
              graph = get_graph()
              # thread_id="user_0001"
              thread_id=request.user_id
              config = RunnableConfig(configurable={
                  "thread_id": thread_id
              })
              state = await run_in_threadpool(graph.invoke, {"input": request.input},config)
              print("===================")
              print(state)
              print("===================")
              # output 是图的统一出口。理论上每条路径都会写它,兜底一层防止图改坏了返回 None
              output = (state.get("output") or "").strip() or ERROR_OUTPUT
              return ProcessResponse(input=request.input, output=output)
          except Exception:
              traceback.print_exc()
              # 错误响应的 input 按文档示例返回空串
              return JSONResponse(
                  status_code=500,
                  content={"input": "", "output": ERROR_OUTPUT},
              )
      
      
      # ======================
      # 接口 3:流式中医问答
      # ======================
      async def _stream_messages(user_input: str, user_id: str):
          """生产者-消费者:把图的执行过程变成一行行 JSON。
      
          生产者是图里的各个节点 —— 每个节点执行完,LangGraph 的 stream(updates) 就吐出
          一条它的状态,producer 把它塞进队列。消费者是这个异步生成器,从队列取出来
          yield 给 StreamingResponse。
      
          为什么要队列:`graph.stream()` 是**同步**生成器,里面是 LLM 调用和 Neo4j 查询,
          一次 5-30 秒。直接在 async 函数里 for 它,事件循环会被卡住,服务退化成单并发
          (和 /process 用 run_in_threadpool 是同一个理由)。所以让它在工作线程里跑,
          通过队列把结果交回事件循环 —— 队列是这两个世界之间唯一的通道。
      
          队列有界(maxsize=32):万一客户端读得慢,节点又一直产出,无界队列会一直吃内存。
          """
          queue: asyncio.Queue = asyncio.Queue(maxsize=32)
          loop = asyncio.get_running_loop()
      
          def producer():
              """在工作线程里跑图,每个节点产出一条消息。
      
              这个函数整体在 run_in_threadpool 的线程里执行,不能直接 await,
              所以往队列里放东西要用 run_coroutine_threadsafe 跨回事件循环。
              """
      
              def put(item):
                  # .result() 是必须的:它让这里
                  # 在队列满时阻塞住,形成反压。
                  # 不等的话,慢客户端场景下 put 会无声堆积,maxsize 就白设了。
                  asyncio.run_coroutine_threadsafe(queue.put(item), loop).result()
      
              try:
                  graph = get_graph()
                  config = RunnableConfig(configurable={"thread_id": user_id})
                  previous: dict = {}
      
                  for chunk in graph.stream(
                      {"input": user_input}, config, stream_mode="updates"
                  ):
                      # chunk 形如 {节点名: 该节点返回的状态}
                      for node_name, node_state in chunk.items():
                          if not isinstance(node_state, dict):
                              continue
                          delta = _state_delta(previous, node_state)
                          previous = {**previous, **node_state}
                          # 没有新增内容的节点(比如纯路由)不推空消息,前端不用过滤
                          if delta:
                              put({"type": "thinking", "node": node_name, "data": delta})
      
                  # 图跑完了,最后一条给最终答案。previous 累积了全程状态,output 在里面
                  output = (previous.get("output") or "").strip() or ERROR_OUTPUT
                  put({"type": "final", "input": user_input, "output": output})
              except Exception:
                  # 堆栈只打在服务端,返回给调用方的是固定文案,和 /process 保持一致
                  traceback.print_exc()
                  put({"type": "error", "input": user_input, "output": ERROR_OUTPUT})
              finally:
                  # 无论成功失败都要放结束标记,否则消费者会一直等下去,请求挂住
                  put(_STREAM_DONE)
      
          # 不 await:让它在后台线程跑,下面一边跑一边从队列取
          task = asyncio.create_task(run_in_threadpool(producer))
          try:
              while True:
                  message = await queue.get()
                  if message is _STREAM_DONE:
                      break
                  # 换行分隔的 JSON(不是严格 SSE),对应文档里的响应格式。
                  # ensure_ascii=False 保留中文原样,前端按行 json.loads 即可
                  yield json.dumps(message, ensure_ascii=False) + "\n"
          finally:
              # 客户端提前断开时,生成器会被关掉。这里把线程任务收干净,
              # 不然异常会以 "Task exception was never retrieved" 的形式漏出来
              if not task.done():
                  task.cancel()
              try:
                  await task
              except (asyncio.CancelledError, Exception):
                  pass
      
      
      @app.post("/process_stream", summary="中医问答接口(流式)")
      async def process_stream(request: ProcessRequest) -> StreamingResponse:
          """和 /process 同样的入参,但每个节点执行完就先返回一条"思考过程"。
      
          一次问答要 5-30 秒,一次性接口在这段时间里什么都没有,用户只能干等;
          流式把中间过程吐出来,前端可以显示"正在抽取实体""正在查图谱"。
      
          响应体不是严格 SSE,是换行分隔的 JSON(text/plain),见接口文档。
          这里不套 try:异常在 producer 内部就收敛成 error 消息了,
          HTTP 状态码已经随第一个字节发出去,改不了了。
          """
          print(f"[/process_stream] user_id={request.user_id} input={request.input!r}")
          return StreamingResponse(
              _stream_messages(request.input, request.user_id),
              media_type="text/plain; charset=utf-8",
              # 关掉 nginx 一类反向代理的缓冲,否则它会攒够一批再转发,流式就没了
              headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
          )
      
      
      if __name__ == "__main__":
          uvicorn.run(app, host="0.0.0.0", port=8001)
      
    • 📄 my_test_api.py 384B
      import requests
      
      
      def query_zhongyi_fastapi(input: str) -> str:
          url = "http://127.0.0.1:8000/process"
          payload = {"input": input, "user_id": "user_id_1"}
      
          res = requests.post(url, json=payload)
          json_dict = res.json()
          return json_dict["output"]
      
      
      print(query_zhongyi_fastapi("你好"))
      print(query_zhongyi_fastapi("你好,我气虚应该吃人参吗?"))
      
    • 📄 my_test_api_stream.py 4.2KB
      """用 requests 手工测 POST /process_stream。
      
      和 test_api.py 的区别:test_api.py 默认拿 FakeGraph 跑离线断言,这个脚本是**打真实服务**,
      用来肉眼看流式效果 —— 每个节点的思考过程是不是一条条到达,而不是最后一次性糊过来。
      
      前提:先在仓库根目录起服务(main.py 里监听的是 8001,不是文档里的 8000)
      
          python -X utf8 -m _005_fastapi.main
      
      运行:
      
          python -X utf8 -m _005_fastapi.my_test_api_stream
          python -X utf8 -m _005_fastapi.my_test_api_stream --base-url http://127.0.0.1:8000
      """
      
      import argparse
      import json
      import time
      
      import requests
      
      BASE_URL = "http://127.0.0.1:8001"
      
      # 两条问题走两条不同的路:第一条是闲聊,图会直接跳到兜底节点;
      # 第二条要查图谱,节点多,流式的效果才看得出来
      QUESTIONS = [
          "你好",
          "你好,我气虚应该吃人参吗?",
      ]
      
      
      def query_stream(question: str, base_url: str = BASE_URL, user_id: str = "user_id_1") -> str:
          """打一次流式接口,边收边打印,返回最终答案。
      
          stream=True 是关键:不加的话 requests 会把整个响应体读完才返回,
          客户端这边就退化成一次性接口,测不出流式。
          iter_lines 正好匹配服务端"换行分隔 JSON"的格式。
          """
          url = f"{base_url}/process_stream"
          payload = {"input": question, "user_id": user_id}
      
          start = time.time()
          first_at = None
          final_output = ""
      
          with requests.post(url, json=payload, stream=True, timeout=180) as res:
              print(f"HTTP {res.status_code}  Content-Type: {res.headers.get('Content-Type')}")
              res.raise_for_status()
      
              for line in res.iter_lines(decode_unicode=True):
                  # 空行是心跳/分隔,跳过
                  if not line or not line.strip():
                      continue
      
                  elapsed = time.time() - start
                  if first_at is None:
                      first_at = elapsed
      
                  message = json.loads(line)
                  kind = message.get("type")
      
                  if kind == "thinking":
                      # data 是这个节点这一步产生的状态增量,可能挺长,截断了看
                      data = json.dumps(message.get("data"), ensure_ascii=False)
                      if len(data) > 160:
                          data = data[:160] + "..."
                      print(f"  [{elapsed:6.2f}s] 思考中 · {message.get('node')}: {data}")
                  elif kind == "final":
                      final_output = message.get("output", "")
                      print(f"  [{elapsed:6.2f}s] 最终答案: {final_output}")
                  elif kind == "error":
                      final_output = message.get("output", "")
                      print(f"  [{elapsed:6.2f}s] 出错: {final_output}")
                  else:
                      print(f"  [{elapsed:6.2f}s] 未知消息类型: {message}")
      
          total = time.time() - start
          # 首条消息明显早于总耗时,才说明是真流式;两个数字贴在一起就是被缓冲了
          print(f"  首条 {first_at if first_at is None else round(first_at, 2)}s / 总计 {total:.2f}s")
          return final_output
      
      
      def main() -> None:
          parser = argparse.ArgumentParser(description="requests 手测 /process_stream")
          parser.add_argument("--base-url", default=BASE_URL, help=f"服务地址,默认 {BASE_URL}")
          parser.add_argument("--input", default=None, help="只测这一个问题")
          args = parser.parse_args()
      
          questions = [args.input] if args.input else QUESTIONS
      
          for question in questions:
              print(f"\n=== 提问: {question} ===")
              try:
                  query_stream(question, base_url=args.base_url)
              except requests.RequestException as exc:
                  print(f"  请求失败: {exc}(服务起了吗?python -X utf8 -m _005_fastapi.main)")
      
          # 顺手验一下入参校验:流式接口和 /process 共用 ProcessRequest,空 input 应该 422
          print("\n=== 入参校验: 空 input ===")
          try:
              res = requests.post(
                  f"{args.base_url}/process_stream", json={"input": "", "user_id": "u"}, timeout=10
              )
              print(f"HTTP {res.status_code}(期望 422) {res.text[:120]}")
          except requests.RequestException as exc:
              print(f"  请求失败: {exc}")
      
      
      if __name__ == "__main__":
          main()
      
    • 📄 test_api.py 18.4KB
      """_005_fastapi 的测试用例
      
      仓库里没有 pytest,测试沿用各模块 `if __name__ == "__main__"` 自测的约定,
      用一个极小的断言 + 计数框架,跑完打印 通过/失败 汇总,失败时退出码非 0。
      
      两种模式:
      
          python -X utf8 -m _005_fastapi.test_api          # 离线(默认)
          python -X utf8 -m _005_fastapi.test_api --live   # 打真实服务
      
      **离线模式是主力**:它把 `main._graph` 换成一张假图,于是不碰 LLM、Neo4j、FAISS,
      一秒内跑完,验证的是接口层自己的行为 —— 状态码、字段、校验、异常收敛。
      真图跑一遍要 5-30 秒还要花钱,不适合当回归测试。
      
      `--live` 是对着 `python -m _005_fastapi.main` 起好的服务打真实请求,验证的是端到端
      真的通(图能加载、答案有内容)。它依赖外部服务,所以是可选的,不进默认流程。
      """
      
      import argparse
      import json
      import sys
      
      from fastapi.testclient import TestClient
      
      from _005_fastapi import main
      
      BASE_URL = "http://localhost:8000"
      
      # 结果计数,_check 往里累加
      passed = 0
      failed = 0
      
      
      def _check(condition: bool, label: str, detail: str = "") -> None:
          """一条断言 = 一行输出。不抛异常,让后面的用例继续跑完"""
          global passed, failed
          if condition:
              passed += 1
              print(f"  [PASS] {label}")
          else:
              failed += 1
              print(f"  [FAIL] {label}" + (f" -> {detail}" if detail else ""))
      
      
      # ======================
      # 假图:替掉真图,测接口层自己
      # ======================
      class FakeGraph:
          """只实现 invoke,返回预先设定的 state 或抛异常。
      
          真图的接口就这一个方法(`graph.invoke(dict) -> dict`),所以替换成本极低。
          """
      
          def __init__(self, state=None, error=None):
              self.state = state
              self.error = error
              self.calls = []
              self.configs = []
      
          def invoke(self, initial_state, config=None):
              """签名要跟真图对齐。
      
              加了 checkpointer 之后 main.py 会带上 `config`(里面是 thread_id),
              少这个参数会让每次调用都 TypeError,然后被 /process 的 except 收敛成 500 ——
              测出来是"图挂了",看不出其实是假图的签名过时了。
              """
              self.calls.append(initial_state)
              self.configs.append(config)
              if self.error is not None:
                  raise self.error
              return self.state
      
      
      def _client(fake_graph) -> TestClient:
          """装上假图,返回 TestClient。
      
          直接给 `main._graph` 赋值即可绕过懒加载 —— `get_graph()` 见到非 None 就不再 import
          真图。TestClient 会触发 lifespan,预热那一步同样命中缓存,不会去加载 FAISS。
          """
          main._graph = fake_graph
          return TestClient(main.app)
      
      
      # ======================
      # 用例 1:健康检查
      # ======================
      def test_health_check() -> None:
          print("\n[1] GET / 健康检查")
          with _client(FakeGraph(state={"output": "不该被调用"})) as client:
              response = client.get("/")
      
          _check(response.status_code == 200, "状态码 200", str(response.status_code))
          body = response.json()
          _check(body.get("status") == "ok", "status == ok", repr(body.get("status")))
          _check(
              body.get("message") == "中医知识图谱问答系统服务运行正常",
              "message 与文档一致",
              repr(body.get("message")),
          )
          _check(set(body) == {"status", "message"}, "字段只有 status/message", str(set(body)))
      
      
      # ======================
      # 用例 2:/process 正常返回
      # ======================
      def test_process_ok() -> None:
          print("\n[2] POST /process 正常问答(图谱路)")
          answer = "人参具有大补元气、复脉固脱、补脾益肺、生津养血、安神益智等功效。"
          graph = FakeGraph(state={"input": "人参有什么功效?", "output": answer})
      
          with _client(graph) as client:
              response = client.post(
                  "/process", json={"input": "人参有什么功效?", "user_id": "user-123"}
              )
      
          _check(response.status_code == 200, "状态码 200", str(response.status_code))
          body = response.json()
          _check(body.get("input") == "人参有什么功效?", "input 原样回显", repr(body.get("input")))
          _check(body.get("output") == answer, "output 取自 state['output']", repr(body.get("output")))
          _check(set(body) == {"input", "output"}, "字段只有 input/output", str(set(body)))
          # 只把 input 传进图,user_id 不进 state(AgentState 里没有这个字段)
          _check(graph.calls == [{"input": "人参有什么功效?"}], "只把 input 传给图", str(graph.calls))
      
      
      # ======================
      # 用例 3:/process 兜底路(非中医提问)
      # ======================
      def test_process_direct_out() -> None:
          print("\n[3] POST /process 兜底路(非中医提问)")
          answer = "抱歉,我无法提供天气信息。我是一个中医知识图谱问答系统。"
          # 兜底路的 state 里没有 neo4j_answer,但 output 一样被写满,接口层不用区分
          graph = FakeGraph(state={"input": "今天天气怎么样?", "direct_out": answer, "output": answer})
      
          with _client(graph) as client:
              response = client.post(
                  "/process", json={"input": "今天天气怎么样?", "user_id": "user-456"}
              )
      
          _check(response.status_code == 200, "状态码 200", str(response.status_code))
          _check(response.json().get("output") == answer, "兜底路答案也能取到", repr(response.json()))
      
      
      # ======================
      # 用例 4:入参校验
      # ======================
      def test_process_validation() -> None:
          print("\n[4] POST /process 入参校验(FastAPI 返回 422)")
          graph = FakeGraph(state={"output": "不该被调用"})
      
          bad_requests = [
              ({"user_id": "user-123"}, "缺 input"),
              ({"input": "人参有什么功效?"}, "缺 user_id"),
              ({"input": "", "user_id": "user-123"}, "input 为空串"),
              ({"input": "   ", "user_id": "user-123"}, "input 全空格"),
              ({"input": "人参有什么功效?", "user_id": ""}, "user_id 为空串"),
              ({}, "两个字段都缺"),
          ]
      
          with _client(graph) as client:
              for payload, label in bad_requests:
                  response = client.post("/process", json=payload)
                  _check(response.status_code == 422, f"{label} -> 422", str(response.status_code))
      
          # 参数不合法就该被挡在图之前,一次大模型都不该调
          _check(graph.calls == [], "非法请求没有触达图", str(graph.calls))
      
      
      # ======================
      # 用例 5:异常收敛成 500 + 固定文案
      # ======================
      def test_process_error() -> None:
          print("\n[5] POST /process 图执行异常")
          graph = FakeGraph(error=RuntimeError("Neo4j 连接失败"))
      
          with _client(graph) as client:
              response = client.post(
                  "/process", json={"input": "人参有什么功效?", "user_id": "user-123"}
              )
      
          _check(response.status_code == 500, "状态码 500", str(response.status_code))
          body = response.json()
          _check(body.get("input") == "", "错误响应 input 为空串", repr(body.get("input")))
          _check(body.get("output") == main.ERROR_OUTPUT, "output 为固定错误文案", repr(body.get("output")))
          # 内部异常信息不该漏给调用方
          _check("Neo4j" not in response.text, "不泄漏内部异常细节", response.text)
      
      
      # ======================
      # 用例 6:图没写 output 时的兜底
      # ======================
      def test_process_empty_output() -> None:
          print("\n[6] POST /process 图返回空 output")
          for state, label in [
              ({"input": "x"}, "state 里没有 output 键"),
              ({"input": "x", "output": None}, "output 为 None"),
              ({"input": "x", "output": "   "}, "output 全空白"),
          ]:
              with _client(FakeGraph(state=state)) as client:
                  response = client.post("/process", json={"input": "x", "user_id": "u"})
              _check(
                  response.status_code == 200
                  and response.json().get("output") == main.ERROR_OUTPUT,
                  f"{label} -> 200 + 错误文案",
                  response.text,
              )
      
      
      # ======================
      # 用例 7:并发不被阻塞
      # ======================
      def test_process_concurrent() -> None:
          """图是同步阻塞的,接口必须把它丢进线程池,否则一个慢请求会卡住整个服务。
      
          这里让假图 sleep,再用线程并发打两个请求:串行执行耗时会是 2 倍 sleep,
          并发则接近 1 倍。用 1.5 倍做判据,够区分两种情况又不至于被机器抖动误判。
          """
          print("\n[7] POST /process 并发(验证没有阻塞事件循环)")
          import time
          from concurrent.futures import ThreadPoolExecutor
      
          delay = 0.5
      
          class SlowGraph(FakeGraph):
              # config 同样要接(见 FakeGraph.invoke)
              def invoke(self, initial_state, config=None):
                  time.sleep(delay)
                  return {"output": "慢回答"}
      
          with _client(SlowGraph()) as client:
              def one(_):
                  return client.post("/process", json={"input": "人参", "user_id": "u"})
      
              start = time.perf_counter()
              with ThreadPoolExecutor(max_workers=2) as pool:
                  responses = list(pool.map(one, range(2)))
              elapsed = time.perf_counter() - start
      
          _check(all(r.status_code == 200 for r in responses), "两个请求都成功")
          _check(elapsed < delay * 1.5, f"并发执行(耗时 {elapsed:.2f}s < {delay * 1.5}s)", f"{elapsed:.2f}s")
      
      
      # ======================
      # 用例 8:流式接口
      # ======================
      class FakeStreamGraph(FakeGraph):
          """假图的流式版本:按给定顺序吐出 {节点名: 状态}。
      
          真图的 stream 是"每个节点执行完产出一条",而且因为我们的节点是改完整个 state
          再返回,产出的是**全量状态**不是增量。这里照着这个形状造数据,才测得到
          main._state_delta 做差那一步。
          """
      
          def __init__(self, chunks=None, error=None):
              super().__init__(state=None, error=error)
              self.chunks = chunks or []
      
          def stream(self, initial_state, config=None, stream_mode=None):
              self.calls.append(initial_state)
              self.configs.append(config)
              if self.error is not None:
                  raise self.error
              for chunk in self.chunks:
                  yield chunk
      
      
      def _read_stream(response) -> list:
          """把换行分隔的响应体解析成消息列表"""
          return [json.loads(line) for line in response.text.splitlines() if line.strip()]
      
      
      def test_process_stream_ok() -> None:
          """逐节点产出 thinking,最后一条 final,且 data 是增量不是全量"""
          print("\n[8] POST /process_stream 正常流式问答")
      
          # 模拟三个节点依次执行,状态逐步累积(真图就是这个形状)
          chunks = [
              {"语义转写节点": {"input": "人参有什么功效?", "input_semantic_trans": "人参有什么功效?"}},
              {
                  "中医意图识别节点": {
                      "input": "人参有什么功效?",
                      "input_semantic_trans": "人参有什么功效?",
                      "is_zhongyi_intent": True,
                  }
              },
              {
                  "图谱答案节点": {
                      "input": "人参有什么功效?",
                      "input_semantic_trans": "人参有什么功效?",
                      "is_zhongyi_intent": True,
                      "neo4j_answer": "人参能大补元气。",
                      "output": "人参能大补元气。",
                  }
              },
          ]
      
          with _client(FakeStreamGraph(chunks)) as client:
              response = client.post(
                  "/process_stream", json={"input": "人参有什么功效?", "user_id": "user-123"}
              )
      
          _check(response.status_code == 200, "状态码 200", str(response.status_code))
          messages = _read_stream(response)
      
          thinking = [m for m in messages if m["type"] == "thinking"]
          finals = [m for m in messages if m["type"] == "final"]
      
          _check(len(finals) == 1, "有且只有一条 final", f"{len(finals)} 条")
          _check(messages[-1]["type"] == "final", "final 是最后一条", messages[-1]["type"])
          _check(len(thinking) >= 2, f"有多条 thinking({len(thinking)} 条)", str(len(thinking)))
      
          # 关键:data 必须是这一步新增的键,不能把全量状态原样推出去
          intent_msg = next((m for m in thinking if m["node"] == "中医意图识别节点"), None)
          _check(intent_msg is not None, "能找到意图识别节点的消息")
          if intent_msg:
              _check(
                  intent_msg["data"] == {"is_zhongyi_intent": True},
                  "data 只含这一步的增量",
                  repr(intent_msg["data"]),
              )
      
          _check(
              finals[0]["output"] == "人参能大补元气。", "final 取到 output", repr(finals[0]["output"])
          )
          _check(finals[0]["input"] == "人参有什么功效?", "final 回显 input", repr(finals[0]["input"]))
      
          # thinking 里不该出现 output/history_messages 这类噪音键
          noisy = [k for m in thinking for k in m["data"] if k in main.STREAM_SKIP_KEYS]
          _check(not noisy, "thinking 不含被过滤的键", repr(noisy))
      
      
      def test_process_stream_error() -> None:
          """图抛异常时,流以 error 消息收尾,而不是把堆栈漏出去或让请求挂住"""
          print("\n[9] POST /process_stream 图执行异常")
      
          with _client(FakeStreamGraph(error=RuntimeError("Neo4j 连接失败"))) as client:
              response = client.post(
                  "/process_stream", json={"input": "人参", "user_id": "u"}
              )
      
          messages = _read_stream(response)
          _check(response.status_code == 200, "状态码 200(头已发出,改不了)", str(response.status_code))
          _check(len(messages) == 1, "只有一条消息", f"{len(messages)} 条")
          _check(messages[-1]["type"] == "error", "最后一条是 error", messages[-1]["type"])
          _check(
              messages[-1]["output"] == main.ERROR_OUTPUT, "错误文案固定", repr(messages[-1]["output"])
          )
          _check("Neo4j" not in response.text, "堆栈/内部信息没有漏出去")
      
      
      def test_process_stream_validation() -> None:
          """入参校验和 /process 共用同一个模型,行为要一致"""
          print("\n[10] POST /process_stream 入参校验")
      
          with _client(FakeStreamGraph([])) as client:
              _check(
                  client.post("/process_stream", json={"input": "", "user_id": "u"}).status_code
                  == 422,
                  "空 input -> 422",
              )
              _check(
                  client.post("/process_stream", json={"input": "人参"}).status_code == 422,
                  "缺 user_id -> 422",
              )
              _check(
                  client.post("/process_stream", json={"input": "   ", "user_id": "u"}).status_code
                  == 422,
                  "全空格 input -> 422",
              )
      
      
      def test_process_stream_thread_id() -> None:
          """user_id 要作为 thread_id 传给图,否则多轮记忆按用户隔离就失效了"""
          print("\n[11] POST /process_stream 透传 user_id 作为 thread_id")
      
          fake = FakeStreamGraph([{"节点": {"output": "答案"}}])
          with _client(fake) as client:
              client.post("/process_stream", json={"input": "人参", "user_id": "user-abc"})
      
          _check(fake.calls == [{"input": "人参"}], "只把 input 传给图", repr(fake.calls))
          thread_id = (fake.configs[0] or {}).get("configurable", {}).get("thread_id")
          _check(thread_id == "user-abc", "thread_id == user_id", repr(thread_id))
      
      
      # ======================
      # --live:打真实服务
      # ======================
      LIVE_QUESTIONS = [
          # 图谱路:药材属性
          "人参有什么功效?",
          # 图谱路:方剂 → 组成
          "四君子汤由哪些药材组成?",
          # 兜底路:非中医提问
          "帮我写一段 Python 快排代码",
      ]
      
      
      def run_live(base_url: str) -> None:
          """对着已启动的服务打真实请求。需要先 python -m _005_fastapi.main"""
          import requests
      
          print(f"\n=== live 模式:{base_url} ===")
      
          print("\n[live-1] GET /")
          try:
              response = requests.get(f"{base_url}/", timeout=10)
          except requests.RequestException as exc:
              _check(False, "连接服务", f"{exc}(服务没启动?先跑 python -m _005_fastapi.main)")
              return
          _check(response.status_code == 200, "状态码 200", str(response.status_code))
          _check(response.json().get("status") == "ok", "status == ok", response.text)
      
          for i, question in enumerate(LIVE_QUESTIONS, start=2):
              print(f"\n[live-{i}] POST /process {question!r}")
              try:
                  # 文档建议 120 秒超时:单次问答 5-30 秒,复杂问题更久
                  response = requests.post(
                      f"{base_url}/process",
                      json={"input": question, "user_id": "test-user-001"},
                      timeout=120,
                  )
              except requests.RequestException as exc:
                  _check(False, "请求成功", str(exc))
                  continue
      
              _check(response.status_code == 200, "状态码 200", str(response.status_code))
              body = response.json()
              _check(body.get("input") == question, "input 回显", repr(body.get("input")))
              answer = body.get("output") or ""
              _check(bool(answer.strip()), "output 非空", repr(answer))
              _check(answer != main.ERROR_OUTPUT, "不是错误文案", repr(answer))
              print(f"    回答: {answer[:120]}")
      
          print("\n[live-last] POST /process 入参校验")
          response = requests.post(f"{base_url}/process", json={"input": ""}, timeout=10)
          _check(response.status_code == 422, "空 input + 缺 user_id -> 422", str(response.status_code))
      
      
      OFFLINE_TESTS = [
          test_health_check,
          test_process_ok,
          test_process_direct_out,
          test_process_validation,
          test_process_error,
          test_process_empty_output,
          test_process_concurrent,
          test_process_stream_ok,
          test_process_stream_error,
          test_process_stream_validation,
          test_process_stream_thread_id,
      ]
      
      
      if __name__ == "__main__":
          parser = argparse.ArgumentParser(description="_005_fastapi 接口测试")
          parser.add_argument(
              "--live",
              action="store_true",
              help="对已启动的服务打真实请求(需要 LLM / Neo4j / FAISS 都可用)",
          )
          parser.add_argument("--base-url", default=BASE_URL, help=f"live 模式的服务地址,默认 {BASE_URL}")
          args = parser.parse_args()
      
          if args.live:
              run_live(args.base_url.rstrip("/"))
          else:
              print("=== 离线模式:用假图替掉真图,不调用 LLM / Neo4j / FAISS ===")
              for test in OFFLINE_TESTS:
                  test()
      
          print(f"\n{'=' * 70}")
          print(f"通过 {passed} 条,失败 {failed} 条")
          sys.exit(1 if failed else 0)
      
    • ⚙️ 需求 526B
  • 📁 _006_streamlit/
    • 📁 pages/
      • 📄 1_流式问答.py 11.4KB
        """中医知识图谱问答 —— 流式页面(调后端 /process_stream)
        
        和首页(app.py,调 /process)问的是同一张图,区别只在**什么时候给反馈**:
        首页要等 5-30 秒才一次性出答案,中间只有一个转圈;这一页把图里每个节点的产出
        边跑边显示出来,用户能看到"正在抽取实体""正在查图谱",等待就不再是黑盒。
        
        Streamlit 1.57 的多页机制:`pages/` 目录下每个 .py 自动成为一页,侧边栏出现导航,
        入口仍然是 app.py。所以这一页只管自己的界面,不用改 app.py。
        
        运行(和首页同一条命令,起来后从侧边栏切页):
        
            python -X utf8 -m _005_fastapi.main       # 1. 后端
            streamlit run _006_streamlit/app.py       # 2. 前端
        
        网络那一层在 api_client.ask_backend_stream,这里只管界面。
        """
        
        import sys
        import uuid
        from pathlib import Path
        
        # 和 app.py 同一个理由:streamlit run 只把**脚本所在目录**放进 sys.path。
        # 这一页在 pages/ 下,比 app.py 还深一层,所以要往上走两级才是仓库根。
        # 这几行必须排在项目内的 import 之前,下面的 E402 是故意的。
        _REPO_ROOT = str(Path(__file__).resolve().parent.parent.parent)
        if _REPO_ROOT not in sys.path:
            sys.path.insert(0, _REPO_ROOT)
        
        import streamlit as st
        
        from _006_streamlit.api_client import (
            DEFAULT_BASE_URL,
            DEFAULT_TIMEOUT,
            BackendError,
            ask_backend_stream,
            check_health,
        )
        
        # 示例问题挑的是**要查图谱**的那种:闲聊只走两个节点,看不出流式的效果
        SAMPLE_QUESTIONS = [
            "人参有什么功效?",
            "四君子汤由哪些药材组成?",
            "我最近总是拉肚子,有什么方剂可以调理?",
        ]
        
        # AgentState 里的键名对用户没有意义,翻译成人看得懂的中文。
        # 键取自 _004_langgraph_more_nodes/agent_state.py —— 那边加字段,这里补一行,
        # 没补也不会坏,下面的 fallback 会直接显示原始键名。
        FIELD_LABELS = {
            "input_semantic_trans": "改写后的问题",
            "is_zhongyi_intent": "是否中医相关",
            "user_input_herbs": "问题里的中药",
            "user_input_formulas": "问题里的方剂",
            "user_input_symptoms": "问题里的症状",
            "user_input_diseases": "问题里的疾病",
            "user_input_effects": "问题里的功效",
            "user_input_sources": "问题里的出处",
            "matched_herbs": "图谱中的中药",
            "matched_formulas": "图谱中的方剂",
            "matched_symptoms": "图谱中的症状",
            "matched_diseases": "图谱中的疾病",
            "matched_effects": "图谱中的功效",
            "matched_sources": "图谱中的出处",
            "cypher_query": "生成的查询语句",
            "is_all_validate_cypher": "查询语句校验",
            "cypher_results": "图谱返回的数据",
        }
        
        st.set_page_config(page_title="流式问答 · 中医知识图谱", page_icon="🌊", layout="centered")
        
        def init_state() -> None:
            """初始化 session_state。rerun 会重跑整个脚本,所以每个键都要判存在再写。"""
            if "stream_messages" not in st.session_state:
                # [{"role", "content", "thinking": [(节点名, 增量), ...]}]。
                # thinking 也要存:rerun 会重跑整个脚本,不存的话一提下一个问题,
                # 上一轮的思考过程就消失了。用和首页不同的键,两页历史互不打扰
                st.session_state.stream_messages = []
            if "user_id" not in st.session_state:
                # 和首页共用这个键:同一个浏览器会话就是同一个用户,
                # 后端拿它当 thread_id,切页不该换身份
                st.session_state.user_id = f"streamlit-{uuid.uuid4().hex[:12]}"
            if "stream_pending" not in st.session_state:
                # 点示例按钮时把问题存这里,交给下一次 rerun 处理。
                # 按钮回调发生在页面渲染中途,直接在那儿发请求会让聊天记录的渲染顺序错乱
                st.session_state.stream_pending = None
        
        
        def render_sidebar() -> tuple[str, int]:
            """侧边栏:后端地址、超时、状态灯、清空按钮。返回 (base_url, timeout)"""
            with st.sidebar:
                st.header("⚙️ 设置")
                base_url = st.text_input(
                    "后端地址",
                    value=DEFAULT_BASE_URL,
                    help="_005_fastapi 服务的地址。端口被占用时改这里,不用改代码",
                    key="stream_base_url",
                )
                timeout = st.number_input(
                    "超时(秒)",
                    min_value=10,
                    max_value=600,
                    value=DEFAULT_TIMEOUT,
                    step=10,
                    help="流式接口的超时是**读一行**的间隔,不是总时长,给到 120 秒很宽裕",
                    key="stream_timeout",
                )
        
                # 手动触发,不自动探活:每次 rerun 都发一个请求属于白费
                if st.button("检查后端连接", use_container_width=True, key="stream_health"):
                    if check_health(base_url):
                        st.success("后端正常")
                    else:
                        st.error("连不上后端,请先启动 _005_fastapi")
        
                st.divider()
                if st.button("🗑️ 清空对话", use_container_width=True, key="stream_clear"):
                    st.session_state.stream_messages = []
                    st.rerun()
        
                st.caption(f"会话 user_id:`{st.session_state.user_id}`")
        
            return base_url, int(timeout)
        
        
        def format_value(value) -> str:
            """把状态值渲染成一行 markdown。
        
            值的形状不统一:布尔、字符串、字符串列表(实体、cypher)、嵌套的查询结果。
            列表用顿号连起来比原始 JSON 好读;cypher 和查询结果太长,截断,
            这里是"思考过程"不是调试面板,看个大概就够。
            """
            if isinstance(value, bool):
                return "是" if value else "否"
            if isinstance(value, list):
                # 全是字符串的列表(实体名、cypher 语句)直接拼
                if all(isinstance(v, str) for v in value):
                    text = "、".join(value)
                else:
                    # cypher_results 这种嵌套结构,只报个条数,展开了没人看
                    return f"共 {len(value)} 组结果"
                return text if len(text) <= 300 else text[:300] + "…"
            text = str(value)
            return text if len(text) <= 300 else text[:300] + "…"
        
        
        def render_thinking(container, node: str, data: dict) -> None:
            """把一个节点的状态增量画进容器。
        
            每个节点一个小标题 + 若干"字段:值"。用 container.markdown 而不是 st.markdown,
            是为了把内容写进 status 里那块固定区域,而不是追加到页面末尾。
            """
            lines = [f"**{node}**"]
            for key, value in data.items():
                # 没登记的键显示原始键名,加字段时不至于漏掉不显示
                label = FIELD_LABELS.get(key, key)
                lines.append(f"- {label}:{format_value(value)}")
            container.markdown("\n".join(lines))
        
        
        def handle_question(question: str, base_url: str, timeout: int) -> None:
            """跑一轮流式问答,边收边画。
        
            先把用户消息写进历史再发请求,这样请求失败时提问也留在页面上,
            用户不用重新打一遍。
            """
            st.session_state.stream_messages.append({"role": "user", "content": question})
            with st.chat_message("user"):
                st.markdown(question)
        
            with st.chat_message("assistant"):
                # st.status 给的是一块带标题和状态图标的固定区域,思考过程往里写。
                # expanded=True 且跑完也不收,这一页存在的意义就是让人看见中间过程
                status = st.status("正在思考…", expanded=True)
                answer = ""
                is_error = False
                # 边收边记,跑完存进历史,rerun 时重放
                thinking_log: list = []
        
                try:
                    for message in ask_backend_stream(
                        question,
                        user_id=st.session_state.user_id,
                        base_url=base_url,
                        timeout=timeout,
                    ):
                        kind = message.get("type")
                        if kind == "thinking":
                            node = message.get("node") or "未知节点"
                            data = message.get("data") or {}
                            # 节点名直接当进度文案,比"处理中"有信息量
                            status.update(label=f"正在执行:{node}")
                            render_thinking(status, node, data)
                            thinking_log.append((node, data))
                        elif kind == "final":
                            answer = (message.get("output") or "").strip()
                        elif kind == "error":
                            # 后端图跑挂了,HTTP 仍是 200,错误文案在 output 里
                            answer = (message.get("output") or "").strip()
                            is_error = True
                except BackendError as exc:
                    # 连不上/超时/参数不合法:流根本没开始
                    answer = f"⚠️ {exc}"
                    is_error = True
        
                if not answer:
                    # 流正常结束但没收到 final,属于后端不该有的行为,兜一下
                    answer = "⚠️ 后端没有返回最终答案,请重试。"
                    is_error = True
        
                # 跑的时候展开(用户要看进度),跑完收起(答案才是结论,中间过程让位)。
                # 点一下标题还能再展开,信息没丢
                status.update(
                    label="思考失败" if is_error else "思考完成",
                    state="error" if is_error else "complete",
                    expanded=False,
                )
                # 回答里带 markdown(后端的 LLM 会输出加粗、列表),用 markdown 渲染
                st.markdown(answer)
        
            st.session_state.stream_messages.append(
                {
                    "role": "assistant",
                    "content": answer,
                    "thinking": thinking_log,
                    "is_error": is_error,
                }
            )
        
        
        def main() -> None:
            init_state()
            base_url, timeout = render_sidebar()
        
            st.title("🌊 流式问答")
            st.caption("和首页问的是同一张图,但会把每个节点的思考过程实时显示出来")
        
            # 空历史时给几个示例,用户不必自己想第一个问题该怎么问
            if not st.session_state.stream_messages:
                st.markdown("**试试这些问题:**")
                for i, sample in enumerate(SAMPLE_QUESTIONS):
                    # key 必须唯一,且不能和首页的 sample-N 撞
                    if st.button(sample, key=f"stream-sample-{i}", use_container_width=True):
                        # 存起来交给下一次 rerun 处理,别在渲染中途发请求
                        st.session_state.stream_pending = sample
                        st.rerun()
        
            # 重放历史。rerun 会重跑整个脚本,页面上的消息全靠这一段重新画出来,
            # 往轮的思考过程装在收起的 expander 里 —— 想回看点开就有,不占版面
            for item in st.session_state.stream_messages:
                with st.chat_message(item["role"]):
                    thinking = item.get("thinking")
                    if thinking:
                        with st.expander("💭 思考过程", expanded=False):
                            for node, data in thinking:
                                render_thinking(st, node, data)
                    st.markdown(item["content"])
        
            # 输入框固定在页面底部,回车提交
            question = st.chat_input("请输入你的问题,例如:人参有什么功效?")
        
            # 示例按钮排在输入框之后处理,两条来源汇成一个入口
            if st.session_state.stream_pending:
                question = st.session_state.stream_pending
                st.session_state.stream_pending = None
        
            if question and question.strip():
                handle_question(question.strip(), base_url, timeout)
        
        
        main()
        
    • 📄 api_client.py 6.4KB
      """调 _005_ 后端 /process、/process_stream 的客户端
      
      单独一个模块、不 import streamlit,为的是能被测试直接导入 —— 导入 streamlit 应用
      会执行页面上所有的 st.* 调用,没法当函数测。UI 在 app.py / pages/,网络这一层在这里。
      
      这一层只跟 HTTP 打交道,不 import 项目里的任何东西(common、_004_ 都不碰)。
      前端进程因此不会去加载 76 MB 的 FAISS 索引和 embedding 模型 —— 那些只属于后端。
      前后端唯一的耦合就是两个接口的请求/响应契约。
      """
      
      import json
      
      import requests
      
      # 后端默认地址。_005_fastapi/main.py 里 uvicorn 监听的是 8001(接口文档写的 8000 是旧值),
      # 端口不对时侧边栏可以直接改,不用动代码
      DEFAULT_BASE_URL = "http://127.0.0.1:8001"
      
      # 单次问答 5-30 秒,复杂问题更久,接口文档建议 120 秒
      DEFAULT_TIMEOUT = 120
      
      # 后端出错时返回的固定文案(_005_fastapi.main.ERROR_OUTPUT),前端照原样展示
      ERROR_OUTPUT = "系统出错了,请重试!"
      
      
      class BackendError(Exception):
          """调后端失败。message 是可以直接显示给用户的中文说明。
      
          包装成一个异常类型,UI 就只需要 catch 一次,不用分别处理 ConnectionError /
          Timeout / HTTPError / JSONDecodeError 四种情况。
          """
      
      
      def ask_backend(
          question: str,
          user_id: str,
          base_url: str = DEFAULT_BASE_URL,
          timeout: int = DEFAULT_TIMEOUT,
      ) -> str:
          """POST /process,返回 output 里的回答文本。
      
          失败一律抛 BackendError,附一句能直接显示的中文原因。
          """
          url = f"{base_url.rstrip('/')}/process"
          payload = {"input": question, "user_id": user_id}
      
          try:
              response = requests.post(url, json=payload, timeout=timeout)
          except requests.Timeout:
              raise BackendError(f"后端响应超时(超过 {timeout} 秒),请稍后重试。")
          except requests.ConnectionError:
              raise BackendError(
                  f"连不上后端 {base_url}。请先启动服务:"
                  "python -X utf8 -m _005_fastapi.main"
              )
          except requests.RequestException as exc:
              raise BackendError(f"请求后端失败:{exc}")
      
          # 422 是入参校验没过,报错信息藏在 detail 里,对着提示改比看到 "422" 有用
          if response.status_code == 422:
              raise BackendError(f"请求参数不合法:{response.text}")
      
          # 500 时后端仍然返回 {"input", "output"} 这个结构,把它的文案透出来
          if response.status_code >= 400:
              try:
                  output = (response.json() or {}).get("output")
              except ValueError:
                  output = None
              raise BackendError(output or f"后端返回 {response.status_code}:{response.text[:200]}")
      
          try:
              body = response.json()
          except ValueError:
              raise BackendError(f"后端返回的不是 JSON:{response.text[:200]}")
      
          answer = (body.get("output") or "").strip()
          if not answer:
              raise BackendError("后端返回了空回答,请重试。")
          return answer
      
      
      def ask_backend_stream(
          question: str,
          user_id: str,
          base_url: str = DEFAULT_BASE_URL,
          timeout: int = DEFAULT_TIMEOUT,
      ):
          """POST /process_stream,逐条 yield 后端推来的消息字典。
      
          这是个**生成器**:调用方 for 一次拿一条,才能边收边往页面上画。
          如果这里先把消息攒成 list 再返回,流式就白做了 —— 用户还是要等满 30 秒。
      
          yield 出来的消息就是接口文档里那三种 type:
      
              {"type": "thinking", "node": 节点名, "data": {该节点的状态增量}}
              {"type": "final",    "input": 原始问题, "output": 最终回答}
              {"type": "error",    "input": 原始问题, "output": 固定错误文案}
      
          error 消息是**后端正常返回的一行数据**(图跑挂了,但 HTTP 是 200,头早发出去了),
          所以不在这里抛异常,照原样 yield,让 UI 决定怎么显示。
          只有连不上、超时、状态码不对这类**没能开始流**的情况才抛 BackendError。
          """
          url = f"{base_url.rstrip('/')}/process_stream"
          payload = {"input": question, "user_id": user_id}
      
          try:
              # stream=True 是关键:不加的话 requests 会把整个响应体读完才返回,
              # 客户端这边就退化成一次性接口。with 保证连接一定被释放(包括中途 break)
              with requests.post(url, json=payload, stream=True, timeout=timeout) as response:
                  if response.status_code == 422:
                      raise BackendError(f"请求参数不合法:{response.text}")
                  if response.status_code >= 400:
                      # 出错响应不会是流,直接读完取文案
                      try:
                          output = (response.json() or {}).get("output")
                      except ValueError:
                          output = None
                      raise BackendError(
                          output or f"后端返回 {response.status_code}:{response.text[:200]}"
                      )
      
                  got_any = False
                  # 后端是换行分隔的 JSON(不是严格 SSE),iter_lines 正好对上
                  for line in response.iter_lines(decode_unicode=True):
                      if not line or not line.strip():
                          continue
                      try:
                          message = json.loads(line)
                      except ValueError:
                          # 单行坏了不该让整轮问答报废,跳过继续读后面的
                          continue
                      got_any = True
                      yield message
      
                  if not got_any:
                      raise BackendError("后端没有返回任何消息,请重试。")
          except requests.Timeout:
              raise BackendError(f"后端响应超时(超过 {timeout} 秒),请稍后重试。")
          except requests.ConnectionError:
              raise BackendError(
                  f"连不上后端 {base_url}。请先启动服务:python -X utf8 -m _005_fastapi.main"
              )
          except requests.RequestException as exc:
              raise BackendError(f"请求后端失败:{exc}")
      
      
      def check_health(base_url: str = DEFAULT_BASE_URL, timeout: int = 5) -> bool:
          """GET /,后端活着返回 True。用于侧边栏那个状态灯,所以超时给得很短。"""
          try:
              response = requests.get(f"{base_url.rstrip('/')}/", timeout=timeout)
              return response.status_code == 200 and response.json().get("status") == "ok"
          except (requests.RequestException, ValueError):
              return False
      
    • 📄 app.py 6.7KB
      """中医知识图谱问答 —— Streamlit 前端
      
      用户在页面上提问,调 _005_ 后端的 POST /process,把回答显示出来。
      
      运行(不能用 python 直接跑,streamlit 应用必须由 streamlit 启动):
      
          # 1. 先起后端
          python -X utf8 -m _005_fastapi.main
          # 2. 再起前端(哪个目录都行,下面的 sys.path 补丁会自己找到仓库根)
          streamlit run _006_streamlit/app.py
      
      网络那一层在 api_client.py,这里只管界面。
      
      两点和 Streamlit 执行模型有关的事:
      
      - **每次交互,整个脚本从头重跑一遍。** 所以历史对话必须放 st.session_state,
        普通变量会在下一次 rerun 时归零。
      - **user_id 每个浏览器会话生成一次**并存进 session_state,正好对上接口文档
        "同一用户多次请求传相同 user_id"。虽然后端目前还没实现会话记忆
        (AgentState 里没有这个字段),前端先把契约守住。
      """
      
      import sys
      import uuid
      from pathlib import Path
      
      # streamlit run 把**脚本自己所在的目录**放进 sys.path(不是仓库根目录,也不是 cwd),
      # 和其它阶段用的 python -m 不一样。所以 `_006_streamlit.api_client` 这个绝对导入
      # 在这里必然 ModuleNotFoundError —— 无论从哪个目录启动。
      # 手动把仓库根补进去,路径从 __file__ 算,跟 common/path_utils.py 同一个思路。
      # 这几行必须排在项目内的 import 之前,所以下面的 E402 是故意的。
      _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
      if _REPO_ROOT not in sys.path:
          sys.path.insert(0, _REPO_ROOT)
      
      import streamlit as st
      
      from _006_streamlit.api_client import (
          DEFAULT_BASE_URL,
          DEFAULT_TIMEOUT,
          BackendError,
          ask_backend,
          check_health,
      )
      
      # 示例问题,取自 API_INTERFACE.md 的"常用测试问题",点一下就能提问,省得手打中文
      SAMPLE_QUESTIONS = [
          "人参有什么功效?",
          "四君子汤由哪些药材组成?",
          "《伤寒论》里记载了哪些方剂?",
          "我最近总是拉肚子,有什么方剂可以调理?",
      ]
      
      st.set_page_config(page_title="中医知识图谱问答", page_icon="🌿", layout="centered")
      
      
      def init_state() -> None:
          """初始化 session_state。rerun 会重跑整个脚本,所以每个键都要判存在再写。"""
          if "messages" not in st.session_state:
              # [(角色, 内容)],角色是 "user" / "assistant"
              st.session_state.messages = []
          if "user_id" not in st.session_state:
              # 每个浏览器会话一个稳定 id,满足接口文档对 user_id 的要求
              st.session_state.user_id = f"streamlit-{uuid.uuid4().hex[:12]}"
          if "pending" not in st.session_state:
              # 点示例按钮时把问题放这里,让下一次 rerun 去处理。
              # 按钮回调发生在页面渲染中途,直接在那儿发请求会让聊天记录的渲染顺序错乱
              st.session_state.pending = None
      
      
      def render_sidebar() -> tuple[str, int]:
          """侧边栏:后端地址、超时、状态灯、清空按钮。返回 (base_url, timeout)"""
          with st.sidebar:
              st.header("⚙️ 设置")
              base_url = st.text_input(
                  "后端地址",
                  value=DEFAULT_BASE_URL,
                  help="_005_fastapi 服务的地址。端口被占用时改这里,不用改代码",
              )
              timeout = st.number_input(
                  "超时(秒)",
                  min_value=10,
                  max_value=600,
                  value=DEFAULT_TIMEOUT,
                  step=10,
                  help="单次问答通常 5-30 秒,接口文档建议 120 秒",
              )
      
              # 手动触发,不自动探活:每次 rerun 都发一个请求属于白费
              if st.button("检查后端连接", use_container_width=True):
                  if check_health(base_url):
                      st.success("后端正常")
                  else:
                      st.error("连不上后端,请先启动 _005_fastapi")
      
              st.divider()
              if st.button("🗑️ 清空对话", use_container_width=True):
                  st.session_state.messages = []
                  st.rerun()
      
              st.caption(f"会话 user_id:`{st.session_state.user_id}`")
      
          return base_url, int(timeout)
      
      
      def handle_question(question: str, base_url: str, timeout: int) -> None:
          """把一轮问答追加进历史。
      
          先把用户消息写进历史再发请求,这样请求失败时提问也留在页面上,
          用户不用重新打一遍。
          """
          st.session_state.messages.append(("user", question))
          with st.chat_message("user"):
              st.markdown(question)
      
          with st.chat_message("assistant"):
              # 后端要跑 LLM + Neo4j,几十秒没有反馈会让人以为页面卡死了
              with st.spinner("正在查询中医知识图谱…"):
                  try:
                      answer = ask_backend(
                          question,
                          user_id=st.session_state.user_id,
                          base_url=base_url,
                          timeout=timeout,
                      )
                  except BackendError as exc:
                      answer = f"⚠️ {exc}"
              # 回答里带 markdown(后端的 LLM 会输出加粗、列表、代码块),用 markdown 渲染
              st.markdown(answer)
      
          st.session_state.messages.append(("assistant", answer))
      
      
      def main() -> None:
          init_state()
          base_url, timeout = render_sidebar()
      
          st.title("🌿 中医知识图谱问答")
          st.caption("基于 Neo4j 知识图谱 + LangGraph + 大模型,可以问中药、方剂、症状、出处")
      
          # 空历史时给几个示例,用户不必自己想第一个问题该怎么问
          if not st.session_state.messages:
              st.markdown("**试试这些问题:**")
              for i, sample in enumerate(SAMPLE_QUESTIONS):
                  # key 必须唯一,否则 Streamlit 会报重复 key
                  if st.button(sample, key=f"sample-{i}", use_container_width=True):
                      # 存起来交给下一次 rerun 处理,别在渲染中途发请求
                      st.session_state.pending = sample
                      st.rerun()
      
          # 重放历史。rerun 会重跑整个脚本,页面上的消息全靠这一段重新画出来
          for role, content in st.session_state.messages:
              with st.chat_message(role):
                  st.markdown(content)
      
          # 输入框固定在页面底部,回车提交
          question = st.chat_input("请输入你的问题,例如:人参有什么功效?")
      
          # 示例按钮排在输入框之后处理,两条来源汇成一个入口
          if st.session_state.pending:
              question = st.session_state.pending
              st.session_state.pending = None
      
          if question and question.strip():
              handle_question(question.strip(), base_url, timeout)
      
      
      main()
      
    • 📄 start_streamlit.bat 117B
      streamlit run D:\heima_creazy_05\04_code\chinese_medical_project_all_project_for_heima_crazy_05\_006_streamlit\app.py
      
    • 📄 test_app.py 21.0KB
      """_006_streamlit 的测试用例
      
          python -X utf8 -m _006_streamlit.test_app          # 离线(默认)
          python -X utf8 -m _006_streamlit.test_app --live   # 打真实后端
      
      测的是 api_client.py —— 前端里唯一有逻辑的部分。`app.py` 全是 st.* 调用,
      import 它就等于渲染一遍页面,没法当函数测;把网络这层拆出来正是为了能测。
      
      离线模式把 `requests.post` / `requests.get` 换成假函数,所以不需要后端在跑。
      `--live` 需要 `python -X utf8 -m _005_fastapi.main` 已经起好。
      
      和 _005_fastapi/test_api.py 同一套框架:_check 计数,失败退出码非 0。
      """
      
      import argparse
      import sys
      
      import requests
      
      from _006_streamlit import api_client
      from _006_streamlit.api_client import (
          BackendError,
          ask_backend,
          ask_backend_stream,
          check_health,
      )
      
      passed = 0
      failed = 0
      
      
      def _check(condition: bool, label: str, detail: str = "") -> None:
          global passed, failed
          if condition:
              passed += 1
              print(f"  [PASS] {label}")
          else:
              failed += 1
              print(f"  [FAIL] {label}" + (f" -> {detail}" if detail else ""))
      
      
      class FakeResponse:
          """够用的 requests.Response 替身:只有 status_code / json() / text"""
      
          def __init__(self, status_code=200, payload=None, text=None):
              self.status_code = status_code
              self._payload = payload
              # text 显式传 "" 时也要尊重,所以用 is None 判断而不是 or
              self.text = text if text is not None else str(payload)
      
          def json(self):
              if self._payload is None:
                  raise ValueError("不是 JSON")
              return self._payload
      
      
      class FakePost:
          """替掉 requests.post。返回固定响应或抛指定异常,并记录收到的参数"""
      
          def __init__(self, response=None, error=None):
              self.response = response
              self.error = error
              self.calls = []
      
          def __call__(self, url, json=None, timeout=None):
              self.calls.append({"url": url, "json": json, "timeout": timeout})
              if self.error is not None:
                  raise self.error
              return self.response
      
      
      def _patch_post(fake) -> None:
          """把 api_client 用的 requests.post 换掉。
      
          api_client 里是 `requests.post(...)`(模块属性访问),所以改 requests 模块上的
          这个名字就能生效。每个用例调用一次,最后一个用例跑完不用还原 —— 进程随即结束。
          """
          requests.post = fake
      
      
      class FakeStreamResponse(FakeResponse):
          """流式响应的替身:iter_lines 逐行吐出给定内容,并支持 with。
      
          真的流式响应是 `with requests.post(..., stream=True) as r:` 用的,
          所以必须实现上下文管理协议,否则 api_client 那句 with 就报 AttributeError。
          closed 记下有没有被正确关闭 —— 连接泄漏在测试里看不出症状,得显式断言。
          """
      
          def __init__(self, lines=None, status_code=200, payload=None, text=None):
              super().__init__(status_code=status_code, payload=payload, text=text)
              self.lines = lines or []
              self.closed = False
      
          def __enter__(self):
              return self
      
          def __exit__(self, *exc_info):
              self.closed = True
              return False
      
          def iter_lines(self, decode_unicode=False):
              for line in self.lines:
                  yield line
      
      
      class FakeStreamPost:
          """替掉 requests.post 的流式版本:额外记录 stream 参数"""
      
          def __init__(self, response=None, error=None):
              self.response = response
              self.error = error
              self.calls = []
      
          def __call__(self, url, json=None, timeout=None, stream=None):
              self.calls.append(
                  {"url": url, "json": json, "timeout": timeout, "stream": stream}
              )
              if self.error is not None:
                  raise self.error
              return self.response
      
      
      def _stream_lines(*messages) -> list:
          """把消息字典转成后端那种"一行一个 JSON"的形状"""
          import json as _json
      
          return [_json.dumps(m, ensure_ascii=False) for m in messages]
      
      
      # ======================
      # 用例 1:正常返回
      # ======================
      def test_ask_ok() -> None:
          print("\n[1] ask_backend 正常返回")
          answer = "人参具有大补元气、补脾益肺等功效。"
          fake = FakePost(FakeResponse(200, {"input": "人参有什么功效?", "output": answer}))
          _patch_post(fake)
      
          result = ask_backend("人参有什么功效?", user_id="u-1", base_url="http://127.0.0.1:8000")
      
          _check(result == answer, "取到 output 里的回答", repr(result))
          call = fake.calls[0]
          _check(call["url"] == "http://127.0.0.1:8000/process", "URL 拼接正确", call["url"])
          _check(
              call["json"] == {"input": "人参有什么功效?", "user_id": "u-1"},
              "请求体是 input + user_id",
              str(call["json"]),
          )
          _check(call["timeout"] == api_client.DEFAULT_TIMEOUT, "默认超时 120 秒", str(call["timeout"]))
      
      
      # ======================
      # 用例 2:base_url 末尾斜杠
      # ======================
      def test_base_url_trailing_slash() -> None:
          print("\n[2] base_url 末尾带斜杠")
          fake = FakePost(FakeResponse(200, {"output": "回答"}))
          _patch_post(fake)
      
          ask_backend("问题", user_id="u", base_url="http://127.0.0.1:8000/")
      
          # 不处理的话会拼出 //process
          _check(
              fake.calls[0]["url"] == "http://127.0.0.1:8000/process",
              "不会拼出双斜杠",
              fake.calls[0]["url"],
          )
      
      
      # ======================
      # 用例 3:网络异常
      # ======================
      def test_network_errors() -> None:
          print("\n[3] 网络异常都收敛成 BackendError")
          cases = [
              (requests.ConnectionError("拒绝连接"), "连不上", "ConnectionError"),
              (requests.Timeout("超时"), "超时", "Timeout"),
              (requests.RequestException("其他错误"), "失败", "RequestException"),
          ]
          for error, keyword, label in cases:
              _patch_post(FakePost(error=error))
              try:
                  ask_backend("问题", user_id="u")
                  _check(False, f"{label} 抛出 BackendError", "没有抛异常")
              except BackendError as exc:
                  _check(keyword in str(exc), f"{label} -> 含'{keyword}'的中文提示", str(exc))
              except Exception as exc:
                  _check(False, f"{label} 抛出 BackendError", f"抛的是 {type(exc).__name__}")
      
          # ConnectionError 的提示里要给出启动命令,用户照着做就行
          _patch_post(FakePost(error=requests.ConnectionError("拒绝连接")))
          try:
              ask_backend("问题", user_id="u")
          except BackendError as exc:
              _check("_005_fastapi.main" in str(exc), "连不上时提示启动命令", str(exc))
      
      
      # ======================
      # 用例 4:HTTP 错误码
      # ======================
      def test_http_errors() -> None:
          print("\n[4] 后端返回错误码")
      
          # 500:后端仍然给 {"input", "output"},把它的文案透出来
          _patch_post(FakePost(FakeResponse(500, {"input": "", "output": api_client.ERROR_OUTPUT})))
          try:
              ask_backend("问题", user_id="u")
              _check(False, "500 抛出 BackendError", "没有抛异常")
          except BackendError as exc:
              _check(str(exc) == api_client.ERROR_OUTPUT, "500 透出后端的错误文案", str(exc))
      
          # 422:入参校验没过,把 detail 带出来,比只看到 "422" 有用
          _patch_post(FakePost(FakeResponse(422, {"detail": [{"msg": "不能为空"}]}, text='{"detail":"不能为空"}')))
          try:
              ask_backend("", user_id="u")
              _check(False, "422 抛出 BackendError", "没有抛异常")
          except BackendError as exc:
              _check("参数不合法" in str(exc), "422 提示参数不合法", str(exc))
      
          # 500 但响应体不是 JSON(比如反向代理返的 HTML 错误页)
          _patch_post(FakePost(FakeResponse(502, None, text="<html>Bad Gateway</html>")))
          try:
              ask_backend("问题", user_id="u")
              _check(False, "502 抛出 BackendError", "没有抛异常")
          except BackendError as exc:
              _check("502" in str(exc), "非 JSON 错误响应也有提示", str(exc))
      
      
      # ======================
      # 用例 5:响应体异常
      # ======================
      def test_bad_body() -> None:
          print("\n[5] 200 但响应体不对")
          cases = [
              (FakeResponse(200, None, text="not json"), "不是 JSON", "不是 JSON"),
              (FakeResponse(200, {"input": "x"}), "空回答", "没有 output 键"),
              (FakeResponse(200, {"output": None}), "空回答", "output 为 None"),
              (FakeResponse(200, {"output": "   "}), "空回答", "output 全空白"),
          ]
          for response, keyword, label in cases:
              _patch_post(FakePost(response))
              try:
                  ask_backend("问题", user_id="u")
                  _check(False, f"{label} 抛出 BackendError", "没有抛异常")
              except BackendError as exc:
                  _check(keyword in str(exc), f"{label} -> 含'{keyword}'的提示", str(exc))
      
      
      # ======================
      # 用例 6:健康检查
      # ======================
      def test_check_health() -> None:
          print("\n[6] check_health")
      
          class FakeGet:
              def __init__(self, response=None, error=None):
                  self.response = response
                  self.error = error
                  self.calls = []
      
              def __call__(self, url, timeout=None):
                  self.calls.append({"url": url, "timeout": timeout})
                  if self.error is not None:
                      raise self.error
                  return self.response
      
          cases = [
              (FakeGet(FakeResponse(200, {"status": "ok", "message": "正常"})), True, "后端正常 -> True"),
              (FakeGet(FakeResponse(200, {"status": "degraded"})), False, "status 不是 ok -> False"),
              (FakeGet(FakeResponse(500, {})), False, "500 -> False"),
              (FakeGet(FakeResponse(200, None, text="not json")), False, "非 JSON -> False"),
              (FakeGet(error=requests.ConnectionError("拒绝连接")), False, "连不上 -> False"),
              (FakeGet(error=requests.Timeout("超时")), False, "超时 -> False"),
          ]
          for fake, expected, label in cases:
              requests.get = fake
              _check(check_health("http://127.0.0.1:8000") is expected, label)
      
          # 健康检查是给状态灯用的,超时必须短,不能拿 120 秒去卡页面
          _check(fake.calls[-1]["timeout"] == 5, "健康检查超时是 5 秒", str(fake.calls[-1]["timeout"]))
      
      
      # ======================
      # 用例 7:流式接口正常返回
      # ======================
      def test_stream_ok() -> None:
          """逐条 yield thinking + final,且请求带上了 stream=True"""
          print("\n[7] ask_backend_stream 正常返回")
      
          messages = [
              {"type": "thinking", "node": "语义转写节点", "data": {"input_semantic_trans": "人参有什么功效?"}},
              {"type": "thinking", "node": "中医意图识别节点", "data": {"is_zhongyi_intent": True}},
              {"type": "final", "input": "人参有什么功效?", "output": "人参能大补元气。"},
          ]
          response = FakeStreamResponse(lines=_stream_lines(*messages))
          fake = FakeStreamPost(response)
          _patch_post(fake)
      
          got = list(
              ask_backend_stream("人参有什么功效?", user_id="u-1", base_url="http://127.0.0.1:8001")
          )
      
          _check(len(got) == 3, "收到 3 条消息", f"{len(got)} 条")
          _check(got == messages, "消息原样透出,没被改写")
          _check(got[-1]["type"] == "final", "最后一条是 final", got[-1]["type"])
      
          call = fake.calls[0]
          _check(call["url"] == "http://127.0.0.1:8001/process_stream", "URL 拼接正确", call["url"])
          # 少了 stream=True,requests 会把响应体读完才返回,流式就退化成一次性接口
          _check(call["stream"] is True, "带上了 stream=True", repr(call["stream"]))
          _check(
              call["json"] == {"input": "人参有什么功效?", "user_id": "u-1"},
              "请求体符合接口契约",
              repr(call["json"]),
          )
          _check(response.closed, "响应被正确关闭(with 退出)")
      
      
      # ======================
      # 用例 8:流式是真的边收边出
      # ======================
      def test_stream_is_lazy() -> None:
          """必须是生成器、按需产出,不能先攒完再返回 —— 否则页面还是要等满 30 秒"""
          print("\n[8] ask_backend_stream 惰性产出")
      
          consumed = []
      
          class CountingResponse(FakeStreamResponse):
              def iter_lines(self, decode_unicode=False):
                  for line in self.lines:
                      consumed.append(line)
                      yield line
      
          messages = [
              {"type": "thinking", "node": "A", "data": {"x": 1}},
              {"type": "thinking", "node": "B", "data": {"y": 2}},
              {"type": "final", "input": "q", "output": "done"},
          ]
          _patch_post(FakeStreamPost(CountingResponse(lines=_stream_lines(*messages))))
      
          generator = ask_backend_stream("q", user_id="u")
          # 只调用还没迭代:一行都不该被读
          _check(consumed == [], "调用后未迭代时不发起读取", f"已读 {len(consumed)} 行")
      
          first = next(generator)
          _check(first["node"] == "A", "取第一条就能拿到第一个节点", repr(first))
          _check(len(consumed) == 1, "取一条只读了一行", f"读了 {len(consumed)} 行")
      
          rest = list(generator)
          _check(len(rest) == 2, "剩下两条能继续取完", f"{len(rest)} 条")
      
      
      # ======================
      # 用例 9:流式的异常与脏数据
      # ======================
      def test_stream_errors() -> None:
          """连不上/超时/422 抛 BackendError;error 消息和坏行不抛,照原样处理"""
          print("\n[9] ask_backend_stream 异常处理")
      
          # 没能开始流的三种情况,都该收敛成 BackendError
          cases = [
              (FakeStreamPost(error=requests.ConnectionError()), "连不上", "连不上后端"),
              (FakeStreamPost(error=requests.Timeout()), "超时", "超时"),
              (
                  FakeStreamPost(FakeStreamResponse(status_code=422, payload={"detail": "x"})),
                  "422",
                  "参数不合法",
              ),
              (
                  FakeStreamPost(
                      FakeStreamResponse(status_code=500, payload={"output": api_client.ERROR_OUTPUT})
                  ),
                  "500",
                  api_client.ERROR_OUTPUT,
              ),
          ]
          for fake, label, keyword in cases:
              _patch_post(fake)
              try:
                  list(ask_backend_stream("q", user_id="u"))
                  _check(False, f"{label} 抛出 BackendError", "没有抛异常")
              except BackendError as exc:
                  _check(keyword in str(exc), f"{label} -> 含'{keyword}'的提示", str(exc))
              except Exception as exc:
                  _check(False, f"{label} 抛出 BackendError", f"抛的是 {type(exc).__name__}")
      
          # 后端图跑挂了:HTTP 是 200,error 是流里正常的一行,不该抛异常
          _patch_post(
              FakeStreamPost(
                  FakeStreamResponse(
                      lines=_stream_lines(
                          {"type": "error", "input": "q", "output": api_client.ERROR_OUTPUT}
                      )
                  )
              )
          )
          got = list(ask_backend_stream("q", user_id="u"))
          _check(len(got) == 1 and got[0]["type"] == "error", "error 消息照原样 yield", repr(got))
      
          # 空行要跳过,坏行不能让整轮问答报废
          _patch_post(
              FakeStreamPost(
                  FakeStreamResponse(
                      lines=[
                          "",
                          "   ",
                          "{坏掉的 json",
                          *_stream_lines({"type": "final", "input": "q", "output": "答案"}),
                      ]
                  )
              )
          )
          got = list(ask_backend_stream("q", user_id="u"))
          _check(len(got) == 1, "空行和坏行被跳过", f"{len(got)} 条")
          _check(got[0]["output"] == "答案", "坏行之后的消息照样收到", repr(got[0]))
      
          # 一条消息都没有:不是正常结束,得报出来而不是静默返回空
          _patch_post(FakeStreamPost(FakeStreamResponse(lines=[])))
          try:
              list(ask_backend_stream("q", user_id="u"))
              _check(False, "空流抛出 BackendError", "没有抛异常")
          except BackendError as exc:
              _check("没有返回任何消息" in str(exc), "空流 -> 提示没有消息", str(exc))
      
      
      # ======================
      # 用例 10:app.py 能正常导入
      # ======================
      def test_app_importable() -> None:
          """页面代码本身跑不了断言,但至少确认它没有语法错误、导入名都对得上。
      
          直接 import app 会执行所有 st.* 调用(脱离 streamlit runtime 会报错),
          所以用 compile + 检查引用的名字确实存在于 api_client 里。
          """
          print("\n[10] 页面静态检查")
          import ast
          from pathlib import Path
      
          # app.py 和 pages/ 下的每一页都过一遍,新增页面不用改这里
          here = Path(__file__).parent
          pages = [here / "app.py", *sorted((here / "pages").glob("*.py"))]
          _check(len(pages) >= 2, f"找到 {len(pages)} 个页面文件", str([p.name for p in pages]))
      
          for page in pages:
              source = page.read_text(encoding="utf-8")
              try:
                  tree = ast.parse(source)
                  _check(True, f"{page.name} 语法正确")
              except SyntaxError as exc:
                  _check(False, f"{page.name} 语法正确", str(exc))
                  continue
      
              # 从 api_client 导入的名字必须真的存在,否则运行时才会炸
              imported = [
                  alias.name
                  for node in ast.walk(tree)
                  if isinstance(node, ast.ImportFrom) and node.module and "api_client" in node.module
                  for alias in node.names
              ]
              _check(bool(imported), f"{page.name} 确实从 api_client 导入了东西", str(imported))
              missing = [name for name in imported if not hasattr(api_client, name)]
              _check(not missing, f"{page.name} 导入的名字都存在", f"缺少 {missing}")
      
      
      # ======================
      # --live:打真实后端
      # ======================
      def run_live(base_url: str) -> None:
          """对着已启动的 _005_ 后端走一遍真实调用。
      
          注意这里不 _patch_post —— 用的是真的 requests。
          """
          print(f"\n=== live 模式:{base_url} ===")
      
          print("\n[live-1] check_health")
          if not check_health(base_url):
              _check(False, "后端可用", f"连不上 {base_url},先跑 python -X utf8 -m _005_fastapi.main")
              return
          _check(True, "后端可用")
      
          for i, question in enumerate(["人参有什么功效?", "帮我写一段 Python 快排代码"], start=2):
              print(f"\n[live-{i}] ask_backend {question!r}")
              try:
                  answer = ask_backend(question, user_id="streamlit-live-test", base_url=base_url)
              except BackendError as exc:
                  _check(False, "拿到回答", str(exc))
                  continue
              _check(bool(answer.strip()), "回答非空", repr(answer))
              _check(answer != api_client.ERROR_OUTPUT, "不是错误文案", repr(answer))
              print(f"    回答: {answer[:120]}")
      
          print("\n[live-stream] ask_backend_stream '四君子汤由哪些药材组成?'")
          try:
              got = list(
                  ask_backend_stream(
                      "四君子汤由哪些药材组成?",
                      user_id="streamlit-live-test",
                      base_url=base_url,
                  )
              )
          except BackendError as exc:
              _check(False, "流式调用成功", str(exc))
              got = []
      
          if got:
              thinking = [m for m in got if m.get("type") == "thinking"]
              finals = [m for m in got if m.get("type") == "final"]
              # 查图谱的问题要走完整条链,节点数明显多于闲聊
              _check(len(thinking) >= 3, f"收到多条 thinking({len(thinking)} 条)", str(len(thinking)))
              _check(len(finals) == 1, "有且只有一条 final", f"{len(finals)} 条")
              _check(got[-1].get("type") == "final", "final 是最后一条", str(got[-1].get("type")))
              if finals:
                  answer = finals[0].get("output") or ""
                  _check(bool(answer.strip()), "final 的 output 非空", repr(answer))
                  _check(answer != api_client.ERROR_OUTPUT, "不是错误文案", repr(answer))
                  print(f"    节点: {[m.get('node') for m in thinking]}")
                  print(f"    回答: {answer[:120]}")
      
          print("\n[live-last] 空问题应被后端挡回")
          try:
              ask_backend("", user_id="u", base_url=base_url)
              _check(False, "空问题抛出 BackendError", "没有抛异常")
          except BackendError as exc:
              _check("参数不合法" in str(exc), "空问题 -> 参数不合法", str(exc))
      
      
      OFFLINE_TESTS = [
          test_ask_ok,
          test_base_url_trailing_slash,
          test_network_errors,
          test_http_errors,
          test_bad_body,
          test_check_health,
          test_stream_ok,
          test_stream_is_lazy,
          test_stream_errors,
          test_app_importable,
      ]
      
      
      if __name__ == "__main__":
          parser = argparse.ArgumentParser(description="_006_streamlit 前端测试")
          parser.add_argument("--live", action="store_true", help="对已启动的后端发真实请求")
          parser.add_argument(
              "--base-url", default=api_client.DEFAULT_BASE_URL, help="live 模式的后端地址"
          )
          args = parser.parse_args()
      
          if args.live:
              run_live(args.base_url.rstrip("/"))
          else:
              print("=== 离线模式:假造 requests,不需要后端在跑 ===")
              for test in OFFLINE_TESTS:
                  test()
      
          print(f"\n{'=' * 70}")
          print(f"通过 {passed} 条,失败 {failed} 条")
          sys.exit(1 if failed else 0)
      
    • ⚙️ 需求 252B
  • 📁 _007_fine_tune/
    • 📄 vllm_client.py 1.1KB
      from langchain_core.messages import HumanMessage, SystemMessage
      from langchain_openai import ChatOpenAI
      from common.config import Config
      
      conf = Config()
      
      # ============ 配置llm区域 ============
      my_llm = ChatOpenAI(
          api_key="EMPTY",
          base_url="https://a674ab0b6f8d4da580fa259ce6f68937--8000.ap-shanghai2.cloudstudio.club/v1",
          model="export/qwen2.5-merged0809"
      )
      
      if __name__ == '__main__':
          # 构造对话消息
          messages = [
              SystemMessage(content="请从以下中医文本中抽取知识图谱结构,包括实体与关系。"),
              HumanMessage(
                  content="【方剂名称】斩梦丹\n- 中医百科\n- 斩梦丹\n斩梦丹的药方\n出处\n《普济方》卷三十三\n组成\n知母30克黄柏30克(去皮)滑石90克\n【制法】\n上药为末,白水和丸。\n斩梦丹的功效\n主治\n治梦泄遗精。\n用法\n空腹时温酒送下。")
          ]
          # 调用模型
          result = ""
          for chunk in my_llm.stream(messages):
              result += chunk.content
              print(chunk.content, end="", flush=True)
          print("\n")
          print("*" * 100)
          print(result)
      
    • 📄 vllm_mutil_lora_client.py 3.4KB
      from langchain_core.messages import HumanMessage, SystemMessage
      from langchain_openai import ChatOpenAI
      from common.config import Config
      
      conf = Config()
      
      # ============ 配置llm区域 ============
      my_llm = ChatOpenAI(
          api_key="AAAA",
          base_url="https://a674ab0b6f8d4da580fa259ce6f68937--8000.ap-shanghai2.cloudstudio.club/v1",
          model="tcm" # tcm  law cs
      )
      
      
      def extract_knowledge_from_text(input_text):
          messages = [
              SystemMessage(content="请从以下中医文本中抽取知识图谱结构,包括实体与关系。"),
              HumanMessage(content=input_text)
          ]
      
          result = ""
          for chunk in my_llm.stream(messages):
              print(chunk.content, end="", flush=True)
              result += chunk.content
      
          return result
      
      
      if __name__ == '__main__':
          # response = my_llm.invoke("介绍一下中国历史。")
          # print(response.content)
          sample_text = """
                      【中药名称】一点血
      - 中医百科
      - 一点血
      1#
      名称
      红砖草(《全展选编•妇产科》)、一点红、石鼓子、威氏秋海棠
      红砖草石鼓子威氏秋海棠
      - 红砖草
      - 石鼓子
      - 威氏秋海棠
      一点血的种植和炮制
      来源
      为秋海棠科植物一点血秋海棠的根茎。秋后采挖,鲜用或晒干用。
      【原形态】
      多年生草本,高20~30厘米。根茎短而肥厚,有环纹,断面红色,具须根。根出叶1-2片,近菱方形,长lO~15厘米,宽lO~12厘米,上部3~7浅裂,裂片三角形,基部偏心形,边缘有突尖细锯齿,上面绿色,具极稀疏的短刺毛,下面略带紫色;叶柄光滑,基部肥厚,长7~15厘米;托叶膜质,光滑,褐色,卵圆披针形。花6~7朵,粉红色,呈伞房状排列,花序梗粗壮,根出,长达10~20厘米;花雌雄同株;雄花花被4片,内外各2,外花被卵圆形,内花被长椭圆形,雄蕊10~16枚,离生;雌花具花被3片,内1外2,外花被宽卵圆形,内花被卵圆形或长椭圆形,子房呈纺锤形,3棱,花柱3枚,离生。蒴果无翅。花期7月。
      【生境分布】
      生长于阴处的岩石缝及小谷底。产四川。
      性味
      味甘;苦;性微寒
      性状
      干燥根茎呈圆柱形,弯曲,外表棕黄色,具扭曲的纵皱纹及须根残痕。质脆,横断面平坦,中心有一棕红色的圆心,如一点血状。
      一点血的效果
      功效
      为秋海棠科植物一点血秋海棠的根茎。用于病后虚弱,劳伤,血虚经闭,崩漏,白带,吐血,咯血,衄血,外伤出血,跌打肿痛。
      主治
      养血补血,散瘀止痛。主病后虚弱,劳伤,血虚经闭,崩漏,白带,吐血,咯血,衄血,外伤出血,跌打肿痛。
      用法用量
      - 内服:煎汤,15-30g;绞汁、炖肉或浸酒。
      - 外用:适量,鲜品捣敷。
      一点血的药方
      - ①治虚弱带下:一点血、一朵云。共研末,炖肉服。
      - ②治女子干病:一点血、玉芙蓉、鹿衔草、蓝布正。炖鸡子服。(性味以下出《四川中药志》)
      - ③治功能性子宫出血:一点血鲜品四至五两,或干品二、三两。炖鸡服。(《全展选编•妇产科》)
      
                      """
      
          # 调用函数进行知识图谱抽取
          final_result = extract_knowledge_from_text(sample_text)
          print(f"\n最终结果长度: {len(final_result)}")
      
  • 📁 common/
    • 📄 config.py 1.7KB
      import os
      from dotenv import load_dotenv
      
      from common.path_utils import get_file_path
      
      load_dotenv()
      #从.env中获取环境变量
      #真实的文件在 common/.env, 之前写成 get_file_path(".env") 指向了根目录下不存在的文件,
      #导致 MODEL_BASE_URL / MODEL_NAME / NEO4J_* 全是 None
      load_dotenv(get_file_path("common/.env"))
      
      
      class Config:
          def __init__(self):
              # 大模型相关
              self.MODEL_API_KEY = os.getenv("MODEL_API_KEY")
              self.MODEL_BASE_URL = os.getenv("MODEL_BASE_URL")
              self.MODEL_NAME = os.getenv("MODEL_NAME")
      
              # neo4j相关
              self.NEO4J_URI = os.getenv("NEO4J_URI")
              self.NEO4J_USER = os.getenv("NEO4J_USER")
              self.NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
      
              # 读取极梦的密钥
              self.JIMENG_AK = os.getenv("JIMENG_AK")
              self.JIMENG_SK = os.getenv("JIMENG_SK")
      
              # # 读取图谱模式层的数据
              self.TCM_METADATA = open(get_file_path("_003_create_neo4j_database/tcm_metadata.json"), "r", encoding="utf-8").read()
              #
              # # embedding模型
              self.EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH")
              #
              # index的路径, faiss 向量数据库对 中文路径特别敏感,不要给路径上带中文.
              self.ENTITY_INDEX_PATH = get_file_path("_003_create_neo4j_database/nero4j_embedding_faiss.index")
              self.ENTITY_ID2TEXT_PATH = get_file_path("_003_create_neo4j_database/nero4j_embedding_faiss_id2text.pkl")
              # 添加历史数据获取的轮数
              self.history_num = 5
      
      if __name__ == "__main__":
          conf = Config()
          # print(conf.MODEL_API_KEY)
          print(conf.TCM_METADATA)
          # print(conf.ENTITY_INDEX_PATH)
          # print(conf.ENTITY_ID2TEXT_PATH)
      
    • 📄 embedding_model.py 1.8KB
      import os
      from typing import List
      
      import torch
      
      from common.config import Config
      
      # 尝试导入sentence-transformers库
      # 如果没有安装,可以使用pip install sentence-transformers
      from sentence_transformers import SentenceTransformer
      
      device = "cuda" if torch.cuda.is_available() else "cpu"
      print(device)
      class EmbeddingModel:
          """嵌入模型类,用于将文本转换为向量表示"""
      
          def __init__(self):
              """初始化嵌入模型"""
              config = Config()
      
              # 如果配置了模型路径,使用配置的路径,否则使用默认模型
              if config.EMBEDDING_MODEL_PATH and os.path.exists(config.EMBEDDING_MODEL_PATH):
                  self.model = SentenceTransformer(config.EMBEDDING_MODEL_PATH,device=device)
              else:
                  # 使用默认的中文预训练模型
                  self.model = SentenceTransformer(
                      r'E:\中医知识图谱备课02\05_扩展\模型权重\bge-large-zh-v1.5',device=device)
      
          def encode(self, texts: List[str], convert_to_numpy=False, normalize_embeddings=False) -> List[List[float]]:
              """
              将文本列表转换为向量表示
      
              Args:
                  texts: 文本列表
                  convert_to_numpy: 是否将结果转换为numpy数组
                  normalize_embeddings: 是否对向量进行归一化
      
              Returns:
                  向量列表或numpy数组,每个向量对应一个文本
              """
              return self.model.encode(texts, convert_to_numpy=convert_to_numpy, normalize_embeddings=normalize_embeddings)
      
      
      # 创建全局的嵌入模型对象
      embedding_model = EmbeddingModel()
      print(embedding_model)
      print(embedding_model.encode(["你好"]))
      # 打印模型维度
      print(embedding_model.model.get_embedding_dimension())
      # print(embedding_model.encode(["你好", "世界"]))
      
    • 📄 llm.py 546B
      from langchain_core.messages import HumanMessage
      from langchain_openai import ChatOpenAI
      from common.config import Config
      
      conf = Config()
      
      # ============ 配置llm区域 ============
      my_llm = ChatOpenAI(
          api_key=conf.MODEL_API_KEY,
          base_url=conf.MODEL_BASE_URL,
          model=conf.MODEL_NAME
      )
      
      if __name__ == '__main__':
          # 构造对话消息
          messages = [
              HumanMessage(content="用一句话介绍一下你自己")
          ]
          # 调用模型
          response = my_llm.invoke(messages)
          print(response.content)
      
    • 📄 neo4j_manager.py 8.1KB
      from neo4j import GraphDatabase
      from common.config import Config
      from tqdm import tqdm
      import json
      
      conf = Config()
      
      
      class Neo4jClient:
          def __init__(self, uri, user, password):
              """初始化连接"""
              self.driver = GraphDatabase.driver(uri, auth=(user, password))
      
          def __del__(self):
              """关闭连接"""
              if self.driver is not None:
                  self.driver.close()
      
          def run_cypher(self, query, parameters=None):
              """
              执行一条 Cypher 语句并返回结果
              :param query: Cypher 查询语句
              :param parameters: 可选参数字典
              :return: 查询结果列表(每一行是一个 dict)
              """
              with self.driver.session() as session:
                  result = session.run(query, parameters or {})
                  return [record.data() for record in result]
      
          def run_multiple_cypher(self, queries_with_params):
              """
              在一个事务里执行多条 Cypher 语句,显示 tqdm 进度条,返回每条语句的结果。
      
              整批是一个事务:任意一条报错,整批回滚,不会写进去一半。
              批量导入图谱时用这个,比一条条 run_cypher 快很多(少了每条一次的往返和提交)。
      
              参数:
                  queries_with_params: List[Tuple[str, Dict]]
                      形式如: [("CREATE (n:Test {name: $name})", {"name": "Alice"}), ...]
                      参数字典可以传 None,等价于空字典。
              返回:
                  List[List[Dict]] —— 与入参一一对应,每项是那条语句的结果行。
                  CREATE/MERGE 这类没有 RETURN 的语句,对应项是空列表 []。
              """
              with self.driver.session() as session:
      
                  def transaction_logic(tx):
                      results = []
                      for query, params in tqdm(queries_with_params, desc="执行 Cypher 语句"):
                          result = tx.run(query, params or {})
                          # 必须在事务里就把记录读出来:Result 是惰性的,事务一提交就失效,
                          # 拿到外面再遍历会抛 ResultConsumedError。
                          results.append([record.data() for record in result])
                      return results
      
                  return session.execute_write(transaction_logic)
      
          def export_tcm_metadata_to_json(self, output_path="tcm_metadata.json"):
              with self.driver.session() as session:
      
                  # 1. 所有节点标签
                  label_query = """
                  MATCH (n)
                  UNWIND labels(n) AS label
                  RETURN DISTINCT label
                  """
                  labels = [record["label"] for record in session.run(label_query)]
      
                  # 2. 所有关系类型
                  rel_query = """
                  MATCH (n)-[r]-()
                  RETURN DISTINCT type(r) AS rel_type
                  """
                  rel_types = [record["rel_type"] for record in session.run(rel_query)]
      
                  # 3. 所有三元组结构
                  triple_query = """
                  MATCH (n)-[r]->(m)
                  WITH head(labels(n)) AS from_label, type(r) AS rel_type, head(labels(m)) AS to_label
                  RETURN DISTINCT from_label, rel_type, to_label
                  """
                  triples = [{
                      "from": record["from_label"],
                      "rel_type": record["rel_type"],
                      "to": record["to_label"],
                      "description": ""
                  } for record in session.run(triple_query)]
      
                  # 4. 节点属性(每个标签下的属性键)
                  node_props_query = """
                  MATCH (n)
                  UNWIND labels(n) AS label
                  UNWIND keys(n) AS prop
                  RETURN DISTINCT label, prop
                  ORDER BY label, prop
                  """
                  label_props = {}
                  for record in session.run(node_props_query):
                      label = record["label"]
                      prop = record["prop"]
                      if prop == "project":  # 忽略 project 字段
                          continue
                      label_props.setdefault(label, []).append({
                          "name": prop,
                          "description": ""
                      })
      
                  # 5. 关系属性(每种关系下的属性键)
                  rel_props_query = """
                  MATCH (n)-[r]->(m)
                  UNWIND keys(r) AS prop
                  RETURN DISTINCT type(r) AS rel_type, prop
                  ORDER BY rel_type, prop
                  """
                  rel_type_props = {}
                  for record in session.run(rel_props_query):
                      rel_type = record["rel_type"]
                      prop = record["prop"]
                      rel_type_props.setdefault(rel_type, []).append({
                          "name": prop,
                          "description": ""
                      })
      
                  # 构建 JSON
                  json_obj = {
                      "labels": [
                          {
                              "name": label,
                              "description": "",
                              "properties": label_props.get(label, [])
                          } for label in labels
                      ],
                      "relationships": [
                          {
                              "type": rel,
                              "description": "",
                              "properties": rel_type_props.get(rel, [])
                          } for rel in rel_types
                      ],
                      "triples": triples
                  }
      
                  # 保存到文件
                  with open(output_path, "w", encoding="utf-8") as f:
                      json.dump(json_obj, f, ensure_ascii=False, indent=2)
      
                  return output_path
      
          #获取所有节点名称
          def get_all_node_names(self, label: str = None):
              """
              获取指定标签下所有节点的 name 属性
              :param label: 节点标签(如 'Effect', 'Symptom', 'Disease')
              :return: List[str]
              """
              if label is None:
                  query = """
                  MATCH (n)
                  RETURN DISTINCT n.name AS name
                  ORDER BY name
                  """
              else:
                  query = f"""
                  MATCH (n:{label})
                  RETURN DISTINCT n.name AS name
                  ORDER BY name
                  """
              with self.driver.session() as session:
                  result = session.run(query)
                  return [record["name"] for record in result if record["name"]]
      
          def validate_cypher(self, query: str) -> bool:
              """
              检测 Cypher 查询语句是否合法(语法层面)
              :param query: 待检测的 Cypher 语句
              :return: True 表示合法,False 表示不合法
              """
              try:
                  with self.driver.session() as session:
                      # 使用 EXPLAIN 只做解析,不执行
                      session.run(f"EXPLAIN {query}")
                  return True
              except Exception as e:
                  print(f"Cypher 语法错误: {e}")
                  return False
      
      neo4j_client = Neo4jClient(conf.NEO4J_URI, conf.NEO4J_USER, conf.NEO4J_PASSWORD)
      if __name__ == '__main__':
          neo4j_client = Neo4jClient(conf.NEO4J_URI, conf.NEO4J_USER, conf.NEO4J_PASSWORD)
          # 查询所有节点 :cyper:MATCH (n) RETURN n
          # nodes = neo4j_client.run_cypher("MATCH (n) RETURN n.name")
          # print(nodes)
      
          # 测试一下 run_multiple_cypher
          # 注意 MATCH 要带 RETURN 才会有结果行;只写 MATCH 不返回任何东西,
          # 对应的结果项就是空列表(原来那条 MATCH (n:Employee ...) 就是这种情况)。
          # result = neo4j_client.run_multiple_cypher(
          #     [
          #         ("CREATE (n:Test {name: $name})", {"name": "Bob"}),
          #         ("MATCH (n:Test {name: $name}) RETURN n.name AS name", {"name": "Bob"}),
          #         ("MATCH (n:Test {name: $name}) DELETE n", {"name": "Bob"}),  # 自测收尾,别留脏数据
          #     ]
          # )
          # print(result)  # [[], [{'name': 'Bob'}], []]
      
          #测试export_tcm_metadata_to_json
          # neo4j_client.export_tcm_metadata_to_json()
          #测试 get_all_node_names
          # print(neo4j_client.get_all_node_names())
          #获取疾病下的所以node
          # print(neo4j_client.get_all_node_names("Disease"))
          result=neo4j_client.validate_cypher("MATCH1 (n:Disease) RETURN n.name")
          print(result)
      
    • 📄 ouput_graph_utils.py 0B
      
      
    • 📄 path_utils.py 773B
      import os
      import sys
      # print(os.path.dirname(os.path.abspath(__file__)))
      # sys.exit(-1)
      root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
      
      
      print(root_dir)
      
      
      def get_file_path(relative_path):
          return os.path.join(root_dir, relative_path)
      
      
      if __name__ == '__main__':
          print(get_file_path('common/path_utils.py'))
          #为啥要这么做
          #比如说你们开发是在windows机器上: D:\heima_creazy_05\04_code\chinese_medical_project_all_project_for_heima_crazy_05\common/path_utils.py
          #上线的时候 部署了 linux 机器: /home/heima_creazy_05/04_code/chinese_medical_project_all_project_for_heima_crazy_05/common/path_utils.py
          #如果 你把代码中的路径写死了,上线的时候,就需要到处改代码
      
    • ⚙️ tcm_metadata.json 14.4KB
  • 📁 resume/
    • 📄 00_中医知识图谱_主版.md 10.4KB
      # 简历 · 大模型应用开发工程师
      
      > 本文件是主版(中医知识图谱问答系统),resume 目录下另有 5 份跨行业迁移版本。
      > 姓名、联系方式、学历、公司名请按实际情况替换 `<>` 占位内容。
      
      ---
      
      ## 基本信息
      
      | | |
      |---|---|
      | 姓名 | `<姓名>` |
      | 岗位 | 大模型应用开发工程师 / LLM Agent 开发 |
      | 经验 | 4 年(其中大模型应用方向 2 年) |
      | 电话 / 邮箱 | `<电话>` / `<邮箱>` |
      | 学历 | `<学校>` `<专业>` `<本科/硕士>` |
      
      ---
      
      ## 技能栏
      
      **大模型应用**
      - LangGraph 有状态编排:`StateGraph` 多节点、条件边路由、带刹车的重试环、`InMemorySaver` 多轮记忆、`stream(updates)` 流式产出
      - LangChain:`prompt | llm | OutputParser` 链式调用,`JsonOutputParser` 结构化输出,OpenAI 兼容端点接入(DeepSeek / vLLM 自部署)
      - Prompt 工程:Few-shot、schema 注入、否定式约束(禁止改写实体、禁止生成写操作)、失败降级的多级解析
      
      **知识图谱与检索**
      - Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
      - 向量检索:FAISS `IndexFlatL2`、bge-large-zh-v1.5(1024 维)、归一化向量的余弦换算、按标签过滤的召回
      - 实体标准化:口语词到图谱标准词的映射("拉肚子" → "腹泻")
      
      **模型微调**
      - Alpaca 格式数据集构建(instruction/input/output),从抽取结果反向生成训练样本
      - LoRA 微调 + vLLM 部署,multi-LoRA 单服务多适配器切换
      
      **工程能力**
      - Python 3.10、FastAPI(异步、`StreamingResponse`、`run_in_threadpool`、`lifespan`)、Streamlit 多页应用
      - 并发:`ThreadPoolExecutor`、线程局部 Session、`asyncio.Queue` 跨线程反压
      - 爬虫:BeautifulSoup 文档序遍历解析、文件系统断点续传、原子写入
      - Git、Black、Windows 开发 / Linux 部署的路径兼容
      
      ---
      
      ## 项目经验
      
      ### 中医知识图谱智能问答系统 `<公司/独立项目>`
      
      **角色**:技术负责人,独立完成全链路设计与实现,带 1 名实习生做数据校验
      
      **背景**:中医知识散落在网页文本里,用户想问"四君子汤由哪些药材组成""我拉肚子吃什么方剂",
      通用大模型答不准也答不全,会编造不存在的方剂和药材。目标是把非结构化中医文本变成可查询的
      知识图谱,再让大模型基于图谱事实回答,做到答案可溯源。
      
      **技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
      FastAPI / Streamlit / vLLM
      
      **七阶段流水线,逐段产出可复用的中间结果:**
      
      **1. 数据采集**(1472 味中药 + 703 个方剂)
      - BeautifulSoup 按**文档顺序**遍历 `h2/h3/p/li`,遇到标题就开新章节 —— 中药页面的子章节因药而异,
        按已知字段名查找会漏掉一半内容,按顺序走才能完整保留
      - 断点续传做在文件系统层:目标 `.txt` 存在即视为完成,待办列表由此推导;写入走
        `<path>.<thread_id>.part` 再 `os.replace`,中断不会留下看起来完整的半截文件
      - `ThreadPoolExecutor` 5 并发,`requests.Session` 按线程隔离,打印加锁
      
      **2. 实体关系抽取**(LLM 结构化输出)
      - Pydantic 定义图谱 schema 作为全链路契约:6 类实体(症状/疾病/方剂/中药/功效/出处)、
        6 类关系(主治/缓解/功效/组成/伴随/出处),属性挂在实体上
      - Prompt 显式要求"单实体无关系时返回空结果" —— 空结果是正确输出而非失败,
        否则模型会为了填满格式而编造关系
      - 成本是主要约束:单次调用 20 秒到 4 分钟,取决于**输出**规模。1472 味药串行约 90 小时,
        10 并发压到 9–15 小时。失败不写文件,因此天然留在待办列表里,重跑即重试;
        空结果**要写**,否则每次重跑都在为同一批无关系药材重复付费
      
      **3. 图谱构建**(18895 节点 / 48344 关系)
      - **先在内存聚合再写库**:49226 条实体记录收敛成 18895 个节点,50627 条关系记录收敛成 48344 条边
        ("咳嗽"出现在数百个文件里)。逐条 MERGE 等于把同样的工作重做几百遍
      - **节点主键是 `(标签, 名称)` 而非名称**:356 个名称同时属于两种类型("热咳"既是疾病也是症状),
        只按名称去重会把它们错误合并
      - 标签、关系类型、属性键无法参数化,全部先过白名单再拼接;值一律走 `$` 参数
      - 属性用 `MERGE (n:L {name:$name}) SET n += $attrs`:属性写进 MERGE 模式会因属性变化而
        匹配失败并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
      - 500 条一个事务,全 MERGE 所以中断后重跑即可修复
      
      **4. 检索层**
      - Neo4j 元数据导出成 schema 描述文件,注入 Cypher 生成的 prompt;三元组的中文描述由人工补齐
        (含行数和用法说明),这段 prose 直接影响生成质量
      - FAISS 索引覆盖全部 6 类标签的实体名,**索引本身不带标签**,所以召回后必须按标签过滤 ——
        否则"咳嗽"可能召回一个药材名并落进 `matched_symptoms`,生成出毫无意义的 Cypher。
        实现上超额取 `top_k * 6` 再过滤,保证过滤后仍有 top_k 个
      - 7 项分级健康检查守住索引与映射的对齐:自检索(已知实体名必须以距离≈0 返回 top-1)是
        真正能证明向量和映射没错位的那一项;另有与 Neo4j 的实体数对账,防止重导数据后索引变陈旧
      
      **5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
      
      ```
      START → 语义转写 → 意图识别
                ├─ 非中医 ─→ 直接回答 ──────────────────────→ END
                └─ 中医  ─→ 实体抽取 → 实体匹配 → cypher生成 ←──┐
                                → cypher检查                    │
                                     ├─ 全部合法 ─→ 执行 → 图谱答案 → END
                                     ├─ 不合法且未超 3 次 ────────┘
                                     └─ 额度用尽 ─→ 直接回答 → END
      ```
      
      - **语义转写排在最前**:多轮对话里"那第二呢?"单看一句没有任何信息,意图识别会判成无关、
        实体抽取一个词也抽不出。转写节点结合历史补全成完整问题,下游才有东西可用。
        因此全图只有转写节点读原始 `input`,其余节点一律读转写结果 —— 这是"用户打了什么"
        和"用户想问什么"的区别
      - **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,所以 `cypher_retry_count`
        必须由生成节点自增。上限 3 次(首次 + 2 次重试),重试时把上一轮不合法的语句回喂给模型,
        否则模型看到的输入和上次完全相同,大概率原样再生成一遍
      - **生成的 Cypher 直连生产库,因此在两个独立位置设闸**,prompt 里的"严禁 CREATE"不算其中之一:
        ① 关键词词边界扫描 + 必须有 RETURN 且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验,
        让 Neo4j 而不是正则来当语法和标签存在性的权威
      - **绝不把用户原话放进 WHERE**:用户说"拉肚子",图里存的是"腹泻"。实体匹配节点的存在意义
        就是弥合这个差距,Cypher prompt 里明确禁止使用问题中的词
      - JSON 解析手写多级降级:剥代码围栏 → 正则提取首个数组 → 解包信封 → 兜底抓取 MATCH 开头的行。
        解析失败必须降级而不能中断整条链
      - Cypher 逐条执行而非批量事务:批量的全有或全无语义会让一条坏查询废掉其余结果;
        出错的位置补空列表,保持结果与语句下标对齐
      
      **6. 服务化**
      - FastAPI 三接口:健康检查、`POST /process`、`POST /process_stream`
      - 图是**懒加载的模块级单例**,`lifespan` 启动时预热 —— 导入图会连带加载 76 MB FAISS 索引和
        embedding 模型,约 50 秒;预热失败只记日志不阻止启动,健康检查照样可用
      - `graph.invoke` 同步阻塞 5–30 秒,必须走 `run_in_threadpool`,否则事件循环被占死、
        服务退化成单并发
      - 流式接口用**生产者-消费者**:图的各节点在工作线程里产出,`asyncio.Queue`(有界 32,
        形成反压)跨回事件循环,`StreamingResponse` 逐行吐换行分隔 JSON。
        推给前端的是**状态增量**而非全量 —— 节点返回的是完整 state,直接推会让每条消息带二十多个键
      - 客户端断开时取消线程任务并收敛异常,避免 "Task exception was never retrieved" 泄漏
      
      **7. 前端与微调**
      - Streamlit 多页:常规问答页 + 流式页。流式页用 `st.status` 承载思考过程,跑时展开、
        跑完收起,节点状态键名做中文映射;思考过程存入 session_state 以便 rerun 后重放
      - 抽取结果顺带产出 Alpaca 数据集(instruction 固定 / input 为原文 / output 为 JSON 字符串)。
        两条写入路径:抽取时追加 JSONL(并发追加整行不会破坏性交错,崩溃最多损失一行),
        另有不调 LLM 的幂等重建脚本 —— 追加路径只覆盖本次运行的条目,续传跳过的历史条目永远拿不到样本
      - LoRA 微调 Qwen2.5 后用 vLLM 部署,multi-LoRA 单服务挂多个适配器按 model 名切换
      
      **成果**
      - 图谱规模 18895 节点 / 48344 关系,schema 6 类实体 6 类关系
      - 图谱路径答案完全可溯源("四君子汤由人参、白术、茯苓、甘草四味药材组成"直接来自图谱边),
        非中医提问由意图识别分流,避免无意义的图谱查询开销
      - 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live 用例;
        前端 49 条离线 + 11 条 live,全部通过
      - 单次问答端到端 3–35 秒,流式首个反馈 0.8 秒内到达
      
      ---
      
      ## 可以聊得更深的技术点
      
      面试时这几处能展开讲设计权衡,都是项目里真实踩过的:
      
      1. 为什么节点主键必须是 `(标签, 名称)` —— 356 个跨类型同名实体
      2. FAISS 索引不带标签,为什么必须过滤而不是加大 top_k
      3. 重试环的计数为什么必须由节点写而不是路由函数
      4. 空结果为什么要写盘 —— 成本考量而非正确性考量
      5. 流式接口为什么需要有界队列 + `.result()` 阻塞 —— 慢客户端下的反压
      6. 元数据裁剪的取舍:删三元组但保留标签会让模型把关系方向写反,
         生成语法合法但永远返回空的 Cypher
      
    • 📄 01_法律行业.md 9.7KB
      # 简历 · 大模型应用开发工程师(法律行业版)
      
      > 由中医知识图谱项目迁移。同一套架构(爬取 → LLM 抽取 → 图谱 → Agent → 服务 → 前端 → 微调),
      > 换成法律法规检索场景。带 `<>` 的请按实际情况替换。
      
      ---
      
      ## 基本信息
      
      | | |
      |---|---|
      | 姓名 | `<姓名>` |
      | 岗位 | 大模型应用开发工程师 / 法律 AI |
      | 经验 | 4 年(其中大模型应用方向 2 年) |
      | 电话 / 邮箱 | `<电话>` / `<邮箱>` |
      | 学历 | `<学校>` `<专业>` `<本科/硕士>` |
      
      ---
      
      ## 技能栏
      
      **大模型应用**
      - LangGraph 有状态编排:多节点条件路由、带上限的重试环、多轮对话记忆、流式增量产出
      - LangChain 链式调用与结构化输出,OpenAI 兼容端点(DeepSeek / vLLM 自部署)
      - Prompt 工程:schema 注入、否定式约束、多级降级解析;法条引用的强制溯源约束
      
      **知识图谱与检索**
      - Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
      - FAISS + bge-large-zh-v1.5 向量召回,按实体类型过滤
      - 法律术语标准化:口语诉求到法定概念的映射("老板不给钱" → "拖欠劳动报酬")
      
      **模型微调**:Alpaca 数据集构建、LoRA 微调、vLLM multi-LoRA 部署
      
      **工程能力**:Python 3.10、FastAPI(异步 / 流式 / 线程池)、Streamlit、并发爬虫、
      `asyncio.Queue` 跨线程反压、Git / Black
      
      ---
      
      ## 项目经验
      
      ### 法律法规智能问答与检索系统 `<公司/独立项目>`
      
      **角色**:技术负责人,负责整体方案与全链路实现,带 1 名实习生做法条校对
      
      **背景**:用户咨询法律问题时用的是生活语言("公司让我加班不给加班费怎么办"),而法律检索
      需要的是法定概念和精确的法条编号。通用大模型回答法律问题时最危险的行为是**编造法条**——
      凭空生成一个"《劳动法》第某条",条号和内容都似真非真,用户无从辨别。目标是把法律条文变成
      可查询的知识图谱,答案中的每一条引用都能回溯到真实条文。
      
      **技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
      FastAPI / Streamlit / vLLM
      
      **七阶段流水线:**
      
      **1. 数据采集**(法律法规全文 + 司法解释 + 指导案例)
      - 从国家法律法规数据库、裁判文书网采集,按**文档顺序**遍历 DOM 而非按已知字段名查找 ——
        法规的层级结构(编/章/节/条/款/项)因法而异,按顺序走才能保住条文的从属关系。
        这一点在法律场景比在其他领域更关键:条文脱离所属章节,含义会变
      - 断点续传做在文件系统层:目标文件存在即完成;写入走 `.part` 再原子替换,
        中断不留半截文件。法规修订后需要重采,续传让增量更新只做缺失部分
      - 并发采集,Session 按线程隔离;对政府站点保留请求间隔,避免触发封禁
      
      **2. 条文结构化抽取**(LLM 结构化输出)
      - Pydantic 定义 schema 作为全链路契约:实体为**法律法规 / 条文 / 法律概念 / 主体 /
        行为 / 法律后果**;关系为**规定 / 适用于 / 违反则 / 引用 / 上位法 / 例外**
      - 属性挂在实体上:条文带条号、生效日期、失效日期、修订版本;法规带效力层级
        (法律 > 行政法规 > 部门规章 > 地方性法规)
      - Prompt 显式要求"单条文无关联时返回空结果" —— 法律场景下模型最容易犯的错就是
        为了填满格式而虚构条文之间的引用关系,空结果是正确输出
      - **抽取时不允许改写条文原文**:`条文内容` 字段必须逐字保留,任何摘要或改写都会
        让后续的引用失去法律意义
      - 成本约束:单次调用耗时取决于**输出**规模,长条文(如民法典分则)显著更慢;
        失败不写文件因此天然留在待办列表,重跑即重试;空结果要写,否则重跑在重复付费
      
      **3. 图谱构建**
      - **先在内存聚合再写库**:同一个法律概念(如"不可抗力")出现在数百个条文里,
        逐条 MERGE 等于把同样的工作重做几百遍
      - **节点主键是 `(类型, 名称)` 而非名称**:法律领域同名不同义极其常见 ——
        "定金"作为法律概念与作为合同条款行为是两类节点,"第十条"必须绑定所属法规才唯一。
        只按名称去重会把不同法规的同号条文错误合并,这是致命错误
      - 标签、关系类型、属性键先过白名单再拼接;值一律走参数化,防注入
      - 属性用 `MERGE ... SET n += $attrs`:法规修订后属性变化,写进 MERGE 模式会匹配失败
        并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
      - **时效性建模**:条文节点带生效/失效区间,查询时按时间点过滤 ——
        法律问答必须回答"当时适用哪一版",而不是只给最新版
      
      **4. 检索层**
      - Neo4j schema 元数据导出后注入 Cypher 生成 prompt;关系的中文描述人工补齐
        (含效力层级说明和适用范围),这段说明直接影响生成质量
      - FAISS 索引覆盖全部实体类型,**索引本身不带类型**,召回后必须按类型过滤 ——
        否则"欺诈"可能召回一个法规名并落进"法律概念"槽位,生成出错误的 Cypher。
        超额取 `top_k * N` 再过滤,保证过滤后数量足够
      - 分级健康检查守住索引与图谱的一致性:自检索(已知实体必须以距离≈0 返回 top-1)
        证明向量与映射未错位;与 Neo4j 对账防止法规更新后索引陈旧
      
      **5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
      
      ```
      START → 语义转写 → 法律意图识别
                ├─ 非法律 ─→ 直接回答 ──────────────────────→ END
                └─ 法律  ─→ 概念抽取 → 概念匹配 → cypher生成 ←──┐
                                → cypher检查                    │
                                     ├─ 全部合法 ─→ 执行 → 引注答案 → END
                                     ├─ 不合法且未超 3 次 ────────┘
                                     └─ 额度用尽 ─→ 直接回答 → END
      ```
      
      - **语义转写排在最前**:法律咨询天然是多轮的 —— 用户先问"加班费怎么算",再问"那不给呢"。
        后一句单看没有任何信息,意图识别会判成无关。转写节点结合历史补全成完整问题,
        下游才有东西可用。全图只有转写节点读原始输入,其余节点一律读转写结果
      - **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,重试次数必须由生成节点自增,
        上限 3 次。重试时把上一轮不合法的语句回喂给模型,否则模型输入不变会原样再生成一遍
      - **生成的 Cypher 直连生产库,两个独立位置设闸**,prompt 里的禁止写操作不算其中之一:
        ① 关键词词边界扫描 + 必须有 RETURN 且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验
      - **绝不把用户原话放进 WHERE**:用户说"老板不给钱",图里存的是"拖欠劳动报酬"。
        概念匹配节点的存在意义就是弥合这个差距
      - **答案生成节点强制引注**:回答必须携带法规全称 + 条号,且这些字段只能来自图谱查询结果,
        不允许模型自行补充。这是整个项目最核心的约束 —— 宁可回答"未检索到相关条文",
        也不能给出一个编造的条号
      - JSON 解析多级降级;Cypher 逐条执行而非批量事务,出错补空列表保持下标对齐
      
      **6. 服务化**
      - FastAPI 三接口:健康检查、同步问答、流式问答
      - 图是懒加载的模块级单例,`lifespan` 启动预热(FAISS 索引 + embedding 模型约 50 秒);
        预热失败只记日志不阻止启动
      - `graph.invoke` 同步阻塞,走 `run_in_threadpool`,否则事件循环被占死、服务退化成单并发
      - 流式接口用生产者-消费者:节点在工作线程产出,有界 `asyncio.Queue`(32,形成反压)
        跨回事件循环。推给前端的是**状态增量**而非全量状态
      - 法律咨询的等待容忍度低,流式让"正在检索《劳动合同法》"这类进度可见
      
      **7. 前端与微调**
      - Streamlit 多页:常规问答 + 流式页。流式页展示检索过程(命中的法规、生成的查询、
        引用的条文),法律用户尤其需要看到检索依据而非只有结论
      - 抽取结果顺带产出 Alpaca 数据集用于微调;两条写入路径 —— 抽取时追加 JSONL
        (并发追加整行不会破坏性交错),另有不调 LLM 的幂等重建脚本覆盖续传跳过的历史条目
      - LoRA 微调后 vLLM 部署,multi-LoRA 单服务挂多个适配器(法律 / 中医 / 通用)按需切换
      
      **成果**
      - 覆盖 `<N>` 部法律法规、`<M>` 万条条文,schema 6 类实体 6 类关系
      - **零编造条号**:所有引用均来自图谱边,检索不到时明确告知而非猜测
      - 支持按时间点查询历史版本条文
      - 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live;
        前端 49 条离线 + 11 条 live
      
      ---
      
      ## 行业适配要点(面试可展开)
      
      1. **效力层级入图**:法律 > 行政法规 > 部门规章 > 地方性法规,冲突时按层级取舍
      2. **时效区间建模**:条文带生效/失效日期,回答"当时适用哪一版"
      3. **同号条文的主键设计**:`第十条` 必须绑定所属法规才唯一,只按名称去重是致命错误
      4. **强制引注**:答案里的条号只能来自图谱,宁可说"未检索到"也不编造
      5. **条文原文逐字保留**:抽取时禁止改写,改写会让引用失去法律意义
      6. 免责声明:系统输出为法律信息检索结果,不构成法律意见,需执业律师复核
      
    • 📄 02_医疗行业.md 10.0KB
      # 简历 · 大模型应用开发工程师(医疗行业版)
      
      > 由中医知识图谱项目迁移。同一套架构(采集 → LLM 抽取 → 图谱 → Agent → 服务 → 前端 → 微调),
      > 换成西医临床知识检索场景。带 `<>` 的请按实际情况替换。
      
      ---
      
      ## 基本信息
      
      | | |
      |---|---|
      | 姓名 | `<姓名>` |
      | 岗位 | 大模型应用开发工程师 / 医疗 AI |
      | 经验 | 4 年(其中大模型应用方向 2 年) |
      | 电话 / 邮箱 | `<电话>` / `<邮箱>` |
      | 学历 | `<学校>` `<专业>` `<本科/硕士>` |
      
      ---
      
      ## 技能栏
      
      **大模型应用**
      - LangGraph 有状态编排:多节点条件路由、带上限的重试环、多轮问诊记忆、流式增量产出
      - LangChain 链式调用与结构化输出,OpenAI 兼容端点(DeepSeek / vLLM 私有化部署)
      - Prompt 工程:schema 注入、否定式约束、多级降级解析;用药建议的强制溯源与免责约束
      
      **知识图谱与检索**
      - Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
      - FAISS + bge-large-zh-v1.5 向量召回,按实体类型过滤
      - 医学术语标准化:主诉口语到标准术语的映射("肚子疼" → "腹痛"),对接 ICD-10 / SNOMED CT
      
      **模型微调**:Alpaca 数据集构建、LoRA 微调、vLLM multi-LoRA 部署
      
      **工程能力**:Python 3.10、FastAPI(异步 / 流式 / 线程池)、Streamlit、并发采集、
      `asyncio.Queue` 跨线程反压、Git / Black;数据脱敏与院内私有化部署
      
      ---
      
      ## 项目经验
      
      ### 临床知识图谱辅助问答系统 `<公司/独立项目>`
      
      **角色**:技术负责人,负责整体方案与全链路实现,与 `<N>` 名临床医师协作做术语与内容审核
      
      **背景**:临床问答对准确性的要求高于任何其他领域。通用大模型会编造不存在的药物相互作用、
      给出错误剂量、把禁忌症说成适应症 —— 这类错误在医疗场景直接对应伤害。目标是把临床指南、
      药品说明书、疾病知识变成可查询的知识图谱,让答案的每一条结论都能回溯到具体来源,
      并且**在证据不足时明确拒答**而非编造。
      
      **技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
      FastAPI / Streamlit / vLLM
      
      **七阶段流水线:**
      
      **1. 数据采集**(临床指南 + 药品说明书 + 疾病知识库)
      - 按**文档顺序**遍历 DOM 而非按已知字段名查找 —— 说明书的章节(适应症/用法用量/不良反应/
        禁忌/注意事项/相互作用)在不同厂家的排布和命名都不统一,按顺序走开新章节才能完整保留。
        漏掉"禁忌"章节的后果不是数据缺失,是安全事故
      - 断点续传做在文件系统层:目标文件存在即完成;写入走 `.part` 再原子替换。
        指南更新版本后重采,续传让增量更新只做缺失部分
      - 并发采集 + Session 按线程隔离;**采集内容全程不含患者信息**,只处理公开的药品与指南文本
      
      **2. 医学实体关系抽取**(LLM 结构化输出)
      - Pydantic 定义 schema 作为全链路契约:实体为**疾病 / 症状 / 药物 / 检查 / 治疗方案 /
        证据来源**;关系为**治疗 / 缓解 / 适应症 / 禁忌 / 药物相互作用 / 需做检查 / 来源**
      - 属性挂在实体上:药物带剂量、给药途径、半衰期、妊娠分级、储存条件;
        疾病带 ICD-10 编码、分期分级
      - **禁忌与适应症必须严格区分**:prompt 中作为最高优先级约束反复强调,
        两者混淆是本项目定义的 P0 错误。抽取结果按此维度做专项抽样复核
      - Prompt 显式要求"无明确关联时返回空结果" —— 模型最危险的行为是为了填满格式而
        虚构药物相互作用,空结果是正确输出
      - **剂量数值逐字保留,禁止换算或改写**:任何单位换算都由下游显式处理,抽取层只做搬运
      - 成本约束:耗时取决于**输出**规模,长说明书显著更慢;失败不写文件因此留在待办列表,
        重跑即重试;空结果要写,否则重跑在重复付费
      
      **3. 图谱构建**
      - **先在内存聚合再写库**:常见症状(如"发热")出现在数千个文档里,
        逐条 MERGE 等于把同样的工作重做数千遍
      - **节点主键是 `(类型, 名称)` 而非名称**:医学领域跨类型同名极其常见 ——
        "高血压"既是疾病也是其他疾病的症状表现,"胰岛素"既是药物也是内源性物质。
        只按名称去重会把它们错误合并,进而让"症状"查询召回药物节点
      - 通用名 / 商品名 / 别名做别名映射而非独立节点:阿司匹林与拜阿司匹灵必须指向同一节点,
        否则禁忌查询会漏
      - 标签、关系类型、属性键先过白名单再拼接;值一律参数化
      - 属性用 `MERGE ... SET n += $attrs`:指南更新后属性变化,写进 MERGE 模式会匹配失败
        并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
      - **证据等级入图**:每条关系带证据来源与推荐强度,答案据此表明可信度
      
      **4. 检索层**
      - Neo4j schema 元数据导出后注入 Cypher 生成 prompt;关系描述人工补齐(含适用人群、
        证据等级说明),由临床医师复核
      - FAISS 索引覆盖全部实体类型,**索引本身不带类型**,召回后必须按类型过滤 ——
        否则"头痛"可能召回一个药物名并落进"症状"槽位,生成出错误的 Cypher。
        超额取 `top_k * N` 再过滤
      - 分级健康检查守住索引与图谱一致性:自检索(已知实体以距离≈0 返回 top-1)证明
        向量与映射未错位;与 Neo4j 对账防止指南更新后索引陈旧
      
      **5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
      
      ```
      START → 语义转写 → 医疗意图识别
                ├─ 非医疗 ─→ 直接回答 ──────────────────────→ END
                └─ 医疗  ─→ 实体抽取 → 术语匹配 → cypher生成 ←──┐
                                → cypher检查                    │
                                     ├─ 全部合法 ─→ 执行 → 循证答案 → END
                                     ├─ 不合法且未超 3 次 ────────┘
                                     └─ 额度用尽 ─→ 直接回答 → END
      ```
      
      - **语义转写排在最前**:问诊天然多轮 —— 先问"二甲双胍怎么吃",再问"那空腹能吃吗"。
        后一句单看没信息,意图识别会判成无关。转写节点结合历史补全,下游才有东西可用。
        全图只有转写节点读原始输入,其余节点读转写结果
      - **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,重试次数由生成节点自增,
        上限 3 次。重试时回喂上一轮不合法的语句,否则模型输入不变会原样再生成
      - **生成的 Cypher 直连生产库,两个独立位置设闸**:① 关键词词边界扫描 + 必须有 RETURN
        且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验
      - **绝不把用户原话放进 WHERE**:用户说"肚子疼",图里存的是"腹痛"。术语匹配节点弥合这个差距
      - **答案生成节点的三条硬约束**(本项目最核心的部分):
        ① 结论必须携带来源(指南名称 / 说明书章节),来源只能来自图谱查询结果;
        ② 检索不到证据时明确回答"未检索到相关证据",绝不由模型补充;
        ③ 输出附免责声明,明确不构成诊疗建议,需执业医师判断
      - JSON 解析多级降级;Cypher 逐条执行而非批量事务,出错补空列表保持下标对齐
      
      **6. 服务化**
      - FastAPI 三接口:健康检查、同步问答、流式问答
      - 图是懒加载的模块级单例,`lifespan` 启动预热(FAISS 索引 + embedding 模型约 50 秒);
        预热失败只记日志不阻止启动,健康检查照样可用 —— 院内系统对可用性要求高
      - `graph.invoke` 同步阻塞,走 `run_in_threadpool`,否则事件循环被占死、服务退化成单并发
      - 流式接口用生产者-消费者:节点在工作线程产出,有界 `asyncio.Queue`(32,形成反压)
        跨回事件循环。推给前端的是**状态增量**而非全量状态
      - 全套可离线私有化部署(vLLM 自托管模型 + 本地 embedding + 院内 Neo4j),
        不依赖外部 API —— 医疗数据不出院是硬性要求
      
      **7. 前端与微调**
      - Streamlit 多页:常规问答 + 流式页。流式页展示检索过程(命中的实体、生成的查询、
        引用的指南),临床用户必须看到证据链而非只有结论
      - 抽取结果顺带产出 Alpaca 数据集用于微调;两条写入路径 —— 抽取时追加 JSONL
        (并发追加整行不会破坏性交错),另有不调 LLM 的幂等重建脚本覆盖续传跳过的历史条目
      - LoRA 微调后 vLLM 私有化部署,multi-LoRA 单服务挂多个科室适配器按需切换
      
      **成果**
      - 覆盖 `<N>` 份临床指南、`<M>` 种药品说明书,schema 6 类实体 6 类关系
      - **零编造**:所有结论均带图谱来源,检索不到时明确拒答
      - 禁忌与相互作用查询做专项回归,抽样复核由临床医师执行
      - 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live;
        前端 49 条离线 + 11 条 live
      - 全链路私有化部署,数据不出院
      
      ---
      
      ## 行业适配要点(面试可展开)
      
      1. **禁忌 vs 适应症**:定义为 P0 错误,prompt 最高优先级约束 + 专项抽样复核
      2. **跨类型同名**:高血压既是疾病也是症状,主键必须是 `(类型, 名称)`
      3. **别名映射**:通用名/商品名指向同一节点,否则禁忌查询会漏
      4. **证据等级入图**:答案表明推荐强度,而非把所有结论等同对待
      5. **拒答优于猜测**:检索不到证据时明确说明,这是医疗场景不可让渡的底线
      6. **私有化部署**:模型、向量库、图谱全部院内自托管,数据不出院
      7. 合规边界:系统定位为医师辅助工具,输出不构成诊疗建议,须执业医师复核
      
    • 📄 03_制造业.md 10.6KB
      # 简历 · 大模型应用开发工程师(制造业版)
      
      > 由中医知识图谱项目迁移。同一套架构(采集 → LLM 抽取 → 图谱 → Agent → 服务 → 前端 → 微调),
      > 换成设备故障诊断与工艺知识检索场景。带 `<>` 的请按实际情况替换。
      
      ---
      
      ## 基本信息
      
      | | |
      |---|---|
      | 姓名 | `<姓名>` |
      | 岗位 | 大模型应用开发工程师 / 工业 AI |
      | 经验 | 4 年(其中大模型应用方向 2 年) |
      | 电话 / 邮箱 | `<电话>` / `<邮箱>` |
      | 学历 | `<学校>` `<专业>` `<本科/硕士>` |
      
      ---
      
      ## 技能栏
      
      **大模型应用**
      - LangGraph 有状态编排:多节点条件路由、带上限的重试环、多轮排障记忆、流式增量产出
      - LangChain 链式调用与结构化输出,OpenAI 兼容端点(DeepSeek / vLLM 厂内部署)
      - Prompt 工程:schema 注入、否定式约束、多级降级解析;维修方案的强制溯源约束
      
      **知识图谱与检索**
      - Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
      - FAISS + bge-large-zh-v1.5 向量召回,按实体类型过滤
      - 工业术语标准化:一线口语到标准故障码的映射("机器嗡嗡响" → "主轴异常振动")
      
      **模型微调**:Alpaca 数据集构建、LoRA 微调、vLLM multi-LoRA 部署
      
      **工程能力**:Python 3.10、FastAPI(异步 / 流式 / 线程池)、Streamlit、并发采集、
      `asyncio.Queue` 跨线程反压、Git / Black;产线内网离线部署
      
      ---
      
      ## 项目经验
      
      ### 设备故障诊断与工艺知识图谱问答系统 `<公司/独立项目>`
      
      **角色**:技术负责人,负责整体方案与全链路实现,与设备科 `<N>` 名工程师协作做知识校验
      
      **背景**:产线设备的排障知识分散在设备手册、历史工单、老师傅的经验里。新人遇到报警只能
      翻纸质手册或打电话问人,停机每分钟都是钱。通用大模型的问题是会编造不存在的故障码和
      维修步骤 —— 按错误步骤操作可能造成设备二次损坏或人身伤害。目标是把手册和历史工单变成
      可查询的知识图谱,让排障建议可回溯到具体手册章节或历史工单编号。
      
      **技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
      FastAPI / Streamlit / vLLM
      
      **七阶段流水线:**
      
      **1. 数据采集**(设备手册 + 历史维修工单 + 工艺规程 + 备件台账)
      - 按**文档顺序**遍历文档结构而非按已知字段名查找 —— 不同厂商设备手册的章节命名和层级
        完全不统一(故障排除/异常处理/Troubleshooting 混用),按顺序走开新章节才能完整保留。
        漏掉"安全注意事项"章节直接对应作业风险
      - 历史工单是半结构化的:现象描述是自由文本,处理措施常带方言和简写,
        这部分交给 LLM 抽取而非规则解析
      - 断点续传做在文件系统层:目标文件存在即完成;写入走 `.part` 再原子替换。
        新设备入场或手册改版后重采,续传让增量更新只做缺失部分
      - 并发采集 + Session 按线程隔离
      
      **2. 故障知识抽取**(LLM 结构化输出)
      - Pydantic 定义 schema 作为全链路契约:实体为**设备 / 部件 / 故障现象 / 故障原因 /
        处理措施 / 备件**;关系为**由...引起 / 表现为 / 处置方式 / 需更换备件 / 属于 / 前置检查**
      - 属性挂在实体上:设备带型号、产线、投产日期;备件带图号、库位、更换周期、供应商;
        措施带工时、所需资质、停机需求
      - **安全前置条件必须单独抽取**:断电挂牌、泄压、上锁等前置动作绝不能与处理步骤混在一起,
        它们的执行顺序不可颠倒。这是本项目定义的 P0 约束
      - Prompt 显式要求"现象与原因无明确关联时返回空结果" —— 模型最危险的行为是为了填满格式
        而虚构因果链,把无关的故障原因关联到现象上,空结果是正确输出
      - **参数数值逐字保留,禁止换算**:扭矩、压力、温度的数值和单位原样搬运,换算在下游显式处理
      - 成本约束:耗时取决于**输出**规模,长手册章节显著更慢;失败不写文件因此留在待办列表,
        重跑即重试;空结果要写,否则重跑在重复付费
      
      **3. 图谱构建**
      - **先在内存聚合再写库**:常见现象(如"异响")出现在数千份工单里,
        逐条 MERGE 等于把同样的工作重做数千遍
      - **节点主键是 `(类型, 名称)` 而非名称**:制造业跨类型同名极其常见 ——
        "轴承过热"既是故障现象也是另一个故障的原因,"液压泵"既是部件也是备件。
        只按名称去重会把它们错误合并,让"现象"查询召回备件节点
      - 型号是必须入主键的维度:同名部件在不同设备型号上的规格和更换方式完全不同,
        只按部件名去重会给出错误的备件图号
      - 标签、关系类型、属性键先过白名单再拼接;值一律参数化
      - 属性用 `MERGE ... SET n += $attrs`:手册改版或备件替代后属性变化,写进 MERGE 模式
        会匹配失败并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
      - **工单频次入图**:关系上带历史出现次数,排障时按频次排序候选原因 ——
        这是知识图谱比纯手册检索更有价值的地方,手册不会告诉你哪个原因最常见
      
      **4. 检索层**
      - Neo4j schema 元数据导出后注入 Cypher 生成 prompt;关系描述人工补齐
        (含适用型号、频次说明),由设备工程师复核
      - FAISS 索引覆盖全部实体类型,**索引本身不带类型**,召回后必须按类型过滤 ——
        否则"漏油"可能召回一个备件名并落进"现象"槽位,生成出错误的 Cypher。
        超额取 `top_k * N` 再过滤
      - 分级健康检查守住索引与图谱一致性:自检索(已知实体以距离≈0 返回 top-1)证明
        向量与映射未错位;与 Neo4j 对账防止工单增量导入后索引陈旧
      
      **5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
      
      ```
      START → 语义转写 → 排障意图识别
                ├─ 非排障 ─→ 直接回答 ──────────────────────→ END
                └─ 排障  ─→ 实体抽取 → 术语匹配 → cypher生成 ←──┐
                                → cypher检查                    │
                                     ├─ 全部合法 ─→ 执行 → 处置方案 → END
                                     ├─ 不合法且未超 3 次 ────────┘
                                     └─ 额度用尽 ─→ 直接回答 → END
      ```
      
      - **语义转写排在最前**:排障天然多轮 —— 先问"3 号线冲床报 E05",再问"换了还是不行"。
        后一句单看没有任何信息,意图识别会判成无关。转写节点结合历史补全成完整问题,
        下游才有东西可用。全图只有转写节点读原始输入,其余节点读转写结果
      - **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,重试次数由生成节点自增,
        上限 3 次。重试时回喂上一轮不合法的语句,否则模型输入不变会原样再生成
      - **生成的 Cypher 直连生产库,两个独立位置设闸**:① 关键词词边界扫描 + 必须有 RETURN
        且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验。生产环境的图谱库绝不能被
        生成的语句写坏
      - **绝不把用户原话放进 WHERE**:一线说"机器嗡嗡响",图里存的是"主轴异常振动"。
        术语匹配节点弥合这个差距 —— 这在制造业尤其重要,一线口语和手册术语几乎没有交集
      - **答案生成节点的硬约束**:处置步骤必须携带来源(手册章节号 / 工单编号),
        来源只能来自图谱查询结果;安全前置条件必须置顶输出;检索不到时明确告知,
        绝不由模型编造维修步骤
      - JSON 解析多级降级;Cypher 逐条执行而非批量事务,出错补空列表保持下标对齐
      
      **6. 服务化**
      - FastAPI 三接口:健康检查、同步问答、流式问答
      - 图是懒加载的模块级单例,`lifespan` 启动预热(FAISS 索引 + embedding 模型约 50 秒);
        预热失败只记日志不阻止启动 —— 产线工具不能因为预热失败就整体不可用
      - `graph.invoke` 同步阻塞,走 `run_in_threadpool`,否则事件循环被占死、服务退化成单并发。
        多个班组同时排障是常态
      - 流式接口用生产者-消费者:节点在工作线程产出,有界 `asyncio.Queue`(32,形成反压)
        跨回事件循环。停机场景下等待成本极高,流式让"正在检索历史工单"的进度可见
      - **产线内网离线部署**:vLLM 自托管模型 + 本地 embedding + 厂内 Neo4j,不依赖外网
      
      **7. 前端与微调**
      - Streamlit 多页:常规问答 + 流式页。流式页展示检索过程(命中的设备型号、候选原因及频次、
        引用的工单号),设备工程师需要看到判断依据。移动端适配以便车间现场使用
      - 抽取结果顺带产出 Alpaca 数据集用于微调;两条写入路径 —— 抽取时追加 JSONL
        (并发追加整行不会破坏性交错),另有不调 LLM 的幂等重建脚本覆盖续传跳过的历史条目
      - LoRA 微调后 vLLM 厂内部署,multi-LoRA 单服务挂多个产线/设备类型适配器按需切换
      
      **成果**
      - 覆盖 `<N>` 类设备、`<M>` 份手册、`<K>` 万条历史工单,schema 6 类实体 6 类关系
      - 候选故障原因按历史频次排序,把老师傅的经验沉淀成可查询的数据
      - **零编造维修步骤**:所有处置方案带手册章节或工单编号,检索不到时明确告知
      - 安全前置条件强制置顶,不与操作步骤混排
      - 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live;
        前端 49 条离线 + 11 条 live
      - 全链路产线内网离线运行
      
      ---
      
      ## 行业适配要点(面试可展开)
      
      1. **型号入主键**:同名部件在不同型号上规格不同,只按名称去重会给出错误备件图号
      2. **安全前置条件独立抽取**:断电挂牌、泄压不能与处理步骤混排,顺序不可颠倒
      3. **工单频次入图**:候选原因按历史频次排序,这是图谱相比手册检索的增量价值
      4. **口语到故障码的映射**:"嗡嗡响" → "主轴异常振动",一线用语和手册术语几乎无交集
      5. **跨类型同名**:轴承过热既是现象也是原因,主键必须是 `(类型, 名称)`
      6. **内网离线部署**:工业数据不出厂,模型与向量库全部本地自托管
      7. 定位为排障辅助工具,高风险操作须持证人员按规程执行
      
    • 📄 04_畜牧业.md 10.9KB
      # 简历 · 大模型应用开发工程师(畜牧业版)
      
      > 由中医知识图谱项目迁移。同一套架构(采集 → LLM 抽取 → 图谱 → Agent → 服务 → 前端 → 微调),
      > 换成畜禽疫病诊断与养殖管理知识检索场景。带 `<>` 的请按实际情况替换。
      
      ---
      
      ## 基本信息
      
      | | |
      |---|---|
      | 姓名 | `<姓名>` |
      | 岗位 | 大模型应用开发工程师 / 农业 AI |
      | 经验 | 4 年(其中大模型应用方向 2 年) |
      | 电话 / 邮箱 | `<电话>` / `<邮箱>` |
      | 学历 | `<学校>` `<专业>` `<本科/硕士>` |
      
      ---
      
      ## 技能栏
      
      **大模型应用**
      - LangGraph 有状态编排:多节点条件路由、带上限的重试环、多轮问诊记忆、流式增量产出
      - LangChain 链式调用与结构化输出,OpenAI 兼容端点(DeepSeek / vLLM 自部署)
      - Prompt 工程:schema 注入、否定式约束、多级降级解析;用药方案的强制溯源与休药期约束
      
      **知识图谱与检索**
      - Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
      - FAISS + bge-large-zh-v1.5 向量召回,按实体类型过滤
      - 术语标准化:养殖户方言口语到标准病名的映射("猪站不起来" → "后肢瘫痪")
      
      **模型微调**:Alpaca 数据集构建、LoRA 微调、vLLM multi-LoRA 部署
      
      **工程能力**:Python 3.10、FastAPI(异步 / 流式 / 线程池)、Streamlit、并发采集、
      `asyncio.Queue` 跨线程反压、Git / Black;弱网环境下的移动端适配
      
      ---
      
      ## 项目经验
      
      ### 畜禽疫病诊断与养殖知识图谱问答系统 `<公司/独立项目>`
      
      **角色**:技术负责人,负责整体方案与全链路实现,与 `<N>` 名执业兽医协作做内容审核
      
      **背景**:中小养殖户遇到畜禽异常时,最近的兽医可能在几十公里外,而疫病扩散以小时计。
      养殖户描述症状用的是方言口语("猪不吃食耷拉着""鸡拉白屎"),而兽医知识库里是标准病名。
      通用大模型的问题是会编造用药方案,尤其是**给出错误剂量或忽略休药期** —— 后者直接导致
      畜产品药残超标,整批货被拒收。目标是把疫病手册、饲养规程、兽药说明书变成可查询的
      知识图谱,让诊断建议可回溯,且用药方案强制携带休药期。
      
      **技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
      FastAPI / Streamlit / vLLM
      
      **七阶段流水线:**
      
      **1. 数据采集**(疫病诊断手册 + 兽药说明书 + 饲养管理规程 + 防疫政策)
      - 按**文档顺序**遍历文档结构而非按已知字段名查找 —— 不同来源的疫病资料章节命名与层级
        完全不统一(临床症状/病理变化/剖检变化/鉴别诊断混用),按顺序走开新章节才能完整保留。
        漏掉"鉴别诊断"章节会让相似症状的疫病无法区分
      - 断点续传做在文件系统层:目标文件存在即完成;写入走 `.part` 再原子替换。
        防疫政策更新频繁,续传让增量更新只做缺失部分
      - 并发采集 + Session 按线程隔离
      
      **2. 疫病知识抽取**(LLM 结构化输出)
      - Pydantic 定义 schema 作为全链路契约:实体为**畜种 / 疫病 / 症状 / 病因 / 兽药 /
        防治措施**;关系为**易感于 / 表现为 / 由...引起 / 治疗 / 预防 / 传播途径**
      - 属性挂在实体上:疫病带传染性、是否强制上报、潜伏期、死亡率;兽药带剂量、
        给药途径、**休药期**、适用畜种、配伍禁忌;措施带成本和实施窗口
      - **休药期是不可省略字段**:抽取时强制要求,缺失即视为抽取失败而非空值。
        这是本项目定义的 P0 约束 —— 漏掉休药期的用药建议会造成药残超标和整批产品报废
      - **法定强制上报疫病单独标记**:非洲猪瘟、高致病性禽流感这类必须触发上报提示而非
        仅给治疗建议,答案生成时置顶输出
      - Prompt 显式要求"症状与疫病无明确关联时返回空结果" —— 模型最危险的行为是为了填满格式
        而虚构因果链,把无关疫病关联到症状上导致误诊,空结果是正确输出
      - **剂量数值逐字保留,禁止换算**:按体重计的剂量原样搬运,换算在下游按实际体重显式处理
      - 成本约束:耗时取决于**输出**规模,长篇疫病资料显著更慢;失败不写文件因此留在待办列表,
        重跑即重试;空结果要写,否则重跑在重复付费
      
      **3. 图谱构建**
      - **先在内存聚合再写库**:常见症状(如"腹泻""不食")出现在数千份资料里,
        逐条 MERGE 等于把同样的工作重做数千遍
      - **节点主键是 `(类型, 名称)` 而非名称**:畜牧领域跨类型同名极其常见 ——
        "腹泻"既是症状也可作为独立疾病诊断,"大肠杆菌"既是病因也是检测项。
        只按名称去重会把它们错误合并
      - **畜种必须入主键维度**:同一个病名在猪和禽上的症状、用药、剂量完全不同;
        更关键的是很多兽药**禁用于特定畜种**,只按药名去重会给出跨畜种的错误用药建议
      - 标签、关系类型、属性键先过白名单再拼接;值一律参数化
      - 属性用 `MERGE ... SET n += $attrs`:兽药目录调整或政策更新后属性变化,写进 MERGE 模式
        会匹配失败并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
      - **禁用药清单入图并做硬拦截**:已禁用兽药在答案生成层拦截,不进入推荐
      
      **4. 检索层**
      - Neo4j schema 元数据导出后注入 Cypher 生成 prompt;关系描述人工补齐
        (含适用畜种、季节性说明),由执业兽医复核
      - FAISS 索引覆盖全部实体类型,**索引本身不带类型**,召回后必须按类型过滤 ——
        否则"咳嗽"可能召回一个兽药名并落进"症状"槽位,生成出错误的 Cypher。
        超额取 `top_k * N` 再过滤
      - 分级健康检查守住索引与图谱一致性:自检索(已知实体以距离≈0 返回 top-1)证明
        向量与映射未错位;与 Neo4j 对账防止资料增量导入后索引陈旧
      
      **5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
      
      ```
      START → 语义转写 → 养殖意图识别
                ├─ 非养殖 ─→ 直接回答 ──────────────────────→ END
                └─ 养殖  ─→ 症状抽取 → 术语匹配 → cypher生成 ←──┐
                                → cypher检查                    │
                                     ├─ 全部合法 ─→ 执行 → 防治方案 → END
                                     ├─ 不合法且未超 3 次 ────────┘
                                     └─ 额度用尽 ─→ 直接回答 → END
      ```
      
      - **语义转写排在最前**:养殖户咨询天然多轮 —— 先问"我的猪不吃食",再问"三天了还这样"。
        后一句单看没有任何信息,意图识别会判成无关。转写节点结合历史补全成完整问题,
        下游才有东西可用。全图只有转写节点读原始输入,其余节点读转写结果。
        **同时负责补全畜种**:养殖户常省略主语,"最近老死"必须结合上文补出是猪还是鸡,
        否则用药建议可能跨畜种
      - **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,重试次数由生成节点自增,
        上限 3 次。重试时回喂上一轮不合法的语句,否则模型输入不变会原样再生成
      - **生成的 Cypher 直连生产库,两个独立位置设闸**:① 关键词词边界扫描 + 必须有 RETURN
        且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验
      - **绝不把用户原话放进 WHERE**:养殖户说"猪站不起来",图里存的是"后肢瘫痪"。
        术语匹配节点弥合这个差距 —— 方言口语与标准病名几乎没有字面交集,
        这是本项目里 embedding 召回价值最大的一环
      - **答案生成节点的四条硬约束**:
        ① 结论必须携带来源(手册章节 / 说明书),来源只能来自图谱查询结果;
        ② 用药建议**必须显示休药期**,无休药期数据则不给该药;
        ③ 命中强制上报疫病时置顶提示上报,且不以治疗建议替代上报;
        ④ 检索不到时明确告知并建议联系执业兽医,绝不编造用药方案
      - JSON 解析多级降级;Cypher 逐条执行而非批量事务,出错补空列表保持下标对齐
      
      **6. 服务化**
      - FastAPI 三接口:健康检查、同步问答、流式问答
      - 图是懒加载的模块级单例,`lifespan` 启动预热(FAISS 索引 + embedding 模型约 50 秒);
        预热失败只记日志不阻止启动
      - `graph.invoke` 同步阻塞,走 `run_in_threadpool`,否则事件循环被占死、服务退化成单并发。
        疫情期间并发咨询量会陡增
      - 流式接口用生产者-消费者:节点在工作线程产出,有界 `asyncio.Queue`(32,形成反压)
        跨回事件循环。养殖场网络条件差,流式让首个反馈在 1 秒内到达,
        比等 30 秒后一次性返回的体验好得多,也更耐受连接不稳
      
      **7. 前端与微调**
      - Streamlit 多页:常规问答 + 流式页。流式页展示检索过程(识别的畜种和症状、候选疫病、
        引用来源)。针对养殖户优化:字号加大、移动端适配、支持语音输入转文字
      - 抽取结果顺带产出 Alpaca 数据集用于微调;两条写入路径 —— 抽取时追加 JSONL
        (并发追加整行不会破坏性交错),另有不调 LLM 的幂等重建脚本覆盖续传跳过的历史条目
      - LoRA 微调后 vLLM 部署,multi-LoRA 单服务挂多个畜种适配器(猪 / 禽 / 牛羊)按需切换
      
      **成果**
      - 覆盖 `<N>` 个畜种、`<M>` 种疫病、`<K>` 份兽药说明书,schema 6 类实体 6 类关系
      - **用药建议 100% 携带休药期**,无休药期数据的药不进入推荐,从源头规避药残超标
      - 强制上报疫病命中时置顶上报提示,不以治疗建议替代上报
      - 方言口语到标准病名的召回是核心能力,弥合养殖户表达与专业术语的鸿沟
      - 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live;
        前端 49 条离线 + 11 条 live
      
      ---
      
      ## 行业适配要点(面试可展开)
      
      1. **休药期不可省略**:缺失即视为抽取失败,漏掉直接导致药残超标和整批产品报废
      2. **畜种入主键**:同名病在猪和禽上用药完全不同,很多兽药禁用于特定畜种
      3. **强制上报疫病**:非洲猪瘟等命中即置顶上报提示,不以治疗建议替代
      4. **禁用药硬拦截**:禁用清单入图,在答案生成层拦截而非依赖 prompt 约束
      5. **方言召回**:"猪站不起来" → "后肢瘫痪",字面无交集,embedding 召回价值最大的一环
      6. **转写节点补全畜种**:养殖户常省略主语,补错会导致跨畜种用药
      7. **弱网适配**:流式让首个反馈 1 秒内到达,比一次性返回更耐受不稳定连接
      8. 定位为辅助工具,诊断与处方须执业兽医确认
      
    • 📄 05_土木行业.md 11.1KB
      # 简历 · 大模型应用开发工程师(土木/建筑行业版)
      
      > 由中医知识图谱项目迁移。同一套架构(采集 → LLM 抽取 → 图谱 → Agent → 服务 → 前端 → 微调),
      > 换成建筑规范与施工技术知识检索场景。带 `<>` 的请按实际情况替换。
      
      ---
      
      ## 基本信息
      
      | | |
      |---|---|
      | 姓名 | `<姓名>` |
      | 岗位 | 大模型应用开发工程师 / 建筑 AI |
      | 经验 | 4 年(其中大模型应用方向 2 年) |
      | 电话 / 邮箱 | `<电话>` / `<邮箱>` |
      | 学历 | `<学校>` `<专业>` `<本科/硕士>` |
      
      ---
      
      ## 技能栏
      
      **大模型应用**
      - LangGraph 有状态编排:多节点条件路由、带上限的重试环、多轮咨询记忆、流式增量产出
      - LangChain 链式调用与结构化输出,OpenAI 兼容端点(DeepSeek / vLLM 自部署)
      - Prompt 工程:schema 注入、否定式约束、多级降级解析;规范条文的强制引注约束
      
      **知识图谱与检索**
      - Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
      - FAISS + bge-large-zh-v1.5 向量召回,按实体类型过滤
      - 术语标准化:现场口语到规范术语的映射("钢筋绑得太稀" → "箍筋间距超限")
      
      **模型微调**:Alpaca 数据集构建、LoRA 微调、vLLM multi-LoRA 部署
      
      **工程能力**:Python 3.10、FastAPI(异步 / 流式 / 线程池)、Streamlit、并发采集、
      `asyncio.Queue` 跨线程反压、Git / Black;工地现场移动端适配
      
      ---
      
      ## 项目经验
      
      ### 建筑规范与施工技术知识图谱问答系统 `<公司/独立项目>`
      
      **角色**:技术负责人,负责整体方案与全链路实现,与 `<N>` 名注册结构/建造师协作做条文校核
      
      **背景**:施工现场遇到技术问题要查规范,而规范体系庞杂(国标/行标/地标/图集),条文之间
      互相引用、还有新旧版本替代关系。现场人员翻纸质规范效率极低,而通用大模型回答规范问题时
      会**编造条号和限值** —— 给出一个"《混凝土结构设计规范》第某条规定保护层厚度为 X mm",
      条号和数值都似真非真。规范限值错误直接对应结构安全与验收不合格。目标是把规范条文变成
      可查询的知识图谱,让每一条引用和每一个限值都能回溯到真实条文。
      
      **技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
      FastAPI / Streamlit / vLLM
      
      **七阶段流水线:**
      
      **1. 数据采集**(国标/行标/地标规范全文 + 标准图集 + 施工工艺标准 + 质量验收标准)
      - 按**文档顺序**遍历文档结构而非按已知字段名查找 —— 规范的层级(篇/章/节/条/款/项)
        和条文说明的排布因标准而异,按顺序走开新章节才能保住条文的从属关系。
        这在规范场景尤为关键:**条文脱离所属章节含义会变**,同一句限值在"一般规定"下
        和在"抗震设计"下的适用范围完全不同
      - **强制性条文单独标记**:黑体字条文(强条)必须与一般条文区分,它们不可通过论证变通
      - 断点续传做在文件系统层:目标文件存在即完成;写入走 `.part` 再原子替换。
        规范修订后重采,续传让增量更新只做缺失部分
      - 并发采集 + Session 按线程隔离
      
      **2. 条文结构化抽取**(LLM 结构化输出)
      - Pydantic 定义 schema 作为全链路契约:实体为**规范标准 / 条文 / 构件 / 材料 /
        技术指标 / 施工工序**;关系为**规定 / 适用于 / 引用 / 替代 / 前置工序 / 检验要求**
      - 属性挂在实体上:条文带条号、是否强制性、实施日期、废止日期;技术指标带限值、
        单位、适用条件(环境类别、抗震等级、结构形式);工序带验收节点和隐蔽工程标记
      - **限值必须连同适用条件一起抽取**:脱离条件的限值毫无意义且危险 ——
        保护层厚度随环境类别变化,同一个数值在不同环境下可能合规也可能违规。
        这是本项目定义的 P0 约束,限值与条件必须绑定为一个整体,不允许拆开存储
      - **条文原文逐字保留,禁止改写**:任何摘要都会让引用失去技术依据意义
      - **数值与单位逐字保留,禁止换算**:MPa/kN/mm 原样搬运,换算在下游显式处理
      - Prompt 显式要求"条文之间无明确引用关系时返回空结果" —— 模型最危险的行为是虚构
        条文之间的引用,空结果是正确输出
      - 成本约束:耗时取决于**输出**规模,长条文(如荷载规范)显著更慢;失败不写文件因此
        留在待办列表,重跑即重试;空结果要写,否则重跑在重复付费
      
      **3. 图谱构建**
      - **先在内存聚合再写库**:常见构件(如"梁""柱")出现在数千条条文里,
        逐条 MERGE 等于把同样的工作重做数千遍
      - **节点主键是 `(类型, 名称)` 而非名称**:土木领域跨类型同名极其常见 ——
        "混凝土"既是材料也是分部工程,"沉降"既是技术指标也是质量缺陷现象。
        只按名称去重会把它们错误合并
      - **条号必须绑定所属标准编号才唯一**:`第 4.1.3 条` 在几十本规范里都存在,
        只按条号去重会把不同规范的同号条文错误合并,这是致命错误。
        主键实际是 `(标准编号, 版本年份, 条号)`
      - 标签、关系类型、属性键先过白名单再拼接;值一律参数化
      - 属性用 `MERGE ... SET n += $attrs`:规范修订后限值变化,写进 MERGE 模式会匹配失败
        并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
      - **版本替代关系入图**:新版规范与被替代版本之间建边,查询时可回答"现行有效版本是哪本",
        同时保留历史版本用于既有项目 —— 在建项目按开工时的规范执行,不能一律给最新版
      
      **4. 检索层**
      - Neo4j schema 元数据导出后注入 Cypher 生成 prompt;关系描述人工补齐
        (含适用范围、强条标记说明),由注册结构工程师复核
      - FAISS 索引覆盖全部实体类型,**索引本身不带类型**,召回后必须按类型过滤 ——
        否则"裂缝"可能召回一个标准名并落进"技术指标"槽位,生成出错误的 Cypher。
        超额取 `top_k * N` 再过滤
      - 分级健康检查守住索引与图谱一致性:自检索(已知实体以距离≈0 返回 top-1)证明
        向量与映射未错位;与 Neo4j 对账防止规范增量导入后索引陈旧
      
      **5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
      
      ```
      START → 语义转写 → 技术意图识别
                ├─ 非技术 ─→ 直接回答 ──────────────────────→ END
                └─ 技术  ─→ 实体抽取 → 术语匹配 → cypher生成 ←──┐
                                → cypher检查                    │
                                     ├─ 全部合法 ─→ 执行 → 引注答案 → END
                                     ├─ 不合法且未超 3 次 ────────┘
                                     └─ 额度用尽 ─→ 直接回答 → END
      ```
      
      - **语义转写排在最前**:技术咨询天然多轮 —— 先问"梁的保护层厚度要求",再问"那二类环境呢"。
        后一句单看没有任何信息,意图识别会判成无关。转写节点结合历史补全成完整问题,
        下游才有东西可用。全图只有转写节点读原始输入,其余节点读转写结果。
        **同时负责补全适用条件**:现场提问常省略环境类别和抗震等级,这两个条件不补全,
        查出来的限值就是错的
      - **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,重试次数由生成节点自增,
        上限 3 次。重试时回喂上一轮不合法的语句,否则模型输入不变会原样再生成
      - **生成的 Cypher 直连生产库,两个独立位置设闸**:① 关键词词边界扫描 + 必须有 RETURN
        且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验
      - **绝不把用户原话放进 WHERE**:现场说"钢筋绑得太稀",图里存的是"箍筋间距超限"。
        术语匹配节点弥合这个差距
      - **答案生成节点的四条硬约束**:
        ① 结论必须携带标准编号 + 版本年份 + 条号,且只能来自图谱查询结果,
           宁可回答"未检索到相关条文"也不编造条号;
        ② **限值必须连同适用条件一起输出**,不允许给出无条件的裸数值;
        ③ 命中强制性条文时明确标注"强条";
        ④ 涉及已废止版本时提示现行有效版本
      - JSON 解析多级降级;Cypher 逐条执行而非批量事务,出错补空列表保持下标对齐
      
      **6. 服务化**
      - FastAPI 三接口:健康检查、同步问答、流式问答
      - 图是懒加载的模块级单例,`lifespan` 启动预热(FAISS 索引 + embedding 模型约 50 秒);
        预热失败只记日志不阻止启动
      - `graph.invoke` 同步阻塞,走 `run_in_threadpool`,否则事件循环被占死、服务退化成单并发
      - 流式接口用生产者-消费者:节点在工作线程产出,有界 `asyncio.Queue`(32,形成反压)
        跨回事件循环。工地网络条件差,流式让首个反馈在 1 秒内到达,也更耐受连接不稳
      
      **7. 前端与微调**
      - Streamlit 多页:常规问答 + 流式页。流式页展示检索过程(识别的构件和条件、命中的规范、
        引用条号),技术人员必须看到引用依据才敢采信。移动端适配以便现场使用
      - 抽取结果顺带产出 Alpaca 数据集用于微调;两条写入路径 —— 抽取时追加 JSONL
        (并发追加整行不会破坏性交错),另有不调 LLM 的幂等重建脚本覆盖续传跳过的历史条目
      - LoRA 微调后 vLLM 部署,multi-LoRA 单服务挂多个专业适配器(结构 / 施工 / 验收)按需切换
      
      **成果**
      - 覆盖 `<N>` 本规范标准、`<M>` 万条条文,schema 6 类实体 6 类关系
      - **零编造条号与限值**:所有引用带标准编号 + 版本 + 条号,检索不到时明确告知
      - 限值强制连同适用条件输出,杜绝脱离条件的裸数值
      - 强制性条文标注,版本替代关系可查,支持既有项目按开工时规范查询
      - 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live;
        前端 49 条离线 + 11 条 live
      
      ---
      
      ## 行业适配要点(面试可展开)
      
      1. **限值与适用条件绑定**:脱离环境类别/抗震等级的裸数值毫无意义且危险,P0 约束
      2. **条号主键**:`第 4.1.3 条` 在几十本规范里都有,主键必须是 `(标准编号, 版本, 条号)`
      3. **强制性条文标记**:黑体强条不可论证变通,答案中必须标注
      4. **版本替代链入图**:在建项目按开工时规范执行,不能一律给最新版
      5. **条文原文逐字保留**:改写会让引用失去技术依据意义
      6. **转写节点补全适用条件**:现场提问常省略环境类别,不补全查出的限值就是错的
      7. **跨类型同名**:混凝土既是材料也是分部工程,主键必须是 `(类型, 名称)`
      8. 定位为规范检索辅助工具,设计与验收结论须注册执业人员签认
      
    • 📄 README.md 3.8KB
      # 简历目录
      
      按「大模型应用开发工程师」岗位、4 年经验(含大模型方向 2 年)口径撰写。
      主版基于本仓库真实实现,5 份行业版是同一套架构的迁移。
      
      | 文件 | 场景 | 该行业的核心差异点 |
      |---|---|---|
      | `00_中医知识图谱_主版.md` | 中医问答(真实项目) | 口语症状标准化、方剂-药材关系、跨类型同名实体 |
      | `01_法律行业.md` | 法规检索 | 效力层级、时效区间、同号条文主键、强制引注 |
      | `02_医疗行业.md` | 临床辅助问答 | 禁忌 vs 适应症(P0)、别名映射、证据等级、私有化部署 |
      | `03_制造业.md` | 设备排障 | 型号入主键、安全前置条件独立抽取、工单频次排序 |
      | `04_畜牧业.md` | 疫病诊断 | 休药期不可省略、畜种入主键、强制上报、方言召回 |
      | `05_土木行业.md` | 规范检索 | 限值与适用条件绑定、条号主键、强条标记、版本替代链 |
      
      ## 使用说明
      
      **投递前必做**:
      1. 替换所有 `<>` 占位符(姓名、联系方式、学历、公司名)
      2. 行业版里的 `<N>` `<M>` `<K>` 是规模数字,按实际情况填写;若该行业项目未真实做过,
         建议只投主版,或在行业版中明确标注为"技术方案设计"而非"已交付项目"
      3. 每份都偏长(适合作为技术附录或详版)。投递主简历时建议压到 1–2 页:
         保留技能栏 + 项目背景 + 架构图 + 3–5 条最硬的技术决策 + 成果
      
      **关于真实性**:主版的所有数字(18895 节点、48344 关系、1472 味中药、703 个方剂、
      9 节点图、27/15/49/11 条用例)都来自本仓库实际运行结果,可以经得起追问。
      5 份行业版是架构迁移方案,其中的技术决策(主键设计、白名单、重试环刹车、双重校验、
      流式反压)是通用的、真实实践过的;行业特有的约束是基于该领域常识推导的合理设计。
      面试时如实区分这两部分,比含糊带过更有说服力。
      
      ## 6 份共有的技术骨架
      
      所有版本共享同一套七阶段流水线和同一批技术决策,这些是真实实践过的部分:
      
      1. **采集**:文档序遍历(不按字段名查找)、文件系统级断点续传、`.part` 原子写入、线程池并发
      2. **抽取**:Pydantic schema 作为全链路契约、空结果是正确输出、失败不写盘因此天然可重试、
         数值逐字保留禁止换算
      3. **图谱**:内存先聚合再写库、主键是 `(类型, 名称)`、三处白名单 + 值参数化、
         `MERGE ... SET n += $attrs` 的取舍、批量事务
      4. **检索**:schema 元数据注入 prompt、FAISS 不带类型所以必须过滤召回、
         自检索 + 对账的分级健康检查
      5. **Agent**:语义转写前置(多轮补全)、条件边分流、重试环靠状态计数刹车、
         生成语句双重独立校验、绝不把用户原话放进查询条件、多级降级解析
      6. **服务**:懒加载单例 + lifespan 预热、`run_in_threadpool` 保并发、
         生产者-消费者 + 有界队列反压的流式、推增量不推全量
      7. **前端与微调**:Streamlit 多页 + 思考过程可视化、Alpaca 数据集双写入路径
         (并发追加 + 幂等重建)、LoRA + vLLM multi-LoRA
      
      ## 面试高频追问的准备方向
      
      - 为什么主键必须带类型维度(主版有 356 个跨类型同名实体的实测数据)
      - FAISS 索引不带标签,为什么是过滤而不是加大 top_k
      - 重试计数为什么必须由节点写而不是路由函数
      - 空结果为什么要写盘(成本考量,不是正确性考量)
      - 流式为什么需要有界队列 + 阻塞式 put(慢客户端下的反压)
      - 元数据裁剪的反直觉结论:删三元组但保留标签,会让模型把关系方向写反,
        生成语法合法但永远返回空的查询
      
  • 📄 .gitignore 0B
    
    
  • 📄 CLAUDE.md 34.2KB
    # CLAUDE.md
    
    This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
    
    ## What this project is
    
    A Chinese-medicine (中医) knowledge-graph + LLM pipeline, built as a Heima/itheima training track. The numbered top-level directories are the **stages of one pipeline**, meant to be built and run in order. Each stage consumes the previous stage's output from disk — there is no orchestrator, you run the stages by hand.
    
    | Directory | Stage | State |
    |---|---|---|
    | `_000_demo/` | Teaching demos, one per library the pipeline uses: `requests`, `BeautifulSoup`, `faiss`, `langgraph` | not part of the pipeline, but `04_langgraph_demo.py` is the reference for wiring `_004_` |
    | `_001__clawler/` | Crawl 中药大全 / 方剂大全 from `zhongyibaike.com` → CSV lists → per-item `.txt` | **done**: 1472 herbs + 703 formulas on disk |
    | `_002__extract_information/` | LLM entity/relation extraction → one json per item, plus Alpaca datasets for `_007_` | formulas **done** 703/703; herbs 1239/1472 |
    | `_003_create_neo4j_database/` | Load triples into Neo4j, export `tcm_metadata.json` (schema layer), build the FAISS entity index | **done**: 18895 nodes + 48344 relations; metadata + index on disk |
    | `_004_langgraph_more_nodes/` | LangGraph agent over the graph — `AgentState` + one file per node | **in progress**: 7 nodes written and individually tested, graph not wired yet |
    | `_005_fastapi/` | HTTP API over the agent — `main.py` (`GET /`, `POST /process`) + `test_api.py` | **done** for those two endpoints; `/process_stream` and `/picture/{filename}` from the spec not written |
    | `_006_streamlit/` | Streamlit UI | empty |
    | `_007_fine_tune/` | Model fine-tuning | empty |
    
    ("clawler" is a typo for crawler, kept as-is. Directory prefixes are inconsistent — `_001__clawler` has a double underscore, others single. Read the real name, don't infer it.)
    
    Requirements arrive as plain-text files named `需求`/`需求1`, written by the user in Chinese, placed inside the stage they describe (or at the repo root). **The user appends to these files as work proceeds** — `_004_langgraph_more_nodes/需求1` has grown one numbered block per node, and the newest block at the bottom is the live task. Check `git diff` on the 需求 file to see what was just added. Earlier stages' 需求 files (`_001_`, `_002_`, `_003_`) are complete and useful as a model for what "done" looks like.
    
    ## Architecture
    
    ### `common/` — the shared layer
    
    Dependency order is `path_utils` → `config` → `llm`/`neo4j_manager`. Respect it; don't import upward.
    
    - **`path_utils.py`** — `get_file_path(relative_path)` resolves against the repo root, computed from `__file__` rather than cwd. Every file path in this project goes through it; hardcoded absolute paths break the Windows-dev → Linux-deploy handoff this function exists for. It also `print`s the root at import time, so every script's first line of output is a stray path.
    - **`config.py`** — `Config` reads all secrets/settings from env vars into instance attributes. Instantiate it (`conf = Config()`); don't call `os.getenv` elsewhere. New settings go here. Note `TCM_METADATA` is the **file contents as a string** (read eagerly in `__init__`, so constructing `Config` touches disk), while `ENTITY_INDEX_PATH` / `ENTITY_ID2TEXT_PATH` are paths.
    - **`llm.py`** — module-level singleton `my_llm`, a `ChatOpenAI` pointed at an OpenAI-compatible endpoint via `MODEL_BASE_URL`. Import it rather than constructing new clients.
    - **`neo4j_manager.py`** — `Neo4jClient` over the official driver, plus module-level singleton `neo4j_client` built from `Config()`. Pass values through `parameters`, not string interpolation. Five methods, each with a distinct job:
      - `run_cypher(query, parameters=None)` → `list[dict]`, one session per call. This is what stage `_004_` uses.
      - `run_multiple_cypher(queries_with_params)` → one **write** transaction for the whole batch with a tqdm bar; any failure rolls back everything. For bulk import (`_003_`), not for read queries.
      - `validate_cypher(query)` → bool, runs `EXPLAIN <query>` so Neo4j compiles without executing. Syntax + label/property existence checking lives here, not in the node.
      - `get_all_node_names(label=None)` → the entity-name list that feeds the FAISS index.
      - `export_tcm_metadata_to_json(output_path)` → dumps labels / relationship types / triples / property keys. Writes relative to **cwd**, unlike everything else here.
    - **`embedding_model.py`** — `EmbeddingModel` wrapping `SentenceTransformer` (bge-large-zh-v1.5, 1024-dim), CUDA when available, plus module-level singleton `embedding_model`. `encode()` forwards `convert_to_numpy` / `normalize_embeddings`; the index was built with `normalize_embeddings=True`, so queries must pass it too. Two traps: the fallback path when `EMBEDDING_MODEL_PATH` is unset is a hardcoded `E:\...` that only exists on this box, and **import has side effects** — it prints the device, the repr, a sample vector and the dimension, so anything importing it emits four stray lines.
    - **`ouput_graph_utils.py`** — still an empty placeholder (note the `ouput` typo; nothing references it).
    - **`tcm_metadata.json`** — byte-identical copy of `_003_create_neo4j_database/tcm_metadata.json`. `Config` reads the `_003_` one; this copy is unreferenced. Edit the `_003_` file.
    
    Every `common/` module ends with an `if __name__ == '__main__':` self-test. That's the established smoke-test convention here — keep it when adding modules.
    
    ### Stage `_001_` — the crawler pattern
    
    Four scripts, two mirrored pairs: `01_herb_list.py` / `03_formula_list.py` write the CSV list, `02_herb_detail.py` / `04_formula_detail.py` consume it and write one `.txt` per item. The `*_detail` pair are near-identical (differing mainly in `方剂名` vs `中药名` column and output dir) — when you change parsing behaviour in one, check whether the other needs the same edit.
    
    Conventions worth preserving, since later stages and re-crawls depend on them:
    
    - **Resume is filesystem-based.** Existence of `中药/<name>.txt` means done; the todo list is built by filtering those out. Detail writes go to `<path>.<thread_id>.part` then `os.replace`, so an interrupted run never leaves a half file that looks complete. Stage `_002_`'s resume should follow the same shape.
    - **Concurrency**: `ThreadPoolExecutor`, default 5 workers, `--workers` to change, `--limit N` to try a few first. `requests.Session` is per-thread via `threading.local()`; `print` goes through a lock (`log()`).
    - **Parsing is order-driven, not field-driven.** The `h3` subsections differ per herb, so both detail parsers walk `h2/h3/p/li` in document order and open a new section on each heading, rather than looking up known field names. List parsers walk `content.descendants` (not `find_all("a")`) so each link can be attributed to the `h2` category above it. Both skip image `figure`s and `ol.p_alternative_list` as noise.
    - CSVs are written `utf-8-sig` (Excel), detail `.txt` plain `utf-8`.
    
    ### Stage `_002_` — extraction contract
    
    `__000__extract_graph_data_utils.py` defines the graph schema in Pydantic and exposes one function: `extract_tcm_knowledge(text) -> dict`, a `prompt | my_llm | JsonOutputParser` chain. The schema is the contract stage `_003_` will load and `_004_` will query, so treat it as the source of truth:
    
    - Entities: `Symptom`, `Disease`, `Formula`, `Herb`, `Effect`, `Source`
    - Relations: `TREATS_DISEASE`, `ALLEVIATES_SYMPTOM`, `HAS_EFFECT`, `HAS_INGREDIENT`, `HAS_SYMPTOM`, `FROM_SOURCE`
    - Attributes hang off entities: `FormulaAttributes` (alias/effect/indication/taboo/usage), `HerbAttributes` (adds dosage/meridian/origin/place/processing/property_flavor/traits)
    
    The prompt deliberately returns `{"entities": [], "relations": []}` when the text describes a single entity with no relation to another — so empty results are expected output, not failure. It also instructs the model not to mix formula and herb attribute sets in one extraction. `parser` is a `JsonOutputParser`, so the return value is a plain dict, not a `TCMKnowledgeGraph` instance — don't assume attribute access.
    
    Four scripts, **two mirrored pairs** matching the crawler layout — `01_`/`02_` for herbs, `03_`/`04_` for formulas. Within a pair: `01_`/`03_` call the LLM and write graph json, `02_`/`04_` rebuild the Alpaca dataset from that json without any LLM calls. The two extractors are near-identical (differing only in input/output dirs and 味/方 wording), as are the two builders — **change one, check whether its mirror needs the same edit.**
    
    The same prompt handles both: `FormulaAttributes` and `HAS_INGREDIENT`/`FROM_SOURCE` were already in the schema. Verified on formulas — every one got a `FROM_SOURCE`, ingredients came through as `HAS_INGREDIENT`, and attributes stayed within `FormulaAttributes` (no herb-only `dosage`/`meridian` leaking in), so the prompt's "don't mix the two attribute sets" instruction holds.
    
    `01_extract_herb_graph.py` is the batch driver over `中药/`. Same resume/concurrency shape as the crawlers (json existence = done, `.part`-then-`os.replace`, `ThreadPoolExecutor`), plus what the LLM step needs on top:
    
    - **Cost is the constraint, not politeness.** Measured 20s–4min per call, driven by *output* size (a herb with 87 entities is slow). Herbs: 1472 files ≈ 90h serially, ≈9–15h at the default 10 workers. Current progress: 1239 of 1472 herbs (233 left), formulas complete at 703/703. `中药图谱/_failed.log` has 2503 lines over 672 distinct names — it's append-only across runs, so most of those have since succeeded; the todo list is derived from json existence, not from this log. Formulas are much cheaper — 703 files averaging 377 chars vs the herbs' 1537, roughly 2–3h. There's no inter-call sleep; unlike the crawlers the LLM endpoint is the bottleneck, not an IP-ban risk.
    - **`--limit` defaults to 0 (no limit), `--workers` to 10**, in both extractors and in the `run()` signatures. A bare invocation is a *full* run — use `--limit 5` to trial.
    - **Failures don't write json**, so they stay in the todo list and a plain re-run retries them. They're also appended to `中药图谱/_failed.log` (`名字<TAB>原因`), which `--retry-failed` reads to narrow a run to just those.
    - Empty extractions *are* written, otherwise every re-run would re-pay for the same no-relation herbs.
    - Per-call retry is 2 attempts with 5s/10s backoff; `extract_one` returns `(name, ok, note, n_entity, n_relation)`.
    
    ### Alpaca fine-tuning dataset (feeds stage `_007_`)
    
    Same extraction, second output. `ALPACA_INSTRUCTION` and `build_alpaca_record()` live in `__000__extract_graph_data_utils.py` — shared constants have to go in a normally-named module because `01_`/`02_` can't import each other. `instruction` is fixed, `input` is the herb `.txt` verbatim, `output` is `json.dumps({"entities", "relations"})` — a **string**, per the Alpaca convention, not a nested object.
    
    Two write paths, deliberately:
    
    - The extractors (`01_`/`03_`) append one JSONL line per item as they go (the "顺便" part). JSONL because concurrent appends of whole lines don't interleave destructively, and a crash costs at most the last line. The append happens *after* the graph json is renamed into place — reverse that order and a crash between the two would let resume re-extract and duplicate the row.
    - The builders (`02_`/`04_`) rebuild the whole dataset from the graph json with no LLM calls. These exist because the append path only covers items extracted *in that run* — resume-skipped and pre-existing items would never get a row. They're idempotent (verified: identical md5 across two runs), so they're what to trust for the real dataset. `--json` for a json array instead of JSONL, `--skip-empty` to drop no-relation samples (kept by default — they teach the model not to invent relations).
    
    `load_source_text()` prefers the graph json's `source_file` and falls back to `<dir>/<name>.txt`, since early extractions may predate that field.
    
    ### Stage `_003_` — Neo4j load
    
    `01_import_graph_to_neo4j.py` reads both 图谱 dirs, builds Cypher in Python, writes through `Neo4jClient.run_multiple_cypher`. One script, not a mirrored pair — herbs and formulas share a schema and *have to* land in one graph, since a formula's `HAS_INGREDIENT` targets the same `Herb` nodes the herb files describe. Verified: 一贯煎's six ingredients resolve to `Herb` nodes carrying `property_flavor` from their own herb file.
    
    - **Aggregate in memory first, then write.** 49226 entity records collapse to 18895 nodes and 50627 relation records to 48344 edges — 咳嗽 appears in hundreds of files. Per-record MERGE would redo the same work hundreds of times.
    - **The node key is `(label, name)`, not `name`.** 356 names carry two types (热咳 is both `Disease` and `Symptom`); deduping on name alone would fuse them.
    - **Labels, relation types and attribute keys are string-concatenated; values are always `$` parameters.** Cypher can't parameterize the former, so all three go through the `ENTITY_TYPES` / `RELATION_TYPES` / `ATTR_KEYS` whitelists (mirrors of the `_002_` schema) before hitting the f-string.
    - Attributes use `MERGE (n:L {name: $name}) SET n += $attrs` — attrs inside the MERGE pattern would make a changed attribute miss and create a duplicate; `ON CREATE SET` would never backfill an existing node. So re-running after extracting more herbs upgrades the attribute-less nodes in place (1038 `Herb` nodes still lack `effect`, matching the 1240/1472 extraction progress).
    - A `WITH` sits between the two relation MATCHes. `MATCH (s), (o)` is two disconnected patterns, so the driver logs a cartesian-product warning per statement — 48k of them buries the output. Both sides are unique-constraint equality lookups, so the product is 1×1; the rewrite only silences the warning.
    - Unique constraints on `name` per label are created first (`IF NOT EXISTS`), otherwise every MERGE is a label scan.
    - Batched at 500 per transaction. Batches aren't atomic with each other, which is fine — everything is MERGE, so an interrupted run is fixed by re-running.
    - Relation endpoints missing from their file's own `entities` list (177 cases) get MERGEd anyway, or the MATCH would silently drop the edge.
    - Two malformed relations in the corpus (`TREATS` in 枇杷叶.json, `TREATS_DISE` in 重楼.json, both with a null object) fail the whitelist and are counted as dropped.
    
    ```bash
    python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --limit 5 --dry-run  # count + sample Cypher, no connection
    python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --limit 5            # 5 per dir, real write
    python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j                      # full, ~1-2 min
    python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --only formula
    python -X utf8 -m _003_create_neo4j_database.01_import_graph_to_neo4j --wipe                # DETACH DELETE the six labels first
    ```
    
    `--wipe` deletes only the six schema labels, leaving anything else in the database alone. The run ends by reading counts back per label and relation type rather than trusting the statement count.
    
    ### Stage `_003_` — the derived artifacts stage `_004_` reads
    
    Both are generated *from the loaded graph*, so **re-importing data without regenerating them leaves them stale**. `__004__test_faiss_index.py`'s last check exists precisely to catch that.
    
    - **`tcm_metadata.json`** (14.8 KB) — the schema layer, exported via `Neo4jClient.export_tcm_metadata_to_json()`. 6 labels, 6 relationship types, 14 triples, properties per label (`Herb` has 13, `Formula` 6, the other four only `name`). Every `description` field starts empty; the triples' descriptions have since been filled in by hand with row counts and usage notes, and that prose goes into the Cypher-generation prompt — **keep writing descriptions when the schema changes**, they're doing real work. The export method writes to cwd, so run it from `_003_/` or pass an absolute path.
    - **`nero4j_embedding_faiss.index`** (76 MB) + **`nero4j_embedding_faiss_id2text.pkl`** — built by `__003__faiss_embedding.py` from `get_all_node_names()` (no label argument, so **all six labels are mixed into one flat index**; consumers must filter by label themselves). `IndexFlatL2` over unit-length vectors, so the returned squared distance converts as `cos = 1 - dist/2`. The pkl is the `id -> name` map, since FAISS stores no metadata — index and pkl must be regenerated as a pair or lookups silently return the wrong names. Note the `nero4j` typo in both filenames, and that FAISS is sensitive to non-ASCII paths, which is why these live in an ASCII-named directory. `__003__faiss_embedding.py` **runs its work at import time** with no `main` guard — importing it rebuilds the index.
    
    `__004__test_faiss_index.py` is the health check, seven graded checks with a pass/fail summary and a nonzero exit on failure: files load, vector count == mapping count with contiguous ids, index dim == current model dim, self-retrieval (a known name must come back as top-1 at distance ≈0 — this is the check that actually proves vectors and mapping are aligned), normalized-vs-unnormalized query comparison, semantic recall on colloquial words not in the index, and reconciliation against Neo4j.
    
    ### Stage `_004_` — the LangGraph agent
    
    Departs from the earlier stages' layout: **one file per node** under `nodes/`, no numeric prefixes, so nodes *can* import each other and be imported by tests. `agent_state.py` holds `AgentState` (a `TypedDict`) and is the contract every node reads and writes — **read it first**, it lists the whole pipeline in field order.
    
    The shape every node follows:
    
    - Signature is `def x_node(state: AgentState) -> AgentState`, mutating and returning the same dict. Nodes get called directly with hand-built dicts holding only their own input keys, so prefer `state.get(key) or []` over `state[key]` (most nodes do; `check_cypher_node` uses subscript access and will `KeyError` on a state without `cypher_query`).
    - **Write every output key on every path, including early returns.** Empty input short-circuits before the LLM call, but still fills all six `user_input_*` keys, so downstream nodes never have to check for absence.
    - **Skip the LLM when the answer is already determined.** Empty input, or no matched entity at all in the Cypher generator — both return early rather than paying for a call whose result can't be useful.
    - A `if __name__ == "__main__":` block runs the node over a handful of hand-written cases including the empty/negative one. Same convention as `common/`. It's the only test mechanism in the repo.
    
    Node by node (execution order, matching `agent_state.py`):
    
    | Node | Reads | Writes |
    |---|---|---|
    | `zhongyi_intent_node` | `input` | `is_zhongyi_intent` |
    | `extract_entity_from_user_input_node` | `input` | `user_input_*` ×6 |
    | `match_entity_from_neo4j_node` | `user_input_*` | `matched_*` ×6 |
    | `generate_neo4j_cypher_node` | `input`, `matched_*`, `Config().TCM_METADATA` | `cypher_query` |
    | `check_cypher_node` | `cypher_query` | `is_all_validate_cypher` |
    | `run_cypher_node` | `cypher_query` | `cypher_results` |
    | `llm_direct_out_node` | `input` | `direct_out`, `output` |
    
    `llm_direct_out_node` is the **other branch**, not a step in the chain: when `is_zhongyi_intent` is False the graph skips straight to it and ends. It writes the same answer to both `direct_out` and `output` — `output` is the graph's single exit, so `_005_` can read one key without caring whether the answer came from the graph or from the model directly.
    
    Still to write: a node turning `cypher_results` into `neo4j_answer` (and on to `output`), and `build_langgraph()` wiring it all up. `_000_demo/04_langgraph_demo.py` is the reference for that last part — `StateGraph` + `add_conditional_edges` with a router function returning a node name, then `.compile()`. Chinese node names as graph keys, as in the demo.
    
    The six entity types are the **same six** as the `_002_` schema and the Neo4j labels, and the same six recur as `user_input_*` / `matched_*` / prompt sections / `TYPE_FIELDS` / `MATCHED_FIELDS` dicts. Adding a seventh type means touching all of them.
    
    Two things that are easy to get wrong and are load-bearing:
    
    - **`matched_*`, never the user's own words, in `WHERE`.** The user says 拉肚子, the graph stores 腹泻. `match_entity_from_neo4j_node` exists solely to bridge that, and the Cypher prompt explicitly forbids using words from the question. Similarly the extractor is told not to rewrite or add suffixes (人参 must not become 人参片) — anything it invents can't match.
    - **The FAISS index is unlabeled, so recall must be filtered by label.** `match_entity_from_neo4j_node` pulls each label's names from Neo4j (`get_all_node_names(label)`, cached per process) and drops hits outside the label being matched — otherwise 咳嗽 can return a herb name and land in `matched_symptoms`, generating meaningless Cypher. It over-fetches `top_k * 6` so filtering still leaves `top_k`. If Neo4j is unreachable it warns and degrades to unfiltered rather than failing. FAISS itself is lazy-loaded and cached at module level (the index is 76 MB — don't load it at import). `TOP_K = 5` matches the 需求; `SIMILARITY_THRESHOLD` is 0.80 while the 需求 and the module docstring both say 70% — tightened deliberately during testing, so don't "fix" it back without checking recall.
    
    Generated Cypher goes straight to the live database, so it's gated in two independent places, and the prompt's "严禁 CREATE/MERGE/..." instruction is not one of them:
    
    1. `generate_neo4j_cypher_node._is_readonly()` — a `FORBIDDEN_KEYWORDS` word-boundary scan plus "has RETURN and starts with MATCH/OPTIONAL MATCH/WITH". Statements failing it are dropped with a warning, capped at `MAX_CYPHER = 5`.
    2. `check_cypher_node` — `Neo4jClient.validate_cypher()`, i.e. `EXPLAIN` per statement, which catches syntax errors and nonexistent labels/properties without executing. Sets `is_all_validate_cypher` False on the first failure. `check_cypher_node_bak.py` is a superseded alternative that reimplemented the checks in Python by regexing against `tcm_metadata.json`; the current version delegates to `validate_cypher` instead, letting Neo4j be the authority. Nothing imports the `_bak` file.
    
    `generate_neo4j_cypher_node` hand-rolls its JSON parsing instead of using `JsonOutputParser`: it strips code fences, retries by regexing out the first `[...]`, unwraps a `{"cypher_query": [...]}` envelope, and finally falls back to scraping `MATCH`-prefixed lines — a parse failure must degrade, not break the chain. `run_cypher_node` runs statements **one at a time** via `run_cypher` (not `run_multiple_cypher`, whose all-or-nothing write transaction would let one bad read query discard the others) and appends an empty list for any that raise, keeping `cypher_results` index-aligned with `cypher_query`.
    
    ### Stage `_005_` — the HTTP API
    
    `main.py` is deliberately thin: validate, run the graph, return `state["output"]`. Two endpoints from `API_INTERFACE.md` — `GET /` (health) and `POST /process` (`{input, user_id}` → `{input, output}`). Three things it does that are load-bearing:
    
    - **The graph is a lazily-loaded module-level singleton** (`get_graph()`, with the import *inside* the function). Importing `langgraph_more_nodes` pulls in the 76 MB FAISS index and the embedding model — ~50s. `lifespan` warms it on startup so the first request doesn't pay for it; a warmup failure only logs, so the service still starts and health check still answers.
    - **`graph.invoke` goes through `run_in_threadpool`.** It's synchronous and takes 5–30s per call; awaiting it on the event loop would serialize the whole service. `test_api.py`'s concurrency case exists to catch a regression here.
    - **`user_id` is validated but not passed to the graph.** `AgentState` has no such field and there's no session store yet — the spec's "对话历史/记忆" is not implemented. Errors collapse to `500 {"input": "", "output": "系统出错了,请重试!"}` with the traceback server-side only.
    
    `test_api.py` follows the repo's `__main__` convention (no pytest) with a `_check` counter and nonzero exit. Default mode swaps `main._graph` for a `FakeGraph`, so the 27 checks touch neither LLM, Neo4j, nor FAISS and run in under two seconds — that's the regression suite. `--live` (15 checks) needs a server already running and does spend money. Verified: 27/27 offline, 15/15 live including the graph path (四君子汤 → 人参、白术、茯苓、炙甘草) and the fallback path.
    
    `nodes/元数据裁剪方案.md` is a **design document, not implemented behaviour** — a five-layer plan for trimming the schema fed to the Cypher prompt once it outgrows the current 14.8 KB. Its own recommendation for the present size is to do nothing. Read it before "optimizing" the prompt; its conclusion is that the first win is deriving a subgraph from `matched_*`, and its warning is that dropping triples while keeping labels makes the model reverse arrow directions, producing valid Cypher that always returns nothing.
    
    Output files: `alpaca_herb_dataset.jsonl` and `alpaca_formula_dataset.jsonl`. **`instruction` is identical across both**, so `cat`-ing the two JSONL files is a valid combined training set — verified no duplicate `input` across them.
    
    Verified end to end on both paths: graph json count matches dataset line count exactly, every `output` parses back to `{entities, relations}`, extracting N more items appends exactly N lines.
    
    ## Environment
    
    - Python 3.10 (conda env `ctm_kg`, registered outside the repo tree). Windows / Git Bash; use forward slashes.
    - Black is the configured formatter (88 chars, double quotes). Existing code is inconsistent about quote style — match Black for new code.
    
    ### Env vars
    
    `common/.env` is the real file. `Config` expects:
    
    ```
    MODEL_API_KEY  MODEL_BASE_URL  MODEL_NAME     # OpenAI-compatible LLM endpoint
    NEO4J_URI  NEO4J_USER  NEO4J_PASSWORD          # graph database
    JIMENG_AK  JIMENG_SK                           # 极梦 image generation
    EMBEDDING_MODEL_PATH                           # local embedding model (stage _004_)
    ```
    
    ## Commands
    
    Run everything with `python -m` **from the repo root**. Imports are absolute (`from common.config import Config`), so `python common/llm.py` fails with `ModuleNotFoundError` — the script's own directory lands on `sys.path` instead of the root.
    
    ```bash
    python -m common.config           # print the loaded API key
    python -m common.llm              # smoke-test the LLM connection
    python -m common.neo4j_manager    # smoke-test Neo4j
    
    # stage _001_ (already run; re-run is incremental thanks to resume)
    python -X utf8 -m _001__clawler.01_herb_list                        # → herb_list.csv
    python -X utf8 -m _001__clawler.02_herb_detail --limit 5            # try 5 first
    python -X utf8 -m _001__clawler.02_herb_detail --workers 10         # full run
    python -X utf8 -m _001__clawler.03_formula_list                     # → formula_list.csv
    python -X utf8 -m _001__clawler.04_formula_detail
    
    python -X utf8 -m _002__extract_information.__000__extract_graph_data_utils   # one-herb extraction demo
    
    # stage _002_ batch driver (resumable; re-running only does what's missing)
    python -X utf8 -m _002__extract_information.01_extract_herb_graph --limit 5   # try 5 first
    python -X utf8 -m _002__extract_information.01_extract_herb_graph             # full run, 5 workers
    python -X utf8 -m _002__extract_information.01_extract_herb_graph --workers 10
    python -X utf8 -m _002__extract_information.01_extract_herb_graph --retry-failed
    
    # formulas — same flags, mirror scripts (703 items, ~2-3h full run)
    python -X utf8 -m _002__extract_information.03_extract_formula_graph --limit 5
    python -X utf8 -m _002__extract_information.03_extract_formula_graph
    
    # rebuild the Alpaca datasets from the extracted json (no LLM calls, idempotent)
    python -X utf8 -m _002__extract_information.02_build_alpaca_dataset             # herbs
    python -X utf8 -m _002__extract_information.04_build_formula_alpaca_dataset     # formulas
    python -X utf8 -m _002__extract_information.02_build_alpaca_dataset --json --skip-empty
    
    # stage _003_ derived artifacts — regenerate after re-importing the graph
    python -X utf8 -c "from common.neo4j_manager import neo4j_client; \
      neo4j_client.export_tcm_metadata_to_json('_003_create_neo4j_database/tcm_metadata.json')"
    python -X utf8 -m _003_create_neo4j_database.__003__faiss_embedding      # rebuilds index + pkl, ~76 MB
    python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index     # 7 checks, exit 1 on failure
    python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index --skip-neo4j
    python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index --query 拉肚子 失眠 --top-k 10
    
    # stage _004_ — each node's self-test, run standalone (needs LLM, Neo4j and the embedding model)
    python -X utf8 -m _004_langgraph_more_nodes.nodes.zhongyi_intent_node
    python -X utf8 -m _004_langgraph_more_nodes.nodes.extract_entity_from_user_input_node
    python -X utf8 -m _004_langgraph_more_nodes.nodes.match_entity_from_neo4j_node
    python -X utf8 -m _004_langgraph_more_nodes.nodes.generate_neo4j_cypher_node
    python -X utf8 -m _004_langgraph_more_nodes.nodes.check_cypher_node
    python -X utf8 -m _004_langgraph_more_nodes.nodes.run_cypher_node
    python -X utf8 -m _004_langgraph_more_nodes.nodes.llm_direct_out_node
    
    # stage _005_ — the HTTP API
    python -X utf8 -m _005_fastapi.main                  # serve on 0.0.0.0:8000, ~50s warmup
    python -X utf8 -m _005_fastapi.test_api              # 27 offline checks, no LLM/Neo4j/FAISS, <2s
    python -X utf8 -m _005_fastapi.test_api --live       # 15 checks against a running server
    python -X utf8 -m _005_fastapi.test_api --live --base-url http://127.0.0.1:8001
    ```
    
    A full stage `_002_` run takes hours — start it in the background and poll, don't block on it.
    
    `-X utf8` matters: this box's console encoding is GBK, and Chinese log output is mojibake without it. Anything that prints 中文 needs it.
    
    Stage `_004_` node output is buried in noise from imports: `path_utils` prints the repo root, `embedding_model` prints the device and a sample vector, and `sentence_transformers` logs weight loading. Filtering with `grep -vE "Loading weights|^<common|^\[tensor|device=|^cuda$|^1024$|^D:"` is the established way to read it.
    
    `requirements.txt` lists unpinned `>=` ranges, several of which are aspirational (`playwright`, `pandas`, `openpyxl`, `volcengine` aren't imported anywhere yet) and the tail has duplicate `python-dotenv` / `langchain_core` entries. Actually in use: `requests`, `beautifulsoup4`, `python-dotenv`, `langchain-core`, `langchain-openai`, `pydantic`, `neo4j`, `tqdm`, `langgraph`, `faiss` (installed as `faiss` 1.14.2, not `faiss-cpu`), `sentence-transformers` 5.5.1, `torch` 2.12+cu130. Pin real versions and drop the duplicates when you next touch dependency setup.
    
    There is no test framework, linter config, or build step — the `__main__` self-test in each module is it, and it's a convention worth keeping when you add modules. If you add pytest, record the commands here.
    
    ## Known traps
    
    - **`.env` path (fixed 2026-08-02).** `config.py` used to call `load_dotenv(get_file_path(".env"))`, resolving to a nonexistent `<root>/.env`, which left `MODEL_BASE_URL`, `MODEL_NAME` and all three `NEO4J_*` as `None` — `common.llm` couldn't even be imported from the repo root (`ChatOpenAI` rejects `model=None`). Now loads `common/.env` and all 8 settings populate. Note `MODEL_API_KEY` is still commented out *in the file* and comes from a machine-level shell env var on this dev box, so it will be missing on a fresh machine; the endpoint is DeepSeek.
    - **Numeric-prefixed module names can be run but never imported.** `01_herb_list`, `02_herb_detail`, `01_extract_herb_graph` etc. aren't valid Python identifiers, so `python -m` works but `from ... import` is a `SyntaxError`. To reach into one from a test or REPL, use `importlib.import_module("_002__extract_information.01_extract_herb_graph")`. Anything meant to be reused across stages belongs in `common/` or a normally-named module.
    - **Path drift in `_005_`'s spec.** `API_INTERFACE.md` says `cd __005_fastapi && python main.py`, and names `start_server.bat`, `quick_test.py`, `full_workflow.py`, `extract_entity_node.py`. The real directory is `_005_fastapi`, `main.py` must be run as `python -m _005_fastapi.main` from the repo root (its imports are absolute), and the `_004_` filenames are different (`langgraph_more_nodes.py`, `extract_entity_from_user_input_node.py`). Its FAISS-path snippets point at a nonexistent `E:\ctm_index\`. Treat the doc as a design target written ahead of the code — the request/response contracts in it are accurate, the paths and filenames aren't.
    - **Importing some modules does work.** `common/embedding_model.py` loads the model and prints four lines of diagnostics at import; `_003_create_neo4j_database/__003__faiss_embedding.py` has no `main` guard at all, so importing it rebuilds the 76 MB index. Both are called from `__main__` blocks elsewhere as if they were inert.
    - **Two `tcm_metadata.json` copies exist** (`common/` and `_003_create_neo4j_database/`), currently byte-identical. Only the `_003_` one is read.
    
    ## Repo hygiene
    
    - **`common/.env` is currently staged and holds live credentials.** Root `.gitignore` is still empty, so a `git add .` picked it up. It is *not yet in any commit* (`git log -- common/.env` is empty), so `git rm --cached common/.env` still fixes it cleanly — once it's committed, only a history rewrite will. Do that, plus populate `.gitignore` (`__pycache__/`, `*.pyc`, `.venv/`, `.env`), before the next commit. Raise it with the user rather than silently unstaging.
    - **22 `.pyc` files are staged** across six `__pycache__/` directories, a mix of `cpython-310` (the project interpreter) and stale `cpython-313`. Same cause, same fix.
    - The 2175 crawled `.txt` files and both CSVs **are** tracked (they're the bulk of the 2205 tracked files). That's the established choice; don't "clean it up" without asking. The 76 MB FAISS index and the pkl are tracked too.
    - `.idea/` is tracked deliberately (PyCharm's own `.idea/.gitignore` already excludes `workspace.xml` and `shelf/`).
    - `README.md` and `cookie` are empty placeholders; `picture/` is an empty directory (`_005_`'s spec plans to serve it at `GET /picture/{filename}`); `main.py` is the untouched PyCharm sample.
    - `.claude/settings.local.json` accumulates one allow-entry per command run. It's a useful record of what has actually been executed in this repo.
    
    **Keep this file current.** As stages `_004_`–`_007_` get filled in, replace their table rows with the real entry points and add the commands above.
    
  • 📄 README.md 0B
    
    
  • ⚙️ cookie 0B
  • 📄 main.py 544B
    # This is a sample Python script.
    
    # Press Shift+F10 to execute it or replace it with your code.
    # Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings.
    
    
    def print_hi(name):
        # Use a breakpoint in the code line below to debug your script.
        print(f'Hi, {name}')  # Press Ctrl+F8 to toggle the breakpoint.
    
    
    # Press the green button in the gutter to run the script.
    if __name__ == '__main__':
        print_hi('PyCharm')
    
    # See PyCharm help at https://www.jetbrains.com/help/pycharm/
    
  • 📄 requirements.txt 735B
    # Web 框架和界面
    streamlit>=1.32.0
    requests>=2.31.0
    beautifulsoup4>=4.12.0
    fastapi>=0.110.0
    uvicorn>=0.27.0
    playwright>=1.40.0
    
    # 数据处理和分析
    pandas>=2.2.0
    numpy>=1.26.0
    openpyxl>=3.1.0  # 用于 Excel 文件处理
    
    # 图数据库
    neo4j>=5.17.0
    
    # AI 和语言模型
    langchain>=0.1.0
    langchain-core>=0.1.0
    langchain-openai>=0.0.5
    openai>=1.12.0
    faiss-cpu>=1.7.4  # 用于向量检索
    langgraph>=0.2.0
    sentence-transformers>=2.3.0
    
    # 工具和进度条
    tqdm>=4.66.0
    python-dotenv>=1.0.0  # 用于环境变量管理
    
    # 类型提示
    typing-extensions>=4.9.0
    
    # 云服务/SDK
    volcengine>=1.0.0
    
    # 数据建模
    pydantic>=2.0.0
    #读取环境变量的文件
    python-dotenv
    
    langchain_core
    
  • ⚙️ 需求1 133B