Files
2026-08-14 21:50:48 +08:00

275 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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]) + "…"
}