gen_field_labels.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. # -*- coding: utf-8 -*-
  2. """
  3. 生成字段中文名字典 ai-server/src/main/resources/agent/field-labels.json。
  4. 数据源(按优先级):
  5. 1. 字段上的 @ExcelProperty("中文名")(导出表头用,最权威)
  6. 2. 紧邻字段的 javadoc 首行(/** 本方姓名 */)
  7. 产物两段:
  8. classes —— 类全限定名 → {字段名: 中文名},用于「按行的实际类型精确取名」(如 CallNightDTO.time = 时间段标识)
  9. "*" —— 字段名 → 中文名 的兜底并集,仅收实体/DTO 的票且多数派占比 ≥ 0.6,
  10. 用于 SQL 列别名 / 手拼 Map 行这类拿不到类型的场景;拿不准的字段留英文不猜
  11. 重新生成:python ai-server/scripts/gen_field_labels.py
  12. """
  13. import json
  14. import pathlib
  15. import re
  16. import sys
  17. ROOT = pathlib.Path(__file__).resolve().parents[1] / "src/main/java"
  18. OUT = pathlib.Path(__file__).resolve().parents[1] / "src/main/resources/agent/field-labels.json"
  19. # 只扫业务数据类所在包:实体 / DTO / VO / 查询对象(服务类字段没有业务含义)
  20. SCAN_PREFIXES = ("com.zsjz.ai.common.model.", "com.zsjz.ai.module.")
  21. PACKAGE_RE = re.compile(r"^\s*package\s+([\w.]+)\s*;")
  22. CLASS_RE = re.compile(r"^\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+)*"
  23. r"(?:class|record|enum)\s+(\w+)")
  24. # 字段声明:私有/受保护/公有 + 非 static/transient + 类型 + 名字 + 分号(方法带括号,天然排除)
  25. FIELD_RE = re.compile(r"^\s*(?:private|protected|public)\s+(?!static\b)(?!transient\b)"
  26. r"([\w.$?<>,\[\]\s]+?)\s+(\w+)\s*(?:=\s*[^;]+)?;\s*$")
  27. EXCEL_RE = re.compile(r'@ExcelProperty\s*\(([^;]*?)\)\s*$')
  28. QUOTED_RE = re.compile(r'"([^"]+)"')
  29. # 历史代码里有一批「//对方名称\")」形态的残缺行注释(Javadoc 被工具改坏了),同样当列名用
  30. BROKEN_COMMENT_RE = re.compile(r'^//\s*"?(.+?)"?\)\s*$')
  31. # javadoc 行内标记 {@link Xxx} / {@code xxx}:取标记里的文字,别让花括号把整条注释判废
  32. INLINE_TAG_RE = re.compile(r"\{@\w+\s+([^}]*)\}")
  33. def clean(text: str) -> str:
  34. """注释行 → 可用的中文列名:去 * 前缀、摊平行内 javadoc 标记、去 HTML 标签与引号,截到第一个句读"""
  35. text = re.sub(r"^[\s*]+|[\s*]+$", "", text).strip()
  36. text = INLINE_TAG_RE.sub(lambda m: m.group(1), text)
  37. text = re.sub(r"</?[a-zA-Z][^>]*>", "", text) # <p> <b> 之类
  38. # {@link #sampleText} 展开后是自引用「#sampleText 的字符数」——去掉引用,别让表头带上字段名
  39. text = re.sub(r"#\w+\s*", "", text).lstrip("的").strip()
  40. text = text.strip('"').strip()
  41. if not text or text.startswith("@") or text.startswith("{@") or text.startswith("<"):
  42. return ""
  43. # 「借贷类型全部='',收入=收入」这类取值域说明不是列名
  44. if any(ch in text for ch in "=;{}"):
  45. return ""
  46. # 「失败原因(FAIL / SKIP 时才有值),供排查」→ 取**位置最早**的句读截断
  47. # (按分隔符优先级顺序找会漏掉更靠前的括号,必须比位置)
  48. cuts = [i for i in (text.find(sep) for sep in ("。", ":", ",", ",", "(", "("))
  49. if 0 < i <= 30]
  50. if cuts:
  51. text = text[:min(cuts)]
  52. return text[:30].strip()
  53. def scan(path: pathlib.Path):
  54. """返回 (类全限定名, {字段: 中文名});无可用中文名的类返回 None"""
  55. lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
  56. package = ""
  57. cls = ""
  58. fields = {}
  59. i = 0
  60. while i < len(lines):
  61. line = lines[i]
  62. if not cls:
  63. m = PACKAGE_RE.match(line)
  64. if m:
  65. package = m.group(1)
  66. m = CLASS_RE.match(line)
  67. if m and package:
  68. cls = m.group(1)
  69. i += 1
  70. continue
  71. m = FIELD_RE.match(line)
  72. if not m:
  73. i += 1
  74. continue
  75. name = m.group(2)
  76. if name == "serialVersionUID" or name in fields:
  77. i += 1
  78. continue
  79. # 回溯:跳过注解行(可能多行),再找紧邻的 javadoc
  80. excel = ""
  81. j = i - 1
  82. while j >= 0 and lines[j].strip().startswith("@"):
  83. em = EXCEL_RE.match(lines[j].strip())
  84. if em:
  85. quoted = QUOTED_RE.findall(em.group(1))
  86. if quoted:
  87. excel = quoted[-1] # 多级表头取最后一级 = 该列标题
  88. j -= 1
  89. label = clean(excel)
  90. if not label and j >= 0:
  91. broken = BROKEN_COMMENT_RE.match(lines[j].strip())
  92. if broken:
  93. label = clean(broken.group(1))
  94. if not label and j >= 0 and lines[j].strip().endswith("*/"):
  95. k = j
  96. while k >= 0 and "/**" not in lines[k]:
  97. k -= 1
  98. if k >= 0:
  99. block = []
  100. for raw in lines[k:j + 1]:
  101. body = raw.strip()
  102. body = body[3:] if body.startswith("/**") else body
  103. body = body[:-2] if body.endswith("*/") else body
  104. block.append(body.lstrip("*").strip())
  105. for row in block:
  106. if not row or row.startswith("@"):
  107. continue
  108. label = clean(row)
  109. if label:
  110. break
  111. if label:
  112. fields[name] = label
  113. i += 1
  114. if not fields:
  115. return None
  116. return f"{package}.{cls}", fields
  117. def main() -> int:
  118. classes = {}
  119. for path in sorted(ROOT.rglob("*.java")):
  120. rel = str(path.relative_to(ROOT)).replace("\\", "/")
  121. if not rel.replace("/", ".").startswith(SCAN_PREFIXES):
  122. continue
  123. found = scan(path)
  124. if found:
  125. classes[found[0]] = found[1]
  126. # 兜底并集(JSON 的 "*" 段):只收「实体 / DTO」这类数据类的票,
  127. # 且要求多数派占比 >= 0.6 —— 拿不准的字段宁可留英文也不猜。
  128. # 同名字段跨域含义不同(personName 在话单是「本方姓名」、在机票是「旅客姓名」;
  129. # count 在统计行是「份数」、在轨迹是「同行次数」),猜错比不翻更误导。
  130. # 行本身带类型(classes 段)或 table_field 里有的字段,不依赖这段兜底。
  131. votes: dict[str, dict[str, int]] = {}
  132. for fqn in sorted(classes):
  133. if ".entity." not in fqn and ".dto." not in fqn:
  134. continue
  135. weight = 2 if ".entity." in fqn else 1
  136. for key, cn in classes[fqn].items():
  137. bucket = votes.setdefault(key, {})
  138. bucket[cn] = bucket.get(cn, 0) + weight
  139. fallback = {}
  140. for key, bucket in votes.items():
  141. ranked = sorted(bucket.items(), key=lambda kv: (-kv[1], kv[0]))
  142. if ranked[0][1] / sum(bucket.values()) >= 0.6:
  143. fallback[key] = ranked[0][0]
  144. payload = {
  145. "_comment": "由 ai-server/scripts/gen_field_labels.py 生成,勿手改;"
  146. "重新生成后按需提交。classes=按类精确取名,*=SQL 别名/Map 行的兜底。",
  147. "classes": classes,
  148. "*": fallback,
  149. }
  150. OUT.parent.mkdir(parents=True, exist_ok=True)
  151. OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
  152. encoding="utf-8")
  153. print(f"classes={len(classes)} fields={sum(len(v) for v in classes.values())} "
  154. f"fallback={len(fallback)} -> {OUT}")
  155. return 0
  156. if __name__ == "__main__":
  157. sys.exit(main())