提交 c632f219 authored 作者: 陈泽健's avatar 陈泽健

feat(keyword-matcher): jieba分词优化 + 评分归一化 (Phase 1&2)

Phase 1 - 关键词提取优化:
- 引入jieba分词替代暴力替换,解决语义丢失问题
- 精简修饰词列表,添加项目词汇白名单
- extract_value支持中文值提取

Phase 2 - 评分体系归一化:
- 精确匹配(1.0)高于包含匹配(0.3)
- click对INPUT减分更激进(0.1),fill优先匹配INPUT
- 归一化评分到[0,1]区间

新增测试脚本验证改动
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3f4fffa2
...@@ -6,15 +6,25 @@ ...@@ -6,15 +6,25 @@
作者:czj 作者:czj
创建日期:2026-08-05 创建日期:2026-08-05
最后修改:2026-08-05 最后修改:2026-08-06
Phase 1 更新:
- 引入jieba分词替代暴力替换
- 精简修饰词列表
- extract_value正则增强支持中文值
""" """
import logging import logging
import re import re
import jieba
import jieba.posseg as pseg
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# jieba分词初始化(首次调用时自动加载,后续缓存)
_jieba_initialized = False
# ==================== 动作词表 ==================== # ==================== 动作词表 ====================
...@@ -43,96 +53,150 @@ ELEMENT_WORDS = [ ...@@ -43,96 +53,150 @@ ELEMENT_WORDS = [
# ==================== 关键词提取 ==================== # ==================== 关键词提取 ====================
def _init_jieba():
"""初始化jieba分词(懒加载)"""
global _jieba_initialized
if not _jieba_initialized:
# 添加项目特定词汇到词典(提高分词准确度)
# 使用 suggest_freq 确保词汇被正确识别
project_words = [
# 菜单名称
'功能中心', '系统设置', '信息发布', '通知公告',
'会议预约', '会议列表', '新建会议', '会议室管理',
'壁纸推送', '脚本命令', '集控控制',
'工单列表', '我的工单', '告警工单',
'资产信息', '资产设备', '资产管理',
'运维设备', '运维巡检', '会议运维',
'会务统筹', '会务工单', '会务管理',
'信息窗管理', '消息通知', '信息管理',
'数据统计', '数据分析', '管理看板',
# 按钮名称
'新建', '确定', '取消', '保存', '删除', '编辑', '查询', '重置',
'展开', '收起', '上传', '下载', '导出', '导入',
# 其他常见词汇
'用户名', '密码', '账号', '备注', '会议名称',
]
for word in project_words:
jieba.add_word(word, freq=1000) # 高频确保被识别
_jieba_initialized = True
logger.debug(f"jieba分词初始化完成,已添加 {len(project_words)} 个项目词汇")
def extract_keywords(description: str) -> List[str]: def extract_keywords(description: str) -> List[str]:
""" """
从自然语言描述中提取关键词 从自然语言描述中提取关键词(jieba分词版本)
策略: 策略:
1. 去除特殊符号(【】《》等 1. 提取【】内完整词组(最高优先级
2. 去除动作词和元素类型 2. jieba分词,按词性过滤保留名词、动
3. 提取剩余的关键词(名词、修饰词等) 3. 去除动作词和元素类型词
4. 按 2-gram 分词获取更精确的关键词 4. 过滤单字和无意义词汇
Args: Args:
description (str): 步骤描述,如 "输入用户名 admin@xty" description (str): 步骤描述,如 "输入用户名 admin@xty"
Returns: Returns:
List[str]: 关键词列表,如 ["用户名", "admin", "admin@xty"] List[str]: 关键词列表,如 ["用户名", "admin@xty"]
Example: Example:
>>> extract_keywords("点击登录按钮") >>> extract_keywords("点击【新建会议】按钮")
["登录"] ["新建会议"]
>>> extract_keywords("输入用户名 admin@xty") >>> extract_keywords("点击展开")
["用户名", "admin", "admin@xty"] ["展开"]
>>> extract_keywords("点击【功能中心】展开") >>> extract_keywords("输入会议名称:自动化新建会议")
["功能中心"] ["会议名称", "自动化", "新建会议"]
""" """
# 去除特殊符号 _init_jieba()
cleaned = description
special_chars = ['【', '】', '《', '》', '「', '」', '『', '』', '〔', '〕', '〈', '〉'] # 项目特定词汇集合(这些词汇应该被保留)
for ch in special_chars: project_vocab = {
cleaned = cleaned.replace(ch, '') # 菜单名称
'功能中心', '系统设置', '信息发布', '通知公告',
# 去除动作词 '会议预约', '会议列表', '新建会议', '会议室管理',
for word in sorted(ACTION_WORDS + ELEMENT_WORDS, key=len, reverse=True): '壁纸推送', '脚本命令', '集控控制',
cleaned = cleaned.replace(word, "") '工单列表', '我的工单', '告警工单',
cleaned = cleaned.strip() '资产信息', '资产设备', '资产管理',
'运维设备', '运维巡检', '会议运维',
# 去除修饰词(这些词不是关键词,但会影响匹配) '会务统筹', '会务工单', '会务管理',
# ⚠️ 注意:'添加'、'新增'、'完成'、'确定'、'取消' 可能是按钮名称,不应移除 '信息窗管理', '消息通知', '信息管理',
modifier_words = ['展开', '分类', '操作', '查看', '是否', '正确', '数据统计', '数据分析', '管理看板',
'条目', '数据', '验证', '成功', '页面', '系统'] # 常见按钮名称
for word in modifier_words: '新建', '确定', '取消', '保存', '删除', '编辑', '查询', '重置',
cleaned = cleaned.replace(word, "") '展开', '收起', '上传', '下载', '导出', '导入',
cleaned = cleaned.strip() # 其他
'用户名', '密码', '账号', '备注', '会议名称',
# ⚠️ 关键修复:先提取【】内的核心词(优先级最高) }
core_keywords = []
bracket_match = re.findall(r'【([^】]+)】', description) # 1. 提取【】内完整词组(最高优先级)
if bracket_match: bracket_keywords = re.findall(r'【([^】]+)】', description)
for match in bracket_match:
core_keywords.append(match) # 2. jieba分词
# 添加 2-gram words = pseg.cut(description)
for i in range(len(match) - 1):
sub = match[i:i+2] # 3. 按词性过滤:保留名词(n/nr/ns/nz)、动词
if sub not in ACTION_WORDS and sub not in ELEMENT_WORDS: # 词性标记说明:
core_keywords.append(sub) # n: 普通名词, nr: 人名, ns: 地名, nz: 其他专名
# vn: 名动词, v: 动词, eng: 英文
keywords = [] valid_pos_tags = {'n', 'nr', 'ns', 'nz', 'vn', 'v', 'eng', 'l'} # l: 成语
# 添加清理后的关键词 # 单字词汇白名单(这些单字应该保留)
if cleaned: single_char_whitelist = {
keywords.append(cleaned) '确定', '取消', '保存', '删除', '编辑', '查询', '新建',
'展开', '收起', '上传', '下载', '导出', '导入',
# 2-gram 分词(提取连续两个字的关键词) }
if len(cleaned) >= 2:
for i in range(len(cleaned) - 1): filtered_words = []
sub = cleaned[i:i+2] for word, flag in words:
if len(sub) == 2 and sub not in ACTION_WORDS and sub not in ELEMENT_WORDS: word = word.strip()
keywords.append(sub) if not word:
continue
# 单字关键词(去掉停用词)
stopwords = {'的', '在', '是', '和', '有', '等', '中', '为', '了', '与', '或', '、', ',', '。', ' '} # 项目特定词汇,直接保留
for ch in cleaned: if word in project_vocab:
if ch.strip() and ch not in stopwords: filtered_words.append(word)
keywords.append(ch) continue
# ⚠️ 【】内的核心词放在最前面(优先级最高) # 英文(可能是ID、变量名等)
if flag == 'eng':
if len(word) >= 2 and word not in ACTION_WORDS and word not in ELEMENT_WORDS:
filtered_words.append(word)
continue
# 中文词汇过滤
if flag in valid_pos_tags:
# 长度>=2 或 在单字白名单中
if len(word) >= 2:
# 不在动作词/元素词表中
if word not in ACTION_WORDS and word not in ELEMENT_WORDS:
# 不是纯数字
if not word.isdigit():
filtered_words.append(word)
elif word in single_char_whitelist:
filtered_words.append(word)
# 4. 精简修饰词移除(仅保留真正的无意义修饰词)
minimal_modifiers = {'的', '了', '着', '过', '一下', '是否', '是否成功', '正确'}
filtered_words = [w for w in filtered_words if w not in minimal_modifiers]
# 5. 合并结果:【】内词组优先
result = [] result = []
seen = set() seen = set()
# 先添加核心关键词 # 先添加【】内的核心关键词
for kw in core_keywords: for kw in bracket_keywords:
if kw not in seen: if kw not in seen:
seen.add(kw) seen.add(kw)
result.append(kw) result.append(kw)
# 再添加其他关键词 # 再添加jieba分词结果
for kw in keywords: for kw in filtered_words:
if kw not in seen: if kw not in seen:
seen.add(kw) seen.add(kw)
result.append(kw) result.append(kw)
logger.debug(f"关键词提取: '{description}' -> {result}")
return result return result
...@@ -144,6 +208,7 @@ def extract_value_from_description(description: str) -> Optional[str]: ...@@ -144,6 +208,7 @@ def extract_value_from_description(description: str) -> Optional[str]:
例如:"输入账号:admin@xty" -> "admin@xty" 例如:"输入账号:admin@xty" -> "admin@xty"
例如:"输入密码:Ubains@13579" -> "Ubains@13579" 例如:"输入密码:Ubains@13579" -> "Ubains@13579"
例如:"输入参考价值:100" -> "100" 例如:"输入参考价值:100" -> "100"
例如:"输入备注:这是一个测试备注" -> "这是一个测试备注"
Args: Args:
description (str): 步骤描述 description (str): 步骤描述
...@@ -151,13 +216,17 @@ def extract_value_from_description(description: str) -> Optional[str]: ...@@ -151,13 +216,17 @@ def extract_value_from_description(description: str) -> Optional[str]:
Returns: Returns:
Optional[str]: 提取的值,未找到返回 None Optional[str]: 提取的值,未找到返回 None
""" """
# 匹配模式1:动作 + 目标 + 冒号 + 值(中文冒号) # 匹配模式1:动作 + 目标 + 冒号 + 值(支持中文值、空格、特殊字符)
# 例如:"输入账号:admin@xty" -> "admin@xty" # 改进:将 [\w@.\-]+ 改为 .+? 以支持中文值
match = re.search(r'(?:输入|填写|填入|键入|写入)\s*[^::]*[::]\s*([\w@.\-]+)\s*$', description) # 例如:"输入备注:这是一个测试备注" -> "这是一个测试备注"
match = re.search(r'(?:输入|填写|填入|键入|写入)\s*[^::]*[::]\s*(.+?)\s*$', description)
if match: if match:
return match.group(1) value = match.group(1).strip()
# 过滤掉明显不是值的内容(如"按钮"、"输入框"等元素类型词)
if value and not any(word in value for word in ELEMENT_WORDS):
return value
# 匹配模式2:动作 + 目标 + 空格 + 值 # 匹配模式2:动作 + 目标 + 空格 + 值(英文字母数字)
# 例如:"输入用户名 admin@xty" -> "admin@xty" # 例如:"输入用户名 admin@xty" -> "admin@xty"
match = re.search(r'(?:输入|填写|填入|键入|写入)\s*.*?\s+([\w@.\-]+)\s*$', description) match = re.search(r'(?:输入|填写|填入|键入|写入)\s*.*?\s+([\w@.\-]+)\s*$', description)
if match: if match:
...@@ -239,6 +308,98 @@ def is_verification_step(description: str) -> bool: ...@@ -239,6 +308,98 @@ def is_verification_step(description: str) -> bool:
# ==================== 元素匹配 ==================== # ==================== 元素匹配 ====================
# ==================== 评分体系常量(Phase 2)====================
# 精确匹配 vs 包含匹配
EXACT_MATCH_SCORE = 1.0 # 精确匹配(完全相等)
CONTAINS_MATCH_SCORE = 0.3 # 包含匹配(子串包含)
# 维度权重(总和为1.0)
DIMENSION_WEIGHTS = {
'data-testid': 0.20,
'id': 0.18,
'name': 0.12,
'placeholder': 0.15,
'aria-label': 0.12,
'text': 0.15,
'action_type': 0.08, # 动作类型适配
}
def _calculate_match_score(keyword: str, target: str) -> float:
"""
计算关键词与目标文本的匹配分数
Args:
keyword: 关键词
target: 目标文本(如ID、placeholder、text等)
Returns:
float: 匹配分数 (0.0 ~ 1.0)
"""
if not keyword or not target:
return 0.0
keyword_lower = keyword.lower()
target_lower = target.lower()
# 精确匹配(完全相等)
if keyword_lower == target_lower:
return EXACT_MATCH_SCORE
# 包含匹配(关键词是目标的子串)
if keyword_lower in target_lower:
# 根据匹配长度比例调整分数
ratio = len(keyword) / len(target)
return CONTAINS_MATCH_SCORE * min(1.0, ratio * 2) # 长度比例越高,分数越高
return 0.0
def _apply_action_type_adjustment(score: float, tag: str, action: str, keywords: List[str]) -> float:
"""
根据动作类型调整分数
Args:
score: 原始分数
tag: 元素标签名
action: 动作类型
keywords: 关键词列表
Returns:
float: 调整后的分数
"""
tag = tag.upper()
if action == 'click':
# click动作对INPUT/TEXTAREA大幅减分
if tag in ['INPUT', 'TEXTAREA']:
input_keywords = ['输入框', '输入', '框', '填写', 'input']
if not any(kw in input_keywords for kw in keywords):
score *= 0.1 # 更激进(原0.3)
# click动作对BUTTON/A/DIV/SPAN/LI/I加分
elif tag in ['BUTTON', 'A', 'DIV', 'SPAN', 'LI', 'I']:
score += 0.15
elif action == 'fill':
# fill动作对INPUT/TEXTAREA加分
if tag in ['INPUT', 'TEXTAREA']:
score += 0.15
# fill动作对非输入元素减分
elif tag in ['BUTTON', 'A', 'DIV', 'SPAN']:
fill_keywords = ['按钮', '点击', 'click']
if not any(kw in fill_keywords for kw in keywords):
score *= 0.3
elif action == 'select':
if tag == 'SELECT':
score += 0.15
# 确保分数在[0, 1.5]范围内(允许略微超过1.0以体现动作适配优势)
return min(max(score, 0.0), 1.5)
def match_element_by_keywords( def match_element_by_keywords(
page, page,
keywords: List[str], keywords: List[str],
...@@ -247,12 +408,13 @@ def match_element_by_keywords( ...@@ -247,12 +408,13 @@ def match_element_by_keywords(
max_candidates: int = 10 max_candidates: int = 10
) -> Tuple[Optional[Any], List[Dict[str, Any]]]: ) -> Tuple[Optional[Any], List[Dict[str, Any]]]:
""" """
通过关键词在页面元素中直接匹配 通过关键词在页面元素中直接匹配(Phase 2 评分归一化版本)
三级匹配策略: 评分策略:
1. 精确匹配:ID, name, data-testid 1. 每个维度计算精确匹配/包含匹配分数
2. 包含匹配:placeholder, aria-label, text 2. 按维度权重加权求和
3. 回退策略:根据动作类型推断 3. 归一化到[0, 1]区间
4. 动作类型适配调整
Args: Args:
page: Playwright Page 对象 page: Playwright Page 对象
...@@ -288,7 +450,7 @@ def match_element_by_keywords( ...@@ -288,7 +450,7 @@ def match_element_by_keywords(
elements = page.locator(base_selector).all() elements = page.locator(base_selector).all()
total_elements = len(elements) total_elements = len(elements)
# 🔧 新增:在所有 iframe 中查找元素 # 在所有 iframe 中查找元素
for frame in page.frames: for frame in page.frames:
if frame == page.main_frame: if frame == page.main_frame:
continue continue
...@@ -321,64 +483,102 @@ def match_element_by_keywords( ...@@ -321,64 +483,102 @@ def match_element_by_keywords(
data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or '' data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or ''
class_name = el.get_attribute('class') or '' class_name = el.get_attribute('class') or ''
score = 0.0 # ==================== Phase 2: 归一化评分 ====================
selectors = [] score_details = {} # 各维度得分详情
selectors = [] # 选择器列表
# 1. ID 匹配(最高优先级)
if el_id: # 对每个关键词计算各维度得分
for kw in keywords: for kw in keywords:
if kw.lower() in el_id.lower(): # 1. data-testid 匹配(最高优先级)
score += 0.4 if data_testid:
selectors.append({ match_score = _calculate_match_score(kw, data_testid)
'type': 'css', if match_score > 0:
'value': f'#{el_id}', dim_score = match_score * DIMENSION_WEIGHTS['data-testid']
'confidence': 0.95, score_details['data-testid'] = max(score_details.get('data-testid', 0), dim_score)
'priority': 1 if match_score == EXACT_MATCH_SCORE:
}) selectors.append({
'type': 'css',
# 2. data-testid 匹配(高优先级) 'value': f'[data-testid="{data_testid}"]',
if data_testid: 'confidence': 0.95,
for kw in keywords: 'priority': 1,
if kw.lower() in data_testid.lower(): 'match_type': 'exact'
score += 0.35 })
selectors.append({
'type': 'css', # 2. ID 匹配
'value': f'[data-testid="{data_testid}"]', if el_id:
'confidence': 0.90, match_score = _calculate_match_score(kw, el_id)
'priority': 2 if match_score > 0:
}) dim_score = match_score * DIMENSION_WEIGHTS['id']
score_details['id'] = max(score_details.get('id', 0), dim_score)
# 3. placeholder 匹配(输入框) if match_score == EXACT_MATCH_SCORE:
if placeholder and tag == 'INPUT': selectors.append({
for kw in keywords: 'type': 'css',
if kw.lower() in placeholder.lower(): 'value': f'#{el_id}',
score += 0.3 'confidence': 0.90,
selectors.append({ 'priority': 2,
'type': 'css', 'match_type': 'exact'
'value': f'input[placeholder*="{placeholder}"]', })
'confidence': 0.85, elif not any(s['value'] == f'#{el_id}' for s in selectors):
'priority': 3 selectors.append({
}) 'type': 'css',
'value': f'#{el_id}',
# 4. aria-label 匹配 'confidence': 0.60,
if aria_label: 'priority': 4,
for kw in keywords: 'match_type': 'contains'
if kw.lower() in aria_label.lower(): })
score += 0.3
selectors.append({ # 3. name 匹配
'type': 'css', if name:
'value': f'[aria-label*="{aria_label}"]', match_score = _calculate_match_score(kw, name)
'confidence': 0.80, if match_score > 0:
'priority': 4 dim_score = match_score * DIMENSION_WEIGHTS['name']
}) score_details['name'] = max(score_details.get('name', 0), dim_score)
if match_score == EXACT_MATCH_SCORE:
# 5. 文本匹配(按钮、链接、可点击的 div/span) selectors.append({
# ⚠️ 扩展:允许 div、span、i、label、li 等元素进行文本匹配 'type': 'css',
text_matchable_tags = ['BUTTON', 'A', 'DIV', 'SPAN', 'I', 'LABEL', 'LI'] 'value': f'[name="{name}"]',
if text and tag in text_matchable_tags: 'confidence': 0.85,
for kw in keywords: 'priority': 5,
if kw.lower() in text.lower(): 'match_type': 'exact'
score += 0.25 })
# 4. placeholder 匹配(输入框)
if placeholder and tag == 'INPUT':
match_score = _calculate_match_score(kw, placeholder)
if match_score > 0:
dim_score = match_score * DIMENSION_WEIGHTS['placeholder']
score_details['placeholder'] = max(score_details.get('placeholder', 0), dim_score)
if match_score >= CONTAINS_MATCH_SCORE:
selectors.append({
'type': 'css',
'value': f'input[placeholder*="{placeholder}"]',
'confidence': 0.80 if match_score == EXACT_MATCH_SCORE else 0.60,
'priority': 3,
'match_type': 'exact' if match_score == EXACT_MATCH_SCORE else 'contains'
})
# 5. aria-label 匹配
if aria_label:
match_score = _calculate_match_score(kw, aria_label)
if match_score > 0:
dim_score = match_score * DIMENSION_WEIGHTS['aria-label']
score_details['aria-label'] = max(score_details.get('aria-label', 0), dim_score)
if match_score >= CONTAINS_MATCH_SCORE:
selectors.append({
'type': 'css',
'value': f'[aria-label*="{aria_label}"]',
'confidence': 0.80 if match_score == EXACT_MATCH_SCORE else 0.55,
'priority': 4,
'match_type': 'exact' if match_score == EXACT_MATCH_SCORE else 'contains'
})
# 6. 文本匹配(按钮、链接、可点击的 div/span)
text_matchable_tags = ['BUTTON', 'A', 'DIV', 'SPAN', 'I', 'LABEL', 'LI']
if text and tag in text_matchable_tags:
match_score = _calculate_match_score(kw, text)
if match_score > 0:
dim_score = match_score * DIMENSION_WEIGHTS['text']
score_details['text'] = max(score_details.get('text', 0), dim_score)
# 根据元素类型调整选择器 # 根据元素类型调整选择器
if tag in ['BUTTON', 'A']: if tag in ['BUTTON', 'A']:
selector_value = f'{tag.lower()}:has-text("{text}")' selector_value = f'{tag.lower()}:has-text("{text}")'
...@@ -387,68 +587,51 @@ def match_element_by_keywords( ...@@ -387,68 +587,51 @@ def match_element_by_keywords(
selectors.append({ selectors.append({
'type': 'css', 'type': 'css',
'value': selector_value, 'value': selector_value,
'confidence': 0.75, 'confidence': 0.85 if match_score == EXACT_MATCH_SCORE else 0.65,
'priority': 5 'priority': 5,
'match_type': 'exact' if match_score == EXACT_MATCH_SCORE else 'contains'
}) })
# 6. name 匹配 # 如果没有任何匹配,跳过该元素
if name: if not score_details:
for kw in keywords: continue
if kw.lower() in name.lower():
score += 0.2
selectors.append({
'type': 'css',
'value': f'[name="{name}"]',
'confidence': 0.70,
'priority': 6
})
# 7. 动作类型加分/减分 # 计算归一化分数
if action == 'fill' and tag in ['INPUT', 'TEXTAREA']: matched_weights = sum(DIMENSION_WEIGHTS[k] for k in score_details.keys() if k != 'action_type')
score += 0.20 if matched_weights > 0:
# ⚠️ click 操作额外加分标签扩展 base_score = sum(score_details.values()) / matched_weights
elif action == 'click' and tag in ['BUTTON', 'A', 'I', 'DIV', 'SPAN', 'LI']: else:
score += 0.25 base_score = 0.0
elif action == 'select' and tag == 'SELECT':
score += 0.20 # 动作类型适配
final_score = _apply_action_type_adjustment(base_score, tag, action, keywords)
# ⚠️ 关键修复:点击操作不应该匹配输入框(除非关键词明确包含输入相关)
if action == 'click' and tag in ['INPUT', 'TEXTAREA']:
# 只有关键词明确包含输入相关词汇时才保留输入框
input_keywords = ['输入框', '输入', '框', '填写', 'input']
if not any(kw in input_keywords for kw in keywords):
score *= 0.3 # 大幅降低分数,让按钮优先
# 填充操作不应该匹配按钮(除非关键词明确包含按钮相关)
if action == 'fill' and tag in ['BUTTON', 'A']:
fill_keywords = ['按钮', '点击', 'click']
if not any(kw in fill_keywords for kw in keywords):
score *= 0.3 # 大幅降低分数
# 8. 类型匹配(password, email, tel 等)
if el_type:
if action == 'fill':
if el_type == 'password' and any(kw in '密码' for kw in keywords):
score += 0.2
selectors.append({
'type': 'css',
'value': 'input[type="password"]',
'confidence': 0.85,
'priority': 3
})
if score > 0.15 and selectors: # 记录动作类型调整分数
if final_score != base_score:
score_details['action_type'] = final_score - base_score
# 只保留有效候选
if final_score > 0.1 and selectors:
# 按优先级排序选择器 # 按优先级排序选择器
selectors.sort(key=lambda x: x['priority']) selectors.sort(key=lambda x: (x['priority'], -x['confidence']))
# 去重选择器
seen_selectors = set()
unique_selectors = []
for sel in selectors:
if sel['value'] not in seen_selectors:
seen_selectors.add(sel['value'])
unique_selectors.append(sel)
candidates.append({ candidates.append({
'element': el, 'element': el,
'score': score, 'score': final_score,
'selectors': selectors, 'score_details': score_details,
'selectors': unique_selectors,
'info': { 'info': {
'tag': tag, 'tag': tag,
'type': el_type, 'type': el_type,
'placeholder': placeholder, 'placeholder': placeholder,
'text': text, 'text': text[:50] if text else '', # 限制长度
'id': el_id 'id': el_id
} }
}) })
...@@ -482,7 +665,7 @@ def match_element_by_keywords( ...@@ -482,7 +665,7 @@ def match_element_by_keywords(
# 日志 # 日志
best = limited_candidates[0] best = limited_candidates[0]
logger.info(f"最佳匹配元素: {best['info']}, 分数: {best['score']:.2f}, 候选数: {len(limited_candidates)}") logger.info(f"最佳匹配元素: {best['info']}, 分数: {best['score']:.3f}, 详情: {best['score_details']}, 候选数: {len(limited_candidates)}")
return elements_list, unique_selectors return elements_list, unique_selectors
......
...@@ -10,4 +10,5 @@ playwright==1.40.0 ...@@ -10,4 +10,5 @@ playwright==1.40.0
websockets==12.0 websockets==12.0
requests==2.31.0 requests==2.31.0
paho-mqtt==2.1.0 paho-mqtt==2.1.0
openpyxl==3.1.2 openpyxl==3.1.2
\ No newline at end of file jieba>=0.42.1
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试脚本:验证 Phase 1 关键词提取优化
测试项:
1. jieba分词替代暴力替换
2. 修饰词精简后不再空关键词
3. 中文值提取
"""
import sys
import os
import io
# 设置标准输出编码为UTF-8(解决Windows GBK编码问题)
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.keyword_matcher import extract_keywords, extract_value_from_description
def test_extract_keywords():
"""测试关键词提取"""
print("=" * 60)
print("测试关键词提取(jieba分词版本)")
print("=" * 60)
test_cases = [
# (输入, 期望包含的关键词)
("点击【新建会议】按钮", ["新建会议"]),
("点击展开", ["展开"]), # 之前返回空列表
("点击系统设置", ["系统设置"]), # 之前返回空列表
("输入会议名称:自动化新建会议", ["会议名称", "自动化", "新建会议"]),
("点击信息发布", ["信息发布"]),
("点击功能中心图标", ["功能中心"]),
("输入备注:这是一个测试备注", ["备注"]),
("点击确定按钮", ["确定"]),
("点击取消按钮", ["取消"]),
("输入用户名:admin@xty", ["用户名"]),
]
passed = 0
failed = 0
for desc, expected_keywords in test_cases:
result = extract_keywords(desc)
# 检查期望的关键词是否在结果中
missing = [kw for kw in expected_keywords if kw not in result]
if missing:
print(f"[FAIL] '{desc}'")
print(f" 期望包含: {expected_keywords}")
print(f" 实际结果: {result}")
print(f" 缺失: {missing}")
failed += 1
else:
print(f"[PASS] '{desc}' -> {result}")
passed += 1
print()
print(f"结果: 通过 {passed}/{passed + failed}")
return failed == 0
def test_extract_value():
"""测试值提取"""
print()
print("=" * 60)
print("测试值提取(支持中文值)")
print("=" * 60)
test_cases = [
# (输入, 期望值)
("输入用户名:admin@xty", "admin@xty"),
("输入账号:admin@xty", "admin@xty"),
("输入密码:Ubains@13579", "Ubains@13579"),
("输入参考价值:100", "100"),
("输入备注:这是一个测试备注", "这是一个测试备注"),
("输入会议名称:自动化测试会议", "自动化测试会议"),
("输入用户名 admin@xty", "admin@xty"),
("输入值'test_value'", "test_value"),
("输入值\"test_value\"", "test_value"),
]
passed = 0
failed = 0
for desc, expected_value in test_cases:
result = extract_value_from_description(desc)
if result == expected_value:
print(f"[PASS] '{desc}' -> '{result}'")
passed += 1
else:
print(f"[FAIL] '{desc}'")
print(f" 期望: '{expected_value}'")
print(f" 实际: '{result}'")
failed += 1
print()
print(f"结果: 通过 {passed}/{passed + failed}")
return failed == 0
def main():
"""运行所有测试"""
print("\n" + "=" * 60)
print("Phase 1 关键词提取优化 - 验证测试")
print("=" * 60)
all_passed = True
# 测试关键词提取
if not test_extract_keywords():
all_passed = False
# 测试值提取
if not test_extract_value():
all_passed = False
print()
print("=" * 60)
if all_passed:
print("[SUCCESS] 所有测试通过!Phase 1 验证成功")
else:
print("[WARNING] 部分测试失败,请检查")
print("=" * 60)
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试脚本:验证 Phase 2 评分体系归一化
测试项:
1. 精确匹配 vs 包含匹配 - 精确匹配应排序更高
2. 动作类型适配 - click优先匹配BUTTON,fill优先匹配INPUT
"""
import sys
import os
import io
# 设置标准输出编码为UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.keyword_matcher import (
_calculate_match_score,
_apply_action_type_adjustment,
EXACT_MATCH_SCORE,
CONTAINS_MATCH_SCORE,
)
def test_match_score():
"""测试匹配分数计算"""
print("=" * 60)
print("测试匹配分数计算")
print("=" * 60)
test_cases = [
# (关键词, 目标文本, 期望精确匹配)
("确定", "确定", True),
("确定", "确定按钮", False),
("新建", "新建会议", False),
("新建会议", "新建会议", True),
("admin", "admin@xty", False),
("admin@xty", "admin@xty", True),
]
passed = 0
failed = 0
for keyword, target, expect_exact in test_cases:
score = _calculate_match_score(keyword, target)
is_exact = (score == EXACT_MATCH_SCORE)
if is_exact == expect_exact:
match_type = "精确" if is_exact else "包含"
print(f"[PASS] '{keyword}' vs '{target}' -> {match_type}匹配 (score={score:.2f})")
passed += 1
else:
print(f"[FAIL] '{keyword}' vs '{target}'")
print(f" 期望: {'精确' if expect_exact else '包含'}匹配")
print(f" 实际: score={score:.2f}")
failed += 1
print()
print(f"结果: 通过 {passed}/{passed + failed}")
return failed == 0
def test_action_type_adjustment():
"""测试动作类型适配"""
print()
print("=" * 60)
print("测试动作类型适配")
print("=" * 60)
test_cases = [
# (原分数, 标签, 动作, 期望结果描述)
(0.5, "BUTTON", "click", "应该加分"),
(0.5, "INPUT", "click", "应该减分"),
(0.5, "INPUT", "fill", "应该加分"),
(0.5, "BUTTON", "fill", "应该减分"),
(0.5, "SELECT", "select", "应该加分"),
]
passed = 0
failed = 0
for base_score, tag, action, expect_desc in test_cases:
adjusted = _apply_action_type_adjustment(base_score, tag, action, [])
if expect_desc == "应该加分" and adjusted > base_score:
print(f"[PASS] {tag}+{action}: {base_score:.2f} -> {adjusted:.2f} ({expect_desc})")
passed += 1
elif expect_desc == "应该减分" and adjusted < base_score:
print(f"[PASS] {tag}+{action}: {base_score:.2f} -> {adjusted:.2f} ({expect_desc})")
passed += 1
else:
print(f"[FAIL] {tag}+{action}: {base_score:.2f} -> {adjusted:.2f} ({expect_desc})")
failed += 1
print()
print(f"结果: 通过 {passed}/{passed + failed}")
return failed == 0
def test_exact_vs_contains_ranking():
"""测试精确匹配排名高于包含匹配"""
print()
print("=" * 60)
print("测试精确匹配排名高于包含匹配")
print("=" * 60)
# 模拟场景:关键词"确定"
# 元素A: text="确定" (精确匹配)
# 元素B: text="确定按钮" (包含匹配)
# 期望:A的分数高于B
score_exact = _calculate_match_score("确定", "确定") * 0.15 # text维度权重
score_contains = _calculate_match_score("确定", "确定按钮") * 0.15
if score_exact > score_contains:
print(f"[PASS] 精确匹配({score_exact:.4f}) > 包含匹配({score_contains:.4f})")
passed = True
else:
print(f"[FAIL] 精确匹配({score_exact:.4f}) <= 包含匹配({score_contains:.4f})")
passed = False
# 模拟场景:关键词"新建"
# 元素A: text="新建" (精确匹配)
# 元素B: text="新建会议" (包含匹配)
score_exact2 = _calculate_match_score("新建", "新建") * 0.15
score_contains2 = _calculate_match_score("新建", "新建会议") * 0.15
if score_exact2 > score_contains2:
print(f"[PASS] 精确匹配({score_exact2:.4f}) > 包含匹配({score_contains2:.4f})")
passed2 = True
else:
print(f"[FAIL] 精确匹配({score_exact2:.4f}) <= 包含匹配({score_contains2:.4f})")
passed2 = False
return passed and passed2
def main():
"""运行所有测试"""
print("\n" + "=" * 60)
print("Phase 2 评分体系归一化 - 验证测试")
print("=" * 60)
all_passed = True
if not test_match_score():
all_passed = False
if not test_action_type_adjustment():
all_passed = False
if not test_exact_vs_contains_ranking():
all_passed = False
print()
print("=" * 60)
if all_passed:
print("[SUCCESS] 所有测试通过!Phase 2 验证成功")
else:
print("[WARNING] 部分测试失败,请检查")
print("=" * 60)
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论