@hupan56/wlkj 3.3.15 → 3.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/qoder/scripts/capability/registry_mcp.py +4 -10
- package/templates/qoder/scripts/capability/smoke_test_report.json.new +94 -0
- package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +4 -1
- package/templates/qoder/scripts/domain/integration/return_to_platform.py +2 -4
- package/templates/qoder/scripts/domain/integration/spec_upload.py +4 -4
- package/templates/qoder/scripts/domain/kg/extract/asset/__init__.py +10 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/asset_tree.py +57 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/discussion_importer.py +62 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/prd_importer.py +146 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/prototype_importer.py +64 -0
- package/templates/qoder/scripts/domain/kg/extract/asset/returns_importer.py +52 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal3.py +104 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal4.py +55 -0
- package/templates/qoder/scripts/domain/kg/extract/build_goal5.py +95 -0
- package/templates/qoder/scripts/domain/kg/extract/db/__init__.py +8 -0
- package/templates/qoder/scripts/domain/kg/extract/db/data_profile.py +22 -0
- package/templates/qoder/scripts/domain/kg/extract/db/fk_extractor.py +55 -0
- package/templates/qoder/scripts/domain/kg/extract/db/schema_extractor.py +90 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/__init__.py +9 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/community_summarizer.py +206 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/embed_builder.py +132 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/naming_matcher.py +80 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/promote.py +59 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/recompute.py +93 -0
- package/templates/qoder/scripts/domain/kg/extract/inference/weak_link.py +421 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/__init__.py +9 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/all.py +79 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/mapper_parser.py +99 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/relation_builder.py +69 -0
- package/templates/qoder/scripts/domain/kg/extract/mybatis/sql_extractor.py +78 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/__init__.py +8 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/prd_chunk_embed.py +105 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/prd_llm_extract.py +153 -0
- package/templates/qoder/scripts/domain/kg/extract/prd/req_anchor.py +120 -0
- package/templates/qoder/scripts/domain/kg/switch_project.py +18 -5
- package/templates/qoder/scripts/domain/kg/sync_repowiki.py +109 -0
- package/templates/qoder/scripts/domain/task/wlkj_panel.py +14 -169
- package/templates/qoder/scripts/engine/poller.py +219 -0
- package/templates/qoder/scripts/orchestration/wlkj.py +0 -106
- package/templates/qoder/settings.json +0 -8
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""embedding 弱关联推理 (L3 核心)。
|
|
3
|
+
|
|
4
|
+
对没有 REQ-ID 标注的老代码:
|
|
5
|
+
1. 算实体 embedding (DashScope text-embedding-v3)
|
|
6
|
+
2. 向量召回最近 anchor (pgvector HNSW; 不可用降级命名匹配)
|
|
7
|
+
3. 按相似度建弱边:
|
|
8
|
+
> 0.85 → confidence=0.9, expires_at=now+30天
|
|
9
|
+
0.6-0.85 → confidence=0.7, expires_at=now+14天
|
|
10
|
+
4. 绑 fingerprint (C方案重算依赖)
|
|
11
|
+
|
|
12
|
+
embedding 成本高 (37515 实体), 用 llm_limit 限量 + 增量。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import json
|
|
19
|
+
import urllib.request
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
|
|
22
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
for _i in range(8):
|
|
24
|
+
_p = os.path.dirname(_THIS)
|
|
25
|
+
if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
|
|
26
|
+
sys.path.insert(0, _p); break
|
|
27
|
+
_THIS = _p
|
|
28
|
+
|
|
29
|
+
from domain.kg.extract.inference import naming_matcher
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def embed_one(text: str) -> list:
|
|
33
|
+
"""调 DashScope text-embedding-v3 算单个向量 (1024维)。失败返回 []。"""
|
|
34
|
+
key = os.environ.get('DASHSCOPE_API_KEY', '')
|
|
35
|
+
if not key or not text:
|
|
36
|
+
return []
|
|
37
|
+
payload = json.dumps({
|
|
38
|
+
'model': 'text-embedding-v3',
|
|
39
|
+
'input': [text[:500]],
|
|
40
|
+
'dimensions': 1024,
|
|
41
|
+
}).encode('utf-8')
|
|
42
|
+
req = urllib.request.Request(
|
|
43
|
+
'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings',
|
|
44
|
+
data=payload,
|
|
45
|
+
headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'},
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
49
|
+
r = json.loads(resp.read())
|
|
50
|
+
return r['data'][0]['embedding']
|
|
51
|
+
except Exception:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cosine_sim(a: list, b: list) -> float:
|
|
56
|
+
"""余弦相似度 (纯 Python, 不依赖 numpy)。"""
|
|
57
|
+
if not a or not b or len(a) != len(b):
|
|
58
|
+
return 0.0
|
|
59
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
60
|
+
na = sum(x * x for x in a) ** 0.5
|
|
61
|
+
nb = sum(y * y for y in b) ** 0.5
|
|
62
|
+
if na == 0 or nb == 0:
|
|
63
|
+
return 0.0
|
|
64
|
+
return dot / (na * nb)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def infer_one(entity: dict, anchors: list, anchor_vecs: dict = None,
|
|
68
|
+
vec_matrix=None, vec_ids=None, preset_vec=None):
|
|
69
|
+
"""对单个实体推理弱关联 → edges 列表。
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
entity: {id, canonical, cn, type, props}
|
|
73
|
+
anchors: [{id, title, kind}] 全部锚点
|
|
74
|
+
anchor_vecs: {anchor_id: vec} 锚点向量缓存 (可选, 没有则用命名匹配)
|
|
75
|
+
vec_matrix: numpy 矩阵 (N, 1024) 持久化向量 (GOAL A 矩阵召回)
|
|
76
|
+
vec_ids: 矩阵对应的 entity_id 列表 (长度 N)
|
|
77
|
+
preset_vec: 预计算的实体向量 (GOAL A 批量 embed, 避免逐个调 API)
|
|
78
|
+
Returns: [{from_id, to_id, edge_type, confidence, source, expires_at, fingerprint, props}]
|
|
79
|
+
"""
|
|
80
|
+
eid = entity['id']
|
|
81
|
+
desc = (entity.get('canonical', '') + ' ' + entity.get('cn', '')).strip() or entity['id']
|
|
82
|
+
|
|
83
|
+
# 路1: numpy 矩阵召回 (GOAL A, 最快) — 一次矩阵乘算 top-K
|
|
84
|
+
if vec_matrix is not None and vec_ids is not None:
|
|
85
|
+
# ★ 优先用预计算向量 (GOAL A 批量 embed), 没有才逐个 embed
|
|
86
|
+
ent_vec = preset_vec or embed_one(desc)
|
|
87
|
+
if ent_vec:
|
|
88
|
+
scored = _numpy_recall(ent_vec, vec_matrix, vec_ids, top_k=5, min_score=0.6)
|
|
89
|
+
if scored:
|
|
90
|
+
return _build_edges(eid, scored, entity, anchors, via='embedding')
|
|
91
|
+
|
|
92
|
+
# 路2: 逐个 cosine_sim 召回 (有 anchor_vecs 时, 旧逻辑)
|
|
93
|
+
if anchor_vecs:
|
|
94
|
+
ent_vec = preset_vec or embed_one(desc)
|
|
95
|
+
if ent_vec:
|
|
96
|
+
scored = []
|
|
97
|
+
for aid, avec in anchor_vecs.items():
|
|
98
|
+
s = cosine_sim(ent_vec, avec)
|
|
99
|
+
if s >= 0.6:
|
|
100
|
+
scored.append((aid, s))
|
|
101
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
102
|
+
if scored:
|
|
103
|
+
return _build_edges(eid, scored, entity, anchors, via='embedding')
|
|
104
|
+
# 路3: 命名匹配降级 (embedding 不可用或没 anchor_vecs 或 embedding 0命中)
|
|
105
|
+
matches = naming_matcher.match_to_anchors(entity.get('canonical', ''), anchors)
|
|
106
|
+
return _build_edges(eid, matches, entity, anchors, via='naming')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _numpy_recall(query_vec: list, matrix, ids: list, top_k: int = 5,
|
|
110
|
+
min_score: float = 0.6) -> list:
|
|
111
|
+
"""numpy 矩阵召回 top-K (GOAL A 核心, 替代逐个 cosine_sim)。
|
|
112
|
+
|
|
113
|
+
query_vec × matrix 一次矩阵乘 → 全量相似度 → top-K。
|
|
114
|
+
比逐个 Python cosine_sim 快 100x。
|
|
115
|
+
Returns: [(id, score)] 按 score 降序
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
import numpy as np
|
|
119
|
+
q = np.array(query_vec, dtype=np.float32)
|
|
120
|
+
# 归一化 (余弦相似度 = 点积/模长)
|
|
121
|
+
q_norm = q / (np.linalg.norm(q) + 1e-8)
|
|
122
|
+
m = matrix # 已归一化的矩阵 (N, dim)
|
|
123
|
+
# 一次矩阵乘: (1, dim) × (dim, N) → (1, N)
|
|
124
|
+
scores = m @ q_norm # m 已归一化, q 已归一化 → 点积=余弦
|
|
125
|
+
# top-K
|
|
126
|
+
top_idx = np.argsort(scores)[::-1][:top_k]
|
|
127
|
+
out = []
|
|
128
|
+
for idx in top_idx:
|
|
129
|
+
s = float(scores[idx])
|
|
130
|
+
if s >= min_score:
|
|
131
|
+
out.append((ids[idx], round(s, 4)))
|
|
132
|
+
return out
|
|
133
|
+
except Exception:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _load_vec_matrix(engine, kinds=None) -> tuple:
|
|
138
|
+
"""从持久化向量加载到 numpy 矩阵 (GOAL A)。
|
|
139
|
+
|
|
140
|
+
优先读 embeddings_ali (rag.py 的 19093 条, 已有), 兜底读 embeddings (GOAL A 新建)。
|
|
141
|
+
Returns: (matrix_normalized, id_list) matrix 是 (N, 1024) 已归一化矩阵
|
|
142
|
+
"""
|
|
143
|
+
import numpy as np
|
|
144
|
+
from sqlalchemy import text
|
|
145
|
+
import json
|
|
146
|
+
# 路1: embeddings_ali (rag.py 的向量源, 数据最多)
|
|
147
|
+
try:
|
|
148
|
+
with engine.connect() as conn:
|
|
149
|
+
rows = conn.execute(text("""
|
|
150
|
+
SELECT entity_id, vec FROM embeddings_ali
|
|
151
|
+
WHERE vec IS NOT NULL AND array_length(vec, 1) > 0
|
|
152
|
+
LIMIT 5000
|
|
153
|
+
""")).all()
|
|
154
|
+
if rows:
|
|
155
|
+
ids, vecs = [], []
|
|
156
|
+
for eid, v in rows:
|
|
157
|
+
if isinstance(v, list) and len(v) > 0:
|
|
158
|
+
ids.append(eid)
|
|
159
|
+
vecs.append(v)
|
|
160
|
+
if vecs:
|
|
161
|
+
matrix = np.array(vecs, dtype=np.float32)
|
|
162
|
+
norms = np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-8
|
|
163
|
+
return matrix / norms, ids
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
# 路2: embeddings (GOAL A 新建, vec_json JSONB)
|
|
167
|
+
try:
|
|
168
|
+
with engine.connect() as conn:
|
|
169
|
+
rows = conn.execute(text("""
|
|
170
|
+
SELECT entity_id, vec_json FROM embeddings
|
|
171
|
+
WHERE vec_json IS NOT NULL
|
|
172
|
+
""")).all()
|
|
173
|
+
except Exception:
|
|
174
|
+
return None, None
|
|
175
|
+
if not rows:
|
|
176
|
+
return None, None
|
|
177
|
+
ids, vecs = [], []
|
|
178
|
+
for eid, vj in rows:
|
|
179
|
+
try:
|
|
180
|
+
v = vj if isinstance(vj, list) else json.loads(vj)
|
|
181
|
+
if isinstance(v, list) and len(v) > 0:
|
|
182
|
+
ids.append(eid)
|
|
183
|
+
vecs.append(v)
|
|
184
|
+
except Exception:
|
|
185
|
+
continue
|
|
186
|
+
if not vecs:
|
|
187
|
+
return None, None
|
|
188
|
+
matrix = np.array(vecs, dtype=np.float32)
|
|
189
|
+
norms = np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-8
|
|
190
|
+
matrix_norm = matrix / norms
|
|
191
|
+
return matrix_norm, ids
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _build_edges(entity_id: str, scored: list, entity: dict, anchors: list, via: str = 'naming'):
|
|
195
|
+
"""按相似度分档建弱边。
|
|
196
|
+
|
|
197
|
+
via: 'embedding' 或 'naming' (影响 props 标注)
|
|
198
|
+
fingerprint: 从 file_signatures 表读 content_hash (C方案重算依赖真实文件指纹)
|
|
199
|
+
"""
|
|
200
|
+
edges = []
|
|
201
|
+
repo_id = entity.get('repo_id', '')
|
|
202
|
+
# fingerprint: 优先从 file_signatures 表读真实 content_hash
|
|
203
|
+
fp = _get_file_fingerprint(entity)
|
|
204
|
+
now = datetime.utcnow()
|
|
205
|
+
|
|
206
|
+
for anchor_id, score in scored[:3]: # 最多建3条弱边
|
|
207
|
+
if score > 0.85:
|
|
208
|
+
conf, days = 0.9, 30
|
|
209
|
+
elif score >= 0.6:
|
|
210
|
+
conf, days = 0.7, 14
|
|
211
|
+
else:
|
|
212
|
+
continue
|
|
213
|
+
edges.append({
|
|
214
|
+
'from_id': entity_id, 'to_id': anchor_id,
|
|
215
|
+
'edge_type': 'relates_to', 'from_repo': repo_id, 'to_repo': repo_id,
|
|
216
|
+
'confidence': conf, 'source': 'inferred',
|
|
217
|
+
'expires_at': now + timedelta(days=days),
|
|
218
|
+
'fingerprint': fp,
|
|
219
|
+
'props': {'score': round(score, 3), 'via': via},
|
|
220
|
+
})
|
|
221
|
+
return edges
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_file_fingerprint(entity: dict) -> str:
|
|
225
|
+
"""从 file_signatures 表读实体文件的 content_hash。
|
|
226
|
+
|
|
227
|
+
entity.props.file 是相对路径 (data/code/fywl-ics\...),
|
|
228
|
+
file_signatures.file_path 是绝对路径, 用路径段末尾匹配。
|
|
229
|
+
读不到时降级用 canonical hash (best-effort)。
|
|
230
|
+
"""
|
|
231
|
+
import hashlib
|
|
232
|
+
props = entity.get('props') or {}
|
|
233
|
+
if isinstance(props, str):
|
|
234
|
+
try:
|
|
235
|
+
import json
|
|
236
|
+
props = json.loads(props)
|
|
237
|
+
except Exception:
|
|
238
|
+
props = {}
|
|
239
|
+
file_path = props.get('file', '') if isinstance(props, dict) else ''
|
|
240
|
+
if not file_path:
|
|
241
|
+
# 无文件信息 → 用 canonical hash 兜底
|
|
242
|
+
return hashlib.md5((entity.get('canonical', '') + entity.get('cn', '')).encode()).hexdigest()[:16]
|
|
243
|
+
# 从 file_signatures 查 content_hash (路径末尾段匹配)
|
|
244
|
+
try:
|
|
245
|
+
from domain.kg.extract.java import pg_upsert
|
|
246
|
+
engine, _ = pg_upsert._get_pg_engine()
|
|
247
|
+
if engine is None:
|
|
248
|
+
return hashlib.md5(file_path.encode()).hexdigest()[:16]
|
|
249
|
+
from sqlalchemy import text
|
|
250
|
+
# 标准化路径: 取最后两段做 LIKE 匹配 (跨绝对/相对路径)
|
|
251
|
+
norm = file_path.replace('\\', '/').split('/')
|
|
252
|
+
tail = '/'.join(norm[-3:]) if len(norm) >= 3 else file_path.replace('\\', '/')
|
|
253
|
+
with engine.connect() as conn:
|
|
254
|
+
row = conn.execute(text("""
|
|
255
|
+
SELECT content_hash FROM file_signatures
|
|
256
|
+
WHERE file_path ILIKE :tail LIMIT 1
|
|
257
|
+
"""), {'tail': '%' + tail}).first()
|
|
258
|
+
if row and row[0]:
|
|
259
|
+
return row[0][:16]
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
return hashlib.md5(file_path.encode()).hexdigest()[:16]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def build_anchor_vectors(anchors: list, logger=print) -> dict:
|
|
266
|
+
"""批量 embed anchors 算向量缓存 → {anchor_id: vec}。
|
|
267
|
+
|
|
268
|
+
让 weak_link 的向量召回主路径真正跑通 (不依赖 pgvector, 用 Python cosine_sim)。
|
|
269
|
+
anchors 多时只 embed 前 N 个 (限量, 避免全量 154 个 anchors 调 154 次 API)。
|
|
270
|
+
"""
|
|
271
|
+
out = {}
|
|
272
|
+
if not anchors:
|
|
273
|
+
return out
|
|
274
|
+
# 只 embed 前 30 个 anchors (够召回, 控成本)
|
|
275
|
+
for a in anchors[:30]:
|
|
276
|
+
desc = (a.get('title') or a.get('id', '')).strip()
|
|
277
|
+
if not desc:
|
|
278
|
+
continue
|
|
279
|
+
vec = embed_one(desc)
|
|
280
|
+
if vec:
|
|
281
|
+
out[a['id']] = vec
|
|
282
|
+
logger(' [infer] anchor 向量缓存: %d/%d' % (len(out), len(anchors)))
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def infer_batch(entities: list, anchors: list, use_embedding: bool = True, logger=print):
|
|
287
|
+
"""批量推理弱关联 (GOAL A 升级: numpy 矩阵召回)。
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
entities: 待推理实体 (无强关联的)
|
|
291
|
+
anchors: 锚点列表
|
|
292
|
+
use_embedding: True 走向量主路径; False 只命名匹配
|
|
293
|
+
Returns: (edges, stats)
|
|
294
|
+
|
|
295
|
+
GOAL A 升级: 优先从 embeddings 表加载持久化向量到 numpy 矩阵,
|
|
296
|
+
每个实体只 embed 一次自己, 用矩阵乘召回 top-K (不再每个 anchor 都 embed)。
|
|
297
|
+
"""
|
|
298
|
+
t0 = time.time()
|
|
299
|
+
all_edges = []
|
|
300
|
+
matched = 0
|
|
301
|
+
|
|
302
|
+
# ★ GOAL A: 优先用持久化向量矩阵 (最快)
|
|
303
|
+
vec_matrix, vec_ids = None, None
|
|
304
|
+
if use_embedding:
|
|
305
|
+
try:
|
|
306
|
+
from domain.kg.extract.java import pg_upsert
|
|
307
|
+
engine, _ = pg_upsert._get_pg_engine()
|
|
308
|
+
if engine:
|
|
309
|
+
vec_matrix, vec_ids = _load_vec_matrix(engine)
|
|
310
|
+
if vec_matrix is not None:
|
|
311
|
+
logger(' [infer] 加载持久化向量矩阵: %d × %d' % vec_matrix.shape)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger(' [infer] 矩阵加载失败, 降级: %s' % str(e)[:50])
|
|
314
|
+
|
|
315
|
+
# 矩阵不可用 → 降级到 build_anchor_vectors (旧路径)
|
|
316
|
+
anchor_vecs = None
|
|
317
|
+
if vec_matrix is None and use_embedding:
|
|
318
|
+
anchor_vecs = build_anchor_vectors(anchors, logger=logger)
|
|
319
|
+
|
|
320
|
+
# ★ GOAL A: 优先复用持久化向量, 缺的才批量 embed
|
|
321
|
+
entity_vecs = {}
|
|
322
|
+
if vec_matrix is not None and use_embedding:
|
|
323
|
+
from domain.kg.extract.inference.embed_builder import embed_batch as _embed_batch
|
|
324
|
+
from domain.kg.extract.java import pg_upsert as _pg
|
|
325
|
+
_engine, _ = _pg._get_pg_engine()
|
|
326
|
+
# ① 先从 embeddings_ali 批量读已有向量 (免 API 调用, 分chunk避免参数过多)
|
|
327
|
+
all_ids = [e['id'] for e in entities]
|
|
328
|
+
if _engine and all_ids:
|
|
329
|
+
try:
|
|
330
|
+
# 分 chunk 查 (每 chunk 100 个 id, 避免 PG 参数上限)
|
|
331
|
+
import json as _json
|
|
332
|
+
for ci in range(0, len(all_ids), 100):
|
|
333
|
+
chunk_ids = all_ids[ci:ci+100]
|
|
334
|
+
# 用 ANY(ARRAY[...]) 避免构造大量 :params
|
|
335
|
+
arr = "ARRAY[" + ','.join("'" + eid.replace("'", "''") + "'" for eid in chunk_ids) + "]"
|
|
336
|
+
from sqlalchemy import text as _text_fn
|
|
337
|
+
with _engine.connect() as conn:
|
|
338
|
+
rows = conn.execute(_text_fn(
|
|
339
|
+
f"SELECT entity_id, vec FROM embeddings_ali WHERE entity_id = ANY({arr}) AND vec IS NOT NULL"
|
|
340
|
+
)).all()
|
|
341
|
+
for eid, v in rows:
|
|
342
|
+
if isinstance(v, list) and len(v) > 0:
|
|
343
|
+
entity_vecs[eid] = v
|
|
344
|
+
logger(' [infer] 复用 embeddings_ali 已有向量: %d/%d' % (len(entity_vecs), len(all_ids)))
|
|
345
|
+
except Exception as _e:
|
|
346
|
+
logger(' [infer] 复用查询异常: %s' % str(_e)[:50])
|
|
347
|
+
# ② 缺的才并发批量 embed (4线程并行, 破10秒关键)
|
|
348
|
+
missing = [e for e in entities if e['id'] not in entity_vecs]
|
|
349
|
+
if missing:
|
|
350
|
+
from domain.kg.extract.inference.embed_builder import embed_batch as _embed2
|
|
351
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
352
|
+
descs = [((e.get('canonical', '') + ' ' + e.get('cn', '')).strip() or e['id'])[:200] for e in missing]
|
|
353
|
+
logger(' [infer] 并发 embed %d 缺失实体 (4线程)...' % len(missing))
|
|
354
|
+
try:
|
|
355
|
+
# 分 4 块并发, 每块调 embed_batch (内含 10条/批)
|
|
356
|
+
chunk = max(50, len(descs) // 4)
|
|
357
|
+
chunks = [descs[i:i+chunk] for i in range(0, len(descs), chunk)]
|
|
358
|
+
with ThreadPoolExecutor(max_workers=4) as ex:
|
|
359
|
+
results = list(ex.map(_embed2, chunks))
|
|
360
|
+
vecs = [v for cv in results for v in cv]
|
|
361
|
+
for ent, v in zip(missing, vecs):
|
|
362
|
+
if v:
|
|
363
|
+
entity_vecs[ent['id']] = v
|
|
364
|
+
logger(' [infer] 实体向量就绪: %d/%d' % (len(entity_vecs), len(entities)))
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger(' [infer] 并发 embed 失败: %s' % str(e)[:50])
|
|
367
|
+
|
|
368
|
+
for i, ent in enumerate(entities):
|
|
369
|
+
try:
|
|
370
|
+
eds = infer_one(ent, anchors, anchor_vecs=anchor_vecs,
|
|
371
|
+
vec_matrix=vec_matrix, vec_ids=vec_ids,
|
|
372
|
+
preset_vec=entity_vecs.get(ent['id']))
|
|
373
|
+
all_edges += eds
|
|
374
|
+
if eds:
|
|
375
|
+
matched += 1
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
if (i + 1) % 100 == 0:
|
|
379
|
+
logger(' [infer] %d/%d (匹配%d, 边%d)' % (i + 1, len(entities), matched, len(all_edges)))
|
|
380
|
+
dt = time.time() - t0
|
|
381
|
+
logger(' [infer] 完成: %d 实体 → %d 匹配 → %d 弱边 / %.1fs' % (
|
|
382
|
+
len(entities), matched, len(all_edges), dt))
|
|
383
|
+
|
|
384
|
+
# ★ GOAL A: 把新 embed 的实体向量持久化 (下次跑免 API 调用)
|
|
385
|
+
if entity_vecs and vec_matrix is not None:
|
|
386
|
+
try:
|
|
387
|
+
_persist_entity_vecs(entity_vecs, entities)
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
return all_edges, {'entities': len(entities), 'matched': matched,
|
|
392
|
+
'edges': len(all_edges), 'seconds': round(dt, 1)}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _persist_entity_vecs(vecs: dict, entities: list, logger=print):
|
|
396
|
+
"""把推理时计算的实体向量存回 embeddings_ali (下次复用, 免 API)。"""
|
|
397
|
+
from domain.kg.extract.java import pg_upsert
|
|
398
|
+
from sqlalchemy import text
|
|
399
|
+
import json
|
|
400
|
+
engine, _ = pg_upsert._get_pg_engine()
|
|
401
|
+
if engine is None:
|
|
402
|
+
return
|
|
403
|
+
ent_map = {e['id']: e for e in entities}
|
|
404
|
+
ok = 0
|
|
405
|
+
with engine.begin() as conn:
|
|
406
|
+
for eid, vec in vecs.items():
|
|
407
|
+
ent = ent_map.get(eid, {})
|
|
408
|
+
try:
|
|
409
|
+
# embeddings_ali 表 vec 是 ARRAY 类型
|
|
410
|
+
arr_str = '{' + ','.join(str(float(v)) for v in vec[:1024]) + '}'
|
|
411
|
+
conn.execute(text("""
|
|
412
|
+
INSERT INTO embeddings_ali (entity_id, kind, text, vec, model, embedded_at)
|
|
413
|
+
VALUES (:id, :kind, :txt, CAST(:vec AS FLOAT[]), 'text-embedding-v3', now())
|
|
414
|
+
ON CONFLICT (entity_id) DO NOTHING
|
|
415
|
+
"""), {'id': eid, 'kind': ent.get('type', 'entity'),
|
|
416
|
+
'txt': (ent.get('canonical', '') or eid)[:200], 'vec': arr_str})
|
|
417
|
+
ok += 1
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
if ok:
|
|
421
|
+
logger(' [infer] 持久化 %d 条实体向量 (下次复用)' % ok)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""MyBatis XML 提取协调器。
|
|
3
|
+
|
|
4
|
+
扫所有 *Mapper.xml → 解析 → 建关系 → 返回 (entities, edges)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import glob
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
13
|
+
for _i in range(8):
|
|
14
|
+
_p = os.path.dirname(_THIS)
|
|
15
|
+
if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
|
|
16
|
+
sys.path.insert(0, _p); break
|
|
17
|
+
_THIS = _p
|
|
18
|
+
|
|
19
|
+
from domain.kg.extract.mybatis import mapper_parser as mp
|
|
20
|
+
from domain.kg.extract.mybatis import relation_builder as rb
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_file(filepath: str, repo_id: str):
|
|
24
|
+
"""单文件提取 → (entities, edges)。"""
|
|
25
|
+
parsed = mp.parse_mapper_xml(filepath)
|
|
26
|
+
if not parsed.get('namespace_short'):
|
|
27
|
+
return [], []
|
|
28
|
+
return rb.build_relations(parsed, repo_id, filepath)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_repo(repo_dir: str, repo_id: str, logger=print):
|
|
32
|
+
"""扫 repo 下所有 mapper XML → 提取。"""
|
|
33
|
+
t0 = time.time()
|
|
34
|
+
patterns = [
|
|
35
|
+
os.path.join(repo_dir, '**', '*Mapper.xml'),
|
|
36
|
+
os.path.join(repo_dir, '**', '*mapper*.xml'),
|
|
37
|
+
os.path.join(repo_dir, '**', 'mapper', '*.xml'),
|
|
38
|
+
]
|
|
39
|
+
xml_files = set()
|
|
40
|
+
for pat in patterns:
|
|
41
|
+
xml_files.update(glob.glob(pat, recursive=True))
|
|
42
|
+
# 跳过编译产物 target/classes 和测试资源
|
|
43
|
+
xml_files = [f for f in xml_files if '/target/' not in f.replace('\\', '/')
|
|
44
|
+
and '\\target\\' not in f.lower()
|
|
45
|
+
and '/test/' not in f.replace('\\', '/').lower()]
|
|
46
|
+
total = len(xml_files)
|
|
47
|
+
logger(' [mybatis] 扫描 %d 个 mapper XML (repo=%s)' % (total, repo_id))
|
|
48
|
+
|
|
49
|
+
all_entities, all_edges = [], []
|
|
50
|
+
err = 0
|
|
51
|
+
for xf in xml_files:
|
|
52
|
+
try:
|
|
53
|
+
es, eds = extract_file(xf, repo_id)
|
|
54
|
+
all_entities += es
|
|
55
|
+
all_edges += eds
|
|
56
|
+
except Exception as e:
|
|
57
|
+
err += 1
|
|
58
|
+
if err <= 2:
|
|
59
|
+
logger(' [mybatis] 失败 %s: %s' % (xf, str(e)[:50]))
|
|
60
|
+
|
|
61
|
+
dt = time.time() - t0
|
|
62
|
+
tables = set()
|
|
63
|
+
for e in all_entities:
|
|
64
|
+
if e['type'] == 'TABLE':
|
|
65
|
+
tables.add(e['canonical'])
|
|
66
|
+
logger(' [mybatis] 完成: %d 实体 / %d 边 / %d 表 / %d 错 / %.1fs' % (
|
|
67
|
+
len(all_entities), len(all_edges), len(tables), err, dt))
|
|
68
|
+
return all_entities, all_edges
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == '__main__':
|
|
72
|
+
import argparse, json
|
|
73
|
+
ap = argparse.ArgumentParser()
|
|
74
|
+
ap.add_argument('repo_dir')
|
|
75
|
+
ap.add_argument('--repo-id', default='')
|
|
76
|
+
args = ap.parse_args()
|
|
77
|
+
rid = args.repo_id or os.path.basename(args.repo_dir.rstrip('/\\'))
|
|
78
|
+
es, eds = extract_repo(args.repo_dir, rid)
|
|
79
|
+
print(json.dumps({'entities': len(es), 'edges': len(eds)}, ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""mapper XML 解析器: namespace + resultMap + 方法→SQL 映射。
|
|
3
|
+
|
|
4
|
+
输出每个 mapper 文件的结构化信息, 供 relation_builder 建 queries 边 + COLUMN 实体。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from . import sql_extractor as _sq
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _extract_table_names(sql):
|
|
15
|
+
"""从 SQL 提取表名 (转发到 sql_extractor)。"""
|
|
16
|
+
return _sq.extract_table_names(sql)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_mapper_xml(filepath: str) -> dict:
|
|
20
|
+
"""解析一个 mapper XML。
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
{
|
|
24
|
+
'namespace': 'com.fywl.ics.demo.mapper.TestDemoMapper',
|
|
25
|
+
'namespace_short': 'TestDemoMapper',
|
|
26
|
+
'statements': [{'id':'customPageList','type':'select','sql':'SELECT * FROM test_demo...', 'tables':['test_demo']}],
|
|
27
|
+
'result_maps': {'TestDemoResult': [{'property':'id','column':'id'}, ...]},
|
|
28
|
+
'result_types': ['com.fywl.ics.demo.domain.vo.TestDemoVo'],
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
tree = ET.parse(filepath)
|
|
33
|
+
root = tree.getroot()
|
|
34
|
+
except Exception:
|
|
35
|
+
return _empty_result()
|
|
36
|
+
|
|
37
|
+
namespace = root.get('namespace', '') or ''
|
|
38
|
+
namespace_short = namespace.rsplit('.', 1)[-1] if namespace else os.path.basename(filepath).replace('.xml', '')
|
|
39
|
+
|
|
40
|
+
statements = []
|
|
41
|
+
result_maps = {}
|
|
42
|
+
result_types = set()
|
|
43
|
+
|
|
44
|
+
for tag in ('select', 'insert', 'update', 'delete'):
|
|
45
|
+
for stmt in root.findall(tag):
|
|
46
|
+
sid = stmt.get('id', '')
|
|
47
|
+
sql_text = _extract_sql_text(stmt)
|
|
48
|
+
tables = _extract_table_names(sql_text)
|
|
49
|
+
if stmt.get('resultType'):
|
|
50
|
+
result_types.add(stmt.get('resultType'))
|
|
51
|
+
statements.append({
|
|
52
|
+
'id': sid, 'type': tag, 'sql': sql_text, 'tables': tables,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
for rm in root.findall('resultMap'):
|
|
56
|
+
rm_id = rm.get('id', '')
|
|
57
|
+
cols = []
|
|
58
|
+
for child_tag in ('id', 'result'):
|
|
59
|
+
for child in rm.findall(child_tag):
|
|
60
|
+
cols.append({
|
|
61
|
+
'property': child.get('property', ''),
|
|
62
|
+
'column': child.get('column', ''),
|
|
63
|
+
})
|
|
64
|
+
if rm.get('type'):
|
|
65
|
+
result_types.add(rm.get('type'))
|
|
66
|
+
result_maps[rm_id] = cols
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
'namespace': namespace,
|
|
70
|
+
'namespace_short': namespace_short,
|
|
71
|
+
'statements': statements,
|
|
72
|
+
'result_maps': result_maps,
|
|
73
|
+
'result_types': list(result_types),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_sql_text(node) -> str:
|
|
78
|
+
"""提取 statement 节点的完整 SQL 文本(含 <if>/<foreach> 展开)。"""
|
|
79
|
+
parts = []
|
|
80
|
+
if node.text:
|
|
81
|
+
parts.append(node.text)
|
|
82
|
+
for child in node:
|
|
83
|
+
# <if>/<foreach>/<choose>/<where>/<set>/<trim> 等, 取 text + tail
|
|
84
|
+
if child.text:
|
|
85
|
+
parts.append(child.text)
|
|
86
|
+
# 递归子节点 (foreach 里可能有 if)
|
|
87
|
+
for sub in child:
|
|
88
|
+
if sub.text:
|
|
89
|
+
parts.append(sub.text)
|
|
90
|
+
if sub.tail:
|
|
91
|
+
parts.append(sub.tail)
|
|
92
|
+
if child.tail:
|
|
93
|
+
parts.append(child.tail)
|
|
94
|
+
return ' '.join(p.strip() for p in parts if p and p.strip())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _empty_result():
|
|
98
|
+
return {'namespace': '', 'namespace_short': '', 'statements': [],
|
|
99
|
+
'result_maps': {}, 'result_types': []}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Mapper ↔ Entity ↔ Table 三向关联构建器。
|
|
3
|
+
|
|
4
|
+
把 mapper_parser 的结构化信息 + sql_extractor 的表名 →
|
|
5
|
+
生成 entities (TABLE/COLUMN) + edges (queries/maps_to/returns)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from . import mapper_parser as mp
|
|
9
|
+
from . import sql_extractor as sq
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_relations(parsed: dict, repo_id: str, filepath: str):
|
|
13
|
+
"""从单个 mapper XML 的解析结果构建实体 + 边。
|
|
14
|
+
|
|
15
|
+
Returns: (entities, edges)
|
|
16
|
+
entities: TABLE / COLUMN 实体
|
|
17
|
+
edges: queries (Mapper→Table) / column_maps (COLUMN 关联)
|
|
18
|
+
"""
|
|
19
|
+
entities, edges = [], []
|
|
20
|
+
ns_short = parsed.get('namespace_short', '')
|
|
21
|
+
ns_full = parsed.get('namespace', '')
|
|
22
|
+
if not ns_short:
|
|
23
|
+
return entities, edges
|
|
24
|
+
|
|
25
|
+
# Mapper 实体 id (对齐 GOAL 2 的 entity_id 规则: MAP:repo:Name)
|
|
26
|
+
mapper_id = 'MAP:%s:%s' % (repo_id, ns_short)
|
|
27
|
+
|
|
28
|
+
# ===== queries 边: Mapper → 每个被访问的 TABLE =====
|
|
29
|
+
all_tables = set()
|
|
30
|
+
for stmt in parsed.get('statements', []):
|
|
31
|
+
for tbl in stmt.get('tables', []):
|
|
32
|
+
all_tables.add(tbl)
|
|
33
|
+
|
|
34
|
+
for tbl in all_tables:
|
|
35
|
+
tbl_id = 'TBL:%s:%s' % (repo_id, tbl)
|
|
36
|
+
# TABLE 实体 (如果 GOAL 2 没建过, 这里补建)
|
|
37
|
+
entities.append({
|
|
38
|
+
'id': tbl_id, 'repo_id': repo_id, 'type': 'TABLE',
|
|
39
|
+
'canonical': tbl, 'cn': '',
|
|
40
|
+
'props': {'from': 'mapper_xml', 'file': filepath},
|
|
41
|
+
})
|
|
42
|
+
# queries 边
|
|
43
|
+
edges.append({
|
|
44
|
+
'from_id': mapper_id, 'to_id': tbl_id,
|
|
45
|
+
'edge_type': 'queries',
|
|
46
|
+
'from_repo': repo_id, 'to_repo': repo_id,
|
|
47
|
+
'confidence': 1.0, 'source': 'mapper_xml',
|
|
48
|
+
'props': {'via': 'mybatis_xml'},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
# ===== COLUMN 实体 (从 resultMap 提取) =====
|
|
52
|
+
for rm_id, cols in parsed.get('result_maps', {}).items():
|
|
53
|
+
for col_map in cols:
|
|
54
|
+
col_name = col_map.get('column', '')
|
|
55
|
+
if not col_name or '$' in col_name:
|
|
56
|
+
continue
|
|
57
|
+
# COLUMN 实体 (关联到第一个被访问的表, 兜底)
|
|
58
|
+
hint_table = list(all_tables)[:1]
|
|
59
|
+
tbl_for_col = hint_table[0] if hint_table else 'unknown'
|
|
60
|
+
col_id = 'COL:%s:%s.%s' % (repo_id, tbl_for_col, col_name)
|
|
61
|
+
entities.append({
|
|
62
|
+
'id': col_id, 'repo_id': repo_id, 'type': 'COLUMN',
|
|
63
|
+
'canonical': col_name, 'cn': '',
|
|
64
|
+
'props': {'table': tbl_for_col,
|
|
65
|
+
'property': col_map.get('property', ''),
|
|
66
|
+
'from': 'result_map', 'file': filepath},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return entities, edges
|