提交 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 @@ ...@@ -57,6 +57,19 @@
"fingerprint": [ "fingerprint": [
{"selector": ".detail_top", "min_count": 1} {"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): ...@@ -58,6 +58,9 @@ class SmartLocateResult(BaseModel):
claude_enhanced: bool = Field(default=False, description="是否经过 Claude 语义增强") claude_enhanced: bool = Field(default=False, description="是否经过 Claude 语义增强")
claude_confidence: float = Field(default=0.0, description="Claude 置信度") claude_confidence: float = Field(default=0.0, description="Claude 置信度")
claude_reason: str = Field(default="", 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): class SmartLocateResponse(BaseModel):
...@@ -67,6 +70,8 @@ class SmartLocateResponse(BaseModel): ...@@ -67,6 +70,8 @@ class SmartLocateResponse(BaseModel):
located_steps: int = Field(..., description="成功定位步骤数") located_steps: int = Field(..., description="成功定位步骤数")
results: List[SmartLocateResult] = Field(default_factory=list, description="定位结果列表") results: List[SmartLocateResult] = Field(default_factory=list, description="定位结果列表")
message: str = Field(default="", description="总体说明") message: str = Field(default="", description="总体说明")
# ⚠️ 阶段6升级:动态数据 CTX 化收集的参数初值
parameters: Dict[str, str] = Field(default_factory=dict, description="动态数据参数初值(param{N}: 值)")
class VerifyStepRequest(BaseModel): class VerifyStepRequest(BaseModel):
...@@ -142,16 +147,18 @@ async def smart_locate( ...@@ -142,16 +147,18 @@ async def smart_locate(
def _sync_locate(): def _sync_locate():
"""同步执行智能定位(在线程池中运行)""" """同步执行智能定位(在线程池中运行)"""
service = SmartLocateService() service = SmartLocateService()
return service.locate_steps( results = service.locate_steps(
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 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')) located_count = sum(1 for r in results if r.get('success'))
...@@ -162,6 +169,7 @@ async def smart_locate( ...@@ -162,6 +169,7 @@ async def smart_locate(
total_steps=len(request.steps), total_steps=len(request.steps),
located_steps=located_count, located_steps=located_count,
results=[SmartLocateResult(**r) for r in results], results=[SmartLocateResult(**r) for r in results],
parameters=parameters,
message=f"成功定位 {located_count}/{len(request.steps)} 个步骤" message=f"成功定位 {located_count}/{len(request.steps)} 个步骤"
) )
......
...@@ -1177,6 +1177,9 @@ def find_element_by_semantic( ...@@ -1177,6 +1177,9 @@ def find_element_by_semantic(
except Exception: except Exception:
continue continue
# 兜底:未匹配到任何元素(此前返回 None 会导致调用方 tuple 解包崩溃)
return None, []
def resolve_selectors( def resolve_selectors(
page, page,
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
import logging import logging
import os import os
import re
import base64 import base64
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from datetime import datetime from datetime import datetime
...@@ -23,7 +24,8 @@ from app.services.keyword_matcher import ( ...@@ -23,7 +24,8 @@ from app.services.keyword_matcher import (
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 # 新增:获取候选元素详情 get_candidate_details, # 新增:获取候选元素详情
_detect_parent_container, # 阶段6升级:父容器推断(semantic.container 反推)
) )
from app.services.selector_extractor import ( from app.services.selector_extractor import (
extract_selectors, extract_selectors,
...@@ -60,6 +62,14 @@ class SmartLocateService: ...@@ -60,6 +62,14 @@ 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 语义增强 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( def locate_steps(
self, self,
...@@ -101,6 +111,10 @@ class SmartLocateService: ...@@ -101,6 +111,10 @@ class SmartLocateService:
self.use_claude = use_claude and settings.CLAUDE_ENABLED 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}")
# 阶段6升级:重置动态数据收集
self.collected_parameters = {}
self._ctx_value_map = {}
results = [] results = []
try: try:
...@@ -164,6 +178,13 @@ class SmartLocateService: ...@@ -164,6 +178,13 @@ class SmartLocateService:
result['message'] = f'定位成功但执行验证失败: {result.get("message", "")}' result['message'] = f'定位成功但执行验证失败: {result.get("message", "")}'
# 后续步骤全部跳过 # 后续步骤全部跳过
skip_remaining = True 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)} 个步骤") logger.info(f"智能定位完成: {len(results)} 个步骤")
...@@ -576,6 +597,35 @@ class SmartLocateService: ...@@ -576,6 +597,35 @@ class SmartLocateService:
result['message'] = f'验证类步骤,但未找到操作结果提示元素或列表搜索框' result['message'] = f'验证类步骤,但未找到操作结果提示元素或列表搜索框'
return result 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) keywords = extract_keywords(name)
logger.debug(f"提取关键词: {keywords}") logger.debug(f"提取关键词: {keywords}")
...@@ -595,12 +645,25 @@ class SmartLocateService: ...@@ -595,12 +645,25 @@ class SmartLocateService:
result['params']['selector'] = pattern_result['selectors'][0] result['params']['selector'] = pattern_result['selectors'][0]
result['message'] = f"模式匹配成功: {pattern_result['type']}" result['message'] = f"模式匹配成功: {pattern_result['type']}"
logger.info(f"步骤 {order} 匹配特殊模式: {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 return result
# 第一级:关键词直接匹配(返回候选列表) # 第一级:关键词直接匹配(返回候选列表)
# Claude 语义增强已能理解元素上下文,不再需要手动区分抽屉内/外 # Claude 语义增强已能理解元素上下文,不再需要手动区分抽屉内/外
elements_list, selectors = match_element_by_keywords(page, keywords, action) elements_list, selectors = match_element_by_keywords(page, keywords, action)
# ⚠️ 阶段6升级:定位成功元素引用(keyword 成功后反向生成 semantic 用)
located_element = None
if elements_list and selectors: if elements_list and selectors:
# === Phase 5: Claude 语义增强(单候选也验证) === # === Phase 5: Claude 语义增强(单候选也验证) ===
if self.use_claude and len(elements_list) >= 1: if self.use_claude and len(elements_list) >= 1:
...@@ -697,10 +760,13 @@ class SmartLocateService: ...@@ -697,10 +760,13 @@ class SmartLocateService:
# 提取元素信息(使用第一个元素) # 提取元素信息(使用第一个元素)
if elements_list: if elements_list:
result['element_info'] = extract_element_info(elements_list[0]) result['element_info'] = extract_element_info(elements_list[0])
located_element = elements_list[0]
else: 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: if element and selectors:
result['success'] = True result['success'] = True
...@@ -710,6 +776,7 @@ class SmartLocateService: ...@@ -710,6 +776,7 @@ class SmartLocateService:
'candidates': selectors 'candidates': selectors
} }
result['element_info'] = extract_element_info(element) result['element_info'] = extract_element_info(element)
located_element = element
result['claude_enhanced'] = False 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}")
...@@ -731,6 +798,19 @@ class SmartLocateService: ...@@ -731,6 +798,19 @@ class SmartLocateService:
logger.info(f"步骤 {order} 自动修正 value: '{current_value}' -> '{extracted_value}'") logger.info(f"步骤 {order} 自动修正 value: '{current_value}' -> '{extracted_value}'")
result['params']['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: try:
screenshot_path = os.path.join( screenshot_path = os.path.join(
...@@ -788,8 +868,14 @@ class SmartLocateService: ...@@ -788,8 +868,14 @@ class SmartLocateService:
'确定创建', '取消会议', '完成'] '确定创建', '取消会议', '完成']
expects_dialog = any(kw in step_name for kw in dialog_keywords) expects_dialog = any(kw in step_name for kw in dialog_keywords)
# 构建替代选择器列表(优先级从高到低) # 构建替代选择器列表(优先级从高到低):
# 主选择器 + 定位结果中的完整候选列表(pattern/语义路径会产出多个候选,
# 仅尝试主选择器会漏掉真正可用的候补,如列表行的 tr:has-text 兜底)
click_selector_chain = [selector] 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 # 如果当前选择器是 div:has-text 格式,尝试补充 button:has-text
if 'div:visible:has-text' in selector or 'div:has-text' in selector: if 'div:visible:has-text' in selector or 'div:has-text' in selector:
...@@ -805,21 +891,31 @@ class SmartLocateService: ...@@ -805,21 +891,31 @@ class SmartLocateService:
if 'input:visible' in selector: if 'input:visible' in selector:
click_selector_chain.append(f'button:has-text("{step_name}")') click_selector_chain.append(f'button:has-text("{step_name}")')
# 逐级尝试点击 # 逐级尝试点击(每级:普通点击 → force 强点;缩短超时避免 30s 默认卡死)
click_success = False click_success = False
actual_selector_used = None actual_selector_used = None
for sel in click_selector_chain: for sel in click_selector_chain:
try: try:
page.wait_for_selector(sel, timeout=5000) page.wait_for_selector(sel, timeout=5000)
page.click(sel) page.click(sel, timeout=5000)
page.wait_for_timeout(800) page.wait_for_timeout(800)
logger.debug(f"点击成功: {sel}") logger.debug(f"点击成功: {sel}")
click_success = True click_success = True
actual_selector_used = sel actual_selector_used = sel
break break
except Exception as e: except Exception as e:
logger.debug(f"选择器 {sel} 点击失败: {e}") # 普通点击失败(元素被遮挡/滚动区/等),重试 force 点击
continue # (对齐示范用例"勾选会议室 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: if not click_success:
logger.warning(f"步骤 {order} 所有选择器点击均失败") logger.warning(f"步骤 {order} 所有选择器点击均失败")
...@@ -885,6 +981,389 @@ class SmartLocateService: ...@@ -885,6 +981,389 @@ class SmartLocateService:
logger.warning(f"执行步骤验证失败: {e}") logger.warning(f"执行步骤验证失败: {e}")
return False 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]]: def _preprocess_step_pattern(self, step_name: str, action: str) -> Optional[Dict[str, Any]]:
""" """
预处理特殊步骤模式,生成选择器 预处理特殊步骤模式,生成选择器
...@@ -934,6 +1413,8 @@ class SmartLocateService: ...@@ -934,6 +1413,8 @@ class SmartLocateService:
f'.el-tree-node:has-text("{item_name}") .el-checkbox', f'.el-tree-node:has-text("{item_name}") .el-checkbox',
f'li:has-text("{item_name}") .el-checkbox', f'li:has-text("{item_name}") .el-checkbox',
f'[class*="list"]: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 'confidence': 0.85
} }
...@@ -951,6 +1432,8 @@ class SmartLocateService: ...@@ -951,6 +1432,8 @@ class SmartLocateService:
f'[role="tab"]:has-text("{tab_name}")', f'[role="tab"]:has-text("{tab_name}")',
f'.tab-item:has-text("{tab_name}")', f'.tab-item:has-text("{tab_name}")',
f'li[role="tab"]:has-text("{tab_name}")', f'li[role="tab"]:has-text("{tab_name}")',
# 自定义 tab 元素(非 el-tabs 结构)兜底:精确文本匹配
f'text="{tab_name}"',
], ],
'confidence': 0.85 '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 { ...@@ -180,6 +180,10 @@ export interface SmartLocateResult {
screenshot?: string screenshot?: string
/** 说明信息 */ /** 说明信息 */
message: string message: string
/** 生成的 semantic 语义目标(阶段6语义化用例生成器) */
semantic?: Record<string, any> | null
/** 条件生成的 verify 状态验证 */
verify?: Record<string, any> | null
} }
/** /**
...@@ -196,6 +200,8 @@ export interface SmartLocateResponse { ...@@ -196,6 +200,8 @@ export interface SmartLocateResponse {
results: SmartLocateResult[] results: SmartLocateResult[]
/** 总体说明 */ /** 总体说明 */
message: string message: string
/** 动态数据参数初值(param{N}: 值,阶段6 CTX 化收集) */
parameters?: Record<string, string>
} }
/** /**
......
...@@ -1123,19 +1123,30 @@ const batchLocate = async () => { ...@@ -1123,19 +1123,30 @@ const batchLocate = async () => {
if (locateResult.selectors?.primary) { if (locateResult.selectors?.primary) {
updatedParams.selector = locateResult.selectors.primary updatedParams.selector = locateResult.selectors.primary
} }
// ⚠️ 阶段6升级:合并 semantic / verify(新生成的优先,保留已有字段)
return { return {
...s, ...s,
params: updatedParams, params: updatedParams,
locator_type: 'css', locator_type: 'css',
locator_value: locateResult.selectors?.primary || '', locator_value: locateResult.selectors?.primary || '',
semantic: locateResult.semantic ?? s.semantic,
verify: locateResult.verify ?? s.verify,
} }
} }
return s return s
}) })
// ⚠️ 阶段6升级:动态数据 CTX 化收集的参数初值合并进用例参数
// (param{N} 键与步骤内 {__CTX:param{N}__} 占位对齐,执行期才能解析)
const mergedParameters = {
...(caseDetail.parameters || {}),
...(result.parameters || {}),
}
// 5. 更新用例到数据库 // 5. 更新用例到数据库
await caseApi.update(locateConfigCaseId.value, { await caseApi.update(locateConfigCaseId.value, {
steps: updatedSteps, steps: updatedSteps,
parameters: mergedParameters,
}) })
let msg = `智能定位完成:成功 ${result.located_steps}/${result.total_steps} 个步骤` let msg = `智能定位完成:成功 ${result.located_steps}/${result.total_steps} 个步骤`
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论