275 lines
8.3 KiB
Go
275 lines
8.3 KiB
Go
// safetyseed —— P2 安全参照表初始化工具
|
||
//
|
||
// 职责:从已爬取的药材知识文档(xk_kb_doc.source_type='crawl')解析
|
||
// "临床应用/用法用量"中的常规剂量范围、"使用禁忌"中的孕妇禁忌级别,
|
||
// 写入 xk_tcm_safety_ref 表,作为 PHP 处方安全校验(标红)的数据源。
|
||
//
|
||
// 用法(在 nl-tcm-agent 项目根目录):go run ./tools/safetyseed
|
||
//
|
||
// 幂等策略:按 name 唯一键 upsert;source='manual'(人工修正过)的行跳过
|
||
// 不覆盖——爬取解析是"初始化",人工修正优先级更高。
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"tcm-agent/internal/config"
|
||
"tcm-agent/internal/dao"
|
||
)
|
||
|
||
// docRow 从 xk_kb_doc 取的原料行
|
||
type docRow struct {
|
||
Title string `gorm:"column:title"`
|
||
MetaJSON *string `gorm:"column:meta_json"`
|
||
Content string `gorm:"column:content"`
|
||
}
|
||
|
||
// safetyRow 解析结果(对应 xk_tcm_safety_ref 一行)
|
||
type safetyRow struct {
|
||
Name string
|
||
AliasesJSON *string
|
||
DoseMin float64
|
||
DoseMax float64
|
||
DoseNote string
|
||
PregnancyLevel int
|
||
PregnancyNote string
|
||
TabooText string
|
||
}
|
||
|
||
// reSection 提取"【标签】单行正文"(爬取入库时每段一行)
|
||
var reSection = regexp.MustCompile(`【([^】]+)】([^\n]*)`)
|
||
|
||
// reDoseRange 剂量区间:"用量3~9克"、"用量1.5-4.5克"(容忍全半角波浪线/横线/至)
|
||
var reDoseRange = regexp.MustCompile(`用量\s*([0-9]+(?:\.[0-9]+)?)\s*[~~—-至-]\s*([0-9]+(?:\.[0-9]+)?)\s*克`)
|
||
|
||
// reDoseSingle 单值剂量:"用量1.5克"(无区间时兜底)
|
||
var reDoseSingle = regexp.MustCompile(`用量\s*([0-9]+(?:\.[0-9]+)?)\s*克`)
|
||
|
||
// reDecoctRange "煎汤,2~6克"式(老版条目"内服:煎汤,X~Y克"没有"用量"二字)
|
||
var reDecoctRange = regexp.MustCompile(`煎汤[,,]?\s*([0-9]+(?:\.[0-9]+)?)\s*[~~—-至-]\s*([0-9]+(?:\.[0-9]+)?)\s*克`)
|
||
|
||
// rePregForbid / rePregCaution 孕妇禁忌级别
|
||
//
|
||
// 容忍中间插入词("孕妇及月经过多者慎用"、"孕妇均禁服"),
|
||
// 限定 15 字内避免跨句误判("孕妇不宜……其他人慎用"这种不算孕妇慎用)
|
||
var rePregForbid = regexp.MustCompile(`孕妇[^。;!?\n]{0,15}?(禁用|禁服|忌用|忌服)`)
|
||
var rePregCaution = regexp.MustCompile(`孕妇[^。;!?\n]{0,15}?(慎用|慎服)`)
|
||
|
||
func main() {
|
||
cfg := config.Load()
|
||
if cfg.DB.DSN == "" {
|
||
log.Fatal("配置缺少 db.dsn(检查 manifest/config/config.yaml)")
|
||
}
|
||
if err := dao.Init(cfg.DB.DSN); err != nil {
|
||
log.Fatalf("连接数据库失败: %v", err)
|
||
}
|
||
|
||
// 1. 拉取全部爬取文档
|
||
var docs []docRow
|
||
if err := dao.DB.Table("xk_kb_doc").
|
||
Select("title, meta_json, content").
|
||
Where("source_type = ? AND deleted_at = 0 AND status = 1", "crawl").
|
||
Find(&docs).Error; err != nil {
|
||
log.Fatalf("查询爬取文档失败: %v", err)
|
||
}
|
||
fmt.Printf("爬取文档共 %d 篇,开始解析...\n", len(docs))
|
||
|
||
// 2. 逐篇解析
|
||
rows := make([]safetyRow, 0, len(docs))
|
||
doseCnt, pregCnt := 0, 0
|
||
for _, d := range docs {
|
||
row := parseDoc(&d)
|
||
if row == nil {
|
||
continue
|
||
}
|
||
if row.DoseMax > 0 {
|
||
doseCnt++
|
||
}
|
||
if row.PregnancyLevel > 0 {
|
||
pregCnt++
|
||
}
|
||
rows = append(rows, *row)
|
||
}
|
||
fmt.Printf("解析完成:%d 行(含剂量 %d 行,孕妇禁忌 %d 行)\n", len(rows), doseCnt, pregCnt)
|
||
|
||
// 3. upsert(source='manual' 的行不覆盖)
|
||
now := time.Now().Unix()
|
||
inserted, updated, skipped := 0, 0, 0
|
||
for _, r := range rows {
|
||
var existing struct {
|
||
ID uint `gorm:"column:id"`
|
||
Source string `gorm:"column:source"`
|
||
}
|
||
err := dao.DB.Table("xk_tcm_safety_ref").
|
||
Select("id, source").
|
||
Where("name = ?", r.Name).
|
||
Take(&existing).Error
|
||
if err == nil && existing.Source == "manual" {
|
||
skipped++
|
||
continue
|
||
}
|
||
values := map[string]any{
|
||
"name": r.Name,
|
||
"aliases_json": r.AliasesJSON,
|
||
"dose_min": r.DoseMin,
|
||
"dose_max": r.DoseMax,
|
||
"dose_note": r.DoseNote,
|
||
"pregnancy_level": r.PregnancyLevel,
|
||
"pregnancy_note": r.PregnancyNote,
|
||
"taboo_text": r.TabooText,
|
||
"source": "zhongyoo",
|
||
"status": 1,
|
||
"updated_at": now,
|
||
"deleted_at": 0,
|
||
}
|
||
if err == nil {
|
||
if uerr := dao.DB.Table("xk_tcm_safety_ref").Where("id = ?", existing.ID).Updates(values).Error; uerr != nil {
|
||
log.Fatalf("更新 %s 失败: %v", r.Name, uerr)
|
||
}
|
||
updated++
|
||
} else {
|
||
values["created_at"] = now
|
||
if ierr := dao.DB.Table("xk_tcm_safety_ref").Create(values).Error; ierr != nil {
|
||
log.Fatalf("插入 %s 失败: %v", r.Name, ierr)
|
||
}
|
||
inserted++
|
||
}
|
||
}
|
||
fmt.Printf("入库完成:新建 %d,更新 %d,跳过人工行 %d\n", inserted, updated, skipped)
|
||
}
|
||
|
||
// parseDoc 单篇文档 → 安全参照行(title 为空返回 nil)
|
||
func parseDoc(d *docRow) *safetyRow {
|
||
name := strings.TrimSpace(d.Title)
|
||
if name == "" {
|
||
return nil
|
||
}
|
||
row := &safetyRow{Name: name}
|
||
|
||
// 别名直接复用爬取 meta_json.aliases(转成纯数组 JSON 存储)
|
||
if d.MetaJSON != nil {
|
||
var meta struct {
|
||
Aliases []string `json:"aliases"`
|
||
}
|
||
if json.Unmarshal([]byte(*d.MetaJSON), &meta) == nil && len(meta.Aliases) > 0 {
|
||
if b, err := json.Marshal(meta.Aliases); err == nil {
|
||
s := string(b)
|
||
row.AliasesJSON = &s
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按标签切段落
|
||
sections := map[string]string{}
|
||
for _, m := range reSection.FindAllStringSubmatch(d.Content, -1) {
|
||
label := strings.TrimSpace(m[1])
|
||
if _, ok := sections[label]; !ok {
|
||
sections[label] = strings.TrimSpace(m[2])
|
||
}
|
||
}
|
||
|
||
// 剂量:优先"临床应用",其次"用法用量"(老版条目标签)
|
||
doseText := sections["临床应用"]
|
||
if doseText == "" {
|
||
doseText = sections["用法用量"]
|
||
}
|
||
if doseText != "" {
|
||
parseDose(doseText, row)
|
||
}
|
||
|
||
// 孕妇禁忌:主看"使用禁忌",没有时兜底扫剂量段(部分条目把禁忌写在临床应用里)
|
||
taboo := sections["使用禁忌"]
|
||
pregSource := taboo
|
||
if pregSource == "" {
|
||
pregSource = doseText
|
||
}
|
||
if pregSource != "" {
|
||
parsePregnancy(pregSource, row)
|
||
}
|
||
if taboo != "" {
|
||
row.TabooText = truncateRunes(taboo, 1000)
|
||
}
|
||
return row
|
||
}
|
||
|
||
// parseDose 从段落文本解析常规剂量范围
|
||
//
|
||
// 只取第一个匹配:甘草"用量2~6克……中毒抢救可用30~60克",
|
||
// 第一个区间才是常规量,后面的是特殊场景不能当上限
|
||
func parseDose(text string, row *safetyRow) {
|
||
if m := reDoseRange.FindStringSubmatchIndex(text); m != nil {
|
||
row.DoseMin = parseF(text[m[2]:m[3]])
|
||
row.DoseMax = parseF(text[m[4]:m[5]])
|
||
row.DoseNote = snippetAround(text, m[0], 60)
|
||
return
|
||
}
|
||
if m := reDecoctRange.FindStringSubmatchIndex(text); m != nil {
|
||
row.DoseMin = parseF(text[m[2]:m[3]])
|
||
row.DoseMax = parseF(text[m[4]:m[5]])
|
||
row.DoseNote = snippetAround(text, m[0], 60)
|
||
return
|
||
}
|
||
if m := reDoseSingle.FindStringSubmatchIndex(text); m != nil {
|
||
v := parseF(text[m[2]:m[3]])
|
||
row.DoseMin, row.DoseMax = v, v
|
||
row.DoseNote = snippetAround(text, m[0], 60)
|
||
}
|
||
}
|
||
|
||
// parsePregnancy 解析孕妇禁忌级别(禁用优先于慎用)
|
||
func parsePregnancy(text string, row *safetyRow) {
|
||
if loc := rePregForbid.FindStringIndex(text); loc != nil {
|
||
row.PregnancyLevel = 2
|
||
row.PregnancyNote = sentenceAround(text, loc[0])
|
||
return
|
||
}
|
||
if loc := rePregCaution.FindStringIndex(text); loc != nil {
|
||
row.PregnancyLevel = 1
|
||
row.PregnancyNote = sentenceAround(text, loc[0])
|
||
}
|
||
}
|
||
|
||
// parseF 字符串转 float(正则已保证格式,失败返回 0)
|
||
func parseF(s string) float64 {
|
||
v, _ := strconv.ParseFloat(s, 64)
|
||
return v
|
||
}
|
||
|
||
// snippetAround 从 byteIdx 起截 n 个 rune 作原文摘录
|
||
func snippetAround(text string, byteIdx, n int) string {
|
||
return truncateRunes(text[byteIdx:], n)
|
||
}
|
||
|
||
// sentenceAround 取 byteIdx 所在的完整句子(按 。;!? 切)作原文摘录
|
||
func sentenceAround(text string, byteIdx int) string {
|
||
start := 0
|
||
for _, sep := range []string{"。", ";", "!", "?"} {
|
||
if i := strings.LastIndex(text[:byteIdx], sep); i >= 0 && i+len(sep) > start {
|
||
start = i + len(sep)
|
||
}
|
||
}
|
||
rest := text[start:]
|
||
end := len(rest)
|
||
for _, sep := range []string{"。", ";", "!", "?"} {
|
||
if i := strings.Index(rest, sep); i >= 0 && i < end {
|
||
end = i
|
||
}
|
||
}
|
||
return truncateRunes(strings.TrimSpace(rest[:end]), 120)
|
||
}
|
||
|
||
// truncateRunes 按 rune 截断(不破坏中文字符边界)
|
||
func truncateRunes(s string, max int) string {
|
||
runes := []rune(s)
|
||
if len(runes) <= max {
|
||
return s
|
||
}
|
||
return string(runes[:max]) + "…"
|
||
}
|