| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- # -*- coding: utf-8 -*-
- """
- 生成字段中文名字典 ai-server/src/main/resources/agent/field-labels.json。
- 数据源(按优先级):
- 1. 字段上的 @ExcelProperty("中文名")(导出表头用,最权威)
- 2. 紧邻字段的 javadoc 首行(/** 本方姓名 */)
- 产物两段:
- classes —— 类全限定名 → {字段名: 中文名},用于「按行的实际类型精确取名」(如 CallNightDTO.time = 时间段标识)
- "*" —— 字段名 → 中文名 的兜底并集,仅收实体/DTO 的票且多数派占比 ≥ 0.6,
- 用于 SQL 列别名 / 手拼 Map 行这类拿不到类型的场景;拿不准的字段留英文不猜
- 重新生成:python ai-server/scripts/gen_field_labels.py
- """
- import json
- import pathlib
- import re
- import sys
- ROOT = pathlib.Path(__file__).resolve().parents[1] / "src/main/java"
- OUT = pathlib.Path(__file__).resolve().parents[1] / "src/main/resources/agent/field-labels.json"
- # 只扫业务数据类所在包:实体 / DTO / VO / 查询对象(服务类字段没有业务含义)
- SCAN_PREFIXES = ("com.zsjz.ai.common.model.", "com.zsjz.ai.module.")
- PACKAGE_RE = re.compile(r"^\s*package\s+([\w.]+)\s*;")
- CLASS_RE = re.compile(r"^\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|final\s+|abstract\s+)*"
- r"(?:class|record|enum)\s+(\w+)")
- # 字段声明:私有/受保护/公有 + 非 static/transient + 类型 + 名字 + 分号(方法带括号,天然排除)
- FIELD_RE = re.compile(r"^\s*(?:private|protected|public)\s+(?!static\b)(?!transient\b)"
- r"([\w.$?<>,\[\]\s]+?)\s+(\w+)\s*(?:=\s*[^;]+)?;\s*$")
- EXCEL_RE = re.compile(r'@ExcelProperty\s*\(([^;]*?)\)\s*$')
- QUOTED_RE = re.compile(r'"([^"]+)"')
- # 历史代码里有一批「//对方名称\")」形态的残缺行注释(Javadoc 被工具改坏了),同样当列名用
- BROKEN_COMMENT_RE = re.compile(r'^//\s*"?(.+?)"?\)\s*$')
- # javadoc 行内标记 {@link Xxx} / {@code xxx}:取标记里的文字,别让花括号把整条注释判废
- INLINE_TAG_RE = re.compile(r"\{@\w+\s+([^}]*)\}")
- def clean(text: str) -> str:
- """注释行 → 可用的中文列名:去 * 前缀、摊平行内 javadoc 标记、去 HTML 标签与引号,截到第一个句读"""
- text = re.sub(r"^[\s*]+|[\s*]+$", "", text).strip()
- text = INLINE_TAG_RE.sub(lambda m: m.group(1), text)
- text = re.sub(r"</?[a-zA-Z][^>]*>", "", text) # <p> <b> 之类
- # {@link #sampleText} 展开后是自引用「#sampleText 的字符数」——去掉引用,别让表头带上字段名
- text = re.sub(r"#\w+\s*", "", text).lstrip("的").strip()
- text = text.strip('"').strip()
- if not text or text.startswith("@") or text.startswith("{@") or text.startswith("<"):
- return ""
- # 「借贷类型全部='',收入=收入」这类取值域说明不是列名
- if any(ch in text for ch in "=;{}"):
- return ""
- # 「失败原因(FAIL / SKIP 时才有值),供排查」→ 取**位置最早**的句读截断
- # (按分隔符优先级顺序找会漏掉更靠前的括号,必须比位置)
- cuts = [i for i in (text.find(sep) for sep in ("。", ":", ",", ",", "(", "("))
- if 0 < i <= 30]
- if cuts:
- text = text[:min(cuts)]
- return text[:30].strip()
- def scan(path: pathlib.Path):
- """返回 (类全限定名, {字段: 中文名});无可用中文名的类返回 None"""
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
- package = ""
- cls = ""
- fields = {}
- i = 0
- while i < len(lines):
- line = lines[i]
- if not cls:
- m = PACKAGE_RE.match(line)
- if m:
- package = m.group(1)
- m = CLASS_RE.match(line)
- if m and package:
- cls = m.group(1)
- i += 1
- continue
- m = FIELD_RE.match(line)
- if not m:
- i += 1
- continue
- name = m.group(2)
- if name == "serialVersionUID" or name in fields:
- i += 1
- continue
- # 回溯:跳过注解行(可能多行),再找紧邻的 javadoc
- excel = ""
- j = i - 1
- while j >= 0 and lines[j].strip().startswith("@"):
- em = EXCEL_RE.match(lines[j].strip())
- if em:
- quoted = QUOTED_RE.findall(em.group(1))
- if quoted:
- excel = quoted[-1] # 多级表头取最后一级 = 该列标题
- j -= 1
- label = clean(excel)
- if not label and j >= 0:
- broken = BROKEN_COMMENT_RE.match(lines[j].strip())
- if broken:
- label = clean(broken.group(1))
- if not label and j >= 0 and lines[j].strip().endswith("*/"):
- k = j
- while k >= 0 and "/**" not in lines[k]:
- k -= 1
- if k >= 0:
- block = []
- for raw in lines[k:j + 1]:
- body = raw.strip()
- body = body[3:] if body.startswith("/**") else body
- body = body[:-2] if body.endswith("*/") else body
- block.append(body.lstrip("*").strip())
- for row in block:
- if not row or row.startswith("@"):
- continue
- label = clean(row)
- if label:
- break
- if label:
- fields[name] = label
- i += 1
- if not fields:
- return None
- return f"{package}.{cls}", fields
- def main() -> int:
- classes = {}
- for path in sorted(ROOT.rglob("*.java")):
- rel = str(path.relative_to(ROOT)).replace("\\", "/")
- if not rel.replace("/", ".").startswith(SCAN_PREFIXES):
- continue
- found = scan(path)
- if found:
- classes[found[0]] = found[1]
- # 兜底并集(JSON 的 "*" 段):只收「实体 / DTO」这类数据类的票,
- # 且要求多数派占比 >= 0.6 —— 拿不准的字段宁可留英文也不猜。
- # 同名字段跨域含义不同(personName 在话单是「本方姓名」、在机票是「旅客姓名」;
- # count 在统计行是「份数」、在轨迹是「同行次数」),猜错比不翻更误导。
- # 行本身带类型(classes 段)或 table_field 里有的字段,不依赖这段兜底。
- votes: dict[str, dict[str, int]] = {}
- for fqn in sorted(classes):
- if ".entity." not in fqn and ".dto." not in fqn:
- continue
- weight = 2 if ".entity." in fqn else 1
- for key, cn in classes[fqn].items():
- bucket = votes.setdefault(key, {})
- bucket[cn] = bucket.get(cn, 0) + weight
- fallback = {}
- for key, bucket in votes.items():
- ranked = sorted(bucket.items(), key=lambda kv: (-kv[1], kv[0]))
- if ranked[0][1] / sum(bucket.values()) >= 0.6:
- fallback[key] = ranked[0][0]
- payload = {
- "_comment": "由 ai-server/scripts/gen_field_labels.py 生成,勿手改;"
- "重新生成后按需提交。classes=按类精确取名,*=SQL 别名/Map 行的兜底。",
- "classes": classes,
- "*": fallback,
- }
- OUT.parent.mkdir(parents=True, exist_ok=True)
- OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
- encoding="utf-8")
- print(f"classes={len(classes)} fields={sum(len(v) for v in classes.values())} "
- f"fallback={len(fallback)} -> {OUT}")
- return 0
- if __name__ == "__main__":
- sys.exit(main())
|