@hupan56/wlkj 3.3.15 → 3.3.16
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 +1 -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 +2 -2
- 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,78 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""SQL 文本提取器: 从 SQL 提取表名 + 列引用 + 操作类型。
|
|
3
|
+
|
|
4
|
+
正则匹配 MyBatis 动态 SQL 里残留的 ${ew.customSqlSegment} 等,
|
|
5
|
+
但 FROM/INTO/UPDATE/JOIN 后的表名通常稳定可提。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
# 表名提取 (FROM/JOIN/INTO/UPDATE 后跟标识符)
|
|
11
|
+
_TABLE_PATTERNS = [
|
|
12
|
+
re.compile(r'\bFROM\s+`?([a-z_][a-z0-9_]*)`?', re.IGNORECASE),
|
|
13
|
+
re.compile(r'\bJOIN\s+`?([a-z_][a-z0-9_]*)`?', re.IGNORECASE),
|
|
14
|
+
re.compile(r'\bINTO\s+`?([a-z_][a-z0-9_]*)`?', re.IGNORECASE),
|
|
15
|
+
re.compile(r'\bUPDATE\s+`?([a-z_][a-z0-9_]*)`?\s+SET', re.IGNORECASE),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# 排除: 显然不是表名的 (变量/占位符/SQL关键字)
|
|
19
|
+
_NOT_TABLES = {'dual', 'select', 'where', 'set', 'values', 'from', 'join',
|
|
20
|
+
'and', 'or', 'not', 'in', 'as', 'on', 'left', 'right', 'inner',
|
|
21
|
+
'outer', 'group', 'order', 'limit', 'distinct', 'case', 'when',
|
|
22
|
+
'then', 'else', 'end', 'null', 'true', 'false'}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_table_names(sql: str) -> list:
|
|
26
|
+
"""从 SQL 文本提取所有被访问的表名。
|
|
27
|
+
|
|
28
|
+
Returns: ['test_demo', 'sys_user'] (去重保序)
|
|
29
|
+
"""
|
|
30
|
+
if not sql:
|
|
31
|
+
return []
|
|
32
|
+
found = []
|
|
33
|
+
seen = set()
|
|
34
|
+
for pat in _TABLE_PATTERNS:
|
|
35
|
+
for m in pat.finditer(sql):
|
|
36
|
+
tbl = m.group(1).lower()
|
|
37
|
+
if tbl in _NOT_TABLES or tbl.startswith('$') or tbl.startswith('#'):
|
|
38
|
+
continue
|
|
39
|
+
# 排除太短或含特殊字符的 (变量插值残留)
|
|
40
|
+
if len(tbl) < 2 or '$' in tbl or '{' in tbl:
|
|
41
|
+
continue
|
|
42
|
+
if tbl not in seen:
|
|
43
|
+
seen.add(tbl)
|
|
44
|
+
found.append(tbl)
|
|
45
|
+
return found
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_column_refs(sql: str) -> list:
|
|
49
|
+
"""提取 SQL 里引用的列名 (粗粒度, 主要看 SELECT 字段列表)。
|
|
50
|
+
|
|
51
|
+
Returns: ['id', 'user_id', 'name'] (去重)
|
|
52
|
+
"""
|
|
53
|
+
if not sql:
|
|
54
|
+
return []
|
|
55
|
+
cols = set()
|
|
56
|
+
# SELECT field1, field2 FROM ... (字段列表)
|
|
57
|
+
m = re.search(r'SELECT\s+(DISTINCT\s+)?(.*?)\s+FROM', sql, re.IGNORECASE | re.DOTALL)
|
|
58
|
+
if m:
|
|
59
|
+
field_part = m.group(2)
|
|
60
|
+
if field_part.strip() != '*':
|
|
61
|
+
for token in re.split(r'[,\s]+', field_part):
|
|
62
|
+
token = token.strip().strip('`')
|
|
63
|
+
# 别名 as xx → 取前半
|
|
64
|
+
token = token.split(' as ')[0].split(' AS ')[0].strip()
|
|
65
|
+
if re.match(r'^[a-z_][a-z0-9_]*$', token, re.I) and len(token) >= 2:
|
|
66
|
+
cols.add(token.lower())
|
|
67
|
+
# xx.column 形式 (表别名.列)
|
|
68
|
+
for m in re.finditer(r'\b[a-z]+\s*\.\s*([a-z_][a-z0-9_]*)', sql, re.IGNORECASE):
|
|
69
|
+
cols.add(m.group(1).lower())
|
|
70
|
+
return sorted(cols)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def classify_operation(stmt_type: str) -> str:
|
|
74
|
+
"""MyBatis statement 类型 → 操作分类。"""
|
|
75
|
+
return {
|
|
76
|
+
'select': 'READ', 'insert': 'WRITE',
|
|
77
|
+
'update': 'WRITE', 'delete': 'WRITE',
|
|
78
|
+
}.get(stmt_type.lower(), 'UNKNOWN')
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PRD 分块 + embedding → LightRAG vector 路。
|
|
3
|
+
|
|
4
|
+
让 rag_search 能搜到 PRD 正文 (语义召回), 不再只搜标题。
|
|
5
|
+
复用工作台 rag.py 的 embed_texts (DashScope text-embedding-v3, 1024维)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
12
|
+
for _i in range(8):
|
|
13
|
+
_p = os.path.dirname(_THIS)
|
|
14
|
+
if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
|
|
15
|
+
sys.path.insert(0, _p); break
|
|
16
|
+
_THIS = _p
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def chunk_text(text: str, size: int = 500, overlap: int = 50) -> list:
|
|
20
|
+
"""滑窗分块。Returns: [chunk1, chunk2, ...]"""
|
|
21
|
+
if not text:
|
|
22
|
+
return []
|
|
23
|
+
chunks = []
|
|
24
|
+
i = 0
|
|
25
|
+
while i < len(text):
|
|
26
|
+
chunk = text[i:i + size]
|
|
27
|
+
chunks.append(chunk)
|
|
28
|
+
i += size - overlap
|
|
29
|
+
return chunks
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def embed_prd(prd_id: str, content_md: str, repo_id: str = ''):
|
|
33
|
+
"""PRD 分块 + embedding → 写 PG embeddings 表。
|
|
34
|
+
|
|
35
|
+
Returns: {chunks, embedded} 失败返回 {'chunks':0,'embedded':0}
|
|
36
|
+
"""
|
|
37
|
+
chunks = chunk_text(content_md)
|
|
38
|
+
if not chunks:
|
|
39
|
+
return {'chunks': 0, 'embedded': 0}
|
|
40
|
+
|
|
41
|
+
# 复用工作台 rag.py 的 embed_texts
|
|
42
|
+
try:
|
|
43
|
+
ws_backend = os.path.join(os.getcwd(), 'wlinkj-workspace', 'backend')
|
|
44
|
+
sys.path.insert(0, ws_backend)
|
|
45
|
+
from app.rag import embed_texts
|
|
46
|
+
except Exception:
|
|
47
|
+
# 兜底: 直接调 DashScope embedding API
|
|
48
|
+
return _embed_fallback(prd_id, chunks, repo_id)
|
|
49
|
+
|
|
50
|
+
vecs = embed_texts(chunks)
|
|
51
|
+
if not vecs:
|
|
52
|
+
return {'chunks': len(chunks), 'embedded': 0}
|
|
53
|
+
|
|
54
|
+
# 写 PG embeddings 表
|
|
55
|
+
_upsert_embeddings(prd_id, chunks, vecs, repo_id)
|
|
56
|
+
return {'chunks': len(chunks), 'embedded': len(vecs)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _embed_fallback(prd_id, chunks, repo_id):
|
|
60
|
+
"""直接调 DashScope text-embedding-v3 (rag.py 不可用时)。"""
|
|
61
|
+
import json, urllib.request
|
|
62
|
+
key = os.environ.get('DASHSCOPE_API_KEY', '')
|
|
63
|
+
if not key:
|
|
64
|
+
return {'chunks': len(chunks), 'embedded': 0}
|
|
65
|
+
# DashScope embedding API
|
|
66
|
+
url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings'
|
|
67
|
+
try:
|
|
68
|
+
req = urllib.request.Request(
|
|
69
|
+
url,
|
|
70
|
+
data=json.dumps({
|
|
71
|
+
'model': 'text-embedding-v3',
|
|
72
|
+
'input': chunks[:10], # 单次上限10条
|
|
73
|
+
'dimensions': 1024,
|
|
74
|
+
}).encode('utf-8'),
|
|
75
|
+
headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'},
|
|
76
|
+
)
|
|
77
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
78
|
+
r = json.loads(resp.read())
|
|
79
|
+
vecs = [d['embedding'] for d in r['data']]
|
|
80
|
+
_upsert_embeddings(prd_id, chunks[:len(vecs)], vecs, repo_id)
|
|
81
|
+
return {'chunks': len(chunks), 'embedded': len(vecs)}
|
|
82
|
+
except Exception:
|
|
83
|
+
return {'chunks': len(chunks), 'embedded': 0}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _upsert_embeddings(prd_id, chunks, vecs, repo_id):
|
|
87
|
+
"""写 PG embeddings 表 (pgvector)。"""
|
|
88
|
+
try:
|
|
89
|
+
from domain.kg.extract.java import pg_upsert
|
|
90
|
+
engine, _ = pg_upsert._get_pg_engine()
|
|
91
|
+
if engine is None:
|
|
92
|
+
return
|
|
93
|
+
from sqlalchemy import text
|
|
94
|
+
import json
|
|
95
|
+
with engine.begin() as conn:
|
|
96
|
+
for chunk, vec in zip(chunks, vecs):
|
|
97
|
+
vec_str = '[' + ','.join(str(v) for v in vec) + ']'
|
|
98
|
+
eid = 'PRDEMB:%s:%d' % (prd_id[:12], hash(chunk) & 0xffff)
|
|
99
|
+
conn.execute(text("""
|
|
100
|
+
INSERT INTO embeddings (entity_id, repo_id, kind, content, vec, embedded_at)
|
|
101
|
+
VALUES (:id, :repo, 'prd', :content, CAST(:vec AS VECTOR), now())
|
|
102
|
+
ON CONFLICT (entity_id) DO UPDATE SET content=EXCLUDED.content, vec=EXCLUDED.vec, embedded_at=now()
|
|
103
|
+
"""), {'id': eid, 'repo': repo_id, 'content': chunk[:500], 'vec': vec_str})
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(' [prd-embed] 写入跳过:', str(e)[:60])
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PRD 正文 → LLM 抽取功能点/字段/规则/端点/权限。
|
|
3
|
+
|
|
4
|
+
输入: PRD markdown 正文
|
|
5
|
+
输出: 结构化知识 (FEATURE/CONSTRAINT/PERMISSION 实体)
|
|
6
|
+
用 DashScope qwen-plus (已接入) 做结构化抽取。
|
|
7
|
+
|
|
8
|
+
被 asset/prd_importer.py 调用 (灌资产中心 prds 表时, 每个PRD正文过一遍LLM)。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
_THIS = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
for _i in range(8):
|
|
19
|
+
_p = os.path.dirname(_THIS)
|
|
20
|
+
if os.path.isfile(os.path.join(_p, 'foundation', 'bootstrap.py')):
|
|
21
|
+
sys.path.insert(0, _p); break
|
|
22
|
+
_THIS = _p
|
|
23
|
+
|
|
24
|
+
PRD_EXTRACT_PROMPT = """从以下PRD正文提取结构化知识, 只返回严格JSON(不要markdown代码块, 不要解释), 格式:
|
|
25
|
+
{"features":[{"name":"资产列表分页","desc":"支持按页查看"}],"fields":[{"name":"owner_id","label":"资产归属人","rule":"必填"}],"rules":[{"rule":"金额>1万需二次审批","type":"business"}],"endpoints":[{"path":"/asset/list","method":"GET","desc":"资产列表"}],"permissions":[{"code":"asset:list","desc":"资产查看权限"}]}
|
|
26
|
+
若某类提取不到就返回空数组。PRD正文:
|
|
27
|
+
---
|
|
28
|
+
%s
|
|
29
|
+
---"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _call_qwen(prompt: str, timeout: int = 60) -> str:
|
|
33
|
+
"""调 DashScope qwen-plus, 返回文本。失败返回 ''。"""
|
|
34
|
+
key = os.environ.get('DASHSCOPE_API_KEY', '')
|
|
35
|
+
base = os.environ.get('ALI_CHAT_BASE', '').rstrip('/')
|
|
36
|
+
model = os.environ.get('ALI_CHAT_MODEL', 'qwen-plus')
|
|
37
|
+
if not key or not base:
|
|
38
|
+
return ''
|
|
39
|
+
req = urllib.request.Request(
|
|
40
|
+
base + '/chat/completions',
|
|
41
|
+
data=json.dumps({
|
|
42
|
+
'model': model,
|
|
43
|
+
'messages': [{'role': 'user', 'content': prompt}],
|
|
44
|
+
'max_tokens': 2000, 'temperature': 0.1,
|
|
45
|
+
}).encode('utf-8'),
|
|
46
|
+
headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'},
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
50
|
+
r = json.loads(resp.read())
|
|
51
|
+
return r['choices'][0]['message']['content']
|
|
52
|
+
except Exception:
|
|
53
|
+
return ''
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_json_lenient(text: str) -> dict:
|
|
57
|
+
"""宽松解析 LLM 返回的 JSON (可能有 markdown 包裹或前后缀)。"""
|
|
58
|
+
if not text:
|
|
59
|
+
return {}
|
|
60
|
+
# 剥 markdown 代码块
|
|
61
|
+
text = re.sub(r'^```(?:json)?\s*', '', text.strip())
|
|
62
|
+
text = re.sub(r'\s*```$', '', text.strip())
|
|
63
|
+
# 找第一个 { 到最后一个 }
|
|
64
|
+
s = text.find('{')
|
|
65
|
+
e = text.rfind('}')
|
|
66
|
+
if s >= 0 and e > s:
|
|
67
|
+
text = text[s:e + 1]
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(text)
|
|
70
|
+
except Exception:
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_prd_knowledge(content_md: str) -> dict:
|
|
75
|
+
"""PRD 正文 → LLM 抽取 → 结构化 dict。
|
|
76
|
+
|
|
77
|
+
Returns: {features, fields, rules, endpoints, permissions}
|
|
78
|
+
"""
|
|
79
|
+
if not content_md or len(content_md) < 50:
|
|
80
|
+
return {'features': [], 'fields': [], 'rules': [], 'endpoints': [], 'permissions': []}
|
|
81
|
+
# 截断过长正文 (避免超 token)
|
|
82
|
+
content = content_md[:6000]
|
|
83
|
+
raw = _call_qwen(PRD_EXTRACT_PROMPT % content)
|
|
84
|
+
parsed = _parse_json_lenient(raw)
|
|
85
|
+
# 兜底确保字段齐全
|
|
86
|
+
return {
|
|
87
|
+
'features': parsed.get('features', []),
|
|
88
|
+
'fields': parsed.get('fields', []),
|
|
89
|
+
'rules': parsed.get('rules', []),
|
|
90
|
+
'endpoints': parsed.get('endpoints', []),
|
|
91
|
+
'permissions': parsed.get('permissions', []),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def to_entities_edges(extracted: dict, prd_id: str, project_id: str, repo_id: str = ''):
|
|
96
|
+
"""把 LLM 抽取结果转成 entities + edges。
|
|
97
|
+
|
|
98
|
+
entities: FEATURE / CONSTRAINT / PERMISSION
|
|
99
|
+
edges: 全部锚到 prd_id (强关联, confidence=1.0)
|
|
100
|
+
"""
|
|
101
|
+
entities, edges = [], []
|
|
102
|
+
base = 'FEAT:%s' % prd_id[:12] if prd_id else 'FEAT:unknown'
|
|
103
|
+
|
|
104
|
+
# 功能点 → FEATURE
|
|
105
|
+
for f in extracted.get('features', []):
|
|
106
|
+
fname = f.get('name', '').strip()
|
|
107
|
+
if not fname:
|
|
108
|
+
continue
|
|
109
|
+
fid = '%s:%s' % (base, fname[:20])
|
|
110
|
+
entities.append({
|
|
111
|
+
'id': fid, 'repo_id': repo_id, 'type': 'FEATURE',
|
|
112
|
+
'canonical': fname, 'cn': f.get('desc', ''),
|
|
113
|
+
'props': {'source': 'prd_llm', 'prd_id': prd_id},
|
|
114
|
+
})
|
|
115
|
+
edges.append(_anchor_edge(fid, prd_id, repo_id))
|
|
116
|
+
|
|
117
|
+
# 业务规则 → CONSTRAINT
|
|
118
|
+
for r in extracted.get('rules', []):
|
|
119
|
+
rule = r.get('rule', '').strip()
|
|
120
|
+
if not rule:
|
|
121
|
+
continue
|
|
122
|
+
cid = 'CONST:%s:%s' % (prd_id[:8], rule[:20])
|
|
123
|
+
entities.append({
|
|
124
|
+
'id': cid, 'repo_id': repo_id, 'type': 'CONSTRAINT',
|
|
125
|
+
'canonical': rule[:50], 'cn': r.get('type', ''),
|
|
126
|
+
'props': {'source': 'prd_llm', 'prd_id': prd_id, 'rule': rule},
|
|
127
|
+
})
|
|
128
|
+
edges.append(_anchor_edge(cid, prd_id, repo_id))
|
|
129
|
+
|
|
130
|
+
# 权限 → PERMISSION
|
|
131
|
+
for p in extracted.get('permissions', []):
|
|
132
|
+
code = p.get('code', '').strip()
|
|
133
|
+
if not code:
|
|
134
|
+
continue
|
|
135
|
+
pid = 'PERM:PRD:%s:%s' % (prd_id[:8], code[:20])
|
|
136
|
+
entities.append({
|
|
137
|
+
'id': pid, 'repo_id': repo_id, 'type': 'PERMISSION',
|
|
138
|
+
'canonical': code, 'cn': p.get('desc', ''),
|
|
139
|
+
'props': {'source': 'prd_llm', 'prd_id': prd_id},
|
|
140
|
+
})
|
|
141
|
+
edges.append(_anchor_edge(pid, prd_id, repo_id))
|
|
142
|
+
|
|
143
|
+
return entities, edges
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _anchor_edge(from_id: str, prd_id: str, repo_id: str) -> dict:
|
|
147
|
+
"""实体 → PRD 的强关联边。"""
|
|
148
|
+
return {
|
|
149
|
+
'from_id': from_id, 'to_id': 'prd:%s' % prd_id,
|
|
150
|
+
'edge_type': 'anchors', 'from_repo': repo_id, 'to_repo': repo_id,
|
|
151
|
+
'confidence': 1.0, 'source': 'explicit',
|
|
152
|
+
'props': {'via': 'prd_llm'},
|
|
153
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""REQ-ID 强关联: PRD/commit/代码注释 三种锚定来源。
|
|
3
|
+
|
|
4
|
+
复用 archive_prd.py 的正则 (REQ-2026-005 / REQ-012 两种格式)。
|
|
5
|
+
锚点写 anchors 表, 关联写 edges (confidence=1.0, source=explicit)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import re
|
|
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
|
+
# 复用 archive_prd.py 的正则 (兼容 REQ-2026-005 标准格式 + REQ-012 老格式)
|
|
20
|
+
_REQ_RE = re.compile(r'REQ-(\d{2,4})(?:-(\d{2,4}))?', re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_req_id(match) -> str:
|
|
24
|
+
"""格式化 REQ-ID (标准化)。"""
|
|
25
|
+
g1, g2 = match.group(1), match.group(2)
|
|
26
|
+
if g2:
|
|
27
|
+
return 'REQ-%s-%s' % (g1, g2)
|
|
28
|
+
return 'REQ-%s' % g1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_req_ids(text: str) -> list:
|
|
32
|
+
"""从文本提取所有 REQ-ID (去重保序)。"""
|
|
33
|
+
seen = set()
|
|
34
|
+
out = []
|
|
35
|
+
for m in _REQ_RE.finditer(text or ''):
|
|
36
|
+
rid = format_req_id(m)
|
|
37
|
+
if rid not in seen:
|
|
38
|
+
seen.add(rid)
|
|
39
|
+
out.append(rid)
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ============ 三种锚定 ============
|
|
44
|
+
|
|
45
|
+
def anchor_prd(prd_id: str, title: str, project_id: str, repo_id: str = ''):
|
|
46
|
+
"""时机1: PRD 创建时锚定。
|
|
47
|
+
|
|
48
|
+
PRD 标题/内容含 REQ-ID → 锚到 anchors 表。
|
|
49
|
+
没有 REQ-ID → 用 PRD id 生成锚点 (REQ-PRD-xxx)。
|
|
50
|
+
Returns: (anchor_entities, anchor_edges)
|
|
51
|
+
"""
|
|
52
|
+
entities, edges = [], []
|
|
53
|
+
req_ids = find_req_ids(title)
|
|
54
|
+
if not req_ids:
|
|
55
|
+
# 无 REQ-ID, 用 PRD 生成一个锚点
|
|
56
|
+
req_ids = ['REQ-PRD-%s' % prd_id[:8]]
|
|
57
|
+
|
|
58
|
+
for rid in req_ids:
|
|
59
|
+
# anchors 表实体 (type=ANCHOR)
|
|
60
|
+
entities.append({
|
|
61
|
+
'id': rid, 'repo_id': repo_id, 'type': 'ANCHOR',
|
|
62
|
+
'canonical': rid, 'cn': title[:60],
|
|
63
|
+
'props': {'kind': 'feature', 'prd_id': prd_id, 'project_id': project_id},
|
|
64
|
+
})
|
|
65
|
+
# PRD → 锚点 强关联边
|
|
66
|
+
edges.append({
|
|
67
|
+
'from_id': 'prd:%s' % prd_id, 'to_id': rid,
|
|
68
|
+
'edge_type': 'anchors', 'from_repo': repo_id, 'to_repo': repo_id,
|
|
69
|
+
'confidence': 1.0, 'source': 'explicit',
|
|
70
|
+
'props': {'via': 'prd_create'},
|
|
71
|
+
})
|
|
72
|
+
return entities, edges
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def anchor_commit(commit_msg: str, changed_files: list, repo_id: str = ''):
|
|
76
|
+
"""时机2: commit message 带 REQ-ID → 代码文件锚定。
|
|
77
|
+
|
|
78
|
+
feat(REQ-2026-005): 资产列表分页
|
|
79
|
+
→ changed_files 里的代码 → 锚到 REQ-2026-005
|
|
80
|
+
Returns: edges (不产生新实体, 只关联已有代码实体到锚点)
|
|
81
|
+
"""
|
|
82
|
+
edges = []
|
|
83
|
+
req_ids = find_req_ids(commit_msg)
|
|
84
|
+
if not req_ids:
|
|
85
|
+
return edges
|
|
86
|
+
|
|
87
|
+
for rid in req_ids:
|
|
88
|
+
for filepath in changed_files:
|
|
89
|
+
# 文件级锚定 (文件可能对应多个实体, 这里先建文件→锚点边)
|
|
90
|
+
file_entity_id = 'FILE:%s:%s' % (repo_id, filepath.replace('\\', '/'))
|
|
91
|
+
edges.append({
|
|
92
|
+
'from_id': file_entity_id, 'to_id': rid,
|
|
93
|
+
'edge_type': 'implements_req', 'from_repo': repo_id, 'to_repo': repo_id,
|
|
94
|
+
'confidence': 1.0, 'source': 'commit',
|
|
95
|
+
'props': {'commit_msg': commit_msg[:100]},
|
|
96
|
+
})
|
|
97
|
+
return edges
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def anchor_inline_code(content: str, filepath: str, repo_id: str = ''):
|
|
101
|
+
"""时机3: 代码注释里的 REQ-ID 锚定。
|
|
102
|
+
|
|
103
|
+
// REQ-2026-005 资产列表分页
|
|
104
|
+
/* REQ-047 老逻辑 */
|
|
105
|
+
Returns: edges
|
|
106
|
+
"""
|
|
107
|
+
edges = []
|
|
108
|
+
req_ids = find_req_ids(content)
|
|
109
|
+
if not req_ids:
|
|
110
|
+
return edges
|
|
111
|
+
|
|
112
|
+
file_entity_id = 'FILE:%s:%s' % (repo_id, filepath.replace('\\', '/'))
|
|
113
|
+
for rid in req_ids:
|
|
114
|
+
edges.append({
|
|
115
|
+
'from_id': file_entity_id, 'to_id': rid,
|
|
116
|
+
'edge_type': 'implements_req', 'from_repo': repo_id, 'to_repo': repo_id,
|
|
117
|
+
'confidence': 1.0, 'source': 'code_comment',
|
|
118
|
+
'props': {'via': 'inline_comment'},
|
|
119
|
+
})
|
|
120
|
+
return edges
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
python switch_project.py --email a@b.c --pw xxx # 非交互:直接指定
|
|
7
7
|
python switch_project.py --project-uuid <UUID> # 非交互:直接指定项目
|
|
8
8
|
|
|
9
|
-
需工作台后端在跑(默认 http://
|
|
9
|
+
需工作台后端在跑(默认 http://127.0.0.1:10010)。
|
|
10
10
|
"""
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
@@ -33,7 +33,7 @@ def _find_config_path():
|
|
|
33
33
|
CONFIG_PATH = _find_config_path()
|
|
34
34
|
|
|
35
35
|
# 平台地址(默认 10010,可 env 覆盖)
|
|
36
|
-
BASE_URL = os.environ.get("WLKJ_BASE_URL", "http://
|
|
36
|
+
BASE_URL = os.environ.get("WLKJ_BASE_URL", "http://127.0.0.1:10010")
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def bind(email, password, base_url=BASE_URL):
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""同步 Qoder Repo Wiki → 平台 PG wiki 表。
|
|
3
|
+
|
|
4
|
+
Qoder 自动为代码仓库生成 Repo Wiki(750+篇业务文档),存在:
|
|
5
|
+
data/code/{repo}/.qoder/repowiki/knowledge/zh/**/*.md
|
|
6
|
+
|
|
7
|
+
本脚本读这些 md → UPSERT 到 PG wiki 表(repo + keyword + title + description)。
|
|
8
|
+
这样知识层的 wiki 搜索(graphrag_ask/rag_search)能命中 Qoder 自动生成的高质量文档。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python sync_repowiki.py # 同步全部仓库
|
|
12
|
+
python sync_repowiki.py --repo fywl-ics # 只同步指定仓库
|
|
13
|
+
python sync_repowiki.py --dry-run # 只统计不写
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import glob
|
|
18
|
+
|
|
19
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
20
|
+
_up1 = os.path.dirname(_THIS_DIR)
|
|
21
|
+
_up2 = os.path.dirname(_up1)
|
|
22
|
+
SCRIPTS_DIR = os.path.dirname(_up2)
|
|
23
|
+
REPO_ROOT = os.path.dirname(SCRIPTS_DIR)
|
|
24
|
+
CODE_ROOT = os.path.join(REPO_ROOT, "data", "code")
|
|
25
|
+
|
|
26
|
+
PG = dict(host="127.0.0.1", port=5432, user="wlinkj", password="changeme", dbname="wlinkj")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def scan_repowiki(repo):
|
|
30
|
+
"""扫描某仓库的 Repo Wiki,返回 [{path, title, content, dir_path}]。"""
|
|
31
|
+
wiki_base = os.path.join(CODE_ROOT, repo, ".qoder", "repowiki")
|
|
32
|
+
if not os.path.isdir(wiki_base):
|
|
33
|
+
return []
|
|
34
|
+
results = []
|
|
35
|
+
for md_path in glob.glob(os.path.join(wiki_base, "**", "*.md"), recursive=True):
|
|
36
|
+
try:
|
|
37
|
+
with open(md_path, encoding="utf-8") as f:
|
|
38
|
+
content = f.read()
|
|
39
|
+
# 标题:md 文件名或第一行 #
|
|
40
|
+
title = os.path.splitext(os.path.basename(md_path))[0]
|
|
41
|
+
for line in content.split("\n")[:5]:
|
|
42
|
+
if line.strip().startswith("#"):
|
|
43
|
+
title = line.strip().lstrip("#").strip()
|
|
44
|
+
break
|
|
45
|
+
# keyword:从目录路径提取业务模块名
|
|
46
|
+
rel = os.path.relpath(md_path, wiki_base)
|
|
47
|
+
parts = rel.replace("\\", "/").split("/")
|
|
48
|
+
keyword = parts[-2] if len(parts) >= 2 else title
|
|
49
|
+
results.append({
|
|
50
|
+
"path": md_path,
|
|
51
|
+
"rel_path": rel,
|
|
52
|
+
"title": title,
|
|
53
|
+
"keyword": keyword,
|
|
54
|
+
"content": content[:2000], # 取前2000字
|
|
55
|
+
})
|
|
56
|
+
except Exception:
|
|
57
|
+
continue
|
|
58
|
+
return results
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sync_to_pg(wiki_items, repo):
|
|
62
|
+
"""UPSERT 到 PG wiki 表。"""
|
|
63
|
+
import psycopg2
|
|
64
|
+
from psycopg2.extras import execute_values
|
|
65
|
+
|
|
66
|
+
conn = psycopg2.connect(**PG)
|
|
67
|
+
conn.autocommit = True
|
|
68
|
+
cur = conn.cursor()
|
|
69
|
+
|
|
70
|
+
# ★ 不删旧数据(原 wiki 表有业务文档),只删 repowiki 来源的记录
|
|
71
|
+
# 用 md_path 前缀 'repowiki:' 标识(避免路径分隔符差异)
|
|
72
|
+
cur.execute("DELETE FROM wiki WHERE project = %s AND md_path LIKE 'repowiki:%%'", (repo,))
|
|
73
|
+
|
|
74
|
+
rows = [(w["keyword"], repo, w["title"], "repowiki:" + w["rel_path"], w["content"][:500])
|
|
75
|
+
for w in wiki_items]
|
|
76
|
+
if rows:
|
|
77
|
+
execute_values(
|
|
78
|
+
cur,
|
|
79
|
+
"INSERT INTO wiki(keyword, project, title, md_path, description) VALUES %s",
|
|
80
|
+
rows,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
print(f"[{repo}] 同步 {len(rows)} 篇 Wiki 到 PG")
|
|
84
|
+
conn.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
import argparse
|
|
89
|
+
parser = argparse.ArgumentParser(description="同步 Qoder Repo Wiki → PG")
|
|
90
|
+
parser.add_argument("--repo", help="指定仓库名")
|
|
91
|
+
parser.add_argument("--dry-run", action="store_true", help="只统计不写")
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
|
|
94
|
+
repos = [args.repo] if args.repo else ["fywl-ui", "fywl-ics", "Carmg-H5"]
|
|
95
|
+
total = 0
|
|
96
|
+
for repo in repos:
|
|
97
|
+
items = scan_repowiki(repo)
|
|
98
|
+
print(f" {repo}: 扫到 {len(items)} 篇 Repo Wiki")
|
|
99
|
+
if items:
|
|
100
|
+
for it in items[:3]:
|
|
101
|
+
print(f" [{it['keyword']}] {it['title']}")
|
|
102
|
+
if not args.dry_run:
|
|
103
|
+
sync_to_pg(items, repo)
|
|
104
|
+
total += len(items)
|
|
105
|
+
print(f"\n总计: {total} 篇 Wiki{'(dry-run 未写入)' if args.dry_run else ' 已同步到 PG'}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|