初始化

This commit is contained in:
2026-08-14 21:50:48 +08:00
commit d7e382f2e7
114 changed files with 24123 additions and 0 deletions

85
tools/applysql/main.go Normal file
View 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
View 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
View 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]) + "…"
}