初始化
This commit is contained in:
85
tools/applysql/main.go
Normal file
85
tools/applysql/main.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// applysql —— 开发辅助工具:把 SQL 文件应用到 z_xk 库
|
||||
//
|
||||
// 为什么需要它:Windows 开发机没有 mysql 客户端,而 Go 项目本身带着
|
||||
// GORM 依赖与 config.Load() 的 DSN 解析逻辑,直接复用即可执行 DDL,
|
||||
// 且凭据不经过命令行/控制台(从 manifest/config/config.yaml 读取)。
|
||||
//
|
||||
// 用法(必须在 nl-tcm-agent 项目根目录执行,保证能读到配置文件):
|
||||
// go run ./tools/applysql <sql文件1> [sql文件2 ...]
|
||||
//
|
||||
// 说明:
|
||||
// - 按 ";\n" 粗粒度拆分语句(我们自己的 SQL 文件都是规范的分号+换行结尾)
|
||||
// - "--" 开头的注释行会被剥掉
|
||||
// - 任一语句失败立即退出(DDL 顺序有依赖,跳过会导致后续更混乱)
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tcm-agent/internal/config"
|
||||
"tcm-agent/internal/dao"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("用法: go run ./tools/applysql <sql文件...>")
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, path := range os.Args[1:] {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Fatalf("读取 %s 失败: %v", path, err)
|
||||
}
|
||||
stmts := splitStatements(string(raw))
|
||||
fmt.Printf("== %s:%d 条语句 ==\n", path, len(stmts))
|
||||
for i, stmt := range stmts {
|
||||
if err := dao.DB.Exec(stmt).Error; err != nil {
|
||||
log.Fatalf("第 %d 条语句执行失败: %v\n语句片段: %.120s", i+1, err, stmt)
|
||||
}
|
||||
fmt.Printf(" [%d/%d] OK %.60s...\n", i+1, len(stmts), firstLine(stmt))
|
||||
}
|
||||
}
|
||||
fmt.Println("全部执行完成")
|
||||
}
|
||||
|
||||
// splitStatements 把 SQL 文本拆成可执行语句:剥注释行后按分号结尾切分
|
||||
func splitStatements(sqlText string) []string {
|
||||
// Windows 编辑器写出的文件是 CRLF,先统一成 LF,否则 ";\n" 切分匹配不到
|
||||
sqlText = strings.ReplaceAll(sqlText, "\r\n", "\n")
|
||||
var sb strings.Builder
|
||||
for _, line := range strings.Split(sqlText, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// 剥掉整行注释(字段内注释在行中不受影响)
|
||||
if strings.HasPrefix(trimmed, "--") || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
parts := strings.Split(sb.String(), ";\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstLine 取语句首行用于进度展示
|
||||
func firstLine(stmt string) string {
|
||||
if i := strings.Index(stmt, "\n"); i > 0 {
|
||||
return stmt[:i]
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
53
tools/p1smoke/main.go
Normal file
53
tools/p1smoke/main.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// P1 冒烟验证工具(临时):验证金方 FULLTEXT 检索 + 别名归一索引
|
||||
//
|
||||
// 用法:go run ./tools/p1smoke
|
||||
// 验证点:
|
||||
// 1. dao.GoldenFormulaSearch 在 BOOLEAN / NATURAL 两种模式下能命中
|
||||
// 2. kb.AliasIndex 能从爬取文档加载别名并做查询扩展
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"tcm-agent/internal/config"
|
||||
"tcm-agent/internal/dao"
|
||||
"tcm-agent/internal/kb"
|
||||
)
|
||||
|
||||
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("初始化 DB 失败: %v", err)
|
||||
}
|
||||
|
||||
// ---- 1. 金方检索(自然语言模式:整段证候)----
|
||||
fmt.Println("== 金方检索(NATURAL):恶寒发热 头痛 汗出 脉浮缓 ==")
|
||||
hits, err := dao.GoldenFormulaSearch("恶寒发热 头痛 汗出 脉浮缓", 3, true)
|
||||
if err != nil {
|
||||
log.Fatalf("金方 NATURAL 检索失败: %v", err)
|
||||
}
|
||||
for _, h := range hits {
|
||||
fmt.Printf(" [%.2f] %s | 主治: %.40s\n", h.Score, h.Name, h.IndicationTranslation)
|
||||
}
|
||||
|
||||
// ---- 2. 金方检索(BOOLEAN 模式:短关键词)----
|
||||
fmt.Println("== 金方检索(BOOLEAN):桂枝 ==")
|
||||
hits2, err := dao.GoldenFormulaSearch("桂枝", 3, false)
|
||||
if err != nil {
|
||||
log.Fatalf("金方 BOOLEAN 检索失败: %v", err)
|
||||
}
|
||||
for _, h := range hits2 {
|
||||
fmt.Printf(" [%.2f] %s\n", h.Score, h.Name)
|
||||
}
|
||||
|
||||
// ---- 3. 别名索引加载 + 查询扩展 ----
|
||||
fmt.Println("== 别名索引 ==")
|
||||
idx := kb.GetAliasIndex()
|
||||
q, appended := idx.ExpandQuery("患者常年脾虚,日常以淮山药、于术调理,近日恶寒")
|
||||
fmt.Printf(" 扩展后查询: %s\n", q)
|
||||
fmt.Printf(" 追加正名: %v\n", appended)
|
||||
}
|
||||
274
tools/safetyseed/main.go
Normal file
274
tools/safetyseed/main.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// 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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user