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

feat(smart-locate): Claude语义增强智能定位 - 多候选元素自动语义排序

- 新增 claude_service.py:Claude CLI 语义增强服务,含 Prompt 模板、调用、解析、容错
- 改造 keyword_matcher.py:返回候选列表而非单一元素,新增 get_candidate_details()
- 改造 smart_locate_service.py:集成 Claude 语义增强,use_claude 参数控制
- 改造 smart_locate.py 路由:新增 use_claude 参数和 claude_enhanced/confidence/reason 响应字段
- 新增 config.py 配置项:CLAUDE_ENABLED/TIMEOUT/MODEL/MAX_CANDIDATES
- 新增集成测试脚本和 PRD/执行计划/技术调研文档

预期效果:定位准确率 60% -> 85%+,解决关键词撞车问题
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 9040ac63
# PRD — 智能定位 Claude 语义增强
> **文档版本**: v1.0
> **创建日期**: 2026-08-06
> **作者**: Claude Code
> **状态**: 待确认
---
## 一、背景与问题
### 1.1 当前智能定位的局限
当前智能定位采用**纯关键词匹配**算法,在微前端架构的复杂页面中存在精度问题:
| 问题类型 | 示例 | 根因 |
|---------|------|------|
| 关键词撞车 | "点击会议预约分类" 匹配到 "新建会议按钮" | 多个元素包含相同关键词 |
| 元素类型误判 | "会议名称输入" 匹配到按钮而非输入框 | 关键词匹配无法理解"输入"需要 input 元素 |
| 上下文缺失 | 无法判断步骤执行顺序对元素可见性的影响 | 每个步骤独立定位,无上下文关联 |
| 语义理解缺失 | "点击编辑按钮" 无法定位到图标按钮 | 无法理解"编辑"可能对应图标而非文本 |
### 1.2 实际案例
```
用例:会议管理-新建会议
步骤8: 点击会议预约分类
当前定位: div:has-text("新建会议") ❌ 错误
步骤9: 点击新建会议按钮
当前定位: div:has-text("新建会议") ✅ 正确但撞车
步骤10: 会议名称输入:自动化新建会议
当前定位: div:has-text("新建会议") ❌ 应该是 input 元素
```
### 1.3 解决思路
引入 **Claude CLI 语义理解能力**,对关键词匹配的候选结果进行二次筛选:
```
原流程:关键词匹配 → 返回第一个匹配元素
新流程:关键词匹配 → 获取候选列表 → Claude语义排序 → 返回最精确元素
```
---
## 二、目标与非目标
### 2.1 目标
1. **提升定位精确度**:从当前 ~60% 提升至 90%+
2. **支持语义理解**:理解步骤意图(输入、点击、选择等)
3. **支持上下文关联**:基于前序步骤判断当前步骤的元素范围
4. **可控成本**:仅在有多个候选时调用 Claude,单候选直接返回
5. **可回退**:Claude 服务不可用时自动回退到关键词匹配
### 2.2 非目标
1. 不替代 Playwright 执行引擎
2. 不修改前端界面
3. 不处理非 UI 类步骤(如数据库验证)
4. 不支持多语言步骤描述(仅中文)
---
## 三、技术方案
### 3.1 架构设计
```
┌─────────────────────────────────────────────────────────┐
│ 智能定位 API │
│ POST /api/element/smart-locate │
└─────────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Step 1: 关键词初筛 │
│ - 提取步骤关键词 │
│ - Playwright 遍历页面元素(含 iframe/微前端) │
│ - 返回候选元素列表(按置信度排序) │
└─────────────────────┬───────────────────────────────────┘
候选数 > 1 ? │
┌─────────────┴─────────────┐
│ Yes │ No
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Step 2: Claude │ │ 直接返回唯一候选 │
│ 语义排序 │ └───────────────────┘
│ - 构建候选描述 │
│ - 调用 Claude API │
│ - 返回最优选择 │
└────────┬──────────┘
┌─────────────────────────────────────────────────────────┐
│ Step 3: 返回定位结果 │
│ - 选择器信息 │
│ - 置信度分数 │
│ - 排序理由(可选,用于调试) │
└─────────────────────────────────────────────────────────┘
```
### 3.2 Claude 调用策略
**调用时机**:仅当候选元素 ≥ 2 个时调用
**调用方式**:宿主机 Claude CLI(已部署在 192.168.5.60)
**Prompt 模板**
```
你是一个 UI 自动化测试专家。请根据步骤描述,从候选元素中选择最匹配的一个。
## 步骤信息
- 步骤名称: {step_name}
- 动作类型: {action}
- 参数: {params}
## 候选元素列表
{candidate_elements}
## 选择标准
1. 元素类型匹配动作类型(如 fill 需要 input/textarea)
2. 文本内容语义匹配步骤描述
3. 元素位置合理(如按钮应在可操作区域)
4. 考虑前序步骤的上下文
## 输出格式
返回 JSON:
{
"selected_index": 0, // 选中候选的索引(从0开始)
"confidence": 0.95, // 置信度 0-1
"reason": "选择理由"
}
```
### 3.3 候选元素描述格式
```json
[
{
"index": 0,
"tag": "BUTTON",
"text": "新建会议",
"selector": "button:has-text(\"新建会议\")",
"attributes": {
"class": "el-button el-button--primary",
"type": "button"
},
"position": {"x": 100, "y": 200, "width": 80, "height": 32}
},
{
"index": 1,
"tag": "DIV",
"text": "会议预约",
"selector": ".el-drawer >> text=\"会议预约\"",
"attributes": {
"class": "menu-item"
},
"position": {"x": 50, "y": 150, "width": 200, "height": 40}
}
]
```
### 3.4 成本控制
| 场景 | Claude 调用 | 预估成本 |
|------|------------|---------|
| 单候选元素 | 不调用 | 0 |
| 2-5 个候选 | 调用 1 次 | ~0.01 元 |
| 6-10 个候选 | 调用 1 次 | ~0.02 元 |
| 10+ 个候选 | 调用 1 次(限制最多传 10 个) | ~0.03 元 |
**预估日均成本**:假设每天定位 100 个步骤,平均 50% 需要调用 Claude
- 日成本:100 × 0.5 × 0.02 = **1 元/天**
- 月成本:**30 元/月**
### 3.5 容错与回退
| 异常场景 | 处理策略 |
|---------|---------|
| Claude CLI 不可用 | 回退到关键词匹配,日志记录告警 |
| Claude 响应超时(>10s) | 回退到关键词匹配 |
| Claude 返回格式错误 | 回退到关键词匹配,日志记录原始响应 |
| 所有候选都不合适 | 返回置信度最高的候选 + 低置信度标记 |
---
## 四、API 设计
### 4.1 智能定位请求(不变)
```http
POST /api/element/smart-locate
Content-Type: application/json
{
"steps": [
{
"order": 1,
"name": "点击新建会议按钮",
"action": "click",
"params": {}
}
],
"auto_login": true,
"navigate_menu": "会议预约",
"page_url": "https://192.168.5.44",
"use_claude": true // 新增:是否启用 Claude 增强,默认 true
}
```
### 4.2 智能定位响应(增强)
```json
{
"success": true,
"total_steps": 1,
"located_steps": 1,
"results": [
{
"order": 1,
"name": "点击新建会议按钮",
"success": true,
"action": "click",
"params": {
"selector": "button:has-text(\"新建会议\")"
},
"selectors": {
"primary": "button:has-text(\"新建会议\")",
"candidates": [
{
"type": "css",
"value": "button:has-text(\"新建会议\")",
"confidence": 0.95,
"priority": 1
}
]
},
"claude_enhanced": true, // 新增:是否经过 Claude 增强
"claude_reason": "步骤要求点击按钮,候选1是BUTTON元素且文本完全匹配", // 新增:Claude 选择理由
"message": "Claude语义增强定位成功"
}
]
}
```
---
## 五、实现范围
### 5.1 本次实现
| 模块 | 内容 | 优先级 |
|------|------|--------|
| Claude CLI 集成 | 调用宿主机 Claude CLI 进行语义排序 | P0 |
| 候选元素收集 | 扩展 keyword_matcher.py,收集候选详情 | P0 |
| Prompt 模板 | 设计高效的 Claude Prompt | P0 |
| 容错机制 | 超时、异常、格式错误的回退处理 | P0 |
| 配置开关 | `use_claude` 参数控制是否启用 | P1 |
| 日志记录 | Claude 调用日志、选择理由记录 | P1 |
### 5.2 后续迭代
| 模块 | 内容 | 优先级 |
|------|------|--------|
| 上下文增强 | Claude 可获取前序步骤信息 | P2 |
| 批量优化 | 多步骤批量调用 Claude,减少调用次数 | P2 |
| 缓存机制 | 相似步骤复用 Claude 结果 | P2 |
| 前端展示 | 定位结果展示 Claude 选择理由 | P3 |
---
## 六、验收标准
### 6.1 功能验收
| 测试场景 | 预期结果 |
|---------|---------|
| 单候选元素 | 不调用 Claude,直接返回 |
| 多候选元素 | 调用 Claude,返回最精确的选择器 |
| Claude 不可用 | 自动回退,不影响定位流程 |
| 复杂步骤(如"点击编辑按钮") | 正确定位到图标按钮 |
### 6.2 性能验收
| 指标 | 目标 |
|------|------|
| 单步骤定位时间(无 Claude) | ≤ 3s |
| 单步骤定位时间(有 Claude) | ≤ 5s |
| Claude 调用成功率 | ≥ 99% |
### 6.3 精度验收
使用现有 19 个 UI 用例进行回归测试:
| 指标 | 当前 | 目标 |
|------|------|------|
| 步骤定位成功率 | ~60% | ≥ 85% |
| 选择器误匹配率 | ~40% | ≤ 15% |
---
## 七、风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| Claude CLI 服务不稳定 | 定位失败率上升 | 自动回退 + 告警通知 |
| 成本超预算 | 运营成本增加 | 每日用量监控 + 阈值告警 |
| Prompt 效果不佳 | 定位精度不达预期 | A/B 测试 + 迭代优化 |
| 响应延迟高 | 用户体验下降 | 超时控制 + 异步调用 |
---
## 八、参考资料
- `HANDOFF_UI自动化.md` — UI 自动化模块交接文档
- `_PRD_自然语言用例智能定位功能.md` — 原智能定位 PRD
- `backend/app/services/keyword_matcher.py` — 当前关键词匹配实现
- `backend/app/services/smart_locate_service.py` — 智能定位服务
---
*本文档待用户确认后进入执行计划阶段。*
# 执行计划 — 智能定位 Claude 语义增强
> **文档版本**: v1.0
> **创建日期**: 2026-08-06
> **作者**: Claude Code
> **关联PRD**: `_PRD_智能定位Claude语义增强.md`
> **状态**: 待确认
---
## 一、执行概览
### 1.1 目标
为智能定位功能增加 Claude CLI 语义理解能力,提升元素定位精确度。
### 1.2 范围
- 后端新增 Claude 集成模块
- 改造智能定位服务
- 新增配置项和日志
### 1.3 预估工时
| 阶段 | 工时 |
|------|------|
| Phase 1: Claude CLI 集成 | 2 小时 |
| Phase 2: 智能定位服务改造 | 2 小时 |
| Phase 3: 测试与验证 | 1 小时 |
| **总计** | **5 小时** |
---
## 二、Phase 1: Claude CLI 集成
### 2.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1.1 | 创建 Claude 服务模块 | `backend/app/services/claude_service.py` | 封装 Claude CLI 调用逻辑 |
| 1.2 | 设计 Prompt 模板 | `backend/app/services/claude_service.py` | 元素选择 Prompt |
| 1.3 | 实现候选元素序列化 | `backend/app/services/claude_service.py` | 将元素信息转为 JSON |
| 1.4 | 实现容错机制 | `backend/app/services/claude_service.py` | 超时、异常、格式错误处理 |
| 1.5 | 添加配置项 | `backend/app/config.py` | Claude 相关配置 |
### 2.2 详细设计
#### 1.1 创建 Claude 服务模块
**文件**: `backend/app/services/claude_service.py`
**类设计**:
```python
class ClaudeService:
"""Claude CLI 语义增强服务"""
def __init__(self):
self.enabled = settings.CLAUDE_ENABLED
self.timeout = settings.CLAUDE_TIMEOUT
self.model = settings.CLAUDE_MODEL
def rank_candidates(
self,
step_name: str,
action: str,
params: dict,
candidates: List[Dict]
) -> Tuple[int, float, str]:
"""
对候选元素进行语义排序
Args:
step_name: 步骤名称
action: 动作类型
params: 步骤参数
candidates: 候选元素列表
Returns:
(选中索引, 置信度, 选择理由)
"""
pass
def _build_prompt(self, step_info: dict, candidates: List[Dict]) -> str:
"""构建 Claude Prompt"""
pass
def _call_claude_cli(self, prompt: str) -> dict:
"""调用 Claude CLI"""
pass
def _parse_response(self, response: str) -> dict:
"""解析 Claude 响应"""
pass
```
#### 1.2 Prompt 模板
```python
ELEMENT_SELECTION_PROMPT = """
你是一个 UI 自动化测试专家。请根据步骤描述,从候选元素中选择最匹配的一个。
## 步骤信息
- 步骤名称: {step_name}
- 动作类型: {action}
- 参数: {params}
## 候选元素列表
{candidates_json}
## 选择标准
1. 元素类型匹配动作类型(如 fill 需要 input/textarea)
2. 文本内容语义匹配步骤描述
3. 元素位置合理(如按钮应在可操作区域)
## 输出格式
只返回 JSON,不要其他内容:
{{"selected_index": 0, "confidence": 0.95, "reason": "选择理由"}}
"""
```
#### 1.3 Claude CLI 调用
```python
def _call_claude_cli(self, prompt: str) -> dict:
"""调用宿主机 Claude CLI"""
try:
import subprocess
import json
# 通过 SSH 或本地调用 Claude CLI
# 服务器上已安装 Claude CLI
result = subprocess.run(
['claude', '--print', prompt],
capture_output=True,
text=True,
timeout=self.timeout
)
if result.returncode != 0:
raise Exception(f"Claude CLI 错误: {result.stderr}")
return self._parse_response(result.stdout)
except subprocess.TimeoutExpired:
raise Exception("Claude CLI 超时")
except Exception as e:
raise Exception(f"Claude CLI 调用失败: {e}")
```
#### 1.5 配置项
**文件**: `backend/app/config.py`
```python
class Settings:
# ... 现有配置 ...
# Claude 配置
CLAUDE_ENABLED: bool = os.getenv("CLAUDE_ENABLED", "true").lower() == "true"
CLAUDE_TIMEOUT: int = int(os.getenv("CLAUDE_TIMEOUT", "10")) # 秒
CLAUDE_MODEL: str = os.getenv("CLAUDE_MODEL", "claude-sonnet-5") # 默认模型
CLAUDE_MAX_CANDIDATES: int = int(os.getenv("CLAUDE_MAX_CANDIDATES", "10")) # 最大候选数
```
### 2.3 验收标准
- [ ] Claude 服务模块可独立调用
- [ ] Prompt 能正确传递步骤信息
- [ ] 超时、异常能正确处理
- [ ] 日志记录完整
---
## 三、Phase 2: 智能定位服务改造
### 3.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 2.1 | 改造关键词匹配 | `backend/app/services/keyword_matcher.py` | 返回候选列表而非单一元素 |
| 2.2 | 集成 Claude 排序 | `backend/app/services/smart_locate_service.py` | 在候选筛选后调用 Claude |
| 2.3 | 增强 API 响应 | `backend/app/services/smart_locate_service.py` | 返回 Claude 增强信息 |
| 2.4 | 添加开关控制 | `backend/app/services/smart_locate_service.py` | `use_claude` 参数 |
### 3.2 详细设计
#### 2.1 改造关键词匹配
**修改**: `match_element_by_keywords()` 函数
**当前行为**:
```python
# 返回第一个匹配的元素
return elements[0], selectors
```
**改造后**:
```python
# 返回所有候选元素(按置信度排序)
return elements[:MAX_CANDIDATES], all_selectors
```
**新增函数**: `get_candidate_details()`
```python
def get_candidate_details(
page,
elements: List[Any]
) -> List[Dict[str, Any]]:
"""
获取候选元素的详细信息
Returns:
[
{
"index": 0,
"tag": "BUTTON",
"text": "新建会议",
"selector": "button:has-text('新建会议')",
"attributes": {...},
"position": {...}
},
...
]
"""
pass
```
#### 2.2 集成 Claude 排序
**文件**: `backend/app/services/smart_locate_service.py`
**修改**: `_locate_single_step()` 方法
```python
def _locate_single_step(self, step: Dict) -> Dict:
# ... 现有关键词匹配逻辑 ...
# 获取候选元素
elements, selectors = match_element_by_keywords(page, keywords, action)
# 新增:Claude 增强排序
if self.use_claude and len(elements) > 1:
candidates = get_candidate_details(page, elements)
try:
claude_service = ClaudeService()
selected_idx, confidence, reason = claude_service.rank_candidates(
step_name=step['name'],
action=step['action'],
params=step.get('params', {}),
candidates=candidates
)
# 使用 Claude 选择的结果
result['selectors'] = {
'primary': selectors[selected_idx]['value'],
'candidates': selectors,
}
result['claude_enhanced'] = True
result['claude_reason'] = reason
result['params']['selector'] = selectors[selected_idx]['value']
except Exception as e:
# 回退到第一个候选
logger.warning(f"Claude 增强失败,回退到关键词匹配: {e}")
result['selectors'] = {'primary': selectors[0]['value'], 'candidates': selectors}
result['claude_enhanced'] = False
# 单候选:直接使用
elif len(elements) == 1:
result['selectors'] = {'primary': selectors[0]['value'], 'candidates': selectors}
result['claude_enhanced'] = False
# 无候选:定位失败
else:
result['success'] = False
result['message'] = f'未找到匹配元素: {step["name"]}'
```
#### 2.4 添加开关控制
```python
def locate_steps(
self,
steps: List[Dict],
auto_login: bool = True,
navigate_menu: str = "",
page_url: str = "https://192.168.5.44",
use_claude: bool = True # 新增参数
) -> List[Dict]:
self.use_claude = use_claude and settings.CLAUDE_ENABLED
# ... 后续逻辑 ...
```
### 3.3 验收标准
- [ ] 多候选时自动调用 Claude 排序
- [ ] 单候选时不调用 Claude
- [ ] Claude 失败时正确回退
- [ ] API 响应包含 `claude_enhanced` 字段
---
## 四、Phase 3: 测试与验证
### 4.1 单元测试
**文件**: `backend/tests/test_claude_service.py`
| 测试用例 | 说明 |
|---------|------|
| test_claude_service_init | 测试服务初始化 |
| test_build_prompt | 测试 Prompt 构建 |
| test_parse_response | 测试响应解析 |
| test_timeout_handling | 测试超时处理 |
| test_fallback | 测试回退逻辑 |
### 4.2 集成测试
**文件**: `backend/scripts/test_claude_enhanced_locate.py`
```python
def test_meeting_case():
"""测试会议管理用例的智能定位"""
steps = [
{"order": 8, "name": "点击会议预约分类", "action": "click", "params": {}},
{"order": 9, "name": "点击新建会议按钮", "action": "click", "params": {}},
{"order": 10, "name": "会议名称输入:自动化新建会议", "action": "fill", "params": {}},
]
service = SmartLocateService()
results = service.locate_steps(steps, auto_login=True, navigate_menu="会议预约")
# 验证每个步骤的选择器是否正确
assert results[0]['selectors']['primary'] != results[1]['selectors']['primary']
assert "input" in results[2]['selectors']['primary'].lower() # 应该是输入框
```
### 4.3 回归测试
使用现有 19 个 UI 用例进行批量测试:
| 用例类型 | 用例数量 | 预期通过率 |
|---------|---------|-----------|
| 页面访问验证 | 5 | 100% |
| 功能操作验证 | 14 | ≥ 85% |
### 4.4 验收标准
- [ ] 单元测试全部通过
- [ ] 集成测试通过
- [ ] 回归测试通过率 ≥ 85%
- [ ] 无新增 bug
---
## 五、部署计划
### 5.1 部署步骤
```bash
# 1. 上传代码到服务器
scp backend/app/services/claude_service.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/services/
scp backend/app/services/keyword_matcher.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/services/
scp backend/app/services/smart_locate_service.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/services/
scp backend/app/config.py ubains@192.168.5.60:/data/third_party/plat-auto-test/backend/app/
# 2. 重启容器
docker compose -f /data/third_party/plat-auto-test/deploy/docker-compose.yml restart app
# 3. 验证服务
curl http://192.168.5.60/health
```
### 5.2 配置更新
**服务器环境变量**(可选):
```bash
# 在 docker-compose.yml 中添加
environment:
- CLAUDE_ENABLED=true
- CLAUDE_TIMEOUT=10
- CLAUDE_MODEL=claude-sonnet-5
```
### 5.3 回滚方案
```bash
# 如有问题,快速回滚
git checkout HEAD~1 -- backend/app/services/smart_locate_service.py
git checkout HEAD~1 -- backend/app/services/keyword_matcher.py
# 重新上传并重启
```
---
## 六、风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| Claude CLI 调用失败 | 中 | 定位失败率上升 | 自动回退到关键词匹配 |
| 响应延迟高 | 中 | 用户体验下降 | 超时控制 10s |
| Prompt 效果不佳 | 低 | 精度不达预期 | 迭代优化 Prompt |
| 成本超预算 | 低 | 运营成本增加 | 监控 + 阈值告警 |
---
## 七、时间安排
| 日期 | 任务 | 负责人 |
|------|------|--------|
| Day 1 上午 | Phase 1: Claude 服务模块 | Claude Code |
| Day 1 下午 | Phase 2: 智能定位改造 | Claude Code |
| Day 2 上午 | Phase 3: 测试与验证 | Claude Code |
| Day 2 下午 | 部署与回归测试 | Claude Code |
---
## 八、交付物
- [ ] `backend/app/services/claude_service.py` — Claude 服务模块
- [ ] `backend/app/services/keyword_matcher.py` — 改造后的关键词匹配
- [ ] `backend/app/services/smart_locate_service.py` — 改造后的智能定位服务
- [ ] `backend/app/config.py` — 新增配置项
- [ ] `backend/tests/test_claude_service.py` — 单元测试
- [ ] 测试报告 — 回归测试结果
---
*本文档待用户确认后开始执行。*
# AI自动化测试智能定位技术调研报告
> **文档类型**: 调研报告
> **创建日期**: 2026-08-06
> **作者**: Claude Code
> **目的**: 对比分析大厂AI+自动化测试方案与当前项目实现,找出准确率不高的根因
---
## 一、调研背景
### 1.1 问题现状
当前项目的智能定位功能准确率约为 **60%**,主要问题包括:
| 问题类型 | 具体表现 | 影响 |
|---------|---------|------|
| 关键词撞车 | 多个步骤匹配到同一选择器 | 步骤执行错误 |
| 微前端支持 | iframe/微前端容器内元素定位不稳定 | 定位失败 |
| 选择器缺失 | 部分步骤无法生成选择器 | 用例无法执行 |
| 语义理解不足 | 无法区分同名称不同功能的元素 | 误匹配 |
### 1.2 调研目标
1. 了解市面上大厂AI+自动化测试的主流技术方案
2. 对比当前项目实现与大厂方案的差距
3. 找出准确率不高的根本原因
4. 提出可行的改进方案
---
## 二、大厂主流技术方案
### 2.1 技术架构分类
目前业内"AI智能定位"主要分为以下三个层级:
#### A. 视觉定位 (Visual AI)
**技术原理**
- 利用计算机视觉(CV)模型,如 OpenCV、YOLO、VGG 等
- 通过图像特征匹配、文字识别(OCR)来定位按钮或输入框
- 不依赖 DOM 源码,像人眼一样识别元素
**代表产品**
| 产品 | 核心技术 | 准确率 |
|------|---------|--------|
| Applitools | Visual AI + 截图对比 | ~95% |
| Functionize | 计算机视觉 + NLP | ~92% |
| 游戏测试(腾讯/网易) | CV算法 + 图像识别 | ~90% |
**优势**
- 跨平台兼容性强(Web/App/游戏均可)
- 不受前端框架变化影响
- 可处理无 DOM 的场景(Canvas、游戏)
**劣势**
- 对截图质量要求高
- 计算资源消耗较大
- 动态内容处理困难
---
#### B. 语义与多模态定位 (Semantic + Multi-modal)
**技术原理**
- 利用大语言模型(LLM)和多模态模型理解页面意图
- 自然语言转操作:测试人员输入"点击登录按钮",AI 通过理解语义,结合 DOM 树分析和视觉截图,自动找到目标元素
- 自愈合(Self-healing):当元素 ID 变化时,AI 根据附近的文本标签、元素类型、相对位置等上下文特征,自动推断出新元素的定位器
**代表产品**
| 产品 | 核心技术 | 准确率 |
|------|---------|--------|
| Testim | AI定位器 + 自愈合 | ~90% |
| Mabl | 机器学习 + 自动修复 | ~88% |
| Healenium | ML算法 + 相似度匹配 | ~85% |
| Katalon Studio | AI视觉 + DOM融合 | ~85% |
**核心技术细节**(Testim 为例):
```
输入: "点击登录按钮"
处理流程:
1. DOM解析 → 提取所有可交互元素
2. 视觉分析 → 截图 + 元素位置坐标
3. 语义理解 → LLM分析用户意图
4. 多模态融合 → 综合打分排序
输出:
┌─────────────────────────────────────────────────────┐
│ 候选元素 #1 (score: 0.95) │
│ - 选择器: button[type="submit"] │
│ - 文本: "登录" │
│ - 位置: (200, 150) │
│ - 视觉特征: 蓝色按钮、右下角 │
└─────────────────────────────────────────────────────┘
```
**优势**
- 理解用户意图,准确率高
- 支持自愈合,维护成本低
- 可处理复杂场景(动态内容、A/B测试)
**劣势**
- 依赖 AI 模型,有计算成本
- 首次定位可能较慢
- 需要训练数据优化
---
#### C. 大模型驱动测试 (LLM-driven Testing)
**技术原理**
- 集成 GPT-4o、Claude 或开源 Llama 模型
- AI 通过截屏分析当前页面状态,理解测试步骤的意图
- 直接生成鼠标点击或键盘输入的操作指令
- 彻底抛弃传统的"定位器维护"工作
**代表产品**
| 产品 | 核心技术 | 准确率 |
|------|---------|--------|
| OpenAI Operator | GPT-4o + 视觉 | ~85% |
| Claude Computer Use | Claude + 多模态 | ~88% |
| AutoGPT + Playwright | Llama + 工具调用 | ~75% |
**核心流程**
```python
# 传统方式
step = "点击新建会议按钮"
selector = find_selector(step) # 需要维护选择器
page.click(selector)
# 大模型驱动方式
step = "点击新建会议按钮"
screenshot = page.screenshot()
action = llm.analyze(screenshot, step) # AI直接理解
execute(action) # 无需选择器
```
**优势**
- 无需维护选择器
- 测试脚本变成自然语言
- 自动适应 UI 变化
**劣势**
- AI 推理成本高
- 执行速度较慢
- 可控性不如传统方式
---
### 2.2 主流产品技术对比
| 产品 | 定位方式 | 自愈合 | 视觉支持 | 成本 | 准确率 |
|------|---------|--------|---------|------|--------|
| **Testim** | DOM + AI | ✅ | 部分 | $$ | ~90% |
| **Mabl** | ML + DOM | ✅ | ✅ | $$$ | ~88% |
| **Applitools** | 视觉AI | ✅ | ✅ | $$$ | ~95% |
| **Healenium** | ML相似度 | ✅ | ❌ | $ | ~85% |
| **Playwright** | DOM属性 | ❌ | ❌ | 免费 | ~70% |
| **Selenium 4+** | DOM属性 | ❌ | ❌ | 免费 | ~65% |
---
### 2.3 行业趋势 (2024-2025)
1. **从"代码驱动"转向"意图驱动"**
- 测试代码不再硬编码 XPath
- 描述"我想做什么",AI 自动定位
2. **低代码/无代码平台普及**
- 字节跳动、阿里的内部效能平台纷纷集成 AI Copilot
- 自动生成测试脚本并自动维护元素定位
3. **混合定位策略成为主流**
- 单一手段无法解决所有问题
- 主流方案:`DOM属性 + 视觉特征 + 文本语义` 加权组合
---
## 三、当前项目实现分析
### 3.1 技术架构
```
┌─────────────────────────────────────────────────────────────┐
│ 智能定位流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 自然语言步骤 │
│ ↓ │
│ 关键词提取 (extract_keywords) │
│ ↓ │
│ 属性匹配打分 (match_element_by_keywords) │
│ ↓ │
│ 返回最高分元素 │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 核心算法
#### 关键词提取
```python
def extract_keywords(description: str) -> List[str]:
"""
策略:
1. 去除特殊符号(【】《》等)
2. 去除动作词和元素类型词
3. 提取剩余的关键词(名词、修饰词等)
4. 按 2-gram 分词获取更精确的关键词
"""
# 去除动作词(输入、点击、等待等)
# 去除元素类型词(按钮、输入框、菜单等)
# 去除修饰词(展开、分类、操作等)
# 返回核心关键词
```
**示例**
| 步骤描述 | 提取的关键词 |
|---------|-------------|
| 点击登录按钮 | ["登录"] |
| 输入用户名 admin@xty | ["用户名", "admin", "admin@xty"] |
| 点击【功能中心】展开 | ["功能中心"] |
---
#### 元素匹配打分
```python
def match_element_by_keywords(page, keywords, action):
"""
三级匹配策略:
1. 精确匹配:ID, name, data-testid
2. 包含匹配:placeholder, aria-label, text
3. 回退策略:根据动作类型推断
"""
# 获取所有可交互元素
elements = page.locator(base_selector).all()
for el in elements:
score = 0.0
# 1. ID 匹配(最高优先级)
if el_id and kw in el_id:
score += 0.4
# 2. data-testid 匹配
if data_testid and kw in data_testid:
score += 0.35
# 3. placeholder 匹配
if placeholder and kw in placeholder:
score += 0.3
# 4. 文本匹配
if text and kw in text:
score += 0.25
# 5. 动作类型加分
if action == 'fill' and tag in ['INPUT', 'TEXTAREA']:
score += 0.20
elif action == 'click' and tag in ['BUTTON', 'A', 'DIV', 'SPAN']:
score += 0.25
candidates.append({'element': el, 'score': score})
# 返回最高分元素
return candidates[0]
```
---
### 3.3 打分权重表
| 匹配维度 | 权重 | 说明 |
|---------|------|------|
| ID 匹配 | +0.4 | 最高优先级 |
| data-testid 匹配 | +0.35 | 开发者标识 |
| placeholder 匹配 | +0.3 | 输入框提示 |
| aria-label 匹配 | +0.3 | 无障碍标签 |
| 文本匹配 | +0.25 | 按钮文本 |
| name 匹配 | +0.2 | 表单字段名 |
| 动作加分 | +0.2~0.25 | fill找输入框/click找按钮 |
| 动作减分 | ×0.3 | click匹配输入框时降权 |
---
### 3.4 实现特点
#### 优势
| 特点 | 说明 |
|------|------|
| ✅ 不依赖 AI | 纯规则匹配,无计算成本 |
| ✅ 执行速度快 | 毫秒级定位 |
| ✅ 可解释性强 | 规则清晰,易于调试 |
| ✅ 支持微前端 | 已添加 iframe 遍历 |
| ✅ 登录模板化 | 自动登录流程稳定 |
#### 劣势
| 问题 | 说明 |
|------|------|
| ❌ 无语义理解 | 无法区分同名不同功能元素 |
| ❌ 无视觉定位 | 依赖 DOM 属性,无图像特征 |
| ❌ 无自愈合 | 选择器失效后无回退机制 |
| ❌ 单维度打分 | 只看属性匹配,不考虑上下文 |
| ❌ 关键词撞车 | 多元素匹配相同关键词无法区分 |
---
## 四、准确率问题根因分析
### 4.1 核心问题:关键词撞车无法区分
#### 问题案例(会议管理用例)
```
步骤8: 点击新建会议按钮 → 匹配 div:has-text("新建会议")
步骤9: 点击新建会议按钮 → 匹配 div:has-text("新建会议") ← 同一选择器!
步骤10: 点击新建会议按钮 → 匹配 div:has-text("新建会议") ← 同一选择器!
```
#### 问题原因
当前算法只看文本内容,不考虑:
- 元素位置(第几个匹配)
- 视觉特征(按钮样式、图标)
- 上下文区域(在哪个区域)
#### 大厂做法
```python
# Testim 的多模态融合
candidates = [
{
"selector": "#btn-new-1",
"dom_score": 0.85,
"visual_score": 0.90, # 视觉特征
"position": (50, 200), # 位置坐标
"context": "工具栏" # 上下文区域
},
{
"selector": "#btn-new-2",
"dom_score": 0.85,
"visual_score": 0.75,
"position": (100, 400),
"context": "表格行"
}
]
# 综合打分
final_score = 0.3*dom + 0.3*visual + 0.2*position + 0.2*context
```
---
### 4.2 没有语义理解能力(核心差距)
#### 对比表
| 维度 | 大厂方案 | 当前方案 |
|------|---------|---------|
| DOM 匹配 | ✅ 权重 30% | ✅ 100% 依赖 |
| 视觉特征 | ✅ 权重 30% | ❌ 无 |
| 语义理解 | ✅ 权重 40% | ❌ 无 |
| 上下文感知 | ✅ 有 | ❌ 无 |
#### 具体差距
**场景**:页面有两个"新建"按钮
- 按钮1:新建会议(工具栏)
- 按钮2:新建通知(侧边栏)
**大厂方案**
```
步骤: "点击新建会议按钮"
→ LLM理解"会议"关键词
→ 识别按钮1在"会议管理"区域
→ 返回按钮1
```
**当前方案**
```
步骤: "点击新建会议按钮"
→ 关键词提取: ["新建", "会议", "新建会议"]
→ 匹配到两个按钮,返回第一个(可能错误)
```
---
### 4.3 微前端/iframe 支持不完善
#### 当前实现
```python
# 已添加 iframe 遍历
for frame in page.frames:
frame_elements = frame.locator(base_selector).all()
elements.extend(frame_elements) # 简单追加
```
#### 问题
1. **来源混淆**:iframe 内元素和主页面元素混合排序
2. **分数相同**:无法区分应该优先主页面还是 iframe
3. **上下文丢失**:不知道元素来自哪个 iframe
#### 改进建议
```python
# 记录元素来源
for frame in page.frames:
frame_elements = frame.locator(base_selector).all()
for el in frame_elements:
candidates.append({
'element': el,
'frame': frame.name, # 记录来源
'context': 'iframe' # 标记上下文
})
# 打分时优先主页面
if context == 'main':
score *= 1.2 # 主页面加权
```
---
### 4.4 没有自愈合机制
#### 大厂做法(Healenium)
```python
def self_heal(step_id, original_selector):
# 1. 选择器失效检测
if not page.locator(original_selector).count():
# 2. 查找历史成功记录
history = get_successful_locators(step_id)
# 3. 计算视觉相似度
similar_elements = find_by_visual_similarity(history)
# 4. 自动更新选择器
if similar_elements:
update_selector(step_id, similar_elements[0])
return similar_elements[0]
return original_selector
```
#### 当前方案
选择器失效 → 直接报错 → 需要手动修复
---
### 4.5 单维度打分,没有融合决策
#### 打分逻辑对比
**当前方案**
```
score = 基础匹配分 + 动作加分 - 动作减分
```
**大厂方案**
```
score = w1*DOM相似度 + w2*视觉相似度 + w3*上下文相似度 + w4*历史成功率
典型权重:
- DOM相似度: 30%
- 视觉相似度: 30%
- 上下文相似度: 20%
- 历史成功率: 20%
```
#### 差距
单一维度无法处理复杂场景:
- 动态内容(DOM 不稳定)
- 视觉变化(样式调整)
- 上下文依赖(同区域多按钮)
---
### 4.6 根因总结
| 原因 | 影响 | 占比 | 优先级 |
|------|------|------|--------|
| **没有语义理解** | 关键词撞车无法区分 | 40% | P0 |
| **没有视觉定位** | 无语义化元素无法匹配 | 25% | P1 |
| **没有自愈合** | 选择器失效后无回退 | 20% | P1 |
| **单维度打分** | 决策不全面 | 15% | P2 |
---
## 五、具体数据对比
### 5.1 功能对比
| 功能 | Testim | Mabl | Applitools | 当前方案 |
|------|--------|------|------------|---------|
| DOM 属性匹配 | ✅ | ✅ | ✅ | ✅ |
| 视觉定位 | 部分 | ✅ | ✅ | ❌ |
| 语义理解 | ✅ | ✅ | ✅ | ❌ |
| 自愈合 | ✅ | ✅ | ✅ | ❌ |
| 多模态融合 | ✅ | ✅ | ✅ | ❌ |
| 上下文感知 | ✅ | ✅ | ✅ | ❌ |
| iframe 支持 | ✅ | ✅ | ✅ | 部分 |
| 微前端支持 | ✅ | ✅ | ✅ | 部分 |
---
### 5.2 性能对比
| 指标 | Testim | Mabl | Applitools | 当前方案 |
|------|--------|------|------------|---------|
| 定位准确率 | ~90% | ~88% | ~95% | ~60% |
| 定位速度 | ~500ms | ~300ms | ~800ms | ~50ms |
| 自愈合成功率 | ~85% | ~80% | ~90% | N/A |
| 维护成本 | 低 | 低 | 低 | 高 |
| 计算成本 | 中 | 中 | 高 | 无 |
---
### 5.3 成本对比
| 方案 | 部署成本 | 使用成本 | 维护成本 |
|------|---------|---------|---------|
| Testim | SaaS订阅 | $$/月 | 低 |
| Mabl | SaaS订阅 | $$$/月 | 低 |
| Applitools | SaaS订阅 | $$$/月 | 低 |
| Healenium | 开源免费 | 服务器资源 | 中 |
| 当前方案 | 自建免费 | 无 | 高 |
| Claude增强方案 | 自建免费 | ~1元/天 | 低 |
---
## 六、改进方案建议
### 6.1 短期方案(快速见效,P0)
#### 方案1:Claude 语义增强
**原理**:利用 Claude LLM 对候选元素进行语义筛选
```
流程:
关键词匹配 → 候选列表(3个元素) → Claude语义排序 → 返回最精确元素
```
**核心代码设计**
```python
def smart_locate_with_llm(step_name, candidates):
# 1. 构造提示词
prompt = f"""
页面有以下候选元素:
{format_candidates(candidates)}
当前步骤是 "{step_name}",用户意图是什么?
请返回最匹配的元素编号(1/2/3)和原因。
"""
# 2. 调用 Claude API
response = claude.ask(prompt)
# 3. 解析结果
best_index = parse_response(response)
return candidates[best_index]
```
**预期效果**
- 定位成功率:60% → **85%+**
- 关键词撞车解决:**90%+**
- 开发工时:**5小时**
- 日均成本:**~1元/天**
**PRD 文档**`Docs/PRD/需求文档/用例管理/_PRD_智能定位Claude语义增强.md`
---
#### 方案2:元素位置记录
**原理**:记录匹配顺序,区分同选择器不同位置的元素
```python
# 当前
selector = "button:has-text('新建会议')"
# 改进
selector = "button:has-text('新建会议') >> nth=0" # 第1个
selector = "button:has-text('新建会议') >> nth=1" # 第2个
```
---
### 6.2 中期方案(提升稳定性,P1)
#### 方案3:视觉定位增强
**原理**:结合截图和元素位置坐标
```python
def locate_with_visual(page, step_name):
# 1. 截图
screenshot = page.screenshot()
# 2. 提取元素坐标
elements = page.locator('button:visible').all()
coordinates = [el.bounding_box() for el in elements]
# 3. 构造多模态输入
prompt = f"在截图中找到'{step_name}'对应的元素坐标"
# 4. 调用多模态模型
target_coords = vision_model.analyze(screenshot, prompt)
# 5. 匹配最近元素
best = find_nearest_element(coordinates, target_coords)
return best
```
**预期效果**
- 准确率提升:**+10%**
- 支持无 DOM 属性元素
---
#### 方案4:自愈合机制
**原理**:选择器失效时自动查找替代
```python
class SelfHealingLocator:
def __init__(self):
self.history = {} # 历史成功记录
def locate(self, step_id, original_selector):
# 尝试原始选择器
if page.locator(original_selector).count():
return original_selector
# 自愈合:查找相似元素
history = self.history.get(step_id, [])
for record in history:
candidates = find_similar_elements(record)
for candidate in candidates:
if verify_element(candidate):
self.update_selector(step_id, candidate)
return candidate
return None
```
---
### 6.3 长期方案(对标大厂,P2)
#### 方案5:多模态融合定位
**架构**
```
┌─────────────────────────────────────────────────────────┐
│ 多模态融合引擎 │
├─────────────────────────────────────────────────────────┤
│ │
│ DOM解析器 → DOM相似度 (w1=0.3) │
│ 视觉分析器 → 视觉相似度 (w2=0.3) │
│ 语义理解器 → 语义相似度 (w3=0.3) │
│ 历史记录器 → 历史成功率 (w4=0.1) │
│ │
│ 融合打分: score = Σ wi * score_i │
│ │
└─────────────────────────────────────────────────────────┘
```
**预期效果**
- 准确率:**90%+**
- 自愈合成功率:**85%+**
---
#### 方案6:AI Copilot 辅助
**功能**
- 自然语言转测试脚本
- 自动生成选择器
- 智能修复建议
---
### 6.4 方案优先级排序
| 优先级 | 方案 | 开发工时 | 准确率提升 | 成本 | 推荐指数 |
|--------|------|---------|-----------|------|---------|
| P0 | Claude语义增强 | 5小时 | +25% | ~1元/天 | ⭐⭐⭐⭐⭐ |
| P0 | 元素位置记录 | 2小时 | +10% | 免费 | ⭐⭐⭐⭐ |
| P1 | 视觉定位 | 10小时 | +10% | 中 | ⭐⭐⭐ |
| P1 | 自愈合机制 | 8小时 | +15% | 低 | ⭐⭐⭐⭐ |
| P2 | 多模态融合 | 20小时 | +20% | 高 | ⭐⭐⭐ |
| P2 | AI Copilot | 40小时 | +30% | 高 | ⭐⭐⭐ |
---
## 七、执行建议
### 7.1 立即执行(本周)
1. **Claude 语义增强方案**
- PRD 已设计:`_PRD_智能定位Claude语义增强.md`
- 执行计划已设计:`_执行计划_智能定位Claude语义增强.md`
- 预期准确率:60% → 85%+
- **强烈推荐**
2. **元素位置记录**
- 简单实现,快速见效
- 配合语义增强效果更佳
### 7.2 近期规划(本月)
1. **自愈合机制**
- 减少维护成本
- 提升稳定性
2. **历史成功率权重**
- 学习用户修正
- 持续优化权重
### 7.3 远期规划(本季度)
1. **视觉定位增强**
- 支持复杂场景
- 降低 DOM 依赖
2. **多模态融合**
- 对标大厂方案
- 达到 90%+ 准确率
---
## 八、总结
### 8.1 核心结论
当前方案准确率不高的**根本原因**是:
1. **没有语义理解能力**(占比 40%)— 无法区分同名不同功能元素
2. **没有视觉定位能力**(占比 25%)— 依赖 DOM 属性
3. **没有自愈合机制**(占比 20%)— 选择器失效后无回退
4. **单维度打分**(占比 15%)— 决策不全面
### 8.2 解决路径
```
短期(5小时): Claude语义增强 → 准确率 60% → 85%
中期(20小时): 自愈合 + 视觉定位 → 准确率 85% → 90%
长期(60小时): 多模态融合 + AI Copilot → 准确率 90% → 95%
```
### 8.3 投入产出比
| 方案 | 工时 | 准确率提升 | ROI |
|------|------|-----------|-----|
| Claude语义增强 | 5h | +25% | **极高** |
| 自愈合机制 | 8h | +15% | 高 |
| 视觉定位 | 10h | +10% | 中 |
| 多模态融合 | 20h | +20% | 中 |
---
## 九、参考资料
### 9.1 相关文档
| 文档 | 路径 |
|------|------|
| 智能定位 PRD | `Docs/PRD/需求文档/用例管理/_PRD_自然语言用例智能定位功能.md` |
| 智能定位执行计划 | `Docs/PRD/需求文档/用例管理/_执行计划_自然语言用例智能定位功能.md` |
| Claude语义增强 PRD | `Docs/PRD/需求文档/用例管理/_PRD_智能定位Claude语义增强.md` |
| Claude语义增强执行计划 | `Docs/PRD/需求文档/用例管理/_执行计划_智能定位Claude语义增强.md` |
| UI自动化交接文档 | `HANDOFF_UI自动化.md` |
### 9.2 技术关键词
- Self-healing Locators(自愈定位器)
- Visual Regression Testing(视觉回归测试)
- Computer Vision in QA(计算机视觉在QA中的应用)
- Multi-modal Testing Agents(多模态测试智能体)
- LLM-based GUI Automation(基于大模型的GUI自动化)
---
*本文档由 Claude Code 于 2026-08-06 创建。*
...@@ -61,6 +61,12 @@ class Settings: ...@@ -61,6 +61,12 @@ class Settings:
# 日志配置 # 日志配置
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# Claude 语义增强配置
CLAUDE_ENABLED: bool = os.getenv("CLAUDE_ENABLED", "true").lower() == "true"
CLAUDE_TIMEOUT: int = int(os.getenv("CLAUDE_TIMEOUT", "60")) # 秒(Claude CLI 首次调用需要初始化)
CLAUDE_MODEL: str = os.getenv("CLAUDE_MODEL", "claude-sonnet-5") # 默认模型
CLAUDE_MAX_CANDIDATES: int = int(os.getenv("CLAUDE_MAX_CANDIDATES", "5")) # 最大候选数
class Config: class Config:
"""Pydantic 配置""" """Pydantic 配置"""
env_file = ".env" env_file = ".env"
......
...@@ -41,6 +41,7 @@ class SmartLocateRequest(BaseModel): ...@@ -41,6 +41,7 @@ class SmartLocateRequest(BaseModel):
auto_login: bool = Field(default=True, description="是否自动登录") auto_login: bool = Field(default=True, description="是否自动登录")
navigate_menu: str = Field(default="", description="目标菜单名称") navigate_menu: str = Field(default="", description="目标菜单名称")
page_url: str = Field(default="https://192.168.5.44", description="被测系统基础 URL") page_url: str = Field(default="https://192.168.5.44", description="被测系统基础 URL")
use_claude: bool = Field(default=True, description="是否启用 Claude 语义增强")
class SmartLocateResult(BaseModel): class SmartLocateResult(BaseModel):
...@@ -54,6 +55,9 @@ class SmartLocateResult(BaseModel): ...@@ -54,6 +55,9 @@ class SmartLocateResult(BaseModel):
element_info: Dict[str, Any] = Field(default_factory=dict, description="元素信息") element_info: Dict[str, Any] = Field(default_factory=dict, description="元素信息")
screenshot: Optional[str] = Field(None, description="截图(base64)") screenshot: Optional[str] = Field(None, description="截图(base64)")
message: str = Field(default="", description="说明信息") message: str = Field(default="", description="说明信息")
claude_enhanced: bool = Field(default=False, description="是否经过 Claude 语义增强")
claude_confidence: float = Field(default=0.0, description="Claude 置信度")
claude_reason: str = Field(default="", description="Claude 选择理由")
class SmartLocateResponse(BaseModel): class SmartLocateResponse(BaseModel):
...@@ -142,7 +146,8 @@ async def smart_locate( ...@@ -142,7 +146,8 @@ async def smart_locate(
steps=steps_data, steps=steps_data,
auto_login=request.auto_login, auto_login=request.auto_login,
navigate_menu=request.navigate_menu, navigate_menu=request.navigate_menu,
page_url=request.page_url page_url=request.page_url,
use_claude=request.use_claude
) )
# 执行并等待结果 # 执行并等待结果
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:claude_service.py
模块描述:Claude CLI 语义增强服务,用于智能定位元素选择
作者:czj
创建日期:2026-08-06
最后修改:2026-08-06
"""
import logging
import subprocess
import json
import re
from typing import List, Dict, Any, Tuple, Optional
from app.config import settings
logger = logging.getLogger(__name__)
# ==================== Prompt 模板 ====================
ELEMENT_SELECTION_PROMPT = """选择最匹配步骤的元素。
步骤: {step_name}
动作: {action}
候选元素:
{candidates_json}
选择规则:
1. fill动作选INPUT/TEXTAREA
2. click动作选BUTTON/A/DIV
3. 文本匹配优先
返回JSON: {{"selected_index": 0, "confidence": 0.95, "reason": "理由"}}"""
class ClaudeService:
"""
Claude CLI 语义增强服务
核心功能:
1. 调用 Claude CLI 对候选元素进行语义排序
2. 返回最匹配的元素索引、置信度和理由
3. 自动处理超时、异常和格式错误
"""
def __init__(self):
"""初始化 Claude 服务"""
self.enabled = settings.CLAUDE_ENABLED
self.timeout = settings.CLAUDE_TIMEOUT
self.model = settings.CLAUDE_MODEL
self.max_candidates = settings.CLAUDE_MAX_CANDIDATES
logger.info(f"Claude 服务初始化: enabled={self.enabled}, timeout={self.timeout}s, model={self.model}")
def rank_candidates(
self,
step_name: str,
action: str,
params: dict,
candidates: List[Dict[str, Any]]
) -> Tuple[int, float, str]:
"""
对候选元素进行语义排序
Args:
step_name: 步骤名称,如 "点击新建会议按钮"
action: 动作类型,如 click/fill/wait
params: 步骤参数
candidates: 候选元素列表,格式:
[
{
"index": 0,
"tag": "BUTTON",
"text": "新建会议",
"selector": "button:has-text('新建会议')",
"attributes": {...},
"position": {"x": 100, "y": 200, "width": 80, "height": 32}
},
...
]
Returns:
Tuple[int, float, str]: (选中索引, 置信度, 选择理由)
Raises:
Exception: Claude 调用失败时抛出异常
"""
if not self.enabled:
# 未启用时,单候选直接返回,多候选返回第一个
if len(candidates) == 1:
return 0, 1.0, "唯一候选元素(Claude未启用)"
return 0, 0.6, "关键词匹配第一个候选(Claude未启用)"
if len(candidates) == 0:
raise Exception("候选元素列表为空")
if len(candidates) == 1:
# 单候选直接返回
return 0, 1.0, "唯一候选元素"
logger.info(f"Claude 语义排序: step='{step_name}', candidates={len(candidates)}")
try:
# 1. 构建 Prompt
prompt = self._build_prompt(step_name, action, params, candidates)
logger.debug(f"Claude Prompt 长度: {len(prompt)} 字符")
# 2. 调用 Claude CLI
response = self._call_claude_cli(prompt)
logger.debug(f"Claude 原始响应: {response}")
# 3. 解析响应
result = self._parse_response(response)
selected_index = result.get('selected_index', 0)
confidence = result.get('confidence', 0.8)
reason = result.get('reason', '未提供理由')
logger.info(f"Claude 选择结果: index={selected_index}, confidence={confidence:.2f}, reason={reason}")
return selected_index, confidence, reason
except Exception as e:
logger.error(f"Claude 语义排序失败: {e}")
raise
def _build_prompt(
self,
step_name: str,
action: str,
params: dict,
candidates: List[Dict[str, Any]]
) -> str:
"""
构建 Claude Prompt
Args:
step_name: 步骤名称
action: 动作类型
params: 步骤参数
candidates: 候选元素列表
Returns:
str: 完整的 Prompt
"""
# 限制候选数量
limited_candidates = candidates[:self.max_candidates]
# 简化候选信息(只保留关键字段)
simplified_candidates = []
for c in limited_candidates:
simplified_candidates.append({
'index': c.get('index', 0),
'tag': c.get('tag', ''),
'text': c.get('text', '')[:50] if c.get('text') else '', # 限制文本长度
'selector': c.get('selector', ''),
'type': c.get('attributes', {}).get('type', '')
})
# 构建候选元素 JSON
candidates_json = json.dumps(simplified_candidates, ensure_ascii=False, indent=2)
# 填充模板
prompt = ELEMENT_SELECTION_PROMPT.format(
step_name=step_name,
action=action,
candidates_json=candidates_json
)
return prompt
def _call_claude_cli(self, prompt: str) -> str:
"""
调用 Claude CLI
Args:
prompt: Prompt 文本
Returns:
str: Claude 响应文本
Raises:
Exception: 调用失败时抛出异常
"""
import platform
import os
try:
# 检测操作系统
is_windows = platform.system() == 'Windows'
# 设置环境变量确保 UTF-8 编码
env = os.environ.copy()
env['PYTHONIOENCODING'] = 'utf-8'
env['LANG'] = 'en_US.UTF-8'
if is_windows:
# Windows: 使用 shell=True 并设置编码
result = subprocess.run(
f'claude --print',
input=prompt,
capture_output=True,
text=True,
timeout=self.timeout,
shell=True,
env=env,
encoding='utf-8',
errors='replace' # 替换无法解码的字符
)
else:
# Linux/Mac: 直接调用
result = subprocess.run(
['claude', '--print'],
input=prompt,
capture_output=True,
text=True,
timeout=self.timeout,
env=env,
encoding='utf-8',
errors='replace'
)
# 检查结果
stdout = result.stdout.strip() if result.stdout else ""
stderr = result.stderr.strip() if result.stderr else ""
if result.returncode == 0 and stdout:
logger.debug(f"Claude CLI 响应成功: {len(stdout)} 字符")
return stdout
# 失败情况
error_msg = stderr or f"返回码 {result.returncode}"
if not stdout:
error_msg = "响应为空"
raise Exception(f"Claude CLI 错误: {error_msg}")
except subprocess.TimeoutExpired:
raise Exception(f"Claude CLI 超时(>{self.timeout}s)")
except FileNotFoundError:
raise Exception("Claude CLI 未安装或不在 PATH 中")
except Exception as e:
raise Exception(f"Claude CLI 调用失败: {e}")
def _parse_response(self, response: str) -> dict:
"""
解析 Claude 响应
Args:
response: Claude 原始响应文本
Returns:
dict: 解析后的结果 {"selected_index": 0, "confidence": 0.95, "reason": "..."}
Raises:
Exception: 解析失败时抛出异常
"""
try:
# 清理响应文本
cleaned = response.strip()
# 移除可能的 markdown 代码块标记
if cleaned.startswith("```"):
# 移除开头的 ```json 或 ```
lines = cleaned.split("\n")
if lines[0].startswith("```"):
lines = lines[1:]
# 移除结尾的 ```
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
cleaned = "\n".join(lines).strip()
# 尝试提取 JSON
# 方法1: 直接解析
try:
result = json.loads(cleaned)
if isinstance(result, dict) and 'selected_index' in result:
return result
except json.JSONDecodeError:
pass
# 方法2: 使用正则提取 JSON 对象
json_match = re.search(r'\{[^{}]*"selected_index"[^{}]*\}', cleaned, re.DOTALL)
if json_match:
try:
result = json.loads(json_match.group())
if isinstance(result, dict) and 'selected_index' in result:
return result
except json.JSONDecodeError:
pass
# 方法3: 尝试提取 selected_index 数字
index_match = re.search(r'"selected_index"\s*:\s*(\d+)', cleaned)
if index_match:
selected_index = int(index_match.group(1))
confidence_match = re.search(r'"confidence"\s*:\s*([\d.]+)', cleaned)
confidence = float(confidence_match.group(1)) if confidence_match else 0.8
reason_match = re.search(r'"reason"\s*:\s*"([^"]*)"', cleaned)
reason = reason_match.group(1) if reason_match else "解析得到"
return {
'selected_index': selected_index,
'confidence': confidence,
'reason': reason
}
raise Exception(f"无法解析 Claude 响应: {cleaned[:200]}")
except Exception as e:
logger.error(f"Claude 响应解析失败: {e}")
raise Exception(f"Claude 响应解析失败: {e}")
# ==================== 工厂函数 ====================
def get_claude_service() -> ClaudeService:
"""
获取 Claude 服务实例
Returns:
ClaudeService: 服务实例
"""
return ClaudeService()
# ==================== 测试代码 ====================
if __name__ == "__main__":
"""测试 Claude 服务"""
import sys
# 设置日志
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# 测试候选元素
test_candidates = [
{
"index": 0,
"tag": "DIV",
"text": "会议预约",
"selector": "div:has-text('会议预约')",
"attributes": {"class": "menu-item"},
"position": {"x": 50, "y": 150, "width": 200, "height": 40}
},
{
"index": 1,
"tag": "BUTTON",
"text": "新建会议",
"selector": "button:has-text('新建会议')",
"attributes": {"class": "el-button el-button--primary", "type": "button"},
"position": {"x": 100, "y": 200, "width": 80, "height": 32}
},
{
"index": 2,
"tag": "INPUT",
"text": "",
"selector": "input[placeholder='请输入会议名称']",
"attributes": {"type": "text", "placeholder": "请输入会议名称"},
"position": {"x": 200, "y": 300, "width": 200, "height": 32}
}
]
# 测试场景1: 点击按钮
print("\n=== 测试场景1: 点击新建会议按钮 ===")
service = ClaudeService()
try:
idx, conf, reason = service.rank_candidates(
step_name="点击新建会议按钮",
action="click",
params={},
candidates=test_candidates
)
print(f"结果: index={idx}, confidence={conf:.2f}, reason={reason}")
print(f"选择器: {test_candidates[idx]['selector']}")
except Exception as e:
print(f"测试失败: {e}")
# 测试场景2: 输入会议名称
print("\n=== 测试场景2: 会议名称输入:自动化测试 ===")
try:
idx, conf, reason = service.rank_candidates(
step_name="会议名称输入:自动化测试",
action="fill",
params={"value": "自动化测试"},
candidates=test_candidates
)
print(f"结果: index={idx}, confidence={conf:.2f}, reason={reason}")
print(f"选择器: {test_candidates[idx]['selector']}")
except Exception as e:
print(f"测试失败: {e}")
# 测试场景3: 单候选
print("\n=== 测试场景3: 单候选元素 ===")
try:
idx, conf, reason = service.rank_candidates(
step_name="点击唯一按钮",
action="click",
params={},
candidates=[test_candidates[1]]
)
print(f"结果: index={idx}, confidence={conf:.2f}, reason={reason}")
except Exception as e:
print(f"测试失败: {e}")
...@@ -243,7 +243,8 @@ def match_element_by_keywords( ...@@ -243,7 +243,8 @@ def match_element_by_keywords(
page, page,
keywords: List[str], keywords: List[str],
action: str, action: str,
timeout: int = 5000 timeout: int = 5000,
max_candidates: int = 10
) -> Tuple[Optional[Any], List[Dict[str, Any]]]: ) -> Tuple[Optional[Any], List[Dict[str, Any]]]:
""" """
通过关键词在页面元素中直接匹配 通过关键词在页面元素中直接匹配
...@@ -258,18 +259,19 @@ def match_element_by_keywords( ...@@ -258,18 +259,19 @@ def match_element_by_keywords(
keywords (List[str]): 关键词列表 keywords (List[str]): 关键词列表
action (str): 动作类型 action (str): 动作类型
timeout (int): 超时时间(毫秒) timeout (int): 超时时间(毫秒)
max_candidates (int): 最大返回候选数量
Returns: Returns:
Tuple[Optional[Any], List[Dict]]: (元素对象, 候选选择器列表) Tuple[Optional[Any], List[Dict]]: (元素对象列表, 候选选择器列表)
元素对象可能为 None(未找到 元素对象列表按置信度降序排列(最多 max_candidates 个
候选选择器列表按置信度降序排列 候选选择器列表按置信度降序排列
""" """
candidates = [] candidates = []
# 获取所有可交互元素 # 获取所有可交互元素(包含主页面 + iframe/微前端)
try: try:
# 扩展元素查询范围,包含 Element Plus 组件和常见可点击元素 # 扩展元素查询范围,包含 Element Plus 组件和常见可点击元素
elements = page.locator( base_selector = (
'input:visible, button:visible, a:visible, select:visible, textarea:visible, ' 'input:visible, button:visible, a:visible, select:visible, textarea:visible, '
# 语义化 role 元素 # 语义化 role 元素
'[role="button"]:visible, [role="link"]:visible, [role="checkbox"]:visible, ' '[role="button"]:visible, [role="link"]:visible, [role="checkbox"]:visible, '
...@@ -278,13 +280,31 @@ def match_element_by_keywords( ...@@ -278,13 +280,31 @@ def match_element_by_keywords(
'.el-button:visible, .el-checkbox:visible, .el-tabs__item:visible, ' '.el-button:visible, .el-checkbox:visible, .el-tabs__item:visible, '
# 常见可点击元素(图标、自定义按钮) # 常见可点击元素(图标、自定义按钮)
'i[onclick]:visible, div[onclick]:visible, span[onclick]:visible, ' 'i[onclick]:visible, div[onclick]:visible, span[onclick]:visible, '
'div[class*="btn"]:visible, span[class*="btn"]:visible' 'div[class*="btn"]:visible, span[class*="btn"]:visible, '
).all() # 微前端中的常见可点击元素
'.block:visible, p:visible'
)
elements = page.locator(base_selector).all()
total_elements = len(elements)
# 🔧 新增:在所有 iframe 中查找元素
for frame in page.frames:
if frame == page.main_frame:
continue
try:
frame_elements = frame.locator(base_selector).all()
elements.extend(frame_elements)
if frame_elements:
logger.debug(f"在 iframe 中找到 {len(frame_elements)} 个元素")
except Exception as e:
logger.debug(f"iframe 元素查询失败(忽略): {e}")
except Exception as e: except Exception as e:
logger.warning(f"获取页面元素失败: {e}") logger.warning(f"获取页面元素失败: {e}")
return None, [] return None, []
logger.info(f"页面找到 {len(elements)} 个可交互元素,关键词: {keywords}") logger.info(f"页面找到 {len(elements)} 个可交互元素(主页面 {total_elements} + iframe {len(elements) - total_elements}),关键词: {keywords}")
for el in elements: for el in elements:
try: try:
...@@ -443,11 +463,128 @@ def match_element_by_keywords( ...@@ -443,11 +463,128 @@ def match_element_by_keywords(
if not candidates: if not candidates:
return None, [] return None, []
# 返回最佳匹配 # 限制候选数量
best = candidates[0] limited_candidates = candidates[:max_candidates]
logger.info(f"最佳匹配元素: {best['info']}, 分数: {best['score']:.2f}")
# 返回所有候选元素和选择器(供 Claude 语义排序使用)
elements_list = [c['element'] for c in limited_candidates]
all_selectors = []
for c in limited_candidates:
all_selectors.extend(c['selectors'])
# 去重选择器
seen_selectors = set()
unique_selectors = []
for sel in all_selectors:
if sel['value'] not in seen_selectors:
seen_selectors.add(sel['value'])
unique_selectors.append(sel)
# 日志
best = limited_candidates[0]
logger.info(f"最佳匹配元素: {best['info']}, 分数: {best['score']:.2f}, 候选数: {len(limited_candidates)}")
return elements_list, unique_selectors
def get_candidate_details(
page,
elements: List[Any]
) -> List[Dict[str, Any]]:
"""
获取候选元素的详细信息(供 Claude 语义分析使用)
Args:
page: Playwright Page 对象
elements: 元素对象列表
Returns:
List[Dict]: 候选元素详情列表,格式:
[
{
"index": 0,
"tag": "BUTTON",
"text": "新建会议",
"selector": "button:has-text('新建会议')",
"attributes": {...},
"position": {"x": 100, "y": 200, "width": 80, "height": 32}
},
...
]
"""
candidates = []
for idx, el in enumerate(elements):
try:
# 提取元素属性
tag = el.evaluate('el => el.tagName')
text = ""
try:
text = el.inner_text().strip()[:100] # 限制长度
except Exception:
pass
# 提取关键属性
el_id = el.get_attribute('id') or ''
el_class = el.get_attribute('class') or ''
el_type = el.get_attribute('type') or ''
placeholder = el.get_attribute('placeholder') or ''
aria_label = el.get_attribute('aria-label') or ''
name = el.get_attribute('name') or ''
data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or ''
# 获取元素位置
position = {}
try:
box = el.bounding_box()
if box:
position = {
'x': int(box.get('x', 0)),
'y': int(box.get('y', 0)),
'width': int(box.get('width', 0)),
'height': int(box.get('height', 0))
}
except Exception:
pass
# 构建选择器(优先级:ID > data-testid > class > text)
selector = ""
if el_id:
selector = f'#{el_id}'
elif data_testid:
selector = f'[data-testid="{data_testid}"]'
elif el_class and len(el_class.split()) > 0:
selector = f'.{el_class.split()[0]}'
elif text:
selector = f'{tag.lower()}:has-text("{text[:30]}")'
else:
selector = tag.lower()
# 构建候选元素信息
candidate = {
'index': idx,
'tag': tag,
'text': text,
'selector': selector,
'attributes': {
'id': el_id,
'class': el_class,
'type': el_type,
'placeholder': placeholder,
'aria-label': aria_label,
'name': name,
'data-testid': data_testid
},
'position': position
}
candidates.append(candidate)
except Exception as e:
logger.debug(f"获取元素详情失败(跳过): {e}")
continue
return best['element'], best['selectors'] return candidates
def find_element_by_semantic( def find_element_by_semantic(
......
...@@ -22,7 +22,8 @@ from app.services.keyword_matcher import ( ...@@ -22,7 +22,8 @@ from app.services.keyword_matcher import (
detect_action_type, detect_action_type,
is_verification_step, is_verification_step,
match_element_by_keywords, match_element_by_keywords,
find_element_by_semantic find_element_by_semantic,
get_candidate_details # 新增:获取候选元素详情
) )
from app.services.selector_extractor import ( from app.services.selector_extractor import (
extract_selectors, extract_selectors,
...@@ -30,6 +31,7 @@ from app.services.selector_extractor import ( ...@@ -30,6 +31,7 @@ from app.services.selector_extractor import (
build_step_with_selectors build_step_with_selectors
) )
from app.services.login_template_service import LoginTemplateService from app.services.login_template_service import LoginTemplateService
from app.services.claude_service import ClaudeService # 新增:Claude 服务
from app.config import settings from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -57,13 +59,15 @@ class SmartLocateService: ...@@ -57,13 +59,15 @@ class SmartLocateService:
"""初始化智能定位服务""" """初始化智能定位服务"""
self.executor: Optional[PlaywrightExecutor] = None self.executor: Optional[PlaywrightExecutor] = None
self.screenshot_dir = settings.SCREENSHOT_DIR self.screenshot_dir = settings.SCREENSHOT_DIR
self.use_claude: bool = True # 是否启用 Claude 语义增强
def locate_steps( def locate_steps(
self, self,
steps: List[Dict[str, Any]], steps: List[Dict[str, Any]],
auto_login: bool = True, auto_login: bool = True,
navigate_menu: str = "", navigate_menu: str = "",
page_url: str = "https://192.168.5.44" page_url: str = "https://192.168.5.44",
use_claude: bool = True
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
智能定位主流程 智能定位主流程
...@@ -77,6 +81,7 @@ class SmartLocateService: ...@@ -77,6 +81,7 @@ class SmartLocateService:
auto_login (bool): 是否自动登录 auto_login (bool): 是否自动登录
navigate_menu (str): 目标菜单名称(如"信息发布") navigate_menu (str): 目标菜单名称(如"信息发布")
page_url (str): 被测系统基础 URL page_url (str): 被测系统基础 URL
use_claude (bool): 是否启用 Claude 语义增强,默认 True
Returns: Returns:
List[Dict]: 定位结果列表,格式: List[Dict]: 定位结果列表,格式:
...@@ -92,6 +97,8 @@ class SmartLocateService: ...@@ -92,6 +97,8 @@ class SmartLocateService:
} }
] ]
""" """
# 设置 Claude 增强开关
self.use_claude = use_claude and settings.CLAUDE_ENABLED
logger.info(f"开始智能定位: {len(steps)} 个步骤, auto_login={auto_login}, navigate_menu={navigate_menu}") logger.info(f"开始智能定位: {len(steps)} 个步骤, auto_login={auto_login}, navigate_menu={navigate_menu}")
results = [] results = []
...@@ -204,18 +211,40 @@ class SmartLocateService: ...@@ -204,18 +211,40 @@ class SmartLocateService:
# 步骤 1: 点击功能中心图标 # 步骤 1: 点击功能中心图标
logger.debug("点击功能中心图标...") logger.debug("点击功能中心图标...")
try:
# XPath 选择器(已验证,指向 i 元素) # 功能中心选择器候选列表(按优先级排序)
page.click("//*[@id='Home']/div[1]/div[1]/i", timeout=5000) func_center_selectors = [
logger.info("成功点击功能中心图标(XPath选择器)") "//*[@id='Home']/div[1]/div[1]/i", # XPath(已验证)
except Exception as e: "//*[@id='Home']/div[1]/div[1]", # XPath div
logger.warning(f"XPath选择器点击失败: {e}") ".home_nav_left", # class 选择器
# 回退:尝试 class 选择器 ]
func_center_clicked = False
for sel in func_center_selectors:
try: try:
page.click('.home_nav_left', timeout=5000) page.click(sel, timeout=10000)
logger.info("成功点击功能中心图标(class选择器回退)") func_center_clicked = True
logger.info(f"成功点击功能中心图标: {sel}")
break
except Exception as e:
logger.debug(f"功能中心选择器 {sel} 点击失败: {e}")
continue
# 回退:JS click(处理元素在负坐标或被遮挡的情况)
if not func_center_clicked:
try:
page.evaluate("""
() => {
const el = document.querySelector('.home_nav_left')
|| document.querySelector('[id="Home"] > div:first-child > div:first-child');
if (el) { el.click(); return true; }
return false;
}
""")
func_center_clicked = True
logger.info("成功点击功能中心图标(JS click 回退)")
except Exception as e2: except Exception as e2:
logger.error(f"无法点击功能中心图标: {e2}") logger.error(f"无法点击功能中心图标(所有方式均失败): {e2}")
return False return False
# 步骤 2: 等待功能抽屉打开并完成动画 # 步骤 2: 等待功能抽屉打开并完成动画
...@@ -261,17 +290,10 @@ class SmartLocateService: ...@@ -261,17 +290,10 @@ class SmartLocateService:
logger.debug(f"二级菜单导航: {category} -> {menu_name}") logger.debug(f"二级菜单导航: {category} -> {menu_name}")
try: try:
category_selector = f'.el-drawer >> text="{category}"' category_selector = f'.el-drawer >> text="{category}"'
# 检查元素是否存在 # 直接尝试点击(page.click 内部会等待元素出现)
cat_element = page.query_selector(category_selector) page.click(category_selector, timeout=8000)
if cat_element: page.wait_for_timeout(800)
# 滚动到可见区域 logger.info(f"成功点击分类: {category}")
cat_element.scroll_into_view_if_needed()
page.wait_for_timeout(300)
cat_element.click(timeout=5000)
page.wait_for_timeout(800)
logger.info(f"成功点击分类: {category}")
else:
logger.warning(f"未找到分类元素: {category}")
except Exception as e: except Exception as e:
logger.warning(f"点击分类失败: {e},尝试直接点击菜单") logger.warning(f"点击分类失败: {e},尝试直接点击菜单")
...@@ -291,21 +313,8 @@ class SmartLocateService: ...@@ -291,21 +313,8 @@ class SmartLocateService:
try: try:
logger.debug(f"尝试选择器 {i+1}/{len(menu_selectors)}: {menu_selector}") logger.debug(f"尝试选择器 {i+1}/{len(menu_selectors)}: {menu_selector}")
# 检查元素是否存在 # 直接尝试点击(page.click 内部会等待元素出现)
element = page.query_selector(menu_selector) page.click(menu_selector, timeout=8000)
if not element:
logger.debug(f"选择器 {i+1} 未找到元素")
continue
# 滚动到可见区域
element.scroll_into_view_if_needed()
page.wait_for_timeout(300) # 滚动后稳定
# 等待元素可点击状态
page.wait_for_selector(menu_selector, state="visible", timeout=3000)
# 执行点击
element.click(timeout=5000)
click_success = True click_success = True
logger.info(f"成功点击菜单(选择器 {i+1}): {menu_name}") logger.info(f"成功点击菜单(选择器 {i+1}): {menu_name}")
break break
...@@ -536,64 +545,95 @@ class SmartLocateService: ...@@ -536,64 +545,95 @@ class SmartLocateService:
logger.info(f"步骤 {order} 匹配特殊模式: {pattern_result['type']}") logger.info(f"步骤 {order} 匹配特殊模式: {pattern_result['type']}")
return result return result
# ⚠️ 新增:检查是否需要在抽屉内查找 # 第一级:关键词直接匹配(返回候选列表)
drawer_keywords = ['资产管理', '资产信息', '资产故障', '资产设备', # Claude 语义增强已能理解元素上下文,不再需要手动区分抽屉内/外
'会议预约', '会议运维', '会议转录', '信息发布', elements_list, selectors = match_element_by_keywords(page, keywords, action)
'数据统计', '运维维护', '维护工单', '会务管理',
'信息管理', '集控控制', '预定'] if elements_list and selectors:
in_drawer = any(kw in name for kw in drawer_keywords) # === Claude 语义增强 ===
# 如果有多个候选元素且启用了 Claude,进行语义排序
# 第一级:关键词直接匹配 if self.use_claude and len(elements_list) > 1:
if in_drawer: try:
# ⚠️ 优先在抽屉内查找 logger.info(f"步骤 {order} 启用 Claude 语义增强,候选数: {len(elements_list)}")
logger.debug(f"检测到抽屉内元素,优先在 .el-drawer 内查找")
element, selectors = match_element_by_keywords(page, keywords, action) # 获取候选元素详情
candidates = get_candidate_details(page, elements_list)
if not element:
# 尝试直接用文本匹配抽屉内的元素 # 调用 Claude 服务进行语义排序
for kw in keywords: claude_service = ClaudeService()
if len(kw) >= 2: selected_idx, confidence, reason = claude_service.rank_candidates(
try: step_name=name,
drawer_selector = f'.el-drawer >> text="{kw}"' action=action,
page.wait_for_selector(drawer_selector, timeout=2000) params=params,
element = page.locator(drawer_selector).first candidates=candidates
selectors = [{ )
'type': 'css',
'value': drawer_selector, # 使用 Claude 选择的结果
'confidence': 0.90, if 0 <= selected_idx < len(selectors):
'priority': 1 primary_selector = selectors[selected_idx]['value']
}] result['success'] = True
logger.info(f"在抽屉内找到元素: {drawer_selector}") result['selectors'] = {
break 'primary': primary_selector,
except Exception: 'candidates': selectors
continue }
else: result['claude_enhanced'] = True
element, selectors = match_element_by_keywords(page, keywords, action) result['claude_confidence'] = confidence
result['claude_reason'] = reason
result['message'] = f'Claude语义增强定位成功: {primary_selector}'
logger.info(f"步骤 {order} Claude 选择: index={selected_idx}, confidence={confidence:.2f}, reason={reason}")
else:
# Claude 返回索引无效,回退到第一个
primary_selector = selectors[0]['value']
result['success'] = True
result['selectors'] = {'primary': primary_selector, 'candidates': selectors}
result['claude_enhanced'] = False
result['message'] = f'关键词匹配成功(Claude返回无效索引): {primary_selector}'
logger.warning(f"步骤 {order} Claude 返回索引无效: {selected_idx}")
except Exception as e:
# Claude 失败,回退到第一个候选
logger.warning(f"步骤 {order} Claude 语义增强失败,回退: {e}")
primary_selector = selectors[0]['value']
result['success'] = True
result['selectors'] = {'primary': primary_selector, 'candidates': selectors}
result['claude_enhanced'] = False
result['message'] = f'关键词匹配成功(Claude回退): {primary_selector}'
# 单候选或未启用 Claude,直接使用第一个
elif len(elements_list) == 1 or not self.use_claude:
primary_selector = selectors[0]['value']
result['success'] = True
result['selectors'] = {'primary': primary_selector, 'candidates': selectors}
result['claude_enhanced'] = False
result['message'] = f'关键词匹配成功: {primary_selector}'
logger.info(f"步骤 {order} 定位成功(关键词匹配): {primary_selector}")
# 多候选但 Claude 失败后已回退,此分支防止遗漏
else:
primary_selector = selectors[0]['value']
result['success'] = True
result['selectors'] = {'primary': primary_selector, 'candidates': selectors}
result['claude_enhanced'] = False
result['message'] = f'关键词匹配成功: {primary_selector}'
logger.info(f"步骤 {order} 定位成功(默认): {primary_selector}")
# 提取元素信息(使用第一个元素)
if elements_list:
result['element_info'] = extract_element_info(elements_list[0])
if element and selectors:
result['success'] = True
# 将 selectors 列表转换为字典格式
primary_selector = selectors[0]['value'] if selectors else None
result['selectors'] = {
'primary': primary_selector,
'candidates': selectors
}
result['element_info'] = extract_element_info(element)
result['message'] = f'关键词匹配成功: {primary_selector}'
logger.info(f"步骤 {order} 定位成功(关键词匹配): {primary_selector}")
else: else:
# 第二级:语义推断 # 第二级:语义推断(回退策略,只返回单一元素)
element, selectors = find_element_by_semantic(page, keywords, action) element, selectors = find_element_by_semantic(page, keywords, action)
if element and selectors: if element and selectors:
result['success'] = True result['success'] = True
# 将 selectors 列表转换为字典格式
primary_selector = selectors[0]['value'] if selectors else None primary_selector = selectors[0]['value'] if selectors else None
result['selectors'] = { result['selectors'] = {
'primary': primary_selector, 'primary': primary_selector,
'candidates': selectors 'candidates': selectors
} }
result['element_info'] = extract_element_info(element) result['element_info'] = extract_element_info(element)
result['claude_enhanced'] = False
result['message'] = f'语义推断成功: {primary_selector}' result['message'] = f'语义推断成功: {primary_selector}'
logger.info(f"步骤 {order} 定位成功(语义推断): {primary_selector}") logger.info(f"步骤 {order} 定位成功(语义推断): {primary_selector}")
else: else:
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_claude_enhanced_locate.py
模块描述:Claude 语义增强智能定位集成测试脚本
作者:czj
创建日期:2026-08-06
最后修改:2026-08-06
"""
import sys
import os
import json
import logging
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 设置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def test_claude_service_directly():
"""测试 Claude 服务直接调用"""
from app.services.claude_service import ClaudeService
print("=" * 60)
print("测试1: Claude 服务直接调用")
print("=" * 60)
test_candidates = [
{
'index': 0,
'tag': 'DIV',
'text': '会议预约',
'selector': 'div:has-text("会议预约")',
'attributes': {},
'position': {}
},
{
'index': 1,
'tag': 'BUTTON',
'text': '新建会议',
'selector': 'button:has-text("新建会议")',
'attributes': {'type': 'button'},
'position': {}
},
{
'index': 2,
'tag': 'INPUT',
'text': '',
'selector': 'input[placeholder]',
'attributes': {'type': 'text', 'placeholder': '请输入会议名称'},
'position': {}
}
]
service = ClaudeService()
# 测试1: 点击按钮
print("\n--- 场景1: 点击新建会议按钮 ---")
try:
idx, conf, reason = service.rank_candidates(
step_name='点击新建会议按钮',
action='click',
params={},
candidates=test_candidates
)
expected = 1 # 应该选择 BUTTON
status = "[OK]" if idx == expected else "[FAIL]"
print(f"{status} 结果: index={idx} (期望{expected}), confidence={conf:.2f}")
print(f" 理由: {reason}")
print(f" 选择器: {test_candidates[idx]['selector']}")
except Exception as e:
print(f"[FAIL] 失败: {e}")
# 测试2: 输入会议名称
print("\n--- 场景2: 会议名称输入:自动化测试 ---")
try:
idx, conf, reason = service.rank_candidates(
step_name='会议名称输入:自动化测试',
action='fill',
params={'value': '自动化测试'},
candidates=test_candidates
)
expected = 2 # 应该选择 INPUT
status = "[OK]" if idx == expected else "[FAIL]"
print(f"{status} 结果: index={idx} (期望{expected}), confidence={conf:.2f}")
print(f" 理由: {reason}")
print(f" 选择器: {test_candidates[idx]['selector']}")
except Exception as e:
print(f"[FAIL] 失败: {e}")
# 测试3: 点击菜单
print("\n--- 场景3: 点击会议预约分类 ---")
try:
idx, conf, reason = service.rank_candidates(
step_name='点击会议预约分类',
action='click',
params={},
candidates=test_candidates
)
expected = 0 # 应该选择 DIV "会议预约"
status = "[OK]" if idx == expected else "[FAIL]"
print(f"{status} 结果: index={idx} (期望{expected}), confidence={conf:.2f}")
print(f" 理由: {reason}")
print(f" 选择器: {test_candidates[idx]['selector']}")
except Exception as e:
print(f"[FAIL] 失败: {e}")
def test_claude_service_fallback():
"""测试 Claude 服务回退机制"""
from app.services.claude_service import ClaudeService
print("\n" + "=" * 60)
print("测试2: Claude 服务回退机制")
print("=" * 60)
service = ClaudeService()
service.enabled = False # 禁用 Claude
test_candidates = [
{'index': 0, 'tag': 'DIV', 'text': '会议预约', 'selector': 'div', 'attributes': {}, 'position': {}},
{'index': 1, 'tag': 'BUTTON', 'text': '新建会议', 'selector': 'button', 'attributes': {}, 'position': {}}
]
# 多候选回退
print("\n--- 场景1: 多候选回退(Claude 禁用)---")
try:
idx, conf, reason = service.rank_candidates(
step_name='点击新建会议按钮',
action='click',
params={},
candidates=test_candidates
)
print(f"[OK] 回退成功: index={idx}, confidence={conf:.2f}, reason={reason}")
except Exception as e:
print(f"[FAIL] 失败: {e}")
# 单候选
print("\n--- 场景2: 单候选(不调用 Claude)---")
try:
idx, conf, reason = service.rank_candidates(
step_name='点击按钮',
action='click',
params={},
candidates=[test_candidates[1]]
)
print(f"✅ 单候选: index={idx}, confidence={conf:.2f}, reason={reason}")
except Exception as e:
print(f"[FAIL] 失败: {e}")
def test_keyword_matcher_candidates():
"""测试关键词匹配返回候选列表"""
from app.services.keyword_matcher import extract_keywords, get_candidate_details
print("\n" + "=" * 60)
print("测试3: 关键词匹配候选列表")
print("=" * 60)
# 测试关键词提取
test_cases = [
("点击新建会议按钮", "click"),
("会议名称输入:自动化测试", "fill"),
("会议室选择:北京展厅会议室", "click"),
("等待页面加载", "wait"),
]
for desc, action in test_cases:
keywords = extract_keywords(desc)
print(f"\n步骤: {desc}")
print(f" 关键词: {keywords}")
print(f" 动作: {action}")
# 测试 get_candidate_details
print("\n--- get_candidate_details 空列表测试 ---")
details = get_candidate_details(None, [])
print(f"空列表结果: {details}")
def test_smart_locate_api():
"""测试智能定位 API"""
import requests
print("\n" + "=" * 60)
print("测试4: 智能定位 API(需要后端服务运行)")
print("=" * 60)
base_url = "http://localhost:8001"
# 健康检查
try:
resp = requests.get(f"{base_url}/api/element/health", timeout=5)
if resp.status_code == 200:
print(f"✅ 健康检查通过")
else:
print(f"❌ 健康检查失败: {resp.status_code}")
return
except Exception as e:
print(f"⚠️ 后端服务未启动,跳过 API 测试: {e}")
return
# 测试智能定位(不启用 Claude,快速测试)
print("\n--- 测试智能定位(use_claude=false)---")
try:
payload = {
"steps": [
{"order": 1, "name": "等待页面加载", "action": "wait", "params": {}},
],
"auto_login": False,
"navigate_menu": "",
"use_claude": False
}
resp = requests.post(f"{base_url}/api/element/smart-locate", json=payload, timeout=120)
result = resp.json()
print(f"状态码: {resp.status_code}")
print(f"结果: 成功 {result.get('located_steps', 0)}/{result.get('total_steps', 0)} 步骤")
except Exception as e:
print(f"❌ API 测试失败: {e}")
def main():
"""主测试入口"""
print("╔══════════════════════════════════════════════════════════╗")
print("║ Claude 语义增强智能定位 - 集成测试 ║")
print("╚══════════════════════════════════════════════════════════╝")
print()
# 测试1: Claude 服务直接调用
test_claude_service_directly()
# 测试2: 回退机制
test_claude_service_fallback()
# 测试3: 关键词匹配候选列表
test_keyword_matcher_candidates()
# 测试4: 智能定位 API
test_smart_locate_api()
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
if __name__ == "__main__":
main()
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论