341 lines
13 KiB
Python
341 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""把旧 docx 标签表迁移为中文 Markdown 标签知识卡片。
|
|
|
|
这个脚本是一次性/阶段性迁移辅助工具,不是运行时分类器。
|
|
长期维护入口应该是 skills/label-master/knowledge 下的 Markdown 文件。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
|
|
|
|
|
|
@dataclass
|
|
class LabelRow:
|
|
domain: str
|
|
name: str
|
|
raw_tag: str
|
|
agent_candidate: str
|
|
abstraction: str
|
|
scope: str
|
|
examples: str
|
|
semantic_points: str
|
|
function_def: str
|
|
boundary: str
|
|
confusing: str
|
|
principle: str
|
|
notes: str
|
|
|
|
|
|
def _cell_text(cell: ET.Element) -> str:
|
|
parts: list[str] = []
|
|
for text_node in cell.findall('.//w:t', NS):
|
|
if text_node.text:
|
|
parts.append(text_node.text)
|
|
return re.sub(r'\s+', ' ', ''.join(parts)).strip()
|
|
|
|
|
|
def _docx_tables(path: Path) -> list[list[list[str]]]:
|
|
with zipfile.ZipFile(path) as zf:
|
|
xml = zf.read('word/document.xml')
|
|
root = ET.fromstring(xml)
|
|
tables: list[list[list[str]]] = []
|
|
for table in root.findall('.//w:tbl', NS):
|
|
rows: list[list[str]] = []
|
|
for tr in table.findall('./w:tr', NS):
|
|
row = [_cell_text(tc) for tc in tr.findall('./w:tc', NS)]
|
|
if any(cell.strip() for cell in row):
|
|
rows.append(row)
|
|
if rows:
|
|
tables.append(rows)
|
|
return tables
|
|
|
|
|
|
def _norm_header(value: str) -> str:
|
|
return re.sub(r'[\s/()()]+', '', value)
|
|
|
|
|
|
def _find_index(headers: list[str], candidates: Iterable[str]) -> int | None:
|
|
normalized = [_norm_header(h) for h in headers]
|
|
for candidate in candidates:
|
|
needle = _norm_header(candidate)
|
|
for idx, header in enumerate(normalized):
|
|
if needle and needle in header:
|
|
return idx
|
|
return None
|
|
|
|
|
|
def _safe_filename(value: str) -> str:
|
|
value = re.sub(r'[a-f0-9]{24,}$', '', value, flags=re.IGNORECASE)
|
|
value = re.sub(r'[\\/:*?"<>|]+', '、', value).strip()
|
|
value = re.sub(r'\s+', '', value)
|
|
return value[:48] or '未命名标签'
|
|
|
|
|
|
def _domain_name(path: Path) -> str:
|
|
stem = path.stem
|
|
return stem.removeprefix('Label定义-')
|
|
|
|
|
|
def _read_cell(row: list[str], idx: int | None) -> str:
|
|
if idx is None or idx >= len(row):
|
|
return ''
|
|
return row[idx].strip()
|
|
|
|
|
|
def _compact_label_name(tag: str, abstraction: str) -> str:
|
|
"""从旧表格的 tag 列中提取更适合人工维护的中文标签名。
|
|
|
|
部分旧表格把标签名和长描述写进了同一个单元格,例如
|
|
“闹钟闹钟/小憩定时闹钟的增删改查...”。这里尽量保留短标签,
|
|
不把整段说明变成文件名或 Agent(tag=...)。
|
|
"""
|
|
|
|
tag = re.sub(r'[a-f0-9]{24,}$', '', tag.strip(), flags=re.IGNORECASE)
|
|
abstraction = abstraction.strip()
|
|
if tag.startswith('应该属于'):
|
|
return abstraction or tag.removeprefix('应该属于').strip()
|
|
if not tag:
|
|
stop_match = re.search(r'(关于|回答|打开|查询|增|删|改|查|的|功能|垂域|/|、|,|。)', abstraction)
|
|
if stop_match and stop_match.start() >= 2:
|
|
return abstraction[:stop_match.start()]
|
|
return abstraction
|
|
if abstraction and tag.startswith(abstraction) and len(abstraction) <= 12:
|
|
return abstraction
|
|
repeated = re.match(r'^(.{2,8})\1', tag)
|
|
if repeated:
|
|
return repeated.group(1)
|
|
if len(tag) <= 24:
|
|
return tag
|
|
stop_match = re.search(r'(关于|回答|打开|查询|增|删|改|查|的|功能|垂域|/|、|,|。)', tag)
|
|
if stop_match and stop_match.start() >= 2:
|
|
return tag[:stop_match.start()]
|
|
return abstraction or tag[:12]
|
|
|
|
|
|
def _agent_candidate_for_label(name: str, raw_tag: str) -> str:
|
|
if not name:
|
|
return ''
|
|
if raw_tag.startswith('应该属于'):
|
|
target = raw_tag.removeprefix('应该属于').strip()
|
|
if target:
|
|
return f'Agent(tag="{target}")'
|
|
if raw_tag and len(raw_tag) <= 24:
|
|
return f'Agent(tag="{name}")'
|
|
if raw_tag.startswith(name):
|
|
return f'Agent(tag="{name}")'
|
|
return ''
|
|
|
|
|
|
def _function_candidate(value: str) -> str:
|
|
value = value.strip()
|
|
if not value:
|
|
return '待确认。'
|
|
# 保留旧表中的函数/参数定义原文,避免在迁移阶段误判最终输出格式。
|
|
return value
|
|
|
|
|
|
def _looks_like_header(row: list[str]) -> bool:
|
|
joined = ''.join(row)
|
|
return 'tag标签' in joined or ('功能抽象' in joined and '功能和范围定义' in joined)
|
|
|
|
|
|
def extract_label_rows(source_dir: Path) -> list[LabelRow]:
|
|
rows: list[LabelRow] = []
|
|
for docx_path in sorted(source_dir.glob('Label定义-*.docx')):
|
|
domain = _domain_name(docx_path)
|
|
for table in _docx_tables(docx_path):
|
|
if not table:
|
|
continue
|
|
header_pos = next((idx for idx, row in enumerate(table) if _looks_like_header(row)), None)
|
|
if header_pos is None:
|
|
continue
|
|
headers = table[header_pos]
|
|
idx_abstraction = _find_index(headers, ['功能抽象'])
|
|
idx_tag = _find_index(headers, ['tag标签'])
|
|
idx_scope = _find_index(headers, ['功能和范围定义'])
|
|
idx_examples = _find_index(headers, ['示例query'])
|
|
idx_semantic = _find_index(headers, ['三级语义功能点举例'])
|
|
idx_function = _find_index(headers, ['Function及参数定义', 'Function及参数定义飘黄部分未共识'])
|
|
idx_boundary = _find_index(headers, ['满足边界问题'])
|
|
idx_confusing = _find_index(headers, ['易混淆tagfunctionAgent', '易混淆tagfunction', '易混淆'])
|
|
idx_principle = _find_index(headers, ['划分原则'])
|
|
idx_notes = _find_index(headers, ['问题备注', '未解决'])
|
|
|
|
for row in table[header_pos + 1:]:
|
|
abstraction = _read_cell(row, idx_abstraction)
|
|
tag = _read_cell(row, idx_tag)
|
|
name = _compact_label_name(tag, abstraction)
|
|
if not name or name in {'/', '-', '待定'}:
|
|
continue
|
|
if len(name) > 80 and not tag:
|
|
continue
|
|
agent_candidate = _agent_candidate_for_label(name, tag)
|
|
rows.append(
|
|
LabelRow(
|
|
domain=domain,
|
|
name=name,
|
|
raw_tag=tag,
|
|
agent_candidate=agent_candidate,
|
|
abstraction=abstraction,
|
|
scope=_read_cell(row, idx_scope),
|
|
examples=_read_cell(row, idx_examples),
|
|
semantic_points=_read_cell(row, idx_semantic),
|
|
function_def=_read_cell(row, idx_function),
|
|
boundary=_read_cell(row, idx_boundary),
|
|
confusing=_read_cell(row, idx_confusing),
|
|
principle=_read_cell(row, idx_principle),
|
|
notes=_read_cell(row, idx_notes),
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def _section(title: str, body: str) -> str:
|
|
body = body.strip()
|
|
if not body:
|
|
body = '待补充。'
|
|
return f'## {title}\n\n{body}\n'
|
|
|
|
|
|
def _table_cell(value: str, *, limit: int = 80) -> str:
|
|
value = re.sub(r'\s+', ' ', value.strip()).replace('|', '、')
|
|
if not value:
|
|
return '待确认'
|
|
return value[:limit] + ('...' if len(value) > limit else '')
|
|
|
|
|
|
def _output_section(row: LabelRow) -> str:
|
|
raw_tag = row.raw_tag or '待确认。'
|
|
agent_candidate = row.agent_candidate or '待确认。'
|
|
function_candidate = _function_candidate(row.function_def)
|
|
return '\n'.join(
|
|
[
|
|
'## 标注输出',
|
|
'',
|
|
'> 注意:旧标签资料中同时存在 tag 标签和 Function 定义。本卡片不直接声明最终训练标签;生成数据前必须结合 `判断维度/标注输出形态.md` 确认当前任务使用 Agent 形式还是 Function 形式。',
|
|
'',
|
|
f'- 旧 tag 标签:{raw_tag}',
|
|
f'- Agent 包装候选(不代表最终):{agent_candidate}',
|
|
f'- Function 输出候选:{function_candidate}',
|
|
'- 推荐输出形态:待确认。',
|
|
'',
|
|
]
|
|
)
|
|
|
|
|
|
def _card_text(row: LabelRow) -> str:
|
|
title = row.name
|
|
return '\n'.join(
|
|
[
|
|
f'# {title}',
|
|
'',
|
|
_output_section(row),
|
|
_section('功能抽象', row.abstraction),
|
|
_section('适用范围', row.scope),
|
|
_section('典型 Query', row.examples),
|
|
_section('三级语义功能点', row.semantic_points),
|
|
_section('Function / Agent 说明', row.function_def),
|
|
_section('满足边界问题', row.boundary),
|
|
_section('易混淆标签', row.confusing),
|
|
_section('划分原则', row.principle),
|
|
_section('未解决问题', row.notes),
|
|
]
|
|
).rstrip() + '\n'
|
|
|
|
|
|
def write_knowledge(rows: list[LabelRow], output_dir: Path) -> None:
|
|
label_root = output_dir / '标签'
|
|
boundary_root = output_dir / '边界'
|
|
index_root = output_dir / '索引'
|
|
label_root.mkdir(parents=True, exist_ok=True)
|
|
boundary_root.mkdir(parents=True, exist_ok=True)
|
|
index_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
domains: dict[str, list[LabelRow]] = {}
|
|
for row in rows:
|
|
domains.setdefault(row.domain, []).append(row)
|
|
domain_dir = label_root / _safe_filename(row.domain)
|
|
domain_dir.mkdir(parents=True, exist_ok=True)
|
|
path = domain_dir / f'{_safe_filename(row.name)}.md'
|
|
path.write_text(_card_text(row), encoding='utf-8')
|
|
|
|
for domain, domain_rows in sorted(domains.items()):
|
|
lines = [f'# {domain}边界', '']
|
|
for row in domain_rows:
|
|
if not row.confusing and not row.principle and not row.boundary:
|
|
continue
|
|
lines.append(f'## {row.name}')
|
|
if row.confusing:
|
|
lines.extend(['', f'- 易混淆:{row.confusing}'])
|
|
if row.principle:
|
|
lines.extend(['', f'- 划分原则:{row.principle}'])
|
|
if row.boundary:
|
|
lines.extend(['', f'- 满足边界问题:{row.boundary}'])
|
|
lines.append('')
|
|
if len(lines) > 2:
|
|
(boundary_root / f'{_safe_filename(domain)}边界.md').write_text('\n'.join(lines).rstrip() + '\n', encoding='utf-8')
|
|
|
|
overview_lines = ['# 标签迁移索引', '']
|
|
label_index_lines = [
|
|
'# 标签索引',
|
|
'',
|
|
'这个索引用于 Agent 第一阶段快速判断候选标签和输出形态。详细边界仍需读取具体标签卡片和边界文件。',
|
|
'',
|
|
'| 领域 | 标签 | 旧 tag 标签 | Agent 形式候选 | Function 形式候选 | 知识卡片 |',
|
|
'| --- | --- | --- | --- | --- | --- |',
|
|
]
|
|
for domain, domain_rows in sorted(domains.items()):
|
|
overview_lines.append(f'## {domain}')
|
|
overview_lines.append('')
|
|
seen_links: set[str] = set()
|
|
for row in sorted(domain_rows, key=lambda item: item.name):
|
|
rel = Path('标签') / _safe_filename(domain) / f'{_safe_filename(row.name)}.md'
|
|
rel_text = rel.as_posix()
|
|
if rel_text in seen_links:
|
|
continue
|
|
seen_links.add(rel_text)
|
|
overview_lines.append(f'- [{row.name}]({rel.as_posix()})')
|
|
rel_from_index = Path('..') / rel
|
|
function_hint = _table_cell(row.function_def, limit=60) if row.function_def else '待确认'
|
|
label_index_lines.append(
|
|
'| {domain} | {name} | {raw_tag} | {agent} | {function} | [{name}]({path}) |'.format(
|
|
domain=_table_cell(domain, limit=20),
|
|
name=_table_cell(row.name, limit=24),
|
|
raw_tag=_table_cell(row.raw_tag, limit=32),
|
|
agent=_table_cell(row.agent_candidate, limit=36),
|
|
function=function_hint,
|
|
path=rel_from_index.as_posix(),
|
|
)
|
|
)
|
|
overview_lines.append('')
|
|
(output_dir / '标签迁移索引.md').write_text('\n'.join(overview_lines).rstrip() + '\n', encoding='utf-8')
|
|
(index_root / '标签索引.md').write_text('\n'.join(label_index_lines).rstrip() + '\n', encoding='utf-8')
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description='迁移 docx 标签定义表为 Markdown 知识卡片')
|
|
parser.add_argument('--source-dir', default='标签定义', help='旧标签定义目录')
|
|
parser.add_argument('--output-dir', default='skills/label-master/knowledge', help='知识库输出目录')
|
|
args = parser.parse_args()
|
|
|
|
source_dir = Path(args.source_dir)
|
|
output_dir = Path(args.output_dir)
|
|
rows = extract_label_rows(source_dir)
|
|
write_knowledge(rows, output_dir)
|
|
print(f'已迁移 {len(rows)} 条标签知识到 {output_dir}')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|