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

feat(smart-locate): 智能定位升级为语义化用例生成器(阶段A-C完成并验证)

- 后端 smart_locate_service:语义解析优先路径 + 反向生成 semantic + 模式步骤锚点值作 text + 反向生成经执行期 resolve_step 校验(row_checkbox→row 降级)+ 动态值 CTX 化 + 全候选点击链
- routers/smart_locate:SmartLocateResult 增加 semantic/verify
- keyword_matcher:find_element_by_semantic 兜底 return None, []
- page_url_mapping:注册会议列表 scope
- 前端:batchLocate 回填 semantic/verify
- 单测 37 用例 + E2E 连跑两次 7/7 全绿 + PRD/执行计划文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 10ed34d7
# PRD:智能定位语义化升级 — 语义化用例生成器
> **模块**: UI 自动化测试 — 智能元素定位
> **优先级**: P1
> **作者**: czj / Claude Code
> **创建日期**: 2026-08-18
> **状态**: 待评审
> **关联机制**: 复杂用例通用执行机制(语义化用例执行,阶段 1-6 已完成)
---
## 一、需求背景
### 1.1 问题现状
智能定位(`POST /api/element/smart-locate`)当前的定位准确率和稳定性已无法满足复杂用例需求。核心问题:
| # | 问题 | 表现 |
|---|------|------|
| 1 | **无页面身份(scope)校验** | 全页面文本关键词打分,无"当前在哪个页面"概念。跨页误配必然(如「会议列表-全部-时间」vs「创建会议-选择时间」),每次新用例都要靠"实测 DOM → 加评分补丁 → 部署验证"循环修 |
| 2 | **结果只回填裸选择器** | 智能定位返回 `params.selector` / `locator_type` / `locator_value`**从不生成 semantic 语义目标**。生成的用例落库后走 keyword 评分路径,无法享受语义化执行机制的稳定性 |
| 3 | **一次性定位、无状态验证** | 定位成功即返回,点击后仅粗验弹窗/抽屉出现。会话 15「会议室选择」假通过(裸 checkbox + force 点击)即此类——定位"成功"但业务状态错误 |
| 4 | **失败一刀切** | 一步失败 `skip_remaining` 跳过全部后续步骤,无法单步诊断 |
| 5 | **Claude 全量调用** | 候选 ≥1 就调 `rank_candidates`,无"规则优先、Claude 按需"的短路机制,慢且贵 |
### 1.2 新建会议用例的关键经验(成功范式)
「新建会议」复杂用例从"21 步 + `element_mapping_service` 30+ 条模糊评分补丁堆叠"(会话 9-20)重写为**语义化机制**后,连跑 4 次全绿零残留(会话 22-24 验收通过)。成功范式:
```
业务意图(semantic) + 执行期现场解析 + 上下文变量 + 状态验证 + 数据自建/自清理
```
| 语义化机制成功经验 | 解决的问题 |
|------|------|
| **scope 页面身份校验**(页面注册表 url_patterns + fingerprint) | 根治跨层级误配(root cause 级) |
| **映射表 v2 精确键**`lookup_by_key(scope, key)` 纯 dict 查找) | 替代 30+ 条模糊评分补丁 |
| **强结构模式选择器**(tab / row_checkbox / checkbox / row) | 确定性 DOM 结构,无需全页扫描 |
| **规则优先、Claude 按需仲裁**(置信度差 ≥0.1 短路) | 降成本、提速 |
| **verify 状态验证**(防假通过) | 校验业务状态而非"点到了" |
| **`{__CTX:key__}` 上下文变量** + `save_as` | 动态数据自建/自断言/自清理同名 |
| **finally 数据自清理** | 执行无残留 |
**这套经验没有回流到智能定位**——智能定位至今仍在走"无 scope 的关键词打分 + 裸选择器输出"旧路。
### 1.3 改造方向
**把智能定位从"裸选择器提取器"升级为"语义化用例生成器"**:定位阶段复用执行期的 `semantic_resolver.resolve_step` 解析链,产出 `semantic` 语义目标(type/scope/text/contains/container + 条件 verify),让智能定位生成的用例**天然走语义化执行机制**,与「新建会议-语义化重写示范」同级别稳定。
同时解决 HANDOFF P2 遗留的"智能定位 API 与用例执行路径统一"——两套逻辑共用一条解析链,一次开发双端受益。
---
## 二、需求目标
### 2.1 核心目标
智能定位产物从 `{selectors, params.selector}` 升级为 `{selectors, semantic, verify?}`,生成语义化用例并稳定执行。
### 2.2 量化指标
| 指标 | 现状 | 目标 |
|------|------|------|
| 定位结果回填 semantic 目标的步骤占比 | 0% | ≥80% |
| 智能定位生成用例的执行通过率 | 依赖 keyword 评分,不稳定 | 复杂用例连跑两次全绿、零残留 |
| 跨页误配次数 | 频繁(靠补丁逐个修) | 借助 scope 校验根治 |
| Claude 调用次数/用例 | 候选≥1 即调 | 仅真歧义时调用(规则优先短路) |
### 2.3 不包含的范围
- **finally 数据自清理自动生成**:智能定位仅生成主流程步骤;`run_on=finally` 的 api_call 清理步骤由用户按示范用例模板手动追加
- **自愈合机制**(选择器失效自动修复):中期目标,本次不实施
- **视觉定位(CV/OCR)**:长期目标,本次不实施
---
## 三、功能需求
### 3.1 需求 1:语义解析优先定位(反向复用解析链)
**模块**: `backend/app/services/smart_locate_service.py`
#### 3.1.1 功能描述
对每个步骤的定位流程,从"关键词打分优先"改为"**语义解析优先**":
```
当前页面 → recognize_current_page 得 scope
→ 构造候选 semantic 目标(步骤名 + action 推断)
→ semantic_resolver.resolve_step(page, step, scope, ctx) 命中 chain?
├─ 命中 → 用 chain 实际执行(复用 _execute_step)→ 执行成功
│ = 该 semantic 目标已通过执行期解析器验证 → 返回
└─ 未命中 → 走现有 keyword 定位 + 执行验证 → 成功后反向生成 semantic
```
#### 3.1.2 关键保证
生成的 semantic 目标**必须经 `resolve_step` 命中并实际执行成功**才返回。落库后执行必稳——复制示范用例"不是选择器正确,而是语义目标能稳定解析并执行"的成功经验。
#### 3.1.3 影响范围
- `backend/app/services/smart_locate_service.py`
### 3.2 需求 2:semantic 目标生成规则
**模块**: `backend/app/services/smart_locate_service.py`
#### 3.2.1 功能描述
定位成功后生成 semantic 目标,字段推断规则:
| 字段 | 推断来源 |
|------|---------|
| `type` | action 映射 + 候选元素 tag/class/role 反推:fill→input/textarea;`.el-checkbox`/`input[type=checkbox]` 且父容器 table→row_checkbox 否则 checkbox;`.el-tabs__item`/`role=tab`→tab;图标/抽屉→icon;`<button>`→button;表格行→row;其余 button |
| `text` | 语义键:步骤名核心词(剔除动作词和动态值),作执行期 v2 键 / pattern / keyword 查找文本 |
| `scope` | `recognize_current_page` 识别的页面 id;识别不到留空(执行期降级安全) |
| `contains` | 步骤名"xxx:值"模式中的锚点值(动态数据用 `{__CTX:key__}` 占位),供 pattern `:has-text()` 精确命中 |
| `container` | 候选元素父容器(`_detect_parent_container` / `_generate_combined_selectors` 运行时容器信息)→ `{type, selector}` |
| `nth` | 步骤名含"第 N 个"时提取;否则不生成 |
#### 3.2.2 影响范围
- `backend/app/services/smart_locate_service.py`
- `backend/app/routers/smart_locate.py``SmartLocateResult` schema 增加 `semantic`/`verify` 字段)
### 3.3 需求 3:条件 verify 生成
**模块**: `backend/app/services/smart_locate_service.py`
#### 3.3.1 功能描述
定位执行后**观察到确定状态变化才生成 verify**
| 观察到的状态 | 生成的 verify |
|------|------|
| 点击后出现成功提示(`.el-message--success` 等) | `{type: "text_visible", text: 提示文案}` |
| 点击后出现弹窗/抽屉/确认框 | `{type: "dialog"}`(或带 text) |
| 点击后出现特定元素(如 `.room .user_list`) | `{type: "element_visible", selector: ...}` |
**保守策略**:观察不到确定状态变化则不生成 verify——避免生成错误断言导致执行失败。verify 用于防假通过,不引入假失败。
#### 3.3.2 影响范围
- `backend/app/services/smart_locate_service.py`
### 3.4 需求 4:动态数据 CTX 化
**模块**: `backend/app/services/smart_locate_service.py`
#### 3.4.1 功能描述
步骤名含"xxx:值"模式时,把值识别为动态数据 → 替换为 `{__CTX:key__}`,初始值写入用例级 `parameters[key]`,保证创建/断言/清理同名(对齐示范用例 `meetingName/room/participant` 经验)。仅对明显动态值生效,静态值保留原样。
#### 3.4.2 影响范围
- `backend/app/services/smart_locate_service.py`
- 前端 `Cases.vue batchLocate` 返回的 `parameters` 合并进用例参数
### 3.5 需求 5:前端回填 semantic
**模块**: `frontend/src/views/Cases.vue`, `frontend/src/api/elementLocate.ts`
#### 3.5.1 功能描述
`batchLocate()` 处理定位结果时,除现有 `params.selector` / `locator_type` / `locator_value` 回填外,**合并 `semantic`/`verify` 进步骤并落库**
```ts
updatedSteps = steps.map(s => {
const r = result.results.find(r => r.order === s.order && r.success)
if (r) {
return { ...s, params: {...}, locator_type, locator_value,
semantic: r.semantic ?? s.semantic,
verify: r.verify ?? s.verify }
}
return s
})
```
保存后编辑模式自动切 semantic(已有判定:任一步有 `semantic.text` 即切)。
#### 3.5.2 复用(无需新建)
- `CaseStepEditor.vue` 现有 semantic/verify/run_on 编辑 UI
- `types/case.ts``SemanticTarget` / `StepVerify` 类型已完备
#### 3.5.3 影响范围
- `frontend/src/views/Cases.vue`
- `frontend/src/api/elementLocate.ts`
### 3.6 需求 6:配套数据(可选增强)
**模块**: `backend/app/data/page_url_mapping.json`, `backend/app/data/elements_mapping.json`
#### 3.6.1 功能描述
提升 scope 覆盖度与映射表命中率:
1. **注册 `meeting_list` 会议列表页 scope**(url_patterns + fingerprint)——当前注册表仅 create_meeting/login/home/meeting_detail 4 页,会议列表无法被 `recognize_current_page` 识别
2. **映射表 v2 页级键对齐**——当前数据层是 v1 扁平结构,service 内存归组存在"会议子页键归到会议预约"的 scope 对齐隐患(`lookup_by_key("meeting_list", key)` 大概率落空)
#### 3.6.2 影响范围
- `backend/app/data/page_url_mapping.json`
- `backend/app/data/elements_mapping.json`
---
## 四、非功能需求
### 4.1 性能要求
| 指标 | 要求 |
|------|------|
| 语义解析优先命中路径 | 命中则跳过关键词打分 + Claude,单步耗时下降 |
| Claude 调用 | 仅规则无法判定时调用(同 semantic_resolver._arbitrate 短路) |
| 智能定位总耗时 | 不高于现状(语义优先路径更短) |
### 4.2 兼容性要求
- **向后兼容**:现有 `selectors` / `params.selector` / `locator_type` / `locator_value` 输出保留,老前端不回归
- **老用例零影响**:生成的 semantic 仅附加到新定位的步骤;已有步骤(含已有选择器)原样保留
- **不修改解析链**`semantic_resolver.py` / `playwright_executor.py` / `element_mapping_service.py` / `page_url_service.py` 只消费不修改
- 数据库 schema 无变更
### 4.3 可维护性要求
- semantic 生成规则集中在 `smart_locate_service.py` 新增辅助方法,纯函数可单测
- 生成规则与执行期解析链解耦(生成器只构造目标,解析器负责验证)
---
## 五、验收标准
### 5.1 功能验收
| # | 验收项 | 验收标准 |
|---|--------|---------|
| 1 | 语义解析优先 | 定位步骤命中语义路径时不走关键词打分,且实际执行成功 |
| 2 | semantic 生成 | 定位结果含 `semantic`(type/scope/text/contains/container),生成后经 `resolve_step` 命中验证 |
| 3 | 条件 verify | 观察到确定状态变化才生成 verify,观察不到不生成 |
| 4 | 前端回填 | `batchLocate` 后步骤含 `semantic`/`verify` 并落库,编辑弹窗自动切语义模式 |
| 5 | 动态数据 CTX | "xxx:值"步骤值转 `{__CTX:key__}`,初始值进 `parameters` |
### 5.2 执行验收(关键)
| # | 验收项 | 验收标准 |
|---|--------|---------|
| 1 | 智能定位生成用例 | 用「新建会议」相关用例调智能定位,生成 semantic 目标并落库 |
| 2 | 连跑两次无残留 | 生成的用例真实执行连跑两次全绿、零残留(对照示范用例验收标准) |
| 3 | 回归 | 老用例(无 semantic 字段)执行不受影响,全部通过 |
### 5.3 测试要求
- 后端 pytest:新增 semantic 生成规则纯函数单测(`_build_candidate_semantic` / `_infer_semantic_from_element` / `_detect_verify_after`
- 前端 `npm run build` 通过
- 已知预存坏 `tests/test_api.py` 用例以 `--deselect` 排除
---
## 六、风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| 生成的 semantic.text 过拟合当前 DOM | 中 | 执行漂移 | 强制"resolve_step 命中 + 实际执行成功才返回" |
| verify 生成错误导致执行失败 | 中 | 假失败 | 保守策略:仅生成观察到的确定状态断言 |
| scope 覆盖不全(注册表仅 4 页) | 中 | 部分步骤 scope 留空 | 识别不到时 semantic.scope 留空,执行期语义解析降级安全 |
| 老用例/老范式回归 | 低 | 存量用例受影响 | 新逻辑仅在智能定位流程启用,keyword 路径与执行器零改动 |
---
## 七、相关文档
| 文档 | 路径 |
|------|------|
| 复杂用例通用执行机制 PRD | `Docs/PRD/需求文档/用例管理/_PRD_复杂用例通用执行机制_语义化用例执行.md` |
| 智能定位准确率提升 PRD | `Docs/PRD/需求文档/用例管理/_PRD_智能定位准确率提升_聚焦核心快速见效.md` |
| 智能定位执行稳定性优化 PRD | `Docs/PRD/需求文档/用例管理/_PRD_智能定位功能执行稳定性优化.md` |
| UI 自动化交接文档 | `HANDOFF_UI自动化.md` |
| 语义化示范用例构造脚本 | `backend/scripts/create_semantic_meeting_case.py` |
---
*本文档由 Claude Code 于 2026-08-18 创建,评审通过后走 prd-plan 生成执行计划。*
# 执行计划:智能定位语义化升级 - 语义化用例生成器
> **文档版本**: v1.0
> **创建日期**: 2026-08-18
> **关联PRD**: `_PRD_智能定位语义化升级_语义化用例生成器.md`
> **预计工期**: 3 个阶段(后端生成 → 前端回填 → 数据配套),每阶段独立交付验证
---
## 一、执行概述
### 1.1 执行目标
把智能定位从"裸选择器提取器"升级为"**语义化用例生成器**":定位阶段复用执行期的 `semantic_resolver.resolve_step` 解析链,产出 `semantic` 语义目标(type/scope/text/contains/container)+ 条件 `verify`,让智能定位生成的用例**天然走语义化执行机制**,与「新建会议-语义化重写示范」同级别稳定。
1. 语义解析优先定位(反向复用解析链,命中即执行验证)
2. semantic 目标生成规则(type/text/scope/contains/container/nth)
3. 条件 verify 生成(防假通过,不引入假失败)
4. 动态数据 CTX 化(`{__CTX:key__}` + `parameters` 初值)
5. 前端 batchLocate 回填 semantic/verify 并落库
6. 配套数据增强(会议列表 scope 注册、映射表 v2 键对齐)
**硬性约束**:不改动解析链(`semantic_resolver.py`/`playwright_executor.py`/`element_mapping_service.py`/`page_url_service.py` 只消费不修改);老用例(无 semantic 字段)执行零影响。
### 1.2 改动范围
| 模块 | 文件 | 改动类型 |
|------|------|----------|
| 智能定位服务 | `backend/app/services/smart_locate_service.py` | **增强**(核心:semantic 生成) |
| 智能定位路由 | `backend/app/routers/smart_locate.py` | 增强(schema 加字段) |
| 前端定位 API | `frontend/src/api/elementLocate.ts` | 增强(类型) |
| 前端用例页 | `frontend/src/views/Cases.vue` | 增强(batchLocate 回填) |
| 页面注册表 | `backend/app/data/page_url_mapping.json` | 增强(meeting_list scope,可选) |
| 元素映射表 | `backend/app/data/elements_mapping.json` | 增强(v2 页级键,可选) |
### 1.3 不改动的部分
- `semantic_resolver.py` / `playwright_executor.py` / `element_mapping_service.py` / `page_url_service.py`(解析链已稳定,只消费)
- `keyword_matcher.py` / `selector_extractor.py` / `claude_service.py`(复用现有能力)
- 数据库 schema(无新表/新列)
- 现有 `selectors` / `params.selector` / `locator_type` / `locator_value` 输出格式(双轨兼容)
- 被测系统(5.44/5.69)无改动
---
## 二、任务分解与实施计划
### 阶段 1:后端 semantic 生成(Phase A)
#### 任务 1.1:候选 semantic 目标构造 `_build_candidate_semantic`
**文件**: `backend/app/services/smart_locate_service.py`
**实施步骤**
1. 新增方法,从步骤名 + action 构造候选 semantic 目标:
```python
def _build_candidate_semantic(self, step: Dict, page) -> Optional[Dict]:
"""
从步骤名/action 构造候选 semantic 目标(语义解析优先路径用)
- type: action 映射(fill→input,click→button 缺省)
- text: 步骤名核心词(剔除动作词、动态值、标点)
- scope: recognize_current_page 识别结果(识别不到 None)
- contains: 步骤名"xxx:值"模式中的锚点值
"""
```
2. `text` 提取:复用 `keyword_matcher` 的动作词剥离思路,取核心词(如"确定创建"/"会议名称"/"预定会议")。**剔除动态值**`:` 后的值,避免污染语义键)。
3. `type` 缺省推断:`action=="fill"``input``action=="click"``button`(后续 `_infer_semantic_from_element` 再按元素细化)。
4. `scope` 复用 `get_page_url_service().recognize_current_page(page)`;识别不到 → `None`(该字段留空,执行期降级安全)。
**验收标准**
- 步骤名"点击【确定创建】按钮" → `{type: "button", text: "确定创建"}`
- 步骤名"勾选会议室:北京展厅会议室" → `{type: "button", text: "勾选会议室", contains: "北京展厅会议室"}`
- scope 识别不到时字段留空不抛异常
#### 任务 1.2:语义解析优先 `_try_semantic_execute`
**文件**: `backend/app/services/smart_locate_service.py`
**实施步骤**
1. 新增方法,构造候选 semantic → `resolve_step` → 命中则执行验证:
```python
def _try_semantic_execute(self, page, step: Dict, scope: Optional[str]) -> Optional[Dict]:
"""
语义解析优先:构造候选 semantic → resolve_step 命中 chain
→ 复用 _execute_step 执行 → 成功则返回 {semantic, selectors, params}
失败返回 None(调用方走 keyword 定位路径)
"""
semantic = self._build_candidate_semantic(step, page)
if not semantic:
return None
ctx = ExecutionContext({})
chain = self._semantic_resolver.resolve_step(page, step, scope, ctx) or []
if not chain:
return None
# 用解析链构造临时结果并执行验证
probe = dict(step, params={**step.get("params", {}), "selectors": chain})
if self._execute_step(probe):
return {"semantic": semantic, "chain": chain, "selectors": chain}
return None
```
2. 依赖注入:`__init__` 增加 `self._semantic_resolver = get_semantic_resolver()``self._page_url_service = get_page_url_service()`
3. `_execute_step` 复用现有实现(fill/click/wait/select + 弹窗检测)。
**验收标准**
- 语义路径命中时返回 `{semantic, chain}`,步骤不进 keyword 打分路径
- 语义路径未命中/执行失败 → 返回 None,调用方回退现有路径,行为不变
#### 任务 1.3:定位成功后反向生成 `_infer_semantic_from_element`
**文件**: `backend/app/services/smart_locate_service.py`
**实施步骤**
1. 新增方法,从候选元素反推细化 semantic:
```python
def _infer_semantic_from_element(self, step: Dict, element_info: Dict,
container: Optional[Dict]) -> Dict:
"""
反向生成 semantic(keyword 定位成功后)
- type: element_info.tag/className/role + action 反推
input/textarea→input;.el-checkbox/input[type=checkbox] 且容器 table→row_checkbox
否则 checkbox;.el-tabs__item/role=tab→tab;img/i/icon→icon;button→button;
tr/.el-table__row→row;其余 button
- container: 父容器信息 {type, selector}
- text/contains/scope: 复用 _build_candidate_semantic 结果
"""
```
2. 容器推断:复用 `keyword_matcher._detect_parent_container(page, element)`(返回 `{container: 'dialog'/'table'/...}`)与 `selector_extractor._generate_combined_selectors` 的运行时容器信息,映射为 `{type, selector}`
3. 返回值含 `type/text/scope/contains/container` 全字段(缺失留空)。
**验收标准**
- fill 步骤 → `type: "input"`
- 点击 `.el-table``.el-checkbox``type: "row_checkbox"` + `container: {type: "table", selector: ".el-table"}`
- 点击 `.el-tabs__item``type: "tab"`
#### 任务 1.4:条件 verify 生成 `_detect_verify_after`
**文件**: `backend/app/services/smart_locate_service.py`
**实施步骤**
1. 新增方法,定位执行后检测确定状态变化:
```python
def _detect_verify_after(self, step: Dict, page) -> Optional[Dict]:
"""
观察到确定状态变化才生成 verify(保守策略)
- 成功提示 .el-message--success → {type: "text_visible", text: 提示文案}
- 弹窗/抽屉/确认框出现 → {type: "dialog"}(带 title 文本)
- 特定元素出现(如 .room .user_list)→ {type: "element_visible", selector}
- 观察不到 → None
"""
```
2. 复用现有验证类步骤的检测逻辑(`is_verification_step` 分支已有 `.el-message--success` 等检测),抽成通用方法。
3. verify 类型对齐前端 `ASSERT_TYPE_OPTIONS`(text_visible / element_visible / dialog)。
**验收标准**
- 点击"确定创建"后出现"创建成功"提示 → `{type: "text_visible", text: "创建成功"}`
- 观察不到状态变化 → 返回 None(不生成错误断言)
#### 任务 1.5:动态数据 CTX 化 `_ctf_param`
**文件**: `backend/app/services/smart_locate_service.py`
**实施步骤**
1. 新增方法,识别步骤名动态值:
```python
def _ctf_param(self, step_name: str) -> Tuple[Optional[str], Optional[str]]:
"""
步骤名含"xxx:值"模式 → (CTX 键名, 初值);无则 (None, None)
值识别为动态数据(非纯静态词)时替换为 {__CTX:key__}
"""
```
2. `locate_steps` 收集所有 CTX 键 → 用例级 `parameters`(返回给调用方合并)。
3. 生成的步骤名中动态值替换为 `{__CTX:key__}`(如"勾选会议室:北京展厅会议室"→"勾选会议室:{__CTX:room__}"),初值 `parameters["room"]="北京展厅会议室"`
**验收标准**
- "勾选会议室:北京展厅会议室" → 步骤名含 `{__CTX:room__}`,parameters 含 `room: "北京展厅会议室"`
- 静态值步骤(无 `:`)原样保留
#### 任务 1.6:定位流程整合 + schema 扩展
**文件**: `backend/app/services/smart_locate_service.py`, `backend/app/routers/smart_locate.py`
**实施步骤**
1. `_locate_single_step` 流程整合(在原 keyword 定位之前插入语义优先路径):
```
定位流程(改造后):
1. 已有选择器 → 直接使用(现状保留)
2. navigate / 登录步骤 → 现状保留
3. 验证类步骤 → 现状保留
4. 【新增】_try_semantic_execute 命中 → 返回 {selectors, semantic, verify}
5. 【新增】keyword 定位成功 → _infer_semantic_from_element 反向生成 semantic
+ _detect_verify_after 条件生成 verify
6. keyword 定位失败 → 现状(语义推断回退 / 失败标记)
```
2. 返回值增加 `semantic` / `verify` 字段(在 `_locate_single_step` 结果 dict 中)。
3. `locate_steps` 收集 `parameters``_ctf_param` 产出)附加到返回结果。
4. `backend/app/routers/smart_locate.py``SmartLocateResult` schema 增加:
```python
semantic: Optional[Dict[str, Any]] = Field(None, description="语义目标")
verify: Optional[Dict[str, Any]] = Field(None, description="状态验证")
```
`SmartLocateResponse` 增加 `parameters: Dict[str, Any] = Field(default_factory=dict)`
**验收标准**
- 定位结果含 `semantic`(命中语义路径或 keyword 成功步骤)
- 老步骤(已有选择器/登录/navigate)结果格式与改造前一致
- API 响应 schema 校验通过
### 阶段 2:前端回填(Phase B)
#### 任务 2.1:elementLocate.ts 类型扩展
**文件**: `frontend/src/api/elementLocate.ts`
**实施步骤**
1. `SmartLocateResult`(L156-183)增加:
```ts
semantic?: Record<string, any>
verify?: Record<string, any>
```
2. `SmartLocateResponse`(L188-199)增加:
```ts
parameters?: Record<string, any>
```
**验收标准**`npm run build` 类型检查通过。
#### 任务 2.2:Cases.vue batchLocate 回填 semantic
**文件**: `frontend/src/views/Cases.vue`
**实施步骤**
1. `batchLocate()`(L1069-1154)步骤回写逻辑(L1110-1134)扩展:
```ts
const updatedSteps = steps.map((s: any) => {
const r = result.results.find((x: any) => x.order === s.order && x.success)
if (r) {
const updatedParams = { ...(s.params || {}), ...(r.params || {}) }
if (r.selectors?.primary) {
updatedParams.selector = r.selectors.primary
}
return {
...s,
params: updatedParams,
locator_type: 'css',
locator_value: r.selectors?.primary || '',
// 新增:回填 semantic / verify(保留步骤已有值)
semantic: r.semantic || s.semantic,
verify: r.verify || s.verify,
}
}
return s
})
```
2. 用例 `parameters` 合并:`result.parameters` 与用例现有 parameters 合并后随 update 提交。
3. 落库后编辑模式自动切 semantic(已有判定 L872-875:任一步有 `semantic.text` 即切,无需改动)。
**验收标准**
- 智能定位后步骤含 `semantic`/`verify` 并落库
- 编辑弹窗打开自动切语义模式,`CaseStepEditor` 显示 semantic 表单
- 老用例(无 semantic 返回)行为不变
### 阶段 3:配套数据增强(Phase C,可选)
#### 任务 3.1:会议列表 scope 注册
**文件**: `backend/app/data/page_url_mapping.json`
**实施步骤**
1. 注册 `meeting_list` 页:url_patterns(`meetingV3.*(Message|message)` 或按抓包 URL 校准)+ fingerprint(`.el-table` 会议列表容器,min_count 1)。
2. 参照 `create_meeting``match_rules`/`skip_steps` 补直达配置(可选)。
**验收标准**`recognize_current_page` 能识别会议列表页;不影响既有 4 页识别。
#### 任务 3.2:元素映射表 v2 页级键对齐
**文件**: `backend/app/data/elements_mapping.json`
**实施步骤**
1. 核对 `_page_id_for` 归组结果,把"会议预约"页下会议子页元素(会议列表/会议模板/会议室/个人日程)在键前缀或 tier 上对齐到独立页级 token,使 `lookup_by_key("meeting_list", key)` 能命中。
**验收标准**`lookup_by_key("meeting_list", "会议列表-表格")` 等命中;语义化执行回归通过。
---
## 三、验收标准
### 3.1 功能验收
| # | 验收项 | 验收标准 |
|---|--------|---------|
| 1 | 语义解析优先 | 命中语义路径的步骤不经 keyword 打分,且实际执行成功 |
| 2 | semantic 生成 | 定位结果含 `semantic`(type/scope/text/contains/container),经 `resolve_step` 命中验证 |
| 3 | 条件 verify | 观察到确定状态变化才生成,观察不到不生成 |
| 4 | 前端回填 | `batchLocate` 后步骤含 semantic/verify 并落库,编辑弹窗自动切语义模式 |
| 5 | 动态数据 CTX | "xxx:值"步骤值转 `{__CTX:key__}`,初值进 `parameters` |
### 3.2 执行验收(关键)
| # | 验收项 | 验收标准 |
|---|--------|---------|
| 1 | 智能定位生成用例 | 「新建会议」相关用例调智能定位 → 生成 semantic 目标 → 落库 |
| 2 | 连跑两次无残留 | 生成用例真实执行连跑两次全绿、零残留 |
| 3 | 回归 | 老用例(无 semantic)执行全部通过,不受影响 |
---
## 四、测试计划
### 4.1 单元测试(后端)
- `_build_candidate_semantic`:步骤名 → semantic 候选(含动态值剔除、scope 留空)
- `_infer_semantic_from_element`:元素信息 → type/container 反推
- `_detect_verify_after`:状态观察 → verify / None
- `_ctf_param`:动态值识别 → (CTX 键, 初值)
运行:`cd backend && pytest tests/ -v --deselect tests/test_api.py`(预存坏用例排除)
### 4.2 构建测试
- `cd frontend && npm run build`
### 4.3 端到端测试
1. 用「新建会议」相关用例步骤调 `POST /api/element/smart-locate`
2. 校验返回的 `semantic`/`verify` 字段
3. 落库 → 真实执行连跑两次 → 校验全绿 + 5.44 会议列表零残留
4. 回归:老用例执行
---
## 五、风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| 生成的 semantic.text 过拟合当前 DOM | 中 | 执行漂移 | 强制"resolve_step 命中 + 实际执行成功才返回" |
| verify 生成错误导致执行失败 | 中 | 假失败 | 保守策略:仅生成观察到的确定状态断言 |
| scope 覆盖不全(注册表仅 4 页) | 中 | 部分步骤 scope 留空 | 识别不到时 semantic.scope 留空,执行期语义解析降级安全 |
| 老用例/老范式回归 | 低 | 存量用例受影响 | 新逻辑仅在智能定位流程启用,keyword 路径与执行器零改动 |
| CTX 化误伤静态值 | 低 | 步骤名变化 | 仅"xxx:值"模式 + 值非纯静态词才替换 |
---
## 六、实施记录
| 日期 | 任务 | 状态 | 说明 |
|------|------|------|------|
| 2026-08-18 | PRD 评审通过 | ✅ | 执行计划生成 |
| 2026-08-18 | 阶段 1 后端 semantic 生成 | ✅ | `smart_locate_service.py``_build_candidate_semantic` / `_try_semantic_execute`(链首 DOM 校验)/ `_infer_semantic_from_element` / `_infer_semantic_from_pattern`(模式值作 text,动态值 CTX,新增 `_extract_pattern_value` / `_ctxify_if_dynamic`)/ `_validate_semantic_resolves`(反向生成经执行期解析器校验,row_checkbox→row 降级)/ `_ctf_param`(语义优先路径同样收集参数)/ `_execute_step` 全候选点击链 + force 重试 |
| 2026-08-18 | schema 扩展 | ✅ | `routers/smart_locate.py` `SmartLocateResult``semantic`/`verify` |
| 2026-08-18 | keyword_matcher 兜底 | ✅ | `find_element_by_semantic` 兜底 `return None, []`,修复 tuple 解包崩溃 |
| 2026-08-18 | 阶段 2 前端回填 | ✅ | `elementLocate.ts` 类型扩展 + `Cases.vue` `batchLocate` 回填 semantic/verify;`npm run build` 通过 |
| 2026-08-18 | 阶段 3 配套数据 | ✅ | `page_url_mapping.json` 会议列表 scope 注册(5 页加载正常) |
| 2026-08-18 | 单测 | ✅ | `test_smart_locate_semantic.py` 37 用例全过(新增模式语义生成/校验降级/`_extract_pattern_value`/`_ctxify_if_dynamic`);全量 `pytest --ignore=test_api.py` 161 过 |
| 2026-08-18 | 端到端验证 | ✅ | `verify_smart_locate_semantic.py` 连跑两次 7/7 全绿;4 个步骤产出 semantic(input×2 + row_checkbox + tab),3 个动态值 CTX 化(param1 会议名/param2 会议室/param3 议题),语义与示范用例 `create_semantic_meeting_case.py` 同构 |
---
## 七、后续工作
- 智能定位自动生成 `finally` api_call 清理步骤(对齐示范用例,本次不实施)
- 自愈合机制(选择器失效自动修复)
- 智能定位生成结果的前端预览(semantic 目标可视化)
---
## 八、附录
### 8.1 关键复用点索引
| 复用点 | 位置 |
|------|------|
| `resolve_step(page, step, scope, ctx)` 解析链入口 | `backend/app/services/semantic_resolver.py` |
| `recognize_current_page(page)` scope 识别 | `backend/app/services/page_url_service.py:262` |
| `ExecutionContext` 上下文变量 | `backend/app/executors/ui_context.py` |
| `match_element_by_keywords` / `get_candidate_details` / `_detect_parent_container` | `backend/app/services/keyword_matcher.py` |
| `extract_element_info` / `_generate_combined_selectors` | `backend/app/services/selector_extractor.py` |
| `/api/debug/resolve` 无侵入语义解析验证接口 | `backend/app/routers/debug.py` |
| semantic 步骤构造模板 | `backend/scripts/create_semantic_meeting_case.py` |
| 执行器 semantic 消费点 | `backend/app/executors/playwright_executor.py:1170-1194` |
### 8.2 相关文档
| 文档 | 路径 |
|------|------|
| 本 PRD | `Docs/PRD/需求文档/用例管理/_PRD_智能定位语义化升级_语义化用例生成器.md` |
| 复杂用例通用执行机制 PRD/计划 | `Docs/PRD/需求文档/用例管理/_PRD_复杂用例通用执行机制_语义化用例执行.md` |
| UI 自动化交接文档 | `HANDOFF_UI自动化.md` |
---
*本文档由 Claude Code 于 2026-08-18 生成,评审通过后按任务分解进入 prd-code 实施。*
......@@ -57,6 +57,19 @@
"fingerprint": [
{"selector": ".detail_top", "min_count": 1}
]
},
{
"id": "meeting_list",
"name": "会议列表",
"url": "https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll",
"url_patterns": [
"meetingV3.*meetingAll",
"meetingV3.*MeetingList",
"meeting/list"
],
"fingerprint": [
{"selector": ".meeting_list", "min_count": 1}
]
}
]
}
......@@ -58,6 +58,9 @@ class SmartLocateResult(BaseModel):
claude_enhanced: bool = Field(default=False, description="是否经过 Claude 语义增强")
claude_confidence: float = Field(default=0.0, description="Claude 置信度")
claude_reason: str = Field(default="", description="Claude 选择理由")
# ⚠️ 阶段6升级:语义化用例生成
semantic: Optional[Dict[str, Any]] = Field(None, description="生成的 semantic 语义目标")
verify: Optional[Dict[str, Any]] = Field(None, description="条件生成的 verify 状态验证")
class SmartLocateResponse(BaseModel):
......@@ -67,6 +70,8 @@ class SmartLocateResponse(BaseModel):
located_steps: int = Field(..., description="成功定位步骤数")
results: List[SmartLocateResult] = Field(default_factory=list, description="定位结果列表")
message: str = Field(default="", description="总体说明")
# ⚠️ 阶段6升级:动态数据 CTX 化收集的参数初值
parameters: Dict[str, str] = Field(default_factory=dict, description="动态数据参数初值(param{N}: 值)")
class VerifyStepRequest(BaseModel):
......@@ -142,16 +147,18 @@ async def smart_locate(
def _sync_locate():
"""同步执行智能定位(在线程池中运行)"""
service = SmartLocateService()
return service.locate_steps(
results = service.locate_steps(
steps=steps_data,
auto_login=request.auto_login,
navigate_menu=request.navigate_menu,
page_url=request.page_url,
use_claude=request.use_claude
)
# ⚠️ 阶段6升级:返回动态数据 CTX 化收集的参数初值
return results, dict(service.collected_parameters)
# 执行并等待结果
results = await loop.run_in_executor(None, _sync_locate)
results, parameters = await loop.run_in_executor(None, _sync_locate)
# 统计成功数量
located_count = sum(1 for r in results if r.get('success'))
......@@ -162,6 +169,7 @@ async def smart_locate(
total_steps=len(request.steps),
located_steps=located_count,
results=[SmartLocateResult(**r) for r in results],
parameters=parameters,
message=f"成功定位 {located_count}/{len(request.steps)} 个步骤"
)
......
......@@ -1177,6 +1177,9 @@ def find_element_by_semantic(
except Exception:
continue
# 兜底:未匹配到任何元素(此前返回 None 会导致调用方 tuple 解包崩溃)
return None, []
def resolve_selectors(
page,
......
......@@ -11,6 +11,7 @@
import logging
import os
import re
import base64
from typing import List, Dict, Any, Optional
from datetime import datetime
......@@ -23,7 +24,8 @@ from app.services.keyword_matcher import (
is_verification_step,
match_element_by_keywords,
find_element_by_semantic,
get_candidate_details # 新增:获取候选元素详情
get_candidate_details, # 新增:获取候选元素详情
_detect_parent_container, # 阶段6升级:父容器推断(semantic.container 反推)
)
from app.services.selector_extractor import (
extract_selectors,
......@@ -60,6 +62,14 @@ class SmartLocateService:
self.executor: Optional[PlaywrightExecutor] = None
self.screenshot_dir = settings.SCREENSHOT_DIR
self.use_claude: bool = True # 是否启用 Claude 语义增强
# ⚠️ 阶段6升级:语义化用例生成器依赖(懒加载,避免 import 循环)
self._semantic_resolver: Optional[Any] = None
self._page_url_service: Optional[Any] = None
self._semantic_enabled_flag: Optional[bool] = None
# 动态数据 CTX 化收集(fill 步骤值 → parameters 初值)
self.collected_parameters: Dict[str, str] = {}
# 动态值 → CTX key 去重映射(同值复用同一 key,保证创建/断言/清理同名)
self._ctx_value_map: Dict[str, str] = {}
def locate_steps(
self,
......@@ -101,6 +111,10 @@ class SmartLocateService:
self.use_claude = use_claude and settings.CLAUDE_ENABLED
logger.info(f"开始智能定位: {len(steps)} 个步骤, auto_login={auto_login}, navigate_menu={navigate_menu}")
# 阶段6升级:重置动态数据收集
self.collected_parameters = {}
self._ctx_value_map = {}
results = []
try:
......@@ -164,6 +178,13 @@ class SmartLocateService:
result['message'] = f'定位成功但执行验证失败: {result.get("message", "")}'
# 后续步骤全部跳过
skip_remaining = True
else:
# ⚠️ 阶段6升级:执行成功后观察到确定状态变化才条件生成 verify
# (此时点击已发生,可检测成功提示/弹窗/抽屉;观察不到不生成,防假失败)
verify = self._detect_verify_after(step, self.executor._page)
if verify:
result['verify'] = verify
logger.info(f"步骤 {step.get('order')} 条件生成 verify: {verify}")
logger.info(f"智能定位完成: {len(results)} 个步骤")
......@@ -576,6 +597,35 @@ class SmartLocateService:
result['message'] = f'验证类步骤,但未找到操作结果提示元素或列表搜索框'
return result
# ⚠️ 阶段6升级:语义解析优先(反向复用执行期解析链)
# 静态步骤(无动态值)先构造候选 semantic → resolve_step 命中 → 实际执行验证
# 成功 = 该 semantic 目标已通过执行期解析器验证,落库后执行必稳
# 动态值步骤(含"xxx:值")不走此路径(避免语义键被值污染),由 keyword 定位后 _infer 反推
if self._semantic_enabled():
sem_result = self._try_semantic_execute(page, step, self._get_page_scope(page))
if sem_result:
chain = sem_result['chain']
result['success'] = True
result['selectors'] = {
'primary': chain[0],
'candidates': [
{'type': 'css', 'value': sel, 'confidence': 0.95, 'priority': i + 1}
for i, sel in enumerate(chain)
]
}
result['params']['selector'] = chain[0]
result['semantic'] = sem_result['semantic']
result['message'] = f'语义解析优先定位成功: {chain[0]}'
logger.info(f"步骤 {order} 语义解析优先命中: {result['semantic']}")
# ⚠️ 阶段6升级:fill 动态值同样 CTX 化(语义优先路径同样收集 parameters 初值)
if action == 'fill' and result['params'].get('value'):
self._ctf_param(result['params'])
# 截图
self._capture_screenshot(page, order, result)
return result
# 提取关键词
keywords = extract_keywords(name)
logger.debug(f"提取关键词: {keywords}")
......@@ -595,12 +645,25 @@ class SmartLocateService:
result['params']['selector'] = pattern_result['selectors'][0]
result['message'] = f"模式匹配成功: {pattern_result['type']}"
logger.info(f"步骤 {order} 匹配特殊模式: {pattern_result['type']}")
# ⚠️ 阶段6升级:模式步骤同样生成 semantic(row_checkbox/tab + contains 锚点值)
if self._semantic_enabled():
semantic = self._infer_semantic_from_pattern(step, pattern_result, page)
if semantic:
result['semantic'] = semantic
logger.info(f"步骤 {order} 模式步骤生成 semantic: {semantic}")
# 截图
self._capture_screenshot(page, order, result)
return result
# 第一级:关键词直接匹配(返回候选列表)
# Claude 语义增强已能理解元素上下文,不再需要手动区分抽屉内/外
elements_list, selectors = match_element_by_keywords(page, keywords, action)
# ⚠️ 阶段6升级:定位成功元素引用(keyword 成功后反向生成 semantic 用)
located_element = None
if elements_list and selectors:
# === Phase 5: Claude 语义增强(单候选也验证) ===
if self.use_claude and len(elements_list) >= 1:
......@@ -697,10 +760,13 @@ class SmartLocateService:
# 提取元素信息(使用第一个元素)
if elements_list:
result['element_info'] = extract_element_info(elements_list[0])
located_element = elements_list[0]
else:
# 第二级:语义推断(回退策略,只返回单一元素)
element, selectors = find_element_by_semantic(page, keywords, action)
# (函数可能返回 None,防御性解包避免定位异常)
_sem_infer = find_element_by_semantic(page, keywords, action) or (None, [])
element, selectors = _sem_infer
if element and selectors:
result['success'] = True
......@@ -710,6 +776,7 @@ class SmartLocateService:
'candidates': selectors
}
result['element_info'] = extract_element_info(element)
located_element = element
result['claude_enhanced'] = False
result['message'] = f'语义推断成功: {primary_selector}'
logger.info(f"步骤 {order} 定位成功(语义推断): {primary_selector}")
......@@ -731,6 +798,19 @@ class SmartLocateService:
logger.info(f"步骤 {order} 自动修正 value: '{current_value}' -> '{extracted_value}'")
result['params']['value'] = extracted_value
# ⚠️ 阶段6升级:keyword 定位成功后反向生成 semantic 目标
# 该 semantic 已通过"实际定位 + 元素信息"双重验证,落库后走语义化执行机制
# (verify 由 locate_steps 在执行成功后统一条件生成,见 _detect_verify_after 调用点)
if result['success'] and result['selectors'].get('primary') and located_element is not None:
semantic = self._infer_semantic_from_element(step, located_element, page)
if semantic:
result['semantic'] = semantic
logger.info(f"步骤 {order} 反向生成 semantic: {semantic}")
# ⚠️ 阶段6升级:fill 步骤动态值 CTX 化(收集到 parameters 初值)
if action == 'fill' and result['success'] and result['params'].get('value'):
self._ctf_param(result['params'])
# 截图
try:
screenshot_path = os.path.join(
......@@ -788,8 +868,14 @@ class SmartLocateService:
'确定创建', '取消会议', '完成']
expects_dialog = any(kw in step_name for kw in dialog_keywords)
# 构建替代选择器列表(优先级从高到低)
# 构建替代选择器列表(优先级从高到低):
# 主选择器 + 定位结果中的完整候选列表(pattern/语义路径会产出多个候选,
# 仅尝试主选择器会漏掉真正可用的候补,如列表行的 tr:has-text 兜底)
click_selector_chain = [selector]
for cand in result.get('selectors', {}).get('candidates', []):
v = cand.get('value') if isinstance(cand, dict) else cand
if v and v != selector and v not in click_selector_chain:
click_selector_chain.append(str(v))
# 如果当前选择器是 div:has-text 格式,尝试补充 button:has-text
if 'div:visible:has-text' in selector or 'div:has-text' in selector:
......@@ -805,21 +891,31 @@ class SmartLocateService:
if 'input:visible' in selector:
click_selector_chain.append(f'button:has-text("{step_name}")')
# 逐级尝试点击
# 逐级尝试点击(每级:普通点击 → force 强点;缩短超时避免 30s 默认卡死)
click_success = False
actual_selector_used = None
for sel in click_selector_chain:
try:
page.wait_for_selector(sel, timeout=5000)
page.click(sel)
page.click(sel, timeout=5000)
page.wait_for_timeout(800)
logger.debug(f"点击成功: {sel}")
click_success = True
actual_selector_used = sel
break
except Exception as e:
logger.debug(f"选择器 {sel} 点击失败: {e}")
continue
# 普通点击失败(元素被遮挡/滚动区/等),重试 force 点击
# (对齐示范用例"勾选会议室 force=True"的经验:列表行勾选常需强点)
try:
page.click(sel, force=True, timeout=5000)
page.wait_for_timeout(800)
logger.debug(f"点击成功(force): {sel}")
click_success = True
actual_selector_used = sel
break
except Exception as e2:
logger.debug(f"选择器 {sel} 点击失败(普通+force): {e2}")
continue
if not click_success:
logger.warning(f"步骤 {order} 所有选择器点击均失败")
......@@ -885,6 +981,389 @@ class SmartLocateService:
logger.warning(f"执行步骤验证失败: {e}")
return False
# ==================== 阶段6升级:语义化用例生成 ====================
# 前导动作动词(语义文本提取时剔除,长词在前避免短词误剥)
_ACTION_VERBS = [
'点击', '输入', '填写', '键入', '选择', '打开', '进入', '切换到', '切换为', '切换',
'取消勾选', '勾选', '拖动', '滚动', '悬停', '等待', '查看', '展开', '收起', '刷新',
]
def _semantic_enabled(self) -> bool:
"""语义化生成器是否可用(懒加载,避免 import 循环;依赖缺失降级为旧路径)"""
if self._semantic_enabled_flag is None:
try:
from app.services.semantic_resolver import get_semantic_resolver
self._semantic_resolver = get_semantic_resolver()
from app.services.page_url_service import get_page_url_service
self._page_url_service = get_page_url_service()
self._semantic_enabled_flag = True
except Exception as e:
logger.warning(f"语义化生成器不可用,降级为旧 keyword 路径: {e}")
self._semantic_enabled_flag = False
return self._semantic_enabled_flag
def _get_page_scope(self, page) -> Optional[str]:
"""运行时页面身份识别(复用执行期 recognize_current_page)"""
if not self._semantic_enabled() or page is None:
return None
try:
rec = self._page_url_service.recognize_current_page(page)
scope_id = rec.get("id") if rec else None
if scope_id:
logger.debug(f"页面身份识别: {rec.get('name')}({scope_id})")
else:
logger.debug("页面身份未识别,semantic.scope 留空(执行期降级安全)")
return scope_id
except Exception as e:
logger.debug(f"页面身份识别异常: {e}")
return None
def _build_candidate_semantic(self, step: Dict[str, Any], scope: Optional[str]) -> Optional[Dict[str, Any]]:
"""
从步骤名/action 构造候选 semantic 目标(语义解析优先路径用)
仅静态步骤(无"xxx:值")调用——动态步骤由 keyword 定位后 _infer 反推
"""
name = step.get('name', '')
action = step.get('action', '')
text = self._extract_semantic_text(name)
if not text:
return None
semantic: Dict[str, Any] = {
'type': self._infer_element_type(action, None),
'text': text,
}
if scope:
semantic['scope'] = scope
nth = self._extract_nth(name)
if nth is not None:
semantic['nth'] = nth
return semantic
def _infer_semantic_from_pattern(self, step: Dict[str, Any], pattern_result: Dict[str, Any], page) -> Optional[Dict[str, Any]]:
"""模式匹配成功后生成 semantic(type 来自模式类型,contains 来自"xxx:值"锚点值)"""
name = step.get('name', '')
# 模式步骤("xxx:值")的语义文本 = 冒号后的锚点值(如 预定会议/北京展厅会议室),
# 与示范用例 semantic.text 对齐(tab=标签值、row_checkbox=行值);
# 明显动态值 CTX 化,静态值保留原样
pattern_value = self._extract_pattern_value(name)
if pattern_value:
text = self._ctxify_if_dynamic(pattern_value)
else:
text = self._extract_semantic_text(name)
if not text:
return None
ptype = pattern_result.get('type', '')
if ptype == 'list_checkbox':
stype = 'row_checkbox' # 列表选择通常命中表格行复选框
elif ptype == 'tab_item':
stype = 'tab'
else:
stype = 'button'
semantic: Dict[str, Any] = {'type': stype, 'text': text}
scope = self._get_page_scope(page)
if scope:
semantic['scope'] = scope
# 模式步骤均为点击类,"xxx:值"锚点值走 contains(动态数据 CTX 化)
contains = self._extract_contains(name)
if contains:
semantic['contains'] = contains
nth = self._extract_nth(name)
if nth is not None:
semantic['nth'] = nth
# 反向生成校验:生成的 semantic 必须能被执行期解析器解析为真实命中的选择器链,
# 否则落库后执行必不稳(复制示范用例"语义目标能稳定解析并执行"的成功经验)。
# row_checkbox → row 降级重试(普通 <tr> 表格行场景,解析器 row 模式含 tr:has-text)
if not self._validate_semantic_resolves(page, semantic):
if semantic.get('type') == 'row_checkbox':
semantic['type'] = 'row'
logger.info(f"步骤语义校验失败,降级 row_checkbox→row: {semantic}")
else:
logger.info(f"步骤语义校验失败,放弃生成 semantic: {semantic}")
return None
if not self._validate_semantic_resolves(page, semantic):
logger.info(f"步骤语义降级后仍校验失败,放弃生成 semantic: {semantic}")
return None
return semantic
def _validate_semantic_resolves(self, page, semantic: Dict[str, Any]) -> bool:
"""
校验 semantic 能被执行期 resolve_step 解析,且选择器链中至少一条真实命中页面元素
(执行期 _do_click 会遍历整条链并逐条回退,故任一条命中即视为可执行)
"""
try:
from app.executors.ui_context import ExecutionContext
scope = semantic.get('scope')
ctx = ExecutionContext(dict(self.collected_parameters or {}))
step: Dict[str, Any] = {
'name': str(semantic.get('text') or ''),
'action': 'click',
'params': {},
'semantic': semantic,
}
chain = self._semantic_resolver.resolve_step(page, step, scope, ctx) or []
for sel in chain:
try:
if page.query_selector(sel) is not None:
return True
except Exception:
continue
except Exception as e:
logger.debug(f"semantic 校验异常: {e}")
return False
def _extract_semantic_text(self, name: str) -> Optional[str]:
"""
从步骤名提取语义键文本
优先级:1. 【】显式语义键 2. 剔除前导动作动词后的核心词
"""
text = (name or '').strip()
if not text:
return None
m = re.search(r'【(.+?)】', text)
if m:
return m.group(1).strip() or None
# 截断"xxx:值"动态模式:语义键仅保留锚点部分(值走 contains / CTX)
text = re.split(r'[::]', text, 1)[0].strip()
for v in self._ACTION_VERBS:
if text.startswith(v):
text = text[len(v):].strip()
break
text = text.strip(',,。 ')
return text or None
def _extract_pattern_value(self, name: str) -> Optional[str]:
"""提取"xxx:值"模式中的锚点值(冒号后的内容),无则返回 None"""
m = re.search(r'[::]\s*(.+)$', name or '')
if not m:
return None
return m.group(1).strip() or None
def _ctxify_if_dynamic(self, value: str) -> str:
"""仅对明显动态值 CTX 化(与 _ctf_param 判定一致),静态值保留原样"""
if re.search(r'[A-Za-z0-9@._\-]', value) or len(value) >= 6:
return self._ctxify(value)
return value
def _extract_contains(self, name: str) -> Optional[str]:
"""步骤名"xxx:值"模式中的锚点值(动态数据 CTX 化,静态值保留原样)"""
value = self._extract_pattern_value(name)
if not value:
return None
return self._ctxify_if_dynamic(value)
def _extract_nth(self, name: str) -> Optional[int]:
"""步骤名含"第 N 个"时提取(否则不生成)"""
m = re.search(r'第\s*([0-9一二三四五六七八九十]+)\s*个', name)
if not m:
return None
text = m.group(1)
if text.isdigit():
return int(text)
cjk = '零一二三四五六七八九'
if text in cjk:
return cjk.index(text)
return None
def _ctxify(self, value: str) -> str:
"""
动态值 CTX 化:写入 collected_parameters 初值,返回 {__CTX:key__} 占位
同值复用同一 key,保证创建/断言/清理同名
"""
if value in self._ctx_value_map:
return f"{{__CTX:{self._ctx_value_map[value]}__}}"
key = f"param{len(self.collected_parameters) + 1}"
self._ctx_value_map[value] = key
self.collected_parameters[key] = value
logger.info(f"动态值 CTX 化: '{value}' -> {{__CTX:{key}__}}")
return f"{{__CTX:{key}__}}"
def _try_semantic_execute(self, page, step: Dict[str, Any], scope: Optional[str]) -> Optional[Dict[str, Any]]:
"""
语义解析优先:构造候选 semantic → resolve_step 命中选择器链
实际执行验证由 locate_steps 的统一 _execute_step 完成(与 keyword 路径一致)
成功 = 该 semantic 目标已通过执行期解析器验证,落库后执行必稳
"""
if not self._semantic_enabled():
return None
name = step.get('name', '')
# 仅静态步骤(无"xxx:值"动态值)走语义解析优先,避免语义键被值污染
if re.search(r'[::]', name):
return None
semantic = self._build_candidate_semantic(step, scope)
if not semantic:
return None
try:
from app.executors.ui_context import ExecutionContext
ctx = ExecutionContext()
# 候选 semantic 注入步骤,供解析器消费
candidate_step = dict(step)
candidate_step['semantic'] = semantic
chain = self._semantic_resolver.resolve_step(page, candidate_step, scope, ctx) or []
if not chain:
return None
# 关键保证:链首选择器必须真实命中页面元素,否则该 semantic 未经执行期验证,
# 直接返回会造成"假命中"(如 type=button 但目标实为 DIV → button:has-text 无元素,
# 后续 _execute_step 必失败)。未命中则回退 keyword 路径,由反向生成产出 semantic。
try:
el = page.query_selector(chain[0])
except Exception as e:
logger.debug(f"语义链首校验异常: {e}")
el = None
if el is None:
logger.info(f"步骤 {step.get('order')} 语义链首未命中页面元素,回退 keyword 定位")
return None
# 用解析链定位真实元素,反推细化 semantic.type(结构化元素纠正 button→checkbox/tab 等)
try:
refined_type = self._infer_element_type(step.get('action', ''), el)
if refined_type != semantic.get('type'):
logger.info(f"步骤 {step.get('order')} 语义类型细化: "
f"{semantic.get('type')} -> {refined_type}")
semantic['type'] = refined_type
except Exception as e:
logger.debug(f"语义类型细化失败(非致命): {e}")
return {'chain': chain, 'semantic': semantic}
except Exception as e:
logger.debug(f"语义解析优先失败: {e}")
return None
def _infer_semantic_from_element(self, step: Dict[str, Any], element, page) -> Optional[Dict[str, Any]]:
"""
keyword 定位成功后反向生成 semantic 目标
从步骤名 + 候选元素 tag/class/role + 父容器 + 运行时 scope 推断
"""
name = step.get('name', '')
action = step.get('action', '')
text = self._extract_semantic_text(name)
if not text:
return None
semantic: Dict[str, Any] = {
'type': self._infer_element_type(action, element),
'text': text,
}
scope = self._get_page_scope(page)
if scope:
semantic['scope'] = scope
# contains:仅点击类步骤"xxx:值"模式的锚点值(动态数据 CTX 化)
# fill/select 值不参与选择器过滤(输入框按占位符定位),值走 _ctf_param 收集
if action == 'click':
contains = self._extract_contains(name)
if contains:
semantic['contains'] = contains
# container:候选元素父容器
container = self._infer_container(element)
if container and container.get('selector'):
semantic['container'] = container
# nth:步骤名"第 N 个"
nth = self._extract_nth(name)
if nth is not None:
semantic['nth'] = nth
return semantic
def _infer_element_type(self, action: str, element) -> str:
"""推断 semantic.type(action 映射 + 候选元素 tag/class/role 反推)"""
if action in ('fill', 'input', 'type'):
tag = self._safe_attr(element, 'tagName')
if tag and tag.lower() == 'textarea':
return 'textarea'
return 'input'
if action in ('select', 'select_option'):
return 'select'
if element is None:
return 'button'
tag = (self._safe_attr(element, 'tagName') or '').lower()
cls = self._safe_attr(element, 'class') or ''
role = self._safe_attr(element, 'role') or ''
# 强结构模式优先
if 'checkbox' in cls or (tag == 'input' and self._safe_attr(element, 'type') == 'checkbox'):
ctype = (self._infer_container(element) or {}).get('type', '')
return 'row_checkbox' if ctype == 'table' else 'checkbox'
if 'el-tabs__item' in cls or role == 'tab':
return 'tab'
if 'el-table__row' in cls or tag == 'tr':
return 'row'
# 其余:默认 button(点击语义)
return 'button'
def _safe_attr(self, element, attr: str, default: str = '') -> str:
"""安全读取元素属性(元素可能已被 Playwright 释放)"""
if element is None:
return default
try:
if attr == 'tagName':
val = element.evaluate("el => el.tagName")
else:
val = element.get_attribute(attr)
return val if val is not None else default
except Exception:
return default
def _infer_container(self, element) -> Optional[Dict[str, Any]]:
"""推断父容器(复用 keyword_matcher._detect_parent_container)"""
if element is None:
return None
try:
return _detect_parent_container(element)
except Exception as e:
logger.debug(f"父容器推断失败: {e}")
return None
def _detect_verify_after(self, step: Dict[str, Any], page) -> Optional[Dict[str, Any]]:
"""
条件 verify:点击后观察到确定状态变化才生成(保守策略)
观察不到确定状态变化则不生成——避免错误断言导致执行失败
"""
if step.get('action') != 'click' or page is None:
return None
name = step.get('name', '')
try:
# 1. 成功提示文案
msg = page.locator('.el-message--success, .el-message.success, .el-notification--success')
if msg.count() > 0 and msg.first.is_visible(timeout=1200):
text = msg.first.inner_text().strip()
if text:
return {'type': 'text_visible', 'text': text}
except Exception:
pass
# 2. 弹窗/抽屉/确认框(步骤名含弹窗关键词时)
dialog_keywords = ['新建', '编辑', '添加', '新增', '创建', '修改', '详情',
'确定创建', '取消会议', '删除', '选择会议室']
if any(kw in name for kw in dialog_keywords):
try:
for sel in ['.el-dialog', '.el-drawer', '.el-message-box']:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible(timeout=1200):
return {'type': 'dialog'}
except Exception:
pass
return None
def _ctf_param(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
fill 步骤动态值 CTX 化(仅对明显动态值生效,静态值保留原样)
value → {__CTX:key__},初值收集进 collected_parameters
"""
value = params.get('value')
if not isinstance(value, str) or len(value) < 4:
return params
# 明显动态值:含字母/数字/@ 等,或较长中文(≥6)
if re.search(r'[A-Za-z0-9@._\-]', value) or len(value) >= 6:
params['value'] = self._ctxify(value)
return params
def _capture_screenshot(self, page, order: int, result: Dict[str, Any]) -> None:
"""截图(复用现有截图逻辑,非致命)"""
try:
screenshot_path = os.path.join(
self.screenshot_dir,
f"smart_locate_{order}_{datetime.now().strftime('%H%M%S')}.png"
)
page.screenshot(path=screenshot_path)
with open(screenshot_path, 'rb') as f:
result['screenshot'] = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.debug(f"截图失败(非致命): {e}")
def _preprocess_step_pattern(self, step_name: str, action: str) -> Optional[Dict[str, Any]]:
"""
预处理特殊步骤模式,生成选择器
......@@ -934,6 +1413,8 @@ class SmartLocateService:
f'.el-tree-node:has-text("{item_name}") .el-checkbox',
f'li:has-text("{item_name}") .el-checkbox',
f'[class*="list"]:has-text("{item_name}") .el-checkbox',
# 普通 <tr> 表格行(会议室等列表常为原生表格,勾选通过点击行触发)
f'tr:has-text("{item_name}")',
],
'confidence': 0.85
}
......@@ -951,6 +1432,8 @@ class SmartLocateService:
f'[role="tab"]:has-text("{tab_name}")',
f'.tab-item:has-text("{tab_name}")',
f'li[role="tab"]:has-text("{tab_name}")',
# 自定义 tab 元素(非 el-tabs 结构)兜底:精确文本匹配
f'text="{tab_name}"',
],
'confidence': 0.85
}
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
部署智能定位功能到 192.168.5.60 服务器
"""
import paramiko
import os
import sys
# 服务器配置
SERVER = "192.168.5.60"
USER = "ubains"
PASSWORD = "Ubains@123"
DEPLOY_DIR = "/data/third_party/plat-auto-test"
# 需要上传的文件列表
FILES_TO_UPLOAD = [
# 新增文件
("backend/app/models/login_template.py", "backend/app/models/login_template.py"),
("backend/app/routers/login_template.py", "backend/app/routers/login_template.py"),
("backend/app/routers/smart_locate.py", "backend/app/routers/smart_locate.py"),
("backend/app/schemas/login_template.py", "backend/app/schemas/login_template.py"),
("backend/app/services/keyword_matcher.py", "backend/app/services/keyword_matcher.py"),
("backend/app/services/login_template_service.py", "backend/app/services/login_template_service.py"),
("backend/app/services/selector_extractor.py", "backend/app/services/selector_extractor.py"),
("backend/app/services/smart_locate_service.py", "backend/app/services/smart_locate_service.py"),
# 修改的文件
("backend/app/main.py", "backend/app/main.py"),
("backend/app/models/__init__.py", "backend/app/models/__init__.py"),
("frontend/src/api/elementLocate.ts", "frontend/src/api/elementLocate.ts"),
("frontend/src/views/Cases.vue", "frontend/src/views/Cases.vue"),
]
def deploy():
"""部署到服务器"""
print("=" * 60)
print("部署智能定位功能到服务器")
print("=" * 60)
print(f"服务器: {SERVER}")
print(f"用户: {USER}")
print(f"部署目录: {DEPLOY_DIR}")
print()
# 连接服务器
print("连接服务器...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(SERVER, username=USER, password=PASSWORD)
print("[OK] SSH 连接成功")
except Exception as e:
print(f"[FAIL] SSH 连接失败: {e}")
return False
sftp = ssh.open_sftp()
# 上传文件
print(f"\n上传 {len(FILES_TO_UPLOAD)} 个文件...")
local_base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
success_count = 0
for local_path, remote_path in FILES_TO_UPLOAD:
full_local = os.path.join(local_base, local_path)
full_remote = f"{DEPLOY_DIR}/{remote_path}"
try:
# 确保远程目录存在
remote_dir = os.path.dirname(full_remote)
try:
sftp.stat(remote_dir)
except FileNotFoundError:
ssh.exec_command(f"mkdir -p {remote_dir}")
# 上传文件
sftp.put(full_local, full_remote)
print(f" [OK] {remote_path}")
success_count += 1
except Exception as e:
print(f" [FAIL] {remote_path}: {e}")
sftp.close()
print(f"\n上传完成: {success_count}/{len(FILES_TO_UPLOAD)} 成功")
# 重启容器
print("\n重启 Docker 容器...")
stdin, stdout, stderr = ssh.exec_command(f"cd {DEPLOY_DIR}/deploy && docker compose restart app")
print(stdout.read().decode())
print(stderr.read().decode())
# 等待服务启动
print("等待服务启动...")
import time
time.sleep(5)
# 验证部署
print("\n验证部署...")
stdin, stdout, stderr = ssh.exec_command("curl -s http://localhost:8001/api/element/health")
result = stdout.read().decode()
if "smart-locate" in result or "healthy" in result:
print("[OK] 智能定位 API 已部署")
else:
print(f"[WARN] 验证结果: {result}")
stdin, stdout, stderr = ssh.exec_command("curl -s http://localhost:8001/api/login-templates")
result = stdout.read().decode()
if "template" in result:
print("[OK] 登录模板 API 已部署")
else:
print(f"[WARN] 验证结果: {result[:200]}")
ssh.close()
print("\n部署完成!")
return True
if __name__ == "__main__":
deploy()
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试脚本:验证智能定位 API 端到端功能
测试内容:
1. 健康检查
2. 智能定位 - 信息发布模块导航验证
"""
import requests
import json
import sys
BASE_URL = "http://localhost:8002"
def test_health():
"""测试健康检查"""
print("\n1. 健康检查...")
resp = requests.get(f"{BASE_URL}/api/element/health")
print(f" Status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f" Service: {data.get('service', 'N/A')}")
print(f" Status: {data.get('status', 'N/A')}")
else:
print(f" Error: {resp.text}")
def test_smart_locate():
"""测试智能定位"""
print("\n2. 智能定位 - 信息发布模块...")
# 构造请求:模拟用户录入的自然语言步骤
# 注意:auto_login=True 时,系统会自动执行登录模板
# 所以用户步骤只需要从登录后的操作开始
request_data = {
"steps": [
# 登录后的操作步骤(登录由 auto_login 自动完成)
{"order": 1, "name": "点击新增按钮", "action": "click", "params": {}},
{"order": 2, "name": "等待页面加载", "action": "wait", "params": {"timeout": 10000}},
],
"auto_login": True,
"navigate_menu": "信息发布",
"page_url": "https://192.168.5.44"
}
print(f" Request: {json.dumps(request_data, ensure_ascii=False, indent=2)}")
print(" Sending request (this may take 30-60 seconds)...")
try:
resp = requests.post(
f"{BASE_URL}/api/element/smart-locate",
json=request_data,
timeout=120 # 2 分钟超时
)
print(f" Status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f" Success: {data.get('success')}")
print(f" Total: {data.get('total_steps')}")
print(f" Located: {data.get('located_steps')}")
print(f" Message: {data.get('message')}")
for result in data.get('results', []):
status = "[OK]" if result.get('success') else "[FAIL]"
selector = result.get('params', {}).get('selector', 'N/A')
print(f" {status} Step {result.get('order')}: {result.get('name')} -> {selector}")
if result.get('message'):
print(f" Message: {result.get('message')}")
else:
print(f" Error: {resp.text}")
except requests.exceptions.Timeout:
print(" [TIMEOUT] Request timed out after 120 seconds")
except Exception as e:
print(f" [ERROR] {e}")
def test_login_templates():
"""测试登录模板 API"""
print("\n3. 登录模板 API...")
resp = requests.get(f"{BASE_URL}/api/login-templates")
print(f" Status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f" Total: {data.get('total')}")
for item in data.get('items', []):
print(f" - {item.get('name')} (type={item.get('template_type')}, steps={len(item.get('steps', []))})")
else:
print(f" Error: {resp.text}")
if __name__ == "__main__":
print("=" * 60)
print("智能定位 API 端到端测试")
print("=" * 60)
test_health()
test_login_templates()
test_smart_locate()
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试智能定位 API 端到端流程
"""
import requests
import json
BASE_URL = "http://localhost:8001"
def test_smart_locate_api():
"""测试智能定位 API"""
print("\n" + "=" * 60)
print("测试智能定位 API")
print("=" * 60)
# 测试步骤:会议管理 - 查看列表
test_steps = [
{"order": 1, "name": "点击新增按钮", "action": "click", "params": {}},
{"order": 2, "name": "等待对话框加载", "action": "wait", "params": {"timeout": 5000}},
{"order": 3, "name": "输入会议主题", "action": "fill", "params": {"value": "测试会议"}},
{"order": 4, "name": "点击确定按钮", "action": "click", "params": {}},
]
payload = {
"steps": test_steps,
"auto_login": True,
"navigate_menu": "会议管理",
"page_url": "https://192.168.5.44"
}
print("\n请求参数:")
print(json.dumps(payload, indent=2, ensure_ascii=False))
print("\n发送请求...")
try:
response = requests.post(
f"{BASE_URL}/api/element/smart-locate",
json=payload,
timeout=180
)
print(f"响应状态码: {response.status_code}")
if response.status_code == 200:
result = response.json()
print("\n响应结果:")
print(json.dumps(result, indent=2, ensure_ascii=False))
print("\n定位汇总:")
print(f" 总步骤: {result.get('total_steps', 0)}")
print(f" 成功定位: {result.get('located_steps', 0)}")
for r in result.get("results", []):
status = "[OK]" if r.get("success") else "[FAIL]"
print(f" {status} 步骤 {r['order']}: {r['name']}")
if r.get("success"):
print(f" 主选择器: {r['selectors'].get('primary', 'N/A')}")
else:
print(f" 失败原因: {r.get('message', 'N/A')}")
return result.get("located_steps", 0) > 0
else:
print(f"请求失败: {response.text}")
return False
except Exception as e:
print(f"请求异常: {e}")
return False
if __name__ == "__main__":
test_smart_locate_api()
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_smart_locate_e2e.py
模块描述:智能定位端到端测试脚本
作者:czj
创建日期:2026-08-05
最后修改:2026-08-05
"""
import sys
import os
# 添加 backend 目录到 Python 路径
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, backend_dir)
import asyncio
from app.services.smart_locate_service import SmartLocateService
from app.services.login_template_service import LoginTemplateService
from app.database import get_db, async_session_maker
async def test_login_templates():
"""测试登录模板服务"""
print("\n" + "=" * 60)
print("测试登录模板服务")
print("=" * 60)
async with async_session_maker() as db:
service = LoginTemplateService(db)
# 获取模板列表
templates, total = await service.get_list()
print(f"\n[OK] 模板列表: {total} 个模板")
for t in templates:
steps_count = len(t.steps) if t.steps else 0
print(f" - {t.name} ({t.template_type}): {steps_count} 步骤")
# 获取默认登录模板
login_template = await service.get_default_template("login")
if login_template:
print(f"\n[OK] 默认登录模板: {login_template.name}")
print(f" 步骤数: {len(login_template.steps)}")
for i, step in enumerate(login_template.steps, 1):
print(f" {i}. {step.get('name', 'N/A')} - {step.get('action', 'N/A')}")
# 获取默认导航模板
nav_template = await service.get_default_template("navigate")
if nav_template:
print(f"\n[OK] 默认导航模板: {nav_template.name}")
print(f" 步骤数: {len(nav_template.steps)}")
return True
async def test_smart_locate():
"""测试智能定位服务"""
print("\n" + "=" * 60)
print("测试智能定位服务")
print("=" * 60)
# 测试用例:会议管理 - 新增会议
test_steps = [
{"order": 1, "name": "点击新增按钮", "action": "click", "params": {}},
{"order": 2, "name": "等待对话框加载", "action": "wait", "params": {"timeout": 5000}},
{"order": 3, "name": "输入会议主题", "action": "fill", "params": {"value": "测试会议"}},
{"order": 4, "name": "点击确定按钮", "action": "click", "params": {}},
]
print("\n测试步骤:")
for step in test_steps:
print(f" {step['order']}. {step['name']} ({step['action']})")
service = SmartLocateService()
try:
# locate_steps 是同步方法,需要在线程池中运行
import asyncio
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
service.locate_steps,
test_steps,
True, # auto_login
"会议管理", # navigate_menu
"https://192.168.5.44" # page_url
)
print(f"\n定位结果:")
print(f" 总步骤: {len(result)}")
located_steps = sum(1 for r in result if r.get("success"))
print(f" 成功定位: {located_steps}")
for r in result:
status = "[OK]" if r.get("success") else "[FAIL]"
print(f"\n {status} 步骤 {r['order']}: {r['name']}")
if r.get("success"):
print(f" 主选择器: {r['selectors'].get('primary', 'N/A')}")
else:
print(f" 失败原因: {r.get('message', 'N/A')}")
return located_steps > 0
except Exception as e:
print(f"\n[ERROR] 测试失败: {str(e)}")
import traceback
traceback.print_exc()
return False
async def main():
"""主测试流程"""
print("\n" + "=" * 60)
print("智能定位端到端测试")
print("=" * 60)
print(f"被测系统: https://192.168.5.44")
print(f"测试菜单: 会议管理")
print(f"验证码: csba (固定)")
results = []
# 测试1: 登录模板
try:
r1 = await test_login_templates()
results.append(("登录模板服务", r1))
except Exception as e:
print(f"\n[ERROR] 登录模板测试失败: {e}")
results.append(("登录模板服务", False))
# 测试2: 智能定位
try:
r2 = await test_smart_locate()
results.append(("智能定位服务", r2))
except Exception as e:
print(f"\n[ERROR] 智能定位测试失败: {e}")
results.append(("智能定位服务", False))
# 汇总结果
print("\n" + "=" * 60)
print("测试汇总")
print("=" * 60)
for name, passed in results:
status = "[OK] 通过" if passed else "[FAIL] 失败"
print(f" {name}: {status}")
all_passed = all(r[1] for r in results)
print("\n" + ("全部测试通过!" if all_passed else "部分测试失败"))
return all_passed
if __name__ == "__main__":
asyncio.run(main())
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:verify_smart_locate_semantic.py
模块描述:智能定位语义化生成端到端验证脚本(阶段6语义化用例生成器)
在真实被测系统上运行 SmartLocateService.locate_steps,验证:
- 静态步骤走语义解析优先路径(产出 semantic)
- "xxx选择/切换为:值" 模式步骤产出 row_checkbox/tab semantic + contains CTX
- 动态 fill 步骤值 CTX 化 → parameters 初值
- 点击弹窗步骤条件生成 verify
- 全流程不产生业务数据副作用(在"确定创建"之前停止)
用法:
cd backend && python scripts/verify_smart_locate_semantic.py
作者:czj
创建日期:2026-08-18
"""
import io
import logging
import sys
from typing import Any, Dict, List
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
logger = logging.getLogger("verify_semantic")
sys.path.insert(0, ".")
def main() -> None:
from app.services.smart_locate_service import SmartLocateService
steps: List[Dict[str, Any]] = [
# ===== 导航步骤(显式稳定选择器,聚焦语义生成验收而非导航智能) =====
# 名称避开 dialog 关键词(新建/创建/新增…),否则 _execute_step 会等待弹窗而
# 实际是页面跳转(CreateMeeting 为整页路由,无 .el-dialog/.el-drawer)→ 假失败
{"order": 1, "name": "点击【功能中心】展开", "action": "click",
"params": {"selector": '//*[@id="Home"]/div[1]/div[1]'}},
{"order": 2, "name": "进入会议模块", "action": "click",
"params": {"selector": '.el-drawer [data-key="reserve_list.create"]'}},
{"order": 3, "name": "等待会议表单加载", "action": "wait",
"params": {"selector": "input[placeholder*='会议名称']", "timeout": 10000}},
# ===== 表单步骤(无选择器 → 智能定位语义化生成验收核心) =====
# 4/7 动态 fill:keyword 定位 → 反向生成 input semantic + 值 CTX 化 → parameters 初值
# 5/6 "xxx:值" 模式 click:keyword 定位 → 反向生成 row_checkbox/tab semantic
# + contains CTX 化
{"order": 4, "name": "填写会议名称", "action": "fill",
"params": {"value": "智能定位验收自动会议X"}},
{"order": 5, "name": "会议室选择:北京展厅会议室", "action": "click", "params": {}},
{"order": 6, "name": "时间切换为:预定会议", "action": "click", "params": {}},
{"order": 7, "name": "输入会议议题", "action": "fill",
"params": {"value": "智能定位验收测试议题"}},
]
logger.info("=== 启动智能定位(真实系统 %s)===", "https://192.168.5.44")
service = SmartLocateService()
# navigate_menu 留空:由步骤自身完成"功能中心→会议模块"导航,
# 避免与 navigate_menu 的预导航冲突(后者已展开菜单,再点功能中心会失效)
results = service.locate_steps(
steps=steps,
auto_login=True,
navigate_menu="",
page_url="https://192.168.5.44",
use_claude=False, # 规则优先,验证语义生成而非 Claude
)
out: List[str] = []
out.append("")
out.append("=== 定位结果 ===")
for r in results:
ok = r.get("success")
sem = r.get("semantic")
ver = r.get("verify")
out.append(
f"#{r.get('order')} {r.get('name')}"
f" | action={r.get('action')} | {'OK' if ok else 'FAIL'}"
)
if ok and r.get('selectors', {}).get('primary'):
out.append(f" selector: {r['selectors']['primary']}")
if sem:
out.append(f" semantic: {sem}")
if ver:
out.append(f" verify: {ver}")
if ok and r.get('params', {}).get('value'):
out.append(f" params.value: {r['params']['value']}")
out.append("")
out.append("=== 动态数据参数初值 (collected_parameters) ===")
for k, v in service.collected_parameters.items():
out.append(f" {k}: {v}")
# ===== 断言汇总 =====
ok_count = sum(1 for r in results if r.get('success'))
sem_count = sum(1 for r in results if r.get('semantic'))
out.append("")
out.append(f"=== 统计: 成功 {ok_count}/{len(results)}, 含 semantic {sem_count} ===")
io.open("_verify_semantic_out.txt", "w", encoding="utf-8").write("\n".join(out))
logger.info("输出已写入 _verify_semantic_out.txt")
for line in out:
print(line)
if __name__ == "__main__":
main()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_smart_locate_semantic.py
模块描述:智能定位语义化生成规则纯函数单测(阶段6语义化用例生成器)
覆盖:
- _extract_semantic_text:语义键文本提取(【】优先 / 剔除动作动词 / 截断动态值)
- _extract_contains:步骤名"xxx:值"锚点值(CTX 化)
- _extract_nth:第 N 个提取
- _ctxify / _ctf_param:动态数据 CTX 化
- _infer_element_type:semantic.type 推断
- _build_candidate_semantic:候选 semantic 构造(语义解析优先路径)
- _infer_semantic_from_element:反向生成(keyword 定位成功后)
作者:czj
创建日期:2026-08-18
"""
from typing import Any, Dict, Optional
import pytest
from app.services.smart_locate_service import SmartLocateService
# ==================== Fixtures ====================
@pytest.fixture
def svc():
"""智能定位服务实例(不启动浏览器)"""
return SmartLocateService()
class _FakeAttr:
"""模拟 Playwright 元素的 tag/class/role/type 读取"""
def __init__(self, attrs: Dict[str, str], container: Optional[Dict[str, Any]] = None):
self._attrs = attrs
self._container = container or {}
def get_attribute(self, name: str):
return self._attrs.get(name)
def evaluate(self, js: str):
if 'el.tagName' in js:
return self._attrs.get('tagName')
if 'parentElement' in js:
return self._container
return None
def _with_scope(svc: SmartLocateService, scope_id: str, scope_name: str) -> None:
"""缓存语义化开关并注入假页面识别服务(_get_page_scope 走 _page_url_service)"""
svc._semantic_enabled() # 缓存 _semantic_enabled_flag,避免再次懒加载覆盖注入
class _FakeUrlSvc:
def recognize_current_page(self, page):
return {"id": scope_id, "name": scope_name}
svc._page_url_service = _FakeUrlSvc()
def _mk_step(name: str, action: str = "click", params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {"order": 1, "name": name, "action": action, "params": params or {}}
# ==================== _extract_semantic_text ====================
class TestExtractSemanticText:
def test_bracket_preferred(self, svc):
"""【】显式语义键优先"""
assert svc._extract_semantic_text("点击【确定创建】按钮") == "确定创建"
def test_strip_leading_verb(self, svc):
assert svc._extract_semantic_text("点击确定创建") == "确定创建"
assert svc._extract_semantic_text("输入会议名称") == "会议名称"
assert svc._extract_semantic_text("打开会议预约") == "会议预约"
def test_switch_verb_ordering(self, svc):
"""切换到优先于切换,避免剥成'到xxx'"""
assert svc._extract_semantic_text("切换到会议预约") == "会议预约"
def test_truncate_dynamic_value(self, svc):
"""'xxx:值' 动态值截断,仅保留锚点部分(剔除动作动词)"""
assert svc._extract_semantic_text("会议室选择:北京展厅会议室") == "会议室选择"
assert svc._extract_semantic_text("输入会议名称:自动会议X") == "会议名称"
def test_empty(self, svc):
assert svc._extract_semantic_text("") is None
assert svc._extract_semantic_text("点击") is None
# ==================== _extract_contains ====================
class TestExtractContains:
def test_basic(self, svc):
assert svc._extract_contains("会议室选择:北京展厅会议室") == "{__CTX:param1__}"
assert svc.collected_parameters == {"param1": "北京展厅会议室"}
def test_no_colon(self, svc):
assert svc._extract_contains("点击确定创建") is None
# ==================== _extract_nth ====================
class TestExtractNth:
def test_arabic(self, svc):
assert svc._extract_nth("点击第 2 个选项") == 2
assert svc._extract_nth("选择第3个") == 3
def test_cjk(self, svc):
assert svc._extract_nth("点击第 三 个") == 3
def test_none(self, svc):
assert svc._extract_nth("点击确定创建") is None
# ==================== _ctxify / _ctf_param ====================
class TestCtxify:
def test_same_value_reuses_key(self, svc):
svc._ctxify("会议A")
svc._ctxify("会议A")
assert len(svc.collected_parameters) == 1
assert svc.collected_parameters == {"param1": "会议A"}
def test_ctf_param_dynamic(self, svc):
params = {"value": "自动会议X"}
svc._ctf_param(params)
assert params["value"] == "{__CTX:param1__}"
assert svc.collected_parameters == {"param1": "自动会议X"}
def test_ctf_param_static_short(self, svc):
"""短值(<4)视为静态值保留原样"""
params = {"value": "是"}
svc._ctf_param(params)
assert params["value"] == "是"
def test_ctf_param_static_long_chinese(self, svc):
"""长度 ≥6 的中文视为动态"""
params = {"value": "北京展厅会议室"}
svc._ctf_param(params)
assert params["value"] == "{__CTX:param1__}"
# ==================== _infer_element_type ====================
class TestInferElementType:
def test_fill_input(self, svc):
assert svc._infer_element_type("fill", None) == "input"
def test_fill_textarea(self, svc):
el = _FakeAttr({"tagName": "TEXTAREA"})
assert svc._infer_element_type("fill", el) == "textarea"
def test_button(self, svc):
el = _FakeAttr({"tagName": "BUTTON"})
assert svc._infer_element_type("click", el) == "button"
def test_checkbox_in_table(self, svc):
el = _FakeAttr({"tagName": "INPUT", "class": "el-checkbox__original", "type": "checkbox"},
container={"type": "table", "selector": ".el-table"})
assert svc._infer_element_type("click", el) == "row_checkbox"
def test_tab(self, svc):
el = _FakeAttr({"tagName": "DIV", "class": "el-tabs__item"}, )
assert svc._infer_element_type("click", el) == "tab"
def test_click_no_element_defaults_button(self, svc):
assert svc._infer_element_type("click", None) == "button"
# ==================== _build_candidate_semantic ====================
class TestBuildCandidateSemantic:
def test_static_click(self, svc):
semantic = svc._build_candidate_semantic(_mk_step("点击确定创建"), "create_meeting")
assert semantic == {"type": "button", "text": "确定创建", "scope": "create_meeting"}
def test_static_fill(self, svc):
semantic = svc._build_candidate_semantic(_mk_step("输入会议名称", "fill"), None)
assert semantic == {"type": "input", "text": "会议名称"}
def test_nth_extracted(self, svc):
semantic = svc._build_candidate_semantic(_mk_step("点击第 2 个选项"), "create_meeting")
assert semantic["nth"] == 2
def test_empty_returns_none(self, svc):
assert svc._build_candidate_semantic(_mk_step("点击"), None) is None
# ==================== _infer_semantic_from_element ====================
class TestInferSemanticFromElement:
def test_reverse_generation_dynamic_click(self, svc):
"""反向生成:表格行复选框点击(checkbox + table 容器 → row_checkbox)"""
_with_scope(svc, "meeting_list", "会议列表")
el = _FakeAttr({"tagName": "INPUT", "class": "el-checkbox__original", "type": "checkbox"},
container={"type": "table", "selector": ".el-table"})
semantic = svc._infer_semantic_from_element(
_mk_step("会议室选择:北京展厅会议室"), el, object()
)
assert semantic["type"] == "row_checkbox"
assert semantic["scope"] == "meeting_list"
assert semantic["contains"] == "{__CTX:param1__}"
assert semantic["container"] == {"type": "table", "selector": ".el-table"}
assert svc.collected_parameters.get("param1") == "北京展厅会议室"
def test_reverse_generation_row(self, svc):
"""反向生成:表格行本身(el-table__row → row 类型)"""
_with_scope(svc, "meeting_list", "会议列表")
el = _FakeAttr({"tagName": "LI", "class": "el-table__row"},
container={"type": "table", "selector": ".el-table"})
semantic = svc._infer_semantic_from_element(
_mk_step("查看详情:会议A"), el, object()
)
assert semantic["type"] == "row"
assert semantic["contains"] == "{__CTX:param1__}"
def test_reverse_generation_fill(self, svc):
"""反向生成:fill 步骤不生成 contains(值走 _ctf_param)"""
_with_scope(svc, "create_meeting", "创建会议")
el = _FakeAttr({"tagName": "INPUT"})
semantic = svc._infer_semantic_from_element(
_mk_step("输入会议名称:自动会议X", "fill"), el, object()
)
assert semantic["type"] == "input"
assert "contains" not in semantic
assert semantic["scope"] == "create_meeting"
def test_reverse_generation_scope_none(self, svc):
"""scope 识别不到时 semantic.scope 留空(执行期降级安全)"""
svc._semantic_enabled()
class _NoScopeSvc:
def recognize_current_page(self, page):
return None
svc._page_url_service = _NoScopeSvc()
el = _FakeAttr({"tagName": "BUTTON"})
semantic = svc._infer_semantic_from_element(
_mk_step("点击确定创建"), el, None
)
assert semantic["type"] == "button"
assert "scope" not in semantic
# ==================== _extract_pattern_value / _ctxify_if_dynamic ====================
class TestExtractPatternValue:
def test_colon_value(self, svc):
assert svc._extract_pattern_value("时间切换为:预定会议") == "预定会议"
assert svc._extract_pattern_value("会议室选择:北京展厅会议室") == "北京展厅会议室"
def test_no_colon(self, svc):
assert svc._extract_pattern_value("点击确定创建") is None
assert svc._extract_pattern_value("") is None
class TestCtxifyIfDynamic:
def test_static_short_chinese_kept(self, svc):
"""静态短中文(如 tab 名 预定会议)不 CTX 化,避免参数污染"""
assert svc._ctxify_if_dynamic("预定会议") == "预定会议"
def test_dynamic_ctxfied(self, svc):
"""明显动态值(长中文/含字母数字)CTX 化并写入参数初值"""
out = svc._ctxify_if_dynamic("北京展厅会议室")
assert out == "{__CTX:param1__}"
assert svc.collected_parameters["param1"] == "北京展厅会议室"
def test_alnum_ctxfied(self, svc):
assert svc._ctxify_if_dynamic("admin@xty12") == "{__CTX:param1__}"
# ==================== 模式步骤 semantic 生成 + 校验降级 ====================
class _FakeResolver:
"""按 semantic.type 返回不同选择器链的假解析器"""
def __init__(self, chains: Dict[str, list]):
self._chains = chains
def resolve_step(self, page, step, scope, ctx):
t = step["semantic"].get("type", "button")
return list(self._chains.get(t, []))
class _FakePage:
"""query_selector 按命中集返回非空,否则 None"""
def __init__(self, hits):
self._hits = set(hits)
def query_selector(self, sel):
return object() if sel in self._hits else None
def _pattern_step(name: str) -> Dict[str, Any]:
return {"order": 1, "name": name, "action": "click", "params": {}}
class TestInferSemanticFromPattern:
def test_tab_value_as_text(self, svc):
"""tab 模式:text=锚点值(非标签),静态值不 CTX"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver(
{"tab": ['[role="tab"]:has-text("预定会议")']})
sem = svc._infer_semantic_from_pattern(
_pattern_step("时间切换为:预定会议"),
{"type": "tab_item"}, _FakePage(['[role="tab"]:has-text("预定会议")']))
assert sem["type"] == "tab"
assert sem["text"] == "预定会议"
assert sem["contains"] == "预定会议"
assert sem["scope"] == "create_meeting"
def test_row_checkbox_downgrade_to_row(self, svc):
"""row_checkbox 校验失败(普通 <tr> 表格行)→ 降级 row(tr:has-text 命中)"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver({
"row_checkbox": ['.el-table__row:has-text("北京展厅会议室") .el-checkbox'],
"row": ['tr:has-text("北京展厅会议室")'],
})
sem = svc._infer_semantic_from_pattern(
_pattern_step("会议室选择:北京展厅会议室"),
{"type": "list_checkbox"},
_FakePage(['tr:has-text("北京展厅会议室")']))
assert sem["type"] == "row"
assert sem["text"] == "{__CTX:param1__}"
def test_both_fail_returns_none(self, svc):
"""row_checkbox 与降级 row 均无法命中 → 放弃生成 semantic(回退 params.selector)"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver({
"row_checkbox": ['.el-table__row:has-text("房间A") .el-checkbox'],
"row": ['tr:has-text("房间A")'],
})
assert svc._infer_semantic_from_pattern(
_pattern_step("会议室选择:房间A"),
{"type": "list_checkbox"}, _FakePage([])) is None
def test_validate_resolves_any_chain_hit(self, svc):
"""校验接受链中任一条命中(执行期 _do_click 遍历全链)"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver({
"row_checkbox": [
'.el-table__row:has-text("北京展厅会议室") .el-checkbox',
'tr:has-text("北京展厅会议室")',
]})
# 链首未命中但第二条 tr:has-text 命中 → 校验通过,无需降级
sem = svc._infer_semantic_from_pattern(
_pattern_step("会议室选择:北京展厅会议室"),
{"type": "list_checkbox"},
_FakePage(['tr:has-text("北京展厅会议室")']))
assert sem["type"] == "row_checkbox"
......@@ -180,6 +180,10 @@ export interface SmartLocateResult {
screenshot?: string
/** 说明信息 */
message: string
/** 生成的 semantic 语义目标(阶段6语义化用例生成器) */
semantic?: Record<string, any> | null
/** 条件生成的 verify 状态验证 */
verify?: Record<string, any> | null
}
/**
......@@ -196,6 +200,8 @@ export interface SmartLocateResponse {
results: SmartLocateResult[]
/** 总体说明 */
message: string
/** 动态数据参数初值(param{N}: 值,阶段6 CTX 化收集) */
parameters?: Record<string, string>
}
/**
......
......@@ -1123,19 +1123,30 @@ const batchLocate = async () => {
if (locateResult.selectors?.primary) {
updatedParams.selector = locateResult.selectors.primary
}
// ⚠️ 阶段6升级:合并 semantic / verify(新生成的优先,保留已有字段)
return {
...s,
params: updatedParams,
locator_type: 'css',
locator_value: locateResult.selectors?.primary || '',
semantic: locateResult.semantic ?? s.semantic,
verify: locateResult.verify ?? s.verify,
}
}
return s
})
// ⚠️ 阶段6升级:动态数据 CTX 化收集的参数初值合并进用例参数
// (param{N} 键与步骤内 {__CTX:param{N}__} 占位对齐,执行期才能解析)
const mergedParameters = {
...(caseDetail.parameters || {}),
...(result.parameters || {}),
}
// 5. 更新用例到数据库
await caseApi.update(locateConfigCaseId.value, {
steps: updatedSteps,
parameters: mergedParameters,
})
let msg = `智能定位完成:成功 ${result.located_steps}/${result.total_steps} 个步骤`
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论