From a91ead3862beb36f4a3093b70c902673060ab0d7 Mon Sep 17 00:00:00 2001 From: chenfuqiang Date: Sun, 13 Sep 2026 14:25:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E7=BB=8F=E8=B4=B9?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=92=AD=E6=8A=A5=E5=8A=A9=E6=89=8B=E9=A6=96?= =?UTF-8?q?=E7=89=88=20v1.0.1=EF=BC=88track-fund-progress-default=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 54 + .github/workflows/release_skill.yaml | 13 + .gitignore | 10 + CHANGELOG.md | 14 + DEMO.md | 22 + LICENSE | 21 + README.md | 67 + SECURITY.md | 20 + SKILL.md | 115 ++ TUTORIAL.md | 95 + acceptance-reports/README.md | 30 + assets/README.md | 13 + assets/actions.json | 389 ++++ assets/examples/README.md | 9 + assets/examples/lookup-action.example.json | 83 + .../path-root-file-action.example.json | 36 + .../examples/render-html.action.example.json | 28 + .../replaces-create.action.example.json | 28 + .../replaces-delete.action.example.json | 33 + .../replaces-import.action.example.json | 38 + .../replaces-update.action.example.json | 34 + ...selection-label-column.action.example.json | 38 + .../skill-cell-presentation.example.json | 29 + ...ill-policy-snapshot.allowlist.example.json | 13 + .../skill-policy-snapshot.scope.example.json | 28 + assets/examples/version-response.json | 4 + .../examples/visible-when-action.example.json | 72 + assets/html/example-report.html | 119 ++ assets/html/monthly-brief.html | 153 ++ assets/html/progress-report.html | 153 ++ assets/naming/platforms.txt | 13 + assets/naming/scopes.txt | 4 + assets/naming/verbs.txt | 25 + assets/schemas/skill-actions.schema.json | 491 +++++ .../skill-cell-presentation.schema.json | 78 + .../schemas/skill-policy-snapshot.schema.json | 196 ++ assets/screenshots/gitea-workflow-success.png | Bin 0 -> 32913 bytes assets/screenshots/gitea-workflow-tab.png | Bin 0 -> 70896 bytes assets/screenshots/jc2009-download-page.png | Bin 0 -> 50478 bytes assets/screenshots/market-installed-skill.png | Bin 0 -> 80489 bytes assets/screenshots/new-task-usage.png | Bin 0 -> 26594 bytes development/ACCOUNT_MANAGER.md | 216 +++ development/ADAPTER.md | 133 ++ development/AI_RPA.md | 234 +++ development/CHECKLIST.md | 201 +++ development/CONFIG.md | 200 +++ development/DATA_PATHS.md | 193 ++ development/DEVELOPMENT.md | 1046 +++++++++++ development/LOGGING.md | 359 ++++ development/NAMING.md | 237 +++ development/POLICY_MATRIX.md | 76 + development/PRODUCT_SURFACES.md | 188 ++ development/README.md | 51 + development/REQUIREMENTS.md | 130 ++ development/RPA.md | 444 +++++ development/RPA_BROWSER.md | 123 ++ development/RPA_DESKTOP.md | 189 ++ development/RPA_ELEMENT_LOCATING.md | 178 ++ development/RPA_MOBILE.md | 141 ++ development/RUNTIME.md | 145 ++ development/SHARED_REPOS.md | 131 ++ development/SKILL_ACTION_RUNTIME.md | 213 +++ development/TESTING.md | 282 +++ development/TUTORIAL_SURFACES.md | 222 +++ evals/README.md | 5 + evals/golden-cases.md | 39 + examples/README.md | 88 + examples/mobile_rpa/README.md | 94 + examples/real_api/README.md | 35 + examples/real_api/scripts/service/.gitkeep | 0 examples/real_api/scripts/util/.gitkeep | 0 examples/real_api/tests/.gitkeep | 0 examples/real_browser_rpa/README.md | 204 +++ .../assets/element-map.sample.yaml | 45 + .../scripts/service/account_client.py | 355 ++++ .../scripts/service/browser_session.py | 135 ++ .../scripts/service/human_verification.py | 450 +++++ .../scripts/service/task_rpa.py | 637 +++++++ .../scripts/service/task_service.py | 146 ++ .../scripts/util/constants.py | 31 + .../real_browser_rpa/scripts/util/logging.py | 34 + examples/real_browser_rpa/tests/conftest.py | 10 + .../tests/test_account_client.py | 63 + .../tests/test_human_verification.py | 170 ++ .../tests/test_stop_reason.py | 34 + examples/real_desktop_rpa/README.md | 152 ++ .../assets/element-map.sample.yaml | 117 ++ .../assets/selector-capture.sample.xml | 26 + .../scripts/service/desktop_session.py | 129 ++ .../scripts/service/uia_locator.py | 127 ++ .../scripts/service/wecom_reference_flow.py | 58 + .../scripts/util/selectors.py | 63 + .../tests/test_selector_contract.py | 99 + examples/simulator_api/README.md | 35 + .../simulator_api/scripts/service/.gitkeep | 0 examples/simulator_api/scripts/util/.gitkeep | 0 examples/simulator_api/tests/.gitkeep | 0 examples/simulator_browser_rpa/README.md | 163 ++ .../sandbox/demo_app.html | 155 ++ .../scripts/service/.gitkeep | 0 .../scripts/service/account_client.py | 355 ++++ .../scripts/service/adapter/__init__.py | 41 + .../scripts/service/adapter/base.py | 32 + .../scripts/service/adapter/mock.py | 36 + .../scripts/service/adapter/simulator_rpa.py | 109 ++ .../scripts/service/browser_session.py | 126 ++ .../scripts/service/simulator_playwright.py | 400 +++++ .../scripts/service/task_service.py | 100 ++ .../scripts/util/.gitkeep | 0 .../scripts/util/constants.py | 52 + .../scripts/util/logging.py | 38 + examples/simulator_browser_rpa/tests/.gitkeep | 0 .../simulator_browser_rpa/tests/conftest.py | 10 + .../tests/test_account_client.py | 63 + .../tests/test_adapter_dispatch.py | 64 + .../tests/test_no_rpa_helpers_import.py | 39 + .../tests/test_simulator_rpa_unit.py | 134 ++ .../tests/test_stop_reason.py | 74 + pytest.ini | 8 + references/ACTIONS.md | 1158 ++++++++++++ references/CELL_PRESENTATION.md | 210 +++ references/CLI.md | 88 + references/POLICY.md | 383 ++++ references/README.md | 27 + references/SCHEMA.md | 841 +++++++++ references/WORK_ITEM_STATUS.md | 157 ++ release.ps1 | 424 +++++ requirements.txt | 1 + scripts/cli/app.py | 249 +++ scripts/db/connection.py | 252 +++ scripts/db/display_metadata.py | 324 ++++ scripts/db/display_metadata_validator.py | 592 ++++++ scripts/db/seed_data.py | 63 + scripts/db/timestamp_columns.py | 50 + scripts/main.py | 38 + scripts/service/entitlement_service.py | 46 + scripts/service/fund_service.py | 511 ++++++ scripts/service/html_page_service.py | 78 + scripts/service/project_service.py | 322 ++++ scripts/service/report_service.py | 251 +++ scripts/service/sibling_bridge.py | 57 + scripts/service/task_run_support.py | 84 + scripts/service/task_service.py | 620 +++++++ scripts/util/argparse_zh.py | 12 + scripts/util/config_bootstrap.py | 19 + scripts/util/constants.py | 6 + scripts/util/logging_config.py | 21 + scripts/util/runtime_paths.py | 155 ++ scripts/util/slug_naming.py | 227 +++ scripts/util/timeutil.py | 20 + tests/.gitignore | 10 + tests/README.md | 174 ++ tests/_support.py | 141 ++ tests/adapter_test_utils.py | 118 ++ tests/desktop/README.md | 63 + tests/desktop/conftest.py | 88 + tests/desktop/record_screencast.py.sample | 116 ++ tests/desktop/test_desktop_smoke.py.sample | 75 + ...st_desktop_smoke_with_attachment.py.sample | 137 ++ tests/fixtures/README.md | 27 + tests/fixtures/adapter_profiles.sample.yaml | 40 + tests/fixtures/expected_response.json | 6 + tests/fixtures/sample_request.json | 11 + tests/integration/README.md | 48 + .../test_real_api_contract.py.sample | 23 + .../test_real_rpa_contract.py.sample | 26 + .../test_simulator_api_contract.py.sample | 24 + .../test_simulator_rpa_contract.py.sample | 21 + tests/run_tests.py | 62 + tests/samples/README.md | 128 ++ .../test_action_core_contract.py.sample | 116 ++ tests/samples/test_golden_cases.py.sample | 37 + tests/samples/test_logging_contract.py.sample | 56 + tests/samples/test_service_contract.py.sample | 46 + tests/samples/test_sync_contract.py.sample | 274 +++ ...test_table_deprecation_migration.py.sample | 66 + tests/test_actions_manifest.py | 669 +++++++ tests/test_adapter_profile_policy.py | 129 ++ tests/test_cell_presentation_contract.py | 93 + tests/test_cli_smoke.py | 113 ++ tests/test_config_bootstrap.py | 122 ++ tests/test_data_management_contract.py | 479 +++++ tests/test_db_smoke.py | 42 + tests/test_development_policy_guard.py | 1598 +++++++++++++++++ tests/test_display_metadata.py | 815 +++++++++ tests/test_docs_standards.py | 737 ++++++++ tests/test_entrypoint_subprocess.py | 68 + tests/test_fund_rules.py | 262 +++ tests/test_health_runtime.py | 178 ++ tests/test_html_page_service.py | 75 + tests/test_no_rpa_helpers_import.py | 39 + tests/test_platform_import.py | 136 ++ tests/test_release_packaging_constraints.py | 165 ++ tests/test_runtime_diagnostics_integration.py | 72 + tests/test_runtime_paths.py | 169 ++ tests/test_scaffold_guard.py | 145 ++ tests/test_skill_metadata.py | 75 + tests/test_slug_naming.py | 87 + tests/test_template_runtime_standard.py | 84 + tests/test_timestamp_columns.py | 358 ++++ tests/test_tutorial_surfaces.py | 221 +++ tests/test_video_service.py | 314 ++++ tools/README.md | 87 + tools/highlight_answers.py | 194 ++ tools/scaffold_skill.ps1 | 140 ++ tools/scaffold_skill.py | 161 ++ tools/verify_highlight.py | 57 + 207 files changed, 30383 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/release_skill.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 DEMO.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 SKILL.md create mode 100644 TUTORIAL.md create mode 100644 acceptance-reports/README.md create mode 100644 assets/README.md create mode 100644 assets/actions.json create mode 100644 assets/examples/README.md create mode 100644 assets/examples/lookup-action.example.json create mode 100644 assets/examples/path-root-file-action.example.json create mode 100644 assets/examples/render-html.action.example.json create mode 100644 assets/examples/replaces-create.action.example.json create mode 100644 assets/examples/replaces-delete.action.example.json create mode 100644 assets/examples/replaces-import.action.example.json create mode 100644 assets/examples/replaces-update.action.example.json create mode 100644 assets/examples/selection-label-column.action.example.json create mode 100644 assets/examples/skill-cell-presentation.example.json create mode 100644 assets/examples/skill-policy-snapshot.allowlist.example.json create mode 100644 assets/examples/skill-policy-snapshot.scope.example.json create mode 100644 assets/examples/version-response.json create mode 100644 assets/examples/visible-when-action.example.json create mode 100644 assets/html/example-report.html create mode 100644 assets/html/monthly-brief.html create mode 100644 assets/html/progress-report.html create mode 100644 assets/naming/platforms.txt create mode 100644 assets/naming/scopes.txt create mode 100644 assets/naming/verbs.txt create mode 100644 assets/schemas/skill-actions.schema.json create mode 100644 assets/schemas/skill-cell-presentation.schema.json create mode 100644 assets/schemas/skill-policy-snapshot.schema.json create mode 100644 assets/screenshots/gitea-workflow-success.png create mode 100644 assets/screenshots/gitea-workflow-tab.png create mode 100644 assets/screenshots/jc2009-download-page.png create mode 100644 assets/screenshots/market-installed-skill.png create mode 100644 assets/screenshots/new-task-usage.png create mode 100644 development/ACCOUNT_MANAGER.md create mode 100644 development/ADAPTER.md create mode 100644 development/AI_RPA.md create mode 100644 development/CHECKLIST.md create mode 100644 development/CONFIG.md create mode 100644 development/DATA_PATHS.md create mode 100644 development/DEVELOPMENT.md create mode 100644 development/LOGGING.md create mode 100644 development/NAMING.md create mode 100644 development/POLICY_MATRIX.md create mode 100644 development/PRODUCT_SURFACES.md create mode 100644 development/README.md create mode 100644 development/REQUIREMENTS.md create mode 100644 development/RPA.md create mode 100644 development/RPA_BROWSER.md create mode 100644 development/RPA_DESKTOP.md create mode 100644 development/RPA_ELEMENT_LOCATING.md create mode 100644 development/RPA_MOBILE.md create mode 100644 development/RUNTIME.md create mode 100644 development/SHARED_REPOS.md create mode 100644 development/SKILL_ACTION_RUNTIME.md create mode 100644 development/TESTING.md create mode 100644 development/TUTORIAL_SURFACES.md create mode 100644 evals/README.md create mode 100644 evals/golden-cases.md create mode 100644 examples/README.md create mode 100644 examples/mobile_rpa/README.md create mode 100644 examples/real_api/README.md create mode 100644 examples/real_api/scripts/service/.gitkeep create mode 100644 examples/real_api/scripts/util/.gitkeep create mode 100644 examples/real_api/tests/.gitkeep create mode 100644 examples/real_browser_rpa/README.md create mode 100644 examples/real_browser_rpa/assets/element-map.sample.yaml create mode 100644 examples/real_browser_rpa/scripts/service/account_client.py create mode 100644 examples/real_browser_rpa/scripts/service/browser_session.py create mode 100644 examples/real_browser_rpa/scripts/service/human_verification.py create mode 100644 examples/real_browser_rpa/scripts/service/task_rpa.py create mode 100644 examples/real_browser_rpa/scripts/service/task_service.py create mode 100644 examples/real_browser_rpa/scripts/util/constants.py create mode 100644 examples/real_browser_rpa/scripts/util/logging.py create mode 100644 examples/real_browser_rpa/tests/conftest.py create mode 100644 examples/real_browser_rpa/tests/test_account_client.py create mode 100644 examples/real_browser_rpa/tests/test_human_verification.py create mode 100644 examples/real_browser_rpa/tests/test_stop_reason.py create mode 100644 examples/real_desktop_rpa/README.md create mode 100644 examples/real_desktop_rpa/assets/element-map.sample.yaml create mode 100644 examples/real_desktop_rpa/assets/selector-capture.sample.xml create mode 100644 examples/real_desktop_rpa/scripts/service/desktop_session.py create mode 100644 examples/real_desktop_rpa/scripts/service/uia_locator.py create mode 100644 examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py create mode 100644 examples/real_desktop_rpa/scripts/util/selectors.py create mode 100644 examples/real_desktop_rpa/tests/test_selector_contract.py create mode 100644 examples/simulator_api/README.md create mode 100644 examples/simulator_api/scripts/service/.gitkeep create mode 100644 examples/simulator_api/scripts/util/.gitkeep create mode 100644 examples/simulator_api/tests/.gitkeep create mode 100644 examples/simulator_browser_rpa/README.md create mode 100644 examples/simulator_browser_rpa/sandbox/demo_app.html create mode 100644 examples/simulator_browser_rpa/scripts/service/.gitkeep create mode 100644 examples/simulator_browser_rpa/scripts/service/account_client.py create mode 100644 examples/simulator_browser_rpa/scripts/service/adapter/__init__.py create mode 100644 examples/simulator_browser_rpa/scripts/service/adapter/base.py create mode 100644 examples/simulator_browser_rpa/scripts/service/adapter/mock.py create mode 100644 examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py create mode 100644 examples/simulator_browser_rpa/scripts/service/browser_session.py create mode 100644 examples/simulator_browser_rpa/scripts/service/simulator_playwright.py create mode 100644 examples/simulator_browser_rpa/scripts/service/task_service.py create mode 100644 examples/simulator_browser_rpa/scripts/util/.gitkeep create mode 100644 examples/simulator_browser_rpa/scripts/util/constants.py create mode 100644 examples/simulator_browser_rpa/scripts/util/logging.py create mode 100644 examples/simulator_browser_rpa/tests/.gitkeep create mode 100644 examples/simulator_browser_rpa/tests/conftest.py create mode 100644 examples/simulator_browser_rpa/tests/test_account_client.py create mode 100644 examples/simulator_browser_rpa/tests/test_adapter_dispatch.py create mode 100644 examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py create mode 100644 examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py create mode 100644 examples/simulator_browser_rpa/tests/test_stop_reason.py create mode 100644 pytest.ini create mode 100644 references/ACTIONS.md create mode 100644 references/CELL_PRESENTATION.md create mode 100644 references/CLI.md create mode 100644 references/POLICY.md create mode 100644 references/README.md create mode 100644 references/SCHEMA.md create mode 100644 references/WORK_ITEM_STATUS.md create mode 100644 release.ps1 create mode 100644 requirements.txt create mode 100644 scripts/cli/app.py create mode 100644 scripts/db/connection.py create mode 100644 scripts/db/display_metadata.py create mode 100644 scripts/db/display_metadata_validator.py create mode 100644 scripts/db/seed_data.py create mode 100644 scripts/db/timestamp_columns.py create mode 100644 scripts/main.py create mode 100644 scripts/service/entitlement_service.py create mode 100644 scripts/service/fund_service.py create mode 100644 scripts/service/html_page_service.py create mode 100644 scripts/service/project_service.py create mode 100644 scripts/service/report_service.py create mode 100644 scripts/service/sibling_bridge.py create mode 100644 scripts/service/task_run_support.py create mode 100644 scripts/service/task_service.py create mode 100644 scripts/util/argparse_zh.py create mode 100644 scripts/util/config_bootstrap.py create mode 100644 scripts/util/constants.py create mode 100644 scripts/util/logging_config.py create mode 100644 scripts/util/runtime_paths.py create mode 100644 scripts/util/slug_naming.py create mode 100644 scripts/util/timeutil.py create mode 100644 tests/.gitignore create mode 100644 tests/README.md create mode 100644 tests/_support.py create mode 100644 tests/adapter_test_utils.py create mode 100644 tests/desktop/README.md create mode 100644 tests/desktop/conftest.py create mode 100644 tests/desktop/record_screencast.py.sample create mode 100644 tests/desktop/test_desktop_smoke.py.sample create mode 100644 tests/desktop/test_desktop_smoke_with_attachment.py.sample create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/adapter_profiles.sample.yaml create mode 100644 tests/fixtures/expected_response.json create mode 100644 tests/fixtures/sample_request.json create mode 100644 tests/integration/README.md create mode 100644 tests/integration/test_real_api_contract.py.sample create mode 100644 tests/integration/test_real_rpa_contract.py.sample create mode 100644 tests/integration/test_simulator_api_contract.py.sample create mode 100644 tests/integration/test_simulator_rpa_contract.py.sample create mode 100644 tests/run_tests.py create mode 100644 tests/samples/README.md create mode 100644 tests/samples/test_action_core_contract.py.sample create mode 100644 tests/samples/test_golden_cases.py.sample create mode 100644 tests/samples/test_logging_contract.py.sample create mode 100644 tests/samples/test_service_contract.py.sample create mode 100644 tests/samples/test_sync_contract.py.sample create mode 100644 tests/samples/test_table_deprecation_migration.py.sample create mode 100644 tests/test_actions_manifest.py create mode 100644 tests/test_adapter_profile_policy.py create mode 100644 tests/test_cell_presentation_contract.py create mode 100644 tests/test_cli_smoke.py create mode 100644 tests/test_config_bootstrap.py create mode 100644 tests/test_data_management_contract.py create mode 100644 tests/test_db_smoke.py create mode 100644 tests/test_development_policy_guard.py create mode 100644 tests/test_display_metadata.py create mode 100644 tests/test_docs_standards.py create mode 100644 tests/test_entrypoint_subprocess.py create mode 100644 tests/test_fund_rules.py create mode 100644 tests/test_health_runtime.py create mode 100644 tests/test_html_page_service.py create mode 100644 tests/test_no_rpa_helpers_import.py create mode 100644 tests/test_platform_import.py create mode 100644 tests/test_release_packaging_constraints.py create mode 100644 tests/test_runtime_diagnostics_integration.py create mode 100644 tests/test_runtime_paths.py create mode 100644 tests/test_scaffold_guard.py create mode 100644 tests/test_skill_metadata.py create mode 100644 tests/test_slug_naming.py create mode 100644 tests/test_template_runtime_standard.py create mode 100644 tests/test_timestamp_columns.py create mode 100644 tests/test_tutorial_surfaces.py create mode 100644 tests/test_video_service.py create mode 100644 tools/README.md create mode 100644 tools/highlight_answers.py create mode 100644 tools/scaffold_skill.ps1 create mode 100644 tools/scaffold_skill.py create mode 100644 tools/verify_highlight.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e39ea3 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 +OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock + +# 目标网站地址:技能要访问的网站或服务地址 +TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 + +# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名/手机号) +DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码 + +# 默认账号编号:仅填账号管理里的纯数字主键(一般留空) +DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号请填上方登录账号 + +# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 +OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) + +# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力 +OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制 + +# 是否录制操作过程:用于留证、复现问题或向他人演示 +OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1 + +# 失败时是否截图:任务出错时自动保存现场截图,便于排查 +OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图 + +# 步骤最短等待:两个正常业务动作之间随机停顿的下限 +STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大 + +# 步骤最长等待:两步操作之间随机停顿的上限 +STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断 + +# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间 +HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300 + +# 浏览器启动超时(可选):限制 launch_persistent_browser 最长时间;空=不限制(默认) +# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器/限频时可开启并调大 + +# 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。 +# 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。 +# 用户可见只有 input(上行)与 output(下行);见 development/DATA_PATHS.md。 + +# 输入目录:用户/Agent 放入的待处理文件(清单、CSV、产品表等) +# SKILL_INPUT_DIR=input # 相对本技能数据目录;留空或保持注释则用默认 input + +# 输出目录:技能/宿主交给用户拿走的结果(视频、报表、导出文件等) +# SKILL_OUTPUT_DIR=output # 相对本技能数据目录;留空或保持注释则用默认 output + +# 上传目录:待发布到外部平台的 staging(可选,不上侧栏) +# SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads + +# 缓存目录:临时缓存文件的位置(不上侧栏) +# SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache + +# 临时目录:运行中临时文件的位置(不上侧栏) +# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp diff --git a/.github/workflows/release_skill.yaml b/.github/workflows/release_skill.yaml new file mode 100644 index 0000000..0b0c53c --- /dev/null +++ b/.github/workflows/release_skill.yaml @@ -0,0 +1,13 @@ +name: 技能自动化发布 +on: + push: + tags: ["v*"] + +jobs: + release: + uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + secrets: + PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }} + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08d4ec6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.env +*.env.local +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5dd05e4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +本文件对应技能市场 **「更新日志」** Tab。 +发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。 + +## 1.0.1 + +- 首个版本:项目经费"智能进度播报"上线 +- 按项目台账自动计算实际支出率与序时进度,诊断 严重滞后 / 支出异常偏快 / 正常(阈值可调) +- 三段式播报摘要(现状/诊断/建议),暂付款冲销与结题临近提醒 +- 《经费执行简报》HTML(含进度柱状图)与月度汇总简报,播报历史入库 +- 月度定时播报(async Action,可挂宿主定时任务,任务中心可见进度) +- 台账支持表单录入、JSON 批量导入与表格直改;预警规则可在数据管理调整 +- 宿主入口:对话任务(播报/查询/导入/统计)、数据管理(表单/行级按钮/HTML 报告)、定时任务(月度播报) diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 0000000..9c7cc44 --- /dev/null +++ b/DEMO.md @@ -0,0 +1,22 @@ +--- +demo_video_url: "" +--- + +演示视频录制后请把地址填入上方 `demo_video_url`。以下是无视频时的图文演示脚本。 + +## 演示一:PI 随口问一句(对话) + +1. 对话任务中说:"查一下我的国自然经费执行得怎么样,我是张三。" +2. 助手返回三段式播报:现状(到账 30 万、已报销 6 万、支出率 20.0%、序时进度 70.0%)、诊断(严重滞后+暂付款超期 156 天)、建议(排查原因、及时冲销)。 +3. 简报在数据管理侧栏打开,红色"严重滞后"标签与进度柱状图一目了然。 + +## 演示二:科研秘书月度催办(数据管理 + 定时) + +1. "项目台账"表顶栏点「经费进度播报」,一键播报全部在研项目。 +2. "播报记录"表按状态筛选"严重滞后",得到本院滞后名单;顶栏「生成经费简报」重建简报。 +3. 宿主定时任务里建"技能直调"选「月度经费播报」,每月 5 号自动出汇总简报。 + +## 演示三:财务处口径校准(管理员) + +1. "预警规则"表把 lag_threshold 行的阈值改为 0.15,观察下一次播报状态变化。 +2. 对话里问"全校经费执行统计一下",查看状态分布与暂付款总额。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bb90574 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 深圳匠厂科技有限公司 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..108f0cc --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +--- +description: "给 PI 和科研秘书的项目经费“体检表”:自动计算执行率与序时进度,标出严重滞后和突击花钱风险,月度定时播报并生成《经费执行简报》。" +--- + +项目经费进度播报助手帮你把"项目还有多少钱、花得快不快"这件事变成每月自动送达的简报:按项目台账自动计算实际支出率与序时进度,标出严重滞后和突击花钱风险,提前提醒暂付款冲销和结题报销。播报仅作提醒,请以财务系统账面为准。 + +--- + +## 这个技能能帮你做什么 + +- 自动计算每个项目的实际支出率(已报销/已到账)与序时进度(已过天数/总执行天数) +- 按"低于序时进度 10% 记严重滞后、高于 20% 记支出异常偏快"的诊断口径标红预警,阈值可调 +- 生成三段式播报摘要:现状、诊断、建议,数字全部来自台账计算,不编造 +- 提醒暂付款冲销(超 90 天预警)与结题临近报销(距结束 90 天内且支出率不足 80%) +- 每月定时自动播报全部在研项目,生成带柱状图的《经费执行简报》汇总页 +- 保存播报历史,财务处可查全校执行状态分布与滞后 Top 名单 + +## 适合什么场景 + +- 项目负责人(PI)想随时知道自己项目的执行率,而不是等月底查账 +- 科研秘书每月要催办本院滞后项目,需要一份现成的进度汇总与滞后名单 +- 财务处经费管理科想做全校经费执行监测,减少人工催报电话 +- 项目临近结题,想确认支出率是否达到结题要求 + +## 使用前需要准备什么 + +- 项目台账:需要每个项目的编号、名称、负责人、总立项经费、已到账、已报销、暂付款、执行期起止;可以逐条录入、批量粘贴 JSON 或在表格里直接改 +- 台账数据当前由人工维护或从财务系统导出后导入,播报时请注明数据基准日(缺省为当天) +- 首次使用时管理员可确认预警阈值(严重滞后 10%、异常偏快 20%、暂付款 90 天、结题预警 90 天)符合本校管理口径 +- 本技能在本地完成全部计算,不需要登录财务系统;执行结果以财务系统账面为准 + +## 怎么使用 + +支持 **数据管理**(录台账、点按钮播报、改阈值)、**对话任务**("查一下我的项目经费执行得怎么样")与 **定时任务**(每月 5 号自动播报)三种方式。逐步操作请查看技能市场「使用教程」Tab。 + +## 执行后你会得到什么 + +- 每个项目一条执行状态:🔴 严重滞后 / 🟠 支出异常偏快 / 🟢 正常(含数据待补录、未到账两种灰色状态) +- 一份三段式播报摘要(现状 / 诊断 / 建议)与《经费执行简报》HTML,可在数据管理侧栏预览 +- 月度播报另生成全校(或全院)汇总简报,含每个项目的进度对比柱状图 +- 播报历史入库:可按状态筛选、可统计滞后 Top 与暂付款总额 + +## 注意事项 + +- 播报是**提醒**不是决定:是否调整支出节奏由 PI 与财务处沟通决定 +- 台账金额靠人工维护时,播报准确度取决于数据新鲜度;简报会标注数据基准日 +- 序时进度按自然日比例计算(更平滑),与"整月"口径略有差异;阈值可按学校口径调整 +- 项目台账金额属敏感经营数据,请注意不要把简报转发给无关人员 +- 执行率以"已到账"为分母;未到账项目会单独标出,不计入滞后诊断 + +## 常见问题 + +### 执行率为什么用"已到账"做分母而不是"总立项"? + +分批拨款的纵向项目按到账口径更贴近可支出额度;如学校管理口径要求用立项总经费,可自行把到账金额按口径填入台账。口径说明见教程。 + +### "严重滞后"是处分吗? + +不是。它只是提示"花钱速度明显慢于时间进度",用于引起关注并尽早规划;是否需要预算调剂由项目负责人与财务处协商。 + +### 数据从哪里来,会自动更新吗? + +当前版本台账由人工录入或批量导入(可从财务系统导出后整理成 JSON);财务系统直连自动刷新在后续版本规划中。 + +### 谁能看到我的项目金额? + +技能运行在你本机的宿主环境内,数据保存在本地数据库;请避免把含金额的简报外发。月度汇总面向科研秘书/财务处的催办场景。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..657e660 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# 安全漏洞披露 + +本仓库是匠厂技能开发模板。请把**未公开的安全漏洞**发给深圳匠厂科技有限公司,不要在公开 Issue、PR 或讨论区里贴完整复现步骤、PoC 或利用细节。 + +## 怎么报告 + +请通过匠厂官网联系渠道提交:[https://www.jc2009.com/](https://www.jc2009.com/) + +建议在联系时注明: + +- 标题或开头带 `[Security]` +- 影响的仓库 / 版本(例如 `skill-template`、tag `v1.0.x`) +- 问题类型、影响范围、复现步骤 +- 若已有修复思路,可一并说明 + +我们会确认收悉,并在有结论后告知是否修复、是否公开披露。 + +## 不在本页范围 + +本页只说明**如何向我们报告漏洞**。技能运行时的权限、RPA 合规、加密发布等产品安全设计,见 `development/` 与 `references/`,不要写进本文件。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..3a8121c --- /dev/null +++ b/SKILL.md @@ -0,0 +1,115 @@ +--- +name: 项目经费进度播报助手 +description: "高校项目经费执行监测与播报:按项目台账自动计算实际支出率与序时进度,识别严重滞后/支出异常偏快,生成三段式播报摘要、暂付款冲销与结题报销提醒,支持月度定时播报并输出《经费执行简报》。" +version: 1.0.1 +author: 深圳匠厂科技有限公司 +metadata: + openclaw: + slug: track-fund-progress-default + platform_kit_min_version: "1.2.11" + emoji: "📊" + category: "财务" + brands: + - jiangchang + - huashang + developer_ids: + - 79017 # 开发者本人匠厂用户 ID(设置 → 用户信息 → 复制) +allowed-tools: + - bash +--- + +# 项目经费进度播报助手(track-fund-progress-default) + +高校项目经费执行监测与播报:按项目台账(立项/到账/报销/暂付款/执行期)自动计算**实际支出率**与**序时进度**,识别"严重滞后 / 支出异常偏快 / 正常",生成三段式播报摘要与《经费执行简报》(含柱状图);支持暂付款冲销提醒、结题临近提醒与**每月定时播报**。全部数字来自本地确定性计算与规则模板,不编造数据;播报仅作提醒,请以财务系统账面为准。 + +## Agent / 宿主编排 + +- **Agent 是入口与编排者**:理解查询意图(如"查一下我的国自然经费")、定位负责人与项目、触发播报 Action,并用业务语言解读播报结论。 +- 台账数据来自本地库(表单录入 / JSON 批量导入 / 数据管理直接编辑);Agent 不得凭空编造项目金额,台账缺失时引导补录。 +- 播报文本由技能内核以规则模板生成(确定性、零幻觉);Agent 转述时可口语化,但**不得更改数字与状态判定**。 +- 本技能无 RPA、无浏览器自动化;不修改任何外部系统数据。 + +## 能力类型与触发 + +| 你要做什么 | Agent 怎么做 | 进任务中心? | +|------------|--------------|--------------| +| 查台账 / 查播报历史 / 查阈值 / 统计 | 短 CLI:`list-projects` / `list-reports` / `view-rules` / `stats` | 否 | +| 播报全部在研项目或指定项目(同步秒级) | `run_skill_action` → `broadcast-progress`(`input.projectNo/pi/asOf`) | 否 | +| 月度定时播报(全部在研项目 + 汇总简报) | `run_skill_action` → `broadcast-monthly` | 是(该 Action 为 async) | +| 录入/更新项目台账 | 数据管理表上操作,或 `run_skill_action` → `add-project`;批量走 `import-projects` | 否 | +| 重建《经费执行简报》HTML | `run_skill_action` → `render-html` | 否 | +| 调整预警阈值 | 数据管理行级按钮 `set-rule`;或短 CLI:`set-rule` | 否 | +| 用户只问用法 / 能做什么 | 读根目录 `README.md`;逐步操作再读 `TUTORIAL.md` | — | + +进不进任务中心**只**看该 Action 的 `executionProfile`(sync 当场返回 / async 进任务中心),与数据管理、对话、定时等触发入口**无关**;每个 Action 都已显式声明 `executionProfile`。 + +本技能为"即时计算"型能力:提交即算、当场出结论,不存在「先准备本地数据、再对外产生副作用」的两段式,因此无需按「准备 vs 执行」拆分;月度播报为无人值守只读计算+写本地记录,安全可定时。本技能无网页 RPA,不涉及账号管理;宿主账号/Profile/租约规范见 `development/ACCOUNT_MANAGER.md`。 + +## 用户意图路由表 + +| 用户意图 | Agent 怎么做 | 进任务中心? | +|------------------------|--------------|--------------| +| "查一下我的国自然经费执行得怎么样" / "播报我的项目进度" | 确定负责人姓名;`run_skill_action` → `broadcast-progress`,`input.pi=负责人`(单项目传 `projectNo`) | 否 | +| "月度播报一次全部项目" / 定时到点自动执行 | `run_skill_action` → `broadcast-monthly` | 是(因该 Action 为 async) | +| "录入一个新项目 / 把这批项目导进去" | 单条走数据管理表单 `add-project`;批量把 JSON 交给 `import-projects` | 否 | +| "我上次播报的结果是什么" | 短 CLI:`list-reports [--status-tag 严重滞后]`;详情 `show-report --id N` | 否 | +| "重新生成简报" | `run_skill_action` → `render-html`;结果在数据管理 HTML 报告侧栏 | 否 | +| "滞后阈值改成 15%" | `run_skill_action` → `set-rule`(`ruleKey=lag_threshold, value=0.15`)或数据管理行级按钮 | 否 | +| "全校经费执行统计" | 短 CLI:`stats` | 否 | + +**硬规则:** + +1. `broadcast-monthly` 是本技能唯一 async Action;禁止用 bash 跑它再轮询等待。 +2. 台账金额必须由用户提供或导入,Agent 不得编造、估算金额与日期。 +3. 播报结论必须说明"仅作提醒,以财务系统账面为准";不得替 PI 决定如何花钱。 +4. 项目台账属敏感经营数据;对话查询应限定负责人本人的项目,滞后 Top 名单面向科研秘书/财务处场景。 +5. 一个业务能力一个 Action,用多个 placements 挂入口;底层同一 `scripts/main.py` CLI。 + +Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`。 + +## 共享 Python(必读) + +宿主共享 runtime 在 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,并已前置到 PATH(同时设 `VIRTUAL_ENV`)。短 CLI 一条命令直接跑: + +```bash +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py version +python {baseDir}/scripts/main.py init-db +python {baseDir}/scripts/main.py broadcast [--pi 负责人] [--project-no X] [--as-of YYYY-MM-DD] +``` + +- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.11`(包名路径 `jiangchang_skill_core`);不要 vendor。 +- **禁止**用 `uv run python` 跑技能脚本(会建临时环境,缺 `jiangchang_skill_core`);**禁止** Agent 自行 `pip install`。 +- 根目录 `requirements.txt` 只声明技能特有依赖(本技能当前无)。 +- 配置:用户 `.env` 在 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`;优先级:进程环境变量 > 用户 `.env` > `.env.example`。 + +## 面向用户问答(LLM 规则) + +- 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。 +- 当用户询问"这个技能是做什么的 / 怎么用 / 适合什么场景 / 用前准备什么 / 得到什么",**必须优先读取**根目录 `README.md`(能力说明)与 `TUTORIAL.md`(逐步用法),并用业务语言回答。 +- **回答「怎么用 / 能做什么」时的要求**:优先读 `TUTORIAL.md`;**禁止**在回答中粘贴 `python …/scripts/main.py …` 或其它命令行(除非用户明确索要命令,或自称技术人员/开发者);**禁止**为答用法去 `dir` / `ls` 列技能目录,或默认打开 `references/`、`development/`。 +- **禁止**向普通用户暴露 JSON 字段名、表名、`error_code` 等实现细节(除非用户明确询问技术实现)。 +- 市场说明 Tab(`README.md`)不含分步操作;排障步骤在 `TUTORIAL.md`「常见卡住怎么办」。 + +## 文档分工 + +| 文档 | 读者 | 用途 | +|------|------|------| +| 根目录 `README.md` | 普通用户 | 市场 **说明** Tab(`readme_md`) | +| 根目录 `TUTORIAL.md` | 普通用户 | 市场 **教程** Tab(`tutorial_md`) | +| 根目录 `DEMO.md` | 普通用户 | 市场 **演示** Tab(`demo_video_url` + `demo_md`) | +| 根目录 `CHANGELOG.md` | 普通用户 | 市场 **更新日志** Tab(`changelog`) | +| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 | +| `references/` | Agent 编排 / 调用 | CLI、Action、schema 契约(Agent 主读) | +| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范 | + +## 目录约定 + +- CLI 入口固定为 `scripts/main.py`;业务逻辑按 `cli / db / service / util` 分层。 +- 业务表:`fund_projects`(项目台账)、`progress_reports`(播报记录)、`alert_rules`(预警规则)。 +- HTML 简报写入技能数据目录 `html/`(单项目 `report-N.html`、月度汇总 `brief-*.html`),重建走固定 Action id `render-html`。 + +## 平台元数据 + +- `metadata.openclaw.developer_ids`:发布后默认可见的用户 ID 列表;**发布前必须替换为本人的匠厂用户 ID**(设置 → 用户信息 → 复制),否则开发期自己无法在市场安装自测。 +- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。 diff --git a/TUTORIAL.md b/TUTORIAL.md new file mode 100644 index 0000000..3a37832 --- /dev/null +++ b/TUTORIAL.md @@ -0,0 +1,95 @@ +数据管理、对话任务与定时任务是本技能三种**平行**的用法:科研秘书在表上录台账、点播报,走数据管理;PI 想随口问一句自己的经费,走对话;每月 5 号自动播报,走定时任务。 + +## 本技能做什么 + +技能维护一份"项目台账"(编号、名称、负责人、立项/到账/报销/暂付款、执行期),播报时对每个在研项目计算实际支出率与序时进度,按阈值诊断出 严重滞后 / 支出异常偏快 / 正常,并给出暂付款冲销、结题报销提醒,最后生成带柱状图的《经费执行简报》。播报历史会保存下来供查询与统计。 + +## 开始前 + +- 准备项目台账数据:至少要有项目编号、负责人、已到账、已报销、执行期起止(金额单位为元) +- 管理员确认预警阈值符合学校口径(缺省:严重滞后 10%、异常偏快 20%、暂付款账龄 90 天、结题预警 90 天) +- 想用月度定时播报,请先确认宿主支持定时任务并已建好(见入口三) + +## 入口一:数据管理 + +左侧表列表中能看到本技能的三张表:项目台账、播报记录、预警规则。 + +### 录入/维护项目台账 + +1. 点左侧"项目台账"表,再点表顶栏的「录入项目台账」按钮 +2. 在表单里填写项目编号(必填)、名称、负责人、总立项经费、已到账、已报销、暂付款、执行期起止后提交;同一编号再次提交即为更新(未填字段不会被覆盖) +3. 也可以直接在表格里双击编辑金额与日期字段 + +### 播报经费进度 + +1. 在"项目台账"表点表顶栏的「经费进度播报」按钮 +2. 缺省播报全部"在研"项目;也可以只填某个项目编号或负责人 +3. 提交后稍等片刻,表格提示播报完成;切到"播报记录"表查看每个项目的状态与偏差 + +### 查看《经费执行简报》 + +1. 在"播报记录"表点表顶栏的「生成经费简报」按钮(不选行时重建最近 10 份) +2. 左侧 HTML 报告区出现 `report-编号`(单项目)与 `brief-批次`(月度汇总)条目,点开即可预览进度柱状图与三段式摘要 + +### 调整预警阈值 + +1. 打开"预警规则"表,找到要调整的规则(如 lag_threshold) +2. 点该行操作列的「更新预警阈值」,填入新值提交;下一次播报即按新阈值诊断 + +## 入口二:对话任务 + +新建任务,选择本技能后直接说事。 + +### 查询自己的项目进度 + +> 查一下我的国自然经费执行得怎么样,我是张三 + +助手会按负责人播报其名下在研项目,并用三段式播报文本回复(现状 / 诊断 / 建议);可以继续追问"哪个项目滞后最严重""我的暂付款要不要处理"。 + +### 录入与批量导入 + +> 录入一个项目:编号 HX2026010,负责人李四,总立项 50 万,已到账 40 万,已报销 35 万,2026-01-01 到 2026-12-31 + +批量导入时把台账 JSON 数组发给助手(可从财务系统导出后整理),助手逐条校验导入并回报新增/更新数量。 + +### 查历史与统计 + +- "上次播报哪些项目滞后了" → 返回播报历史(可只看严重滞后) +- "全校经费执行统计一下" → 返回状态分布、滞后 Top 与暂付款总额 + +### 重建简报 + +> 重新生成一下经费简报 + +助手重建最近 10 份简报,去数据管理 HTML 报告区打开即可。 + +## 入口三:定时任务 + +本技能提供月度无人值守播报(对应"每月 5 号上午 9:00 自动执行"的场景): + +1. 在宿主"定时任务"里新建任务,类型选"技能直调",选择本技能的「月度经费播报」 +2. 执行时刻按学校要求设置(建议每月 5 号上午,错开月底报销高峰;具体时间由你在宿主配置) +3. 到点自动播报全部在研项目并生成月度汇总简报;执行过程与结果可在任务中心查看,无需人在场 + +月度播报只做本地计算与记录写入,不改任何外部系统,可放心无人值守。 + +## 主能力对照表 + +| 能力 | 数据管理 | 对话任务 | 定时任务 | +|------|----------|----------|----------| +| 经费进度播报 | 「经费进度播报」按钮(可指定项目/负责人) | 直接说事由+负责人 | 月度播报自动执行 | +| 月度汇总简报 | 由播报自动生成 | 由月度播报自动生成 | 每月自动生成 | +| 录入/更新台账 | 顶栏按钮 + 表格直改 | 对话说明项目信息 | 不适用 | +| 批量导入 | 不支持 | 粘贴 JSON 给助手 | 不适用 | +| 查看简报 | 「生成经费简报」按钮 + 侧栏预览 | 让助手重建后去侧栏打开 | 自动生成 | +| 调整预警阈值 | 行级「更新预警阈值」 | 也可以让助手改 | 不适用 | +| 历史与统计 | 查看播报记录表 | 对话查询 | 不适用 | + +## 常见卡住怎么办 + +- **播报提示"没有可播报的项目"**:台账里没有"在研"项目或筛选条件不匹配;先在"项目台账"录入或调整状态 +- **执行率显示"—"**:该项目已到账为 0(未到账)或台账缺数字;到账后自动恢复,或补录金额 +- **状态一直是"待补录"**:台账缺总立项经费、到账、报销或执行期起止中的某项,按提示补齐后重新播报 +- **诊断结果和预期不一致**:检查预警阈值("预警规则"表)与台账数据基准日;播报口径为"已到账"分母 +- **看不到简报**:先在"播报记录"表点一次「生成经费简报」,再到左侧 HTML 报告区打开 +- **月度定时没跑**:检查宿主定时任务是否启用、指向的 Action 是否为「月度经费播报」,任务中心有无失败记录 diff --git a/acceptance-reports/README.md b/acceptance-reports/README.md new file mode 100644 index 0000000..893a44e --- /dev/null +++ b/acceptance-reports/README.md @@ -0,0 +1,30 @@ +# 验收报告目录(acceptance-reports) + +本目录存放**技能交活验收报告**,供技术人员查看并按未通过项整改。 + +## 谁写、写什么 + +- 验收人(或 AI 编程工具)对照 `development/CHECKLIST.md` 执行验收后,**必须**在本目录落盘报告。 +- 标准与条目定义见:`development/CHECKLIST.md`(含强制输出规则)。 + +## 命名(强制) + +```text +YYYYMMDD-HHMMSS-acceptance.md +``` + +示例:`20260722-175000-acceptance.md` + +- 使用验收执行时的本地时间 +- 不要覆盖旧报告;保留历史便于对比是否修好 + +## 技术人员怎么用 + +1. `git pull` 拉取含报告的最新代码(或验收人推送后的提交) +2. 打开本目录中**时间戳最新**的一份 `*-acceptance.md` +3. 只看文末「给技术人员的修改清单」及所有 `FAIL` / 未通过的 `NEED_MANUAL` +4. 改完后告知验收人复验;新一轮会再生成一份新时间戳报告 + +## 模板仓说明 + +本 `skill-template` 仓库通常不产生业务验收报告;此目录随模板复制到新技能后使用。 diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..1355737 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,13 @@ +# assets + +- `actions.json`:Skill Action manifest 最小示例(按入口定位表决定是否保留/扩展;见 `development/PRODUCT_SURFACES.md`)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。 +- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。 +- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。 +- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。 +- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。 + +`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列);placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层默认展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 + +- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);写法契约见 [`development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md) +- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)) +- 开发规范见 [`development/`](../development/) diff --git a/assets/actions.json b/assets/actions.json new file mode 100644 index 0000000..42b48b2 --- /dev/null +++ b/assets/actions.json @@ -0,0 +1,389 @@ +{ + "schemaVersion": 1, + "skill": "track-fund-progress-default", + "actions": [ + { + "id": "broadcast-progress", + "label": "经费进度播报", + "description": "按项目台账计算实际支出率与序时进度,识别严重滞后/支出异常偏快,生成三段式播报与《经费执行简报》。缺省播报全部在研项目。", + "placements": ["agent", "toolbar"], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["fund_projects"] + }, + "entrypoint": { + "type": "cli", + "command": "broadcast", + "args": [ + "--project-no", "{{projectNo}}", + "--pi", "{{pi}}", + "--as-of", "{{asOf}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectNo": { + "type": "string", + "title": "项目编号", + "description": "只播报该项目。留空播报全部在研项目。" + }, + "pi": { + "type": "string", + "title": "负责人", + "description": "按负责人姓名模糊筛选。留空不筛选。" + }, + "asOf": { + "type": "string", + "title": "评估基准日", + "description": "数据截止日期 YYYY-MM-DD,缺省今天。", + "display_type": "date" + } + } + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + } + }, + { + "id": "broadcast-monthly", + "label": "月度经费播报", + "description": "遍历全部在研项目逐项播报并生成月度汇总简报;适合每月定时执行(任务中心可见进度)。", + "placements": ["agent", "cron"], + "executionProfile": "async", + "entrypoint": { + "type": "cli", + "command": "broadcast-monthly", + "args": [] + } + }, + { + "id": "add-project", + "label": "录入项目台账", + "description": "按项目编号录入或更新项目台账(幂等,未提供字段不覆盖)。", + "placements": ["toolbar"], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["fund_projects"] + }, + "entrypoint": { + "type": "cli", + "command": "add-project", + "args": [ + "--project-no", "{{projectNo}}", + "--name", "{{name}}", + "--type", "{{type}}", + "--pi", "{{pi}}", + "--department", "{{department}}", + "--total-approved", "{{totalApproved}}", + "--total-received", "{{totalReceived}}", + "--total-spent", "{{totalSpent}}", + "--frozen", "{{frozen}}", + "--frozen-since", "{{frozenSince}}", + "--start", "{{start}}", + "--end", "{{end}}", + "--status", "{{status}}", + "--remark", "{{remark}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectNo": { + "type": "string", + "title": "项目编号", + "description": "项目唯一编号(必填)。" + }, + "name": { + "type": "string", + "title": "项目名称", + "description": "项目全称。" + }, + "type": { + "type": "string", + "title": "项目类别", + "description": "纵向 / 横向 / 其他。", + "enum": ["纵向", "横向", "其他"] + }, + "pi": { + "type": "string", + "title": "负责人", + "description": "项目负责人姓名。" + }, + "department": { + "type": "string", + "title": "所属学院", + "description": "二级学院/单位。" + }, + "totalApproved": { + "type": "number", + "title": "总立项经费(元)", + "description": "立项批复总经费。" + }, + "totalReceived": { + "type": "number", + "title": "已到账(元)", + "description": "已到账经费,执行率分母。" + }, + "totalSpent": { + "type": "number", + "title": "已报销(元)", + "description": "已报销支出,执行率分子。" + }, + "frozen": { + "type": "number", + "title": "冻结/暂付款(元)", + "description": "冻结或暂付款金额。可选。" + }, + "frozenSince": { + "type": "string", + "title": "暂付款最早发生日", + "description": "YYYY-MM-DD,用于账龄提醒。可选。", + "display_type": "date" + }, + "start": { + "type": "string", + "title": "执行期开始", + "description": "YYYY-MM-DD。", + "display_type": "date" + }, + "end": { + "type": "string", + "title": "执行期结束", + "description": "YYYY-MM-DD。", + "display_type": "date" + }, + "status": { + "type": "string", + "title": "项目状态", + "description": "在研 / 已结题 / 暂缓,缺省在研。", + "enum": ["在研", "已结题", "暂缓"] + }, + "remark": { + "type": "string", + "title": "备注", + "description": "维护说明。可选。" + } + }, + "required": ["projectNo"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + } + }, + { + "id": "import-projects", + "label": "批量导入台账", + "description": "粘贴或指定项目台账 JSON 数组,按项目编号批量导入/更新。", + "placements": ["agent"], + "executionProfile": "sync", + "interaction": "form", + "entrypoint": { + "type": "cli", + "command": "import-projects", + "args": [ + "--projects-json", "{{projectsJson}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "projectsJson": { + "type": "string", + "title": "台账 JSON", + "description": "项目对象数组,字段如 projectNo、projectName、piName、totalApproved、totalReceived、totalSpent、frozenAmount、startDate、endDate、status。", + "display_type": "textarea" + } + }, + "required": ["projectsJson"] + } + }, + { + "id": "list-projects", + "label": "查询项目台账", + "description": "按负责人或状态查询项目台账。", + "placements": ["agent"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "list-projects", + "args": [ + "--pi", "{{pi}}", + "--status", "{{status}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "pi": { + "type": "string", + "title": "负责人", + "description": "姓名模糊筛选。可选。" + }, + "status": { + "type": "string", + "title": "状态", + "description": "在研 / 已结题 / 暂缓。可选。", + "enum": ["在研", "已结题", "暂缓"] + } + } + } + }, + { + "id": "list-reports", + "label": "查询播报历史", + "description": "查看最近的经费播报记录,可按执行状态筛选。", + "placements": ["agent"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "list-reports", + "args": [ + "--limit", "{{limit}}", + "--status-tag", "{{statusTag}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "number", + "title": "条数", + "description": "返回条数,默认 20。可选。" + }, + "statusTag": { + "type": "string", + "title": "执行状态", + "description": "严重滞后 / 支出异常偏快 / 正常。可选。", + "enum": ["严重滞后", "支出异常偏快", "正常"] + } + } + } + }, + { + "id": "render-html", + "label": "生成经费简报", + "description": "重建《经费执行简报》HTML 并写入技能数据目录 html/(缺省重建最近 10 份)。", + "placements": ["toolbar", "skill-detail", "agent"], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": ["progress_reports"] + }, + "entrypoint": { + "type": "cli", + "command": "render-html", + "args": [] + }, + "steps": [ + { + "when": "afterSuccess", + "do": "refreshCatalog" + } + ] + }, + { + "id": "view-rules", + "label": "查看预警阈值", + "description": "查看严重滞后/异常偏快/暂付款账龄/结题预警阈值。", + "placements": ["agent", "skill-detail"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "view-rules", + "args": [] + } + }, + { + "id": "set-rule", + "label": "更新预警阈值", + "description": "按规则键更新阈值;修改后下一次播报生效。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["alert_rules"], + "inputMapping": { + "ruleKey": "{{rule_key}}" + } + }, + "entrypoint": { + "type": "cli", + "command": "set-rule", + "args": [ + "--rule-key", "{{ruleKey}}", + "--value", "{{value}}" + ] + }, + "inputSchema": { + "type": "object", + "properties": { + "ruleKey": { + "type": "string", + "title": "规则键", + "description": "由所在行预填。" + }, + "value": { + "type": "number", + "title": "新阈值", + "description": "比例为小数(0.10=10个百分点),天数为天数。" + } + }, + "required": ["ruleKey", "value"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + } + }, + { + "id": "stats", + "label": "播报统计", + "description": "查看项目总量、播报状态分布、严重滞后 Top 与暂付款总额。", + "placements": ["agent", "skill-detail"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "stats", + "args": [] + } + }, + { + "id": "health", + "label": "运行检查", + "description": "检查技能运行环境并返回结构化结果。", + "placements": ["skill-detail"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "health", + "args": [] + } + }, + { + "id": "version", + "label": "查看版本", + "description": "返回技能版本和机器标识。", + "placements": ["skill-detail"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "version", + "args": [] + } + }, + { + "id": "config-path", + "label": "查看配置位置", + "description": "返回用户 .env 与模板路径。", + "placements": ["skill-detail"], + "executionProfile": "sync", + "entrypoint": { + "type": "cli", + "command": "config-path", + "args": [] + } + } + ] +} diff --git a/assets/examples/README.md b/assets/examples/README.md new file mode 100644 index 0000000..b205908 --- /dev/null +++ b/assets/examples/README.md @@ -0,0 +1,9 @@ +# 示例 JSON + +- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。 +- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。 +- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。 +- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。 +- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。 +- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。 +- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。 diff --git a/assets/examples/lookup-action.example.json b/assets/examples/lookup-action.example.json new file mode 100644 index 0000000..9757f3b --- /dev/null +++ b/assets/examples/lookup-action.example.json @@ -0,0 +1,83 @@ +{ + "_comment": "形状参考:Action 参数引用同库另一张表时用 Lookup。单选与多选同一 lookup 对象,多选只加 multiple:true。完整契约见 references/ACTIONS.md「跨表引用:Lookup」与 references/SCHEMA.md。", + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "send-with-template", + "label": "按模板发送(示例)", + "description": "演示内联 lookup 单选:从 message_templates 选模板;-- 表示自动挑选。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "form", + "bind": { + "tables": ["leads"] + }, + "entrypoint": { + "type": "cli", + "command": "engage", + "args": ["--template-id", "{{templateId}}", "--content", "{{content}}"] + }, + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "title": "正文", + "description": "填了就按这里发;留空则用下面的话术模板。", + "display_type": "textarea" + }, + "templateId": { + "type": "number", + "title": "话术模板", + "description": "仅在正文留空时生效。选 -- = 自动挑选。", + "display_type": "lookup", + "lookup": { + "ref_table": "message_templates", + "value_column": "id", + "label_column": "name", + "extra_column": "channel", + "filter_column": "is_active", + "filter_value": 1 + } + } + } + } + }, + { + "id": "probe-with-engines", + "label": "按引擎抽检(多选示例)", + "description": "演示 lookup.multiple:从 engines 多选,提交为逗号分隔 id。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "form", + "bind": { + "tables": ["queries"] + }, + "entrypoint": { + "type": "cli", + "command": "query", + "args": ["batch-probe", "--engine-ids", "{{engineIds}}"] + }, + "inputSchema": { + "type": "object", + "properties": { + "engineIds": { + "type": "string", + "title": "将使用的引擎", + "description": "可多选;提交值为逗号分隔编号,如 1,2。", + "display_type": "lookup", + "lookup": { + "ref_table": "engines", + "value_column": "id", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1, + "multiple": true + } + } + } + } + } + ] +} diff --git a/assets/examples/path-root-file-action.example.json b/assets/examples/path-root-file-action.example.json new file mode 100644 index 0000000..84361b5 --- /dev/null +++ b/assets/examples/path-root-file-action.example.json @@ -0,0 +1,36 @@ +{ + "_comment": "形状参考:Action 表单 file/directory 用 pathRoot 指定原生对话框起始目录(相对技能数据根)。省略则宿主默认 {skill_data}/input。完整契约见 references/ACTIONS.md「本地路径选择与 pathRoot」。勿与宿主导入面板(replaces:import)混用本示例。", + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "import-pack", + "label": "导入数据包", + "description": "从本技能输入目录子文件夹选择 JSON 并写入业务表。", + "placements": ["toolbar", "agent"], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["business_items"] }, + "inputSchema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "title": "数据包文件", + "description": "请选择 input/数据包/ 下的 JSON。", + "format": "file", + "display_type": "file", + "pathRoot": "input/数据包" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "import-pack", + "args": ["--file", "{{file}}"] + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }] + } + ] +} diff --git a/assets/examples/render-html.action.example.json b/assets/examples/render-html.action.example.json new file mode 100644 index 0000000..717f90e --- /dev/null +++ b/assets/examples/render-html.action.example.json @@ -0,0 +1,28 @@ +{ + "id": "render-html", + "label": "生成 HTML", + "description": "根据业务数据生成 HTML 报告,写入技能数据目录 html/(示例:请改 bind.tables / entrypoint)。", + "placements": [ + "toolbar", + "skill-detail", + "agent" + ], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": [ + "your_source_table" + ] + }, + "entrypoint": { + "type": "cli", + "command": "render-html", + "args": [] + }, + "steps": [ + { + "when": "afterSuccess", + "do": "refreshCatalog" + } + ] +} diff --git a/assets/examples/replaces-create.action.example.json b/assets/examples/replaces-create.action.example.json new file mode 100644 index 0000000..94b7ba6 --- /dev/null +++ b/assets/examples/replaces-create.action.example.json @@ -0,0 +1,28 @@ +{ + "id": "item-create", + "label": "新增记录", + "description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。", + "replaces": "create", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + }, + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["create", "--name", "{{name}}", "--note", "{{note}}"] + } +} diff --git a/assets/examples/replaces-delete.action.example.json b/assets/examples/replaces-delete.action.example.json new file mode 100644 index 0000000..390f758 --- /dev/null +++ b/assets/examples/replaces-delete.action.example.json @@ -0,0 +1,33 @@ +{ + "id": "item-delete", + "label": "删除记录", + "description": "删除业务记录并清理关联副作用。", + "replaces": "delete", + "placements": [], + "executionProfile": "sync", + "interaction": "confirm", + "confirmation": { + "message": "确定删除该记录?关联数据将一并清理。" + }, + "bind": { + "tables": ["items"], + "inputMapping": { + "id": "$row.$pk" + } + }, + "hostEffects": { + "onSuccess": ["reloadTable", "clearSelection"] + }, + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["delete", "--id", "{{id}}"] + } +} diff --git a/assets/examples/replaces-import.action.example.json b/assets/examples/replaces-import.action.example.json new file mode 100644 index 0000000..16c4ce0 --- /dev/null +++ b/assets/examples/replaces-import.action.example.json @@ -0,0 +1,38 @@ +{ + "id": "item-import", + "label": "导入记录", + "description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。", + "replaces": "import", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + }, + "inputSchema": { + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { + "type": "string", + "title": "导入文件", + "description": "由宿主导入面板传入的 Excel/CSV 路径。", + "format": "file" + }, + "onError": { + "type": "string", + "title": "导入规则(出错时)", + "enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"], + "default": "跳过该行,继续导入" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"] + } +} diff --git a/assets/examples/replaces-update.action.example.json b/assets/examples/replaces-update.action.example.json new file mode 100644 index 0000000..c2f5b0b --- /dev/null +++ b/assets/examples/replaces-update.action.example.json @@ -0,0 +1,34 @@ +{ + "id": "item-update", + "label": "编辑记录", + "description": "编辑业务记录并重算派生字段。", + "replaces": "update", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"], + "inputMapping": { + "id": "$row.$pk", + "name": "$row.name", + "note": "$row.note" + } + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + }, + "inputSchema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true }, + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"] + } +} diff --git a/assets/examples/selection-label-column.action.example.json b/assets/examples/selection-label-column.action.example.json new file mode 100644 index 0000000..b6e42a4 --- /dev/null +++ b/assets/examples/selection-label-column.action.example.json @@ -0,0 +1,38 @@ +{ + "id": "process-selected", + "label": "处理勾选", + "description": "对勾选行执行业务处理。示例:声明 labelColumn,让确认弹层「已选择」摘要显示可读名称。", + "placements": ["toolbar", "batch"], + "executionProfile": "async", + "interaction": "confirm", + "confirmation": { + "message": "将按技能业务规则处理勾选记录(具体是否跳过某些状态由 CLI 决定)。" + }, + "bind": { + "tables": ["business_items"], + "inputMapping": { + "ids": "$selection.ids" + }, + "labelColumn": "display_name" + }, + "inputSchema": { + "type": "object", + "required": ["ids"], + "properties": { + "ids": { + "type": "string", + "title": "编号列表", + "description": "逗号分隔主键;宿主会预填勾选行", + "readOnly": true + } + } + }, + "entrypoint": { + "type": "cli", + "command": "process", + "args": ["--ids", "{{ids}}"] + }, + "hostEffects": { + "onSuccess": ["reloadTable"] + } +} diff --git a/assets/examples/skill-cell-presentation.example.json b/assets/examples/skill-cell-presentation.example.json new file mode 100644 index 0000000..6ce7040 --- /dev/null +++ b/assets/examples/skill-cell-presentation.example.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "table": "business_records", + "rowKey": "id", + "generatedAt": "2026-08-10T06:00:00Z", + "cells": [ + { + "rowId": "42", + "column": "qty", + "tone": "danger", + "message": "库存低于安全线", + "alert": true + }, + { + "rowId": "42", + "column": "status", + "tone": "warning", + "message": "待审核" + }, + { + "rowId": "99", + "column": "print_done_at", + "tone": "danger", + "message": "环节到期未填", + "alert": true + } + ] +} diff --git a/assets/examples/skill-policy-snapshot.allowlist.example.json b/assets/examples/skill-policy-snapshot.allowlist.example.json new file mode 100644 index 0000000..812fc8b --- /dev/null +++ b/assets/examples/skill-policy-snapshot.allowlist.example.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { + "userId": "u1", + "displayName": "张三", + "roles": ["clerk"] + }, + "actions": { + "mode": "allowlist", + "ids": ["whoami", "create-record", "sync-records"] + } +} diff --git a/assets/examples/skill-policy-snapshot.scope.example.json b/assets/examples/skill-policy-snapshot.scope.example.json new file mode 100644 index 0000000..80adf17 --- /dev/null +++ b/assets/examples/skill-policy-snapshot.scope.example.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { + "userId": "u1" + }, + "actions": { + "mode": "open" + }, + "data": { + "tables": { + "orders": { + "rows": { + "mode": "scope", + "scope": { + "all": [ + { + "field": "owner_user_id", + "op": "eq", + "valueFrom": "actor.userId" + } + ] + } + } + } + } + } +} diff --git a/assets/examples/version-response.json b/assets/examples/version-response.json new file mode 100644 index 0000000..eef63c3 --- /dev/null +++ b/assets/examples/version-response.json @@ -0,0 +1,4 @@ +{ + "version": "1.0.92", + "skill": "your-skill-slug" +} diff --git a/assets/examples/visible-when-action.example.json b/assets/examples/visible-when-action.example.json new file mode 100644 index 0000000..8b5de4f --- /dev/null +++ b/assets/examples/visible-when-action.example.json @@ -0,0 +1,72 @@ +{ + "_comment": "形状参考:互斥模式用 inputSchema.properties.*.visibleWhen 联动显隐,勿只靠 description 灰字。完整契约见 references/ACTIONS.md「字段条件显隐」。勿与 POLICY.md L3 行态 visibleWhen 混淆。", + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "scrape-links-demo", + "label": "采集链接(示例)", + "description": "演示按采集方式切换参数:关键词路径 vs 作者主页路径。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "form", + "bind": { + "tables": ["demo_items"] + }, + "confirmation": { + "message": "将按所选方式采集链接(示例确认文案)。是否继续?" + }, + "entrypoint": { + "type": "cli", + "command": "run", + "args": [ + "--collect-mode", + "{{collectMode}}", + "--keyword", + "{{keyword}}", + "--author-url", + "{{authorUrl}}", + "--max-count", + "{{maxCount}}" + ] + }, + "inputSchema": { + "type": "object", + "required": ["collectMode"], + "properties": { + "collectMode": { + "type": "string", + "title": "采集方式", + "description": "先选方式,下方只出现对应参数。", + "enum": ["按关键词采集", "按作者主页采集"], + "default": "按关键词采集" + }, + "keyword": { + "type": "string", + "title": "关键词", + "description": "例如 rpa。", + "visibleWhen": { + "field": "collectMode", + "equals": "按关键词采集" + } + }, + "authorUrl": { + "type": "string", + "title": "作者主页链接", + "description": "例如 https://example.com/author/...", + "display_type": "url", + "visibleWhen": { + "collectMode": "按作者主页采集" + } + }, + "maxCount": { + "type": "number", + "title": "数量上限", + "default": 50, + "minimum": 1 + } + } + } + } + ] +} diff --git a/assets/html/example-report.html b/assets/html/example-report.html new file mode 100644 index 0000000..6d40dc0 --- /dev/null +++ b/assets/html/example-report.html @@ -0,0 +1,119 @@ + + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

报告生成于 · {{GENERATED_AT}}

+
+
+
+ {{BODY}} +
+
+ + diff --git a/assets/html/monthly-brief.html b/assets/html/monthly-brief.html new file mode 100644 index 0000000..7c74e8c --- /dev/null +++ b/assets/html/monthly-brief.html @@ -0,0 +1,153 @@ + + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

项目经费进度播报助手 · 月度汇总 · 数据截至 {{GENERATED_AT}}

+
+
+
+ {{BODY}} +
+
+ + diff --git a/assets/html/progress-report.html b/assets/html/progress-report.html new file mode 100644 index 0000000..6c19346 --- /dev/null +++ b/assets/html/progress-report.html @@ -0,0 +1,153 @@ + + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

项目经费进度播报助手 · {{GENERATED_AT}}

+
+
+
+ {{BODY}} +
+
+ + diff --git a/assets/naming/platforms.txt b/assets/naming/platforms.txt new file mode 100644 index 0000000..db8a041 --- /dev/null +++ b/assets/naming/platforms.txt @@ -0,0 +1,13 @@ +amazon +shopee +xinghang +alibaba +kingdee +icbc +pingpong +worldfirst +paypal +lianlian +single-window +export-rebate +etax diff --git a/assets/naming/scopes.txt b/assets/naming/scopes.txt new file mode 100644 index 0000000..3c41cf5 --- /dev/null +++ b/assets/naming/scopes.txt @@ -0,0 +1,4 @@ +all +batch +multi +default diff --git a/assets/naming/verbs.txt b/assets/naming/verbs.txt new file mode 100644 index 0000000..afca45b --- /dev/null +++ b/assets/naming/verbs.txt @@ -0,0 +1,25 @@ +scrape +receive +download +export +add +fill +enter +bind +withdraw +reconcile +verify +submit +query +process +reply +generate +draft +review +confirm +track +upload +match +triage +ship +reprice diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json new file mode 100644 index 0000000..c958855 --- /dev/null +++ b/assets/schemas/skill-actions.schema.json @@ -0,0 +1,491 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-actions.schema.json", + "title": "SkillActionManifest", + "description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)与可选 hoverHint(数据管理工具栏悬停,需宿主已支持该字段)。", + "type": "object", + "required": ["schemaVersion", "skill", "actions"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "与 SKILL.md metadata.openclaw.slug 一致" + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/action" }, + "description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。" + }, + "policy": { + "$ref": "#/$defs/policyDeclaration", + "description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。" + }, + "cellPresentation": { + "$ref": "#/$defs/cellPresentationDeclaration", + "description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。" + } + }, + "$defs": { + "cellPresentationDeclaration": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "仅 true 启用;false 等同 P0。" + } + } + }, + "policyDeclaration": { + "type": "object", + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "仅 true 启用;false 等同 L0。" + }, + "entrypoint": { + "$ref": "#/$defs/entrypoint", + "description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。" + }, + "appliesTo": { + "type": "array", + "items": { + "type": "string", + "enum": ["data-management"] + }, + "description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。" + } + }, + "allOf": [ + { + "if": { + "properties": { "enabled": { "const": true } }, + "required": ["enabled"] + }, + "then": { + "required": ["entrypoint"] + } + } + ] + }, + "placement": { + "type": "string", + "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"], + "description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。" + }, + "mutationKind": { + "type": "string", + "enum": ["create", "update", "delete", "import"], + "description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。" + }, + "scalarArg": { + "type": ["string", "number", "boolean"] + }, + "inputProperty": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object"], + "description": "模板 Phase 1 仅推荐 string/number/boolean;根 inputSchema.type=object 的 properties 使用基础标量类型" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": {}, + "enum": { + "type": "array", + "minItems": 1 + }, + "sensitive": { "type": "boolean" }, + "readOnly": { + "type": "boolean", + "description": "只读。有「已选择」且字段来自 inputMapping(或约定 id 字段)时,宿主 form 默认不渲染该控件,预设值仍提交;无摘要时只读展示。勿用 visibleWhen 代替(visibleWhen 隐藏不提交)。" + }, + "revealInForm": { + "type": "boolean", + "description": "可选逃生阀。true 时即使弹层有「已选择」摘要,也仍渲染该 readOnly 字段。不改变提交 payload。省略=映射只读字段在有摘要时默认折叠。" + }, + "column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "可选。绑定业务表物理列名;Action 弹窗(toolbar/batch)与 replaces create/update 抽屉均可复用该列的中文名与 lookup 下拉" + }, + "display_type": { + "type": "string", + "enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"], + "description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。" + }, + "format": { + "type": "string", + "enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"], + "description": "可选。display_type 别名,语义相同。" + }, + "pathRoot": { + "type": "string", + "minLength": 1, + "description": "可选。仅对 display_type/format 为 file 或 directory 有意义:原生选文件/目录对话框的起始文件夹,相对当前技能数据根(如 input/库存数据)。省略则宿主默认打开 {skill_data}/input。禁止绝对路径、盘符与 ..。Agent/CLI 忽略。见 references/ACTIONS.md「本地路径选择与 pathRoot」。" + }, + "lookup": { + "$ref": "#/$defs/lookupConfig", + "description": "可选。内联同库表关联 Lookup(与列 options_json lookup 对象同构)。用于当前表没有对应列时;推荐同时设 display_type=lookup。见 ACTIONS.md「跨表引用:Lookup」。" + }, + "visibleWhen": { + "description": "可选。表单字段条件显隐。宿主在数据管理/定时任务参数表单中:规则不满足则隐藏该字段、提交时省略、且不校验 required。支持 {field,equals} / {field,in:[...]},或单键简写 {collectMode:\"按关键词采集\"} / {collectMode:[\"a\",\"b\"]}。Agent/CLI 无 UI 时同样按当前 input 求值后剥离隐藏字段。", + "oneOf": [ + { + "type": "object", + "required": ["field", "equals"], + "additionalProperties": false, + "properties": { + "field": { "type": "string", "minLength": 1 }, + "equals": { "type": ["string", "number", "boolean"] } + } + }, + { + "type": "object", + "required": ["field", "in"], + "additionalProperties": false, + "properties": { + "field": { "type": "string", "minLength": 1 }, + "in": { + "type": "array", + "minItems": 1, + "items": { "type": ["string", "number", "boolean"] } + } + } + }, + { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": { + "oneOf": [ + { "type": ["string", "number", "boolean"] }, + { + "type": "array", + "minItems": 1, + "items": { "type": ["string", "number", "boolean"] } + } + ] + } + } + ] + } + } + }, + "lookupConfig": { + "type": "object", + "required": ["ref_table", "value_column", "label_column"], + "additionalProperties": false, + "properties": { + "ref_table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "同库被引用表名" + }, + "value_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "提交值列(编号 / 外键)" + }, + "label_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "主文案列" + }, + "extra_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "可选。第三展示列;有值时宿主拼成「value · label(extra)」" + }, + "filter_column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "可选。等值过滤列" + }, + "filter_value": { + "type": ["string", "number", "boolean", "null"], + "description": "可选。与 filter_column 配对的等值" + }, + "multiple": { + "type": "boolean", + "description": "可选。true=多选 Lookup(存/提交 TEXT 逗号分隔 id);省略或 false=单选(默认)。与 CRUD options_json lookup 同构。" + } + }, + "description": "同库 FK 式 Lookup。展示:value · label;有 extra 时 value · label(extra)。multiple 时多选。宿主亦接受 camelCase 键名。" + }, + "inputSchema": { + "type": "object", + "required": ["type", "properties"], + "additionalProperties": false, + "properties": { + "type": { "const": "object" }, + "required": { + "type": "array", + "items": { "type": "string" } + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/inputProperty" } + } + } + }, + "entrypoint": { + "type": "object", + "required": ["type", "command", "args"], + "additionalProperties": false, + "properties": { + "type": { "const": "cli" }, + "command": { + "type": "string", + "minLength": 1 + }, + "args": { + "type": "array", + "items": { "$ref": "#/$defs/scalarArg" }, + "description": "仅允许 string/number/boolean 标量数组" + } + } + }, + "confirmation": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "minLength": 1 + } + } + }, + "bind": { + "type": "object", + "required": ["tables"], + "additionalProperties": false, + "properties": { + "tables": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "英文 snake_case 业务表名;须真实存在于技能 SQLite / _jiangchang_tables" + }, + "description": "数据管理表级绑定:决定 toolbar/row/batch Action 出现在哪些表。宿主兼容旧技能时,缺失 bind 可视为全表展示;新技能 toolbar Action 必须显式声明。" + }, + "inputMapping": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 }, + "description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。" + }, + "columnMapping": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 Action 弹窗与 replaces create/update 抽屉复用列 displayName / lookup options。" + }, + "labelColumn": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "可选。数据管理 Action 确认/表单弹层「已选择」摘要用的可读标签列(#id + 标签)。仅影响展示,不改变提交 payload。省略时宿主按常见列名启发(title/name 等),再不行只显示编号。" + } + }, + "description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping / labelColumn。concurrency/locks 勿写入。" + }, + "hostEffectId": { + "type": "string", + "enum": ["reloadTable", "refreshCatalog", "clearSelection"], + "description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。" + }, + "hostEffects": { + "type": "object", + "additionalProperties": false, + "properties": { + "onSuccess": { + "type": "array", + "items": { "$ref": "#/$defs/hostEffectId" }, + "uniqueItems": true, + "description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。" + }, + "onFailure": { + "type": "array", + "items": { "$ref": "#/$defs/hostEffectId" }, + "uniqueItems": true, + "description": "sync 失败,或 async 启动被拒时触发。" + } + }, + "description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。" + }, + "actionStep": { + "type": "object", + "required": ["when", "do"], + "additionalProperties": false, + "properties": { + "when": { + "type": "string", + "enum": ["before", "afterSuccess", "afterFailure"], + "description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。" + }, + "do": { + "type": "string", + "enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"], + "description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。" + }, + "table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "deleteByFk / deleteRows 目标表(snake_case)。" + }, + "fk": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "deleteByFk 时子表外键列名。" + } + }, + "allOf": [ + { + "if": { + "properties": { "do": { "const": "deleteByFk" } }, + "required": ["do"] + }, + "then": { "required": ["table", "fk"] } + }, + { + "if": { + "properties": { "do": { "const": "deleteRows" } }, + "required": ["do"] + }, + "then": { "required": ["table"] } + } + ] + }, + "action": { + "type": "object", + "required": ["id", "label", "description", "placements", "entrypoint", "executionProfile"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "hoverHint": { + "type": "string", + "minLength": 1, + "description": "可选。数据管理工具栏悬停文案。与 description / confirmation.message 独立;省略则不显示悬停,不回退。需宿主已支持该字段。当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出。" + }, + "placements": { + "type": "array", + "minItems": 0, + "items": { "$ref": "#/$defs/placement" }, + "description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。" + }, + "replaces": { "$ref": "#/$defs/mutationKind" }, + "entrypoint": { "$ref": "#/$defs/entrypoint" }, + "inputSchema": { "$ref": "#/$defs/inputSchema" }, + "confirmation": { "$ref": "#/$defs/confirmation" }, + "executionProfile": { + "type": "string", + "enum": ["sync", "async"], + "description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。" + }, + "interaction": { + "type": "string", + "enum": ["auto", "direct", "confirm", "form"], + "description": "数据管理等可点击入口的交互方式(与 executionProfile 正交)。direct=一点即跑;confirm=轻量确认(文案用 confirmation.message);form=参数表单;auto/省略=宿主按 inputSchema+confirmation 推断。cron 触发与 agent 忽略本字段。" + }, + "interactionByPlacement": { + "type": "object", + "additionalProperties": false, + "properties": { + "toolbar": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "row": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "batch": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "cron": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "agent": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "create": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "update": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "delete": { "type": "string", "enum": ["direct", "confirm", "form"] }, + "import": { "type": "string", "enum": ["direct", "confirm", "form"] } + }, + "description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。" + }, + "bind": { "$ref": "#/$defs/bind" }, + "hostEffects": { "$ref": "#/$defs/hostEffects" }, + "steps": { + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/actionStep" }, + "description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。" + }, + "authz": { + "type": "object", + "additionalProperties": false, + "properties": { + "capability": { + "type": "string", + "minLength": 1, + "description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。" + } + } + } + }, + "if": { + "properties": { + "placements": { + "contains": { "const": "toolbar" } + } + }, + "required": ["placements"] + }, + "then": { + "required": ["bind"] + }, + "allOf": [ + { + "if": { + "required": ["replaces"] + }, + "then": { + "required": ["bind"] + } + } + ], + "description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。" + } + } +} diff --git a/assets/schemas/skill-cell-presentation.schema.json b/assets/schemas/skill-cell-presentation.schema.json new file mode 100644 index 0000000..983c48f --- /dev/null +++ b/assets/schemas/skill-cell-presentation.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json", + "title": "SkillCellPresentationDocument", + "description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。", + "type": "object", + "required": ["schemaVersion", "skill", "table", "rowKey", "cells"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 1, + "description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致" + }, + "table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "业务表物理名(snake_case)" + }, + "rowKey": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列" + }, + "generatedAt": { + "type": "string", + "minLength": 1, + "description": "可选。ISO-8601 或技能约定的生成时间,便于排障" + }, + "cells": { + "type": "array", + "description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。", + "items": { "$ref": "#/$defs/cellItem" } + } + }, + "$defs": { + "tone": { + "type": "string", + "enum": ["danger", "warning", "info", "success", "muted"], + "description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。" + }, + "cellItem": { + "type": "object", + "required": ["rowId", "column", "tone"], + "additionalProperties": false, + "properties": { + "rowId": { + "type": "string", + "minLength": 1, + "description": "rowKey 列的字符串形式主键/业务键" + }, + "column": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "要强调的业务列名(snake_case),必须是真实列" + }, + "tone": { "$ref": "#/$defs/tone" }, + "message": { + "type": "string", + "maxLength": 500, + "description": "可选。悬停/无障碍说明(短文案);不要塞长报告" + }, + "alert": { + "type": "boolean", + "description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭" + } + } + } + } +} diff --git a/assets/schemas/skill-policy-snapshot.schema.json b/assets/schemas/skill-policy-snapshot.schema.json new file mode 100644 index 0000000..71d4322 --- /dev/null +++ b/assets/schemas/skill-policy-snapshot.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json", + "title": "SkillDataPolicySnapshot", + "description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。", + "type": "object", + "required": ["schemaVersion", "skill", "actor", "actions"], + "additionalProperties": true, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 1 + }, + "actor": { + "type": "object", + "required": ["userId"], + "additionalProperties": true, + "properties": { + "userId": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string" }, + "roles": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "attributes": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean", "null"] + } + } + } + }, + "actions": { + "type": "object", + "required": ["mode"], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["allowlist", "open"], + "description": "v1 仅允许 allowlist/open;denylist 非法。" + }, + "ids": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "description": "mode=allowlist 时必填;mode=open 时应省略或 []。" + } + }, + "allOf": [ + { + "if": { + "properties": { "mode": { "const": "allowlist" } }, + "required": ["mode"] + }, + "then": { + "required": ["ids"] + } + } + ] + }, + "hostCapabilities": { + "type": "array", + "items": { + "type": "string", + "enum": ["refresh", "export", "view", "edit", "delete", "import"] + }, + "description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。" + }, + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "tables": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/tablePolicy" } + } + } + }, + "extensions": { + "type": "object", + "description": "技能私有扩展;宿主必须忽略未知键。" + } + }, + "$defs": { + "predicate": { + "type": "object", + "required": ["field", "op"], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "ne", + "in", + "not_in", + "gt", + "gte", + "lt", + "lte", + "is_null", + "is_not_null" + ] + }, + "value": { + "description": "与 valueFrom 互斥;in/not_in 时为非空数组。" + }, + "valueFrom": { + "type": "string", + "const": "actor.userId", + "description": "v1 仅允许 actor.userId。" + } + } + }, + "scope": { + "oneOf": [ + { "$ref": "#/$defs/predicate" }, + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/predicate" } + } + } + }, + { + "type": "object", + "required": ["any"], + "additionalProperties": false, + "properties": { + "any": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/predicate" } + } + } + } + ] + }, + "tablePolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessible": { "type": "boolean" }, + "columns": { + "type": "object", + "additionalProperties": false, + "properties": { + "visible": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + } + }, + "writable": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + } + } + } + }, + "rows": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["scope", "open", "delegate"] + }, + "scope": { "$ref": "#/$defs/scope" } + } + } + } + } + } +} diff --git a/assets/screenshots/gitea-workflow-success.png b/assets/screenshots/gitea-workflow-success.png new file mode 100644 index 0000000000000000000000000000000000000000..9cce6a0575030ff40a36d28252a68450f259fd81 GIT binary patch literal 32913 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 literal 0 HcmV?d00001 diff --git a/assets/screenshots/gitea-workflow-tab.png b/assets/screenshots/gitea-workflow-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..6727162c473f04f792b7a713f015a6613259a953 GIT binary patch literal 70896 zcmeFZ2Ut^0w=W)g2k9L_5+GCo=^(uY=}icrbm<)gL=XX`_g+34n$M z0HEEz05>aW)2j0FPqcJ2l~f-o{wDMRZWHEx0Km!F(_Kedj`^{XF*DB6?;5{o=9V6= zzrO!Tx|Mr2`Aa$gFvjywc>ZU_cvjXPmbVPwZr?!nTjjULQrzY#Y=7rDe&sEG=OusT zeLP(~Z+WzS<=ypkL*CNW{TF}KEsvy=qt`E8ztS&@39X&=b#K3M zZ{G|64}cCp86fwo{kOkwlS@7TAbJV_V8s1vnMFDPP#+2a(9Hg8888|Zf)HFr1vLmcMqC%TOd0B}?U01z1g0OTV806z2&+U>_bv5onb#Bghu%k9e+;0Uk= zFauNp&HzgQ?`=*1a38=A5WblM$OACY(SIGc3G?>A!o|YE#KgkK!NJBQ#3v*qz$YLe zA||^-L`+IdKyZiZ4kW zjfaJUiSf%kG*XsZI=?Iynf5nxw^wqFK*U9oWQEoeI#WK7EWM}trJ ze=;Vv6r$wxaz*^nbd&-)N@i=En;aAdY6QJk3kY7{lZSnDEyb{xOG@rjQRNBNfgSG| zvhJ6PGjeIqERJ)}sYgof8j_*%9vXRt$4`FD6Vs9pB>;q!153ncd!JF(Yk?toKZq2~ zdvn|ft7jo8s}Xn*nSEeRhn68^s+MLHb#A_dApam zLB!egp89sP*D0*85o%q%8WC?lpEmBrf~F=PBb=WUC(^j-!@(-q<8?)Fk~w;D^kQpt zf^CrnvDSRgM`=z)?fJks;`r5&_eA*+BhO@sN!#X0^r#l!%JlFeWjmZuboH>PcV3Rc zIKFjTRIo`0Kd2wgsG}mCNnU5Q_L3O1VX9)xAUmL!Ab|U@N)c;7CmB6?=yY+h`~rK| z^EKy~NA^T?ue%RHG2P{+)PX9ZbeF0H&S)@clFcA0EWnw(XPIGDFyFss3fFcF9W)u(j6QA)>} z_lz#aX#5<%Csr(S7q*d#%okC08b{kEGo&RfMQs^C1CWe{wiYH{cn?vof)Pc$&dreS z?Ep0b!|7F1ME4k((%I%CROVx_Hslo~h&ebx?2-#C1LsnloU^9w+1~vmx0NMRQ}J$Xz8*!KY@cKYTn2ut z{6F#F&_H`QfW31UVRAng>N7VVj|s|q!?>t*16W`Exg|WH&(rWmiz*cKOe#XPPspzS(AX2%$ivbx z=%xnv$-#$8%GED%*TrW=QN3Z1SJ4e6M4A`}(RS*faXxGp%V>rWg9PnHO=sa1C)pz9 zM2(ROX)zK%sT!(h0V#2Jop4J9p;?W9$tvUk+sa?SS5LZx>No1Z=Mo^67wt9R)y)hdOy9a za`SbL3{B0<#U#5j0>eA&0}`C}W}l0oah(*nI-@M(A;LNm#DUCZvtMjyKWag}M|~`f zb||~4sCfv5_bbTsU3H1njOQTFUGt~)f@k%zz{-IrQPoOaRAO2hD>?a&ufLPHw6>AA zi*q8A)R@T^a@&SnH4~b-(V$@X{nR^2EckLv5Ir&-I9Qp+u*_@*`*R)J>sim9cVli9 znl*NDJNZ44OrpBGTb;6TDRD{3AO{?jWX<5c)=~;QecKn4hB!`B z)N6v4$e#Ma_g!vh#yOop@w!eyCKa758VyDj6)*@hsJiqtx^-RU741ZiJJ}>kulO@X za;*xE8x&T=TD25E7%y}$nFpeijFa#%QJmK~zt!H?;H`+Nhcz1kzFnkAFIm-KxELe^ zl)D7B1$$tUxNt_koO#fyn^)nw8cv#YdO*|zJ>t=PQiT8*6H72uKBf2JtWI%-KyJk#D!PyDsH!zM$|^X8I0)yp>4pWN3Y1rW7?w zwJVt`{%nEmkT`F6f0LpmAuL1lteGt2)ijoq9vr>p27ug`_88AJsI4Pb+<+2p4@Roh z&*oQ?861C02x&MT=Akd^49BSw(7^3`7z9`J6_J0|s$K+pvf)t3z8gFXdyh9gS-c*T zT(4C)4RxwcttTjv%LGf&pg}umPHKm7Y8KQi-PcaCC8kCmd*L>nvXc=;e_)j5V3KwBqa&li3^ESB9D>BNn779tA(Q{{int*#1ngQ z`@?c?|G@Xte99UN2c-Ipk@?zfmdWm%fIG4aeV7cv?qU(MVA&1oa(cCFQRDvGQn|4-7vY>ZiRLS2 zb|2Aw%FhTtrd^-$3sr7AqNi+16I9`d54=K$q-o|Q(G3@itJdP9Xt3f~Wif`vmDaD2 zJcv%NA^p?`C8xV(bvYTWtdMEkXm>xS=?|n*z%Ila-Pc>$t0*2$i7szjWHX3^d4}6y zU&ubzu};on!6hbp(xOL3-YI4tGe)lQpz=awE8DFO$~3|oXXbuxFAIS>t)v}MNEbkd zW7Ie{o8R?kEg5S4>PW1L`0K6Ba3;^~y9Y7P<2QBfbh>!|TBLTTN%^uM2k1Kn3v3cw zQO;I}bIbj$g2r!VFX5#31b?H7|7ISa{V!4fh1LH%&Oip}UjtiEtK0xQJ-TAWJ!8+6 zB~^~gS}n<21&^`Bd)WPKH}D_Vp`;fW4p~2CvtEsUCX?Lx`nc=sqn{lb@52|0cV${M$s`-|c}%n*IiA`){~a zoc=cPZ|$*|`a4knR=kZAe&N0OPXUh`N8UBqbrD`^b`1@-uV<78FS^;u1(b?XyeI$n zqVj*%B?G@ahD7|I^~u>rudg{z8*1F2EIHWzl$$FqfamOu@640YU4tEU$qS3c$B&+k z!b;Er@ZTj3xi8_w>&V09#b_~@WAvwXKivS1f=ha?-(D>DWbld3A*V1bQ+%AYHC@VT zkTBeD*Wnj2kCd2vw$bXClDk0mTL+IoVyx^Kd27<3fD6g?ad9pedd*R4b+=-#y6PFn z+DC8C^5aso?OYC}n6bbrfD^k@zcM_B3#I+&^L!}nj9I1#Rvy|NeMFuc4j2=C@ysLU zdAH$-{kZr$-77X^hlruw_Y0eEj%N=zF^#s;f`Y6M%3#XeIWJoEl}ombvFE*>jb32= zKsWiWIhfFeeHV!cTU3i_AiKZ1gq+@RufvGdVyEn^EgTmD^=f1?ju}eX2P5p)!}sOF z->-e3<3pbh5$g6-bwp_J6>N(LptS>+#-n~5uV^r3*VylBGn%(Q+&^lXPr$+W3)>VTZ$ANsZP-q}QV}AX8ojZ7Ds+&)mI%; z*R&hld-gW#gklYWq=JJ842#iW#e~PALa7xzC$2FrlTvE6Q`6cF4yvWuv>Jrm&Ayhc!4aOvEO>*MyE86w_By!U&5K&yAQ;rOb}tF$W;l3q_nG+U2Ct}SU2 z)K~A9kaX`U30WKA(Yi?Sv#mj6%V*#^g=*|HJ>`46x;#8Z0>O1eQ^W@pB5pZWkiY)-X@WgZ*XiOoOBtRyZ4{LC_lrMes_}imIz9 zWssP+!405Th_j9wm7XnSIeU8n=v>=0zaUGdIKau0O5r-(Bw<}u$D`oFhmt5^w)HhB zSM1V^z3%UB1s8ZBGM@=kn3a=@UfM7Ei9gv&Sv0Ymc(Al!EW1|AVM`<0*Vj?L@Lbxu zzSXn_!BX9P)ok#{wBodub~fNjw2*db$mc+yGdX)Pu89gixW3Vyrd_O0X3hYS_zdZQ zR$DYbr6cr2x4A~E>t!hacho@ImXG+;+A4A9w6cjkA^YpE%`2&fo4rftSX|w_yNKL6 z664(m4s*Ho9kmmW3ycUJak9pxN)=L!r7QjDWH?dLT39&Cnq+mjv>87vm(U(3+Sjnj z98XbKY36BGz0V3qrvtL!y5_0yf;CvbWiuN9ME6PxB__779&(kPyNq7^WcpO)Fi2JO zVTXXH+K1euN^*{xO`Mu^gs00T*9FYX1>Q_bkg9uEiMx+(P&!7JxGhv(`>8U!2)Nyk z+#c?J`;p5T8Z0i5dZECPfWQ21jFUrWlZea&*OavQ$kXwG?hL8{a>p<;3`SvTId z-IhO)8%hiVFYC*s6KX`q79S6d6>4RnYd)0#(&X-lmqyDr2vaYFqH4jNLkS^fz?NNZIsGr`DF=CbQxeq0^oZ z09cC}lkPBd+glW-hd0!hc{nqVmzlVIYiHhiH%v#{_m{zS7lE8?(X;Ov#p=mo3o z*X`E4W`R>y_g6Te83TFbv*Uv*j+NW%~5Hbx;iAXdT&?l^SED_i{VTzvlyGE zLcI@}3hjcNUcpdIyMx%19PzZ=l`VGtRnb1w4oWocZ%`{YjyjDTeGn3$NHmwE=(f&yxpOXNP+V>tU{3{ZF*pn&r%k(O z(tBrCL~e3-a6#qsIy*qWc#1%BLLlwsJt`g9bZ3%cUt?bwLvl)4#``o2Xsp7cJ^w{L&;PRph~9UEE^X% zC^({hV`E3@BL6G8%0~zQB~bIm$9dnV_v-$ibFt^1sX2!F*M};Fy6-K6n}+*?8LG|FOM%aJe#_7ong50N14Jyl!?+QIBwzR#l z21_TnDIZ*pE-bfWlBh2w%!<9+FE$u6S+!3PLce4FN>xi%MuV6O@mBQXAV&OA)`DZfd_)XTw|e9ubVh^DDoS&L zb_4Kw5qm}O<;#2JA_FUP4C`0V06nm_$9x!<(ve=J8PEp`KVQ&AU9 zkIbg8bcWQ=R7!!`*27&O}Xs5l{t=u8nb= zrH#n9^O;kN+97b*;&h@PR+Ve#0`pj}VNXMKsidavWOD+nDj8Bd#uIs=*}S*We= z7mlFxC%O4B5of&?B+s;H8Quu+`T|x1qZDo>KWWf7+`Qau)ETc z%IDnJQXhSl$hYq#C*1@$3z`+DX1yKH%=w>3?4^FRx5ImKl6l2;u)U`(MbPqwzriD- z?7P>~t$~Y4(XImo2Qc(dVtT9I-(z?0qAhh|)65b@b(x&0_Y5y#c^LVrL6qWWzs`k5 z`YSrQaAVQ#gy?lj4@ZFNMPtd4XPMfhbf?0MV|A?s{W3#y(W7V9&SeEhE1e3d^c!aWXb^;7Fx?u9`HKEVM2Nc$4W% z+jDG>f0xwEYj&uwo`|HvvPMAgh#_KymGgyl{|du>l-@mdv1eqpPpxIhTyezrGKJ%b zF4058gtujh<5u?F9(mVS5sRow9>(~>d63}Wqv6A3#-;Ew)$EA3NlJdL-W{r=PBd*% z4E9M)Zo~e6VOjs68o(yR`#N}+>Z4m@XRGl-4Ac?Pli1tKTOzu5f?geveri+n_%*C4 zDL!1QZ%r)}c&*;e5ZIs(&HZe|3gRicn+P(x0R)v=-2fhkUf(KjHZ_p0l(?poUMVG* z$luAXw%cc|tldb+I%?`X{m`&--#9k51XXs)c<&WN@H7dRveD!zzgK;KC>W~`Wvt7a z%KU4&4a^)fjE@U3T=#~?+PO) znhJh9AbTv)0Ox&Y@a^Ibi8*CxGQv^I`RnYusYQ3r$7f!SeXhcChOJGq256slnx&HS z9LLn5XHHdA+Ai_nij>*r*VRwBcux-MTU1mqi!g)k#NRI7SLk>~HB=83rDY5s+*Pod zzdKGHj-_JWqZX8BmG?C7YAJy~lxx~GYM6Xr;o5uZfm5BKUjMjOhJNM63gpOmL+L(_ zU{d_6EK;Z>K?t;5XH0p`f4qJ%FhmDSlR0)fmWWP$MLYxWyJX<69*(RjTO zWNoN=uvI3#kR8f$?}1Q1Zq~|(RstFE@?(Y+SM2S6O3Ha_fDS4QkC+wbxETNY4L~Bw z)i-D^qIZ4ZHXgN13TkvFJE4R{6H`&@7Lqpa%sg6F+2Nge5#7sq*&w+8P%geYTP{ruZ!#C)s>^?{uMc zF_|q3jy^nUoIJ>bOh*nKvJ;z#ffUiCyPXH+A#nCFWJ;td#(iVAIv7ZS^z*}r1X;|pOLxp7WI@3ya&}?-Eal@{0vWxY4lSt=!p&@BlppU{ zjcK-!MTptXw00KPbEfD`yhUgv2u)NQ_)dY)@BJ$?&xShALkUBSB5;unu~2Dni7C&yDK6A> zUrSRRJdTXY=}5jKXvlkag!QMvhp*%&1c7JDZT=)zIASd zIx|O{#*qM8hpR0{wofSiHsU4TZ2i!-{ZaTWGOBcb<{Je*s(0h(``*GT4RV58`1?x^A@8X9Y^ zx@OP*&3JI45_*x3131K*ZV?b?#|03rS>hzb#6bJ^nEPU`QLVFWx54gBA zh#>(CXRFD!U~OMksMGuMvuWga?tE@h1SpKw(VQob83e|d2B0_WN-N{=O32pPt1Fg} z`o!8b(8E+noe1T5RncUNfa0;LsA=tmz0IMGW>e1j1-M1p*QIrs>BU^>vRC23CgaC~ zqJ=Y;RsXw*LZ`;=C?v4D%mgF|I$${)@^o%Gklm)>-{xXw$niz5rpR4shFpj?*FjGT z{Kt#M?V-cGiti&8JXO756lHJpK2(lU0?GJuo)3E#dHcZcHfAPf*bJ?AH88DApyhv6 zA(lHo_h`-W{p9czF0R$fu0Bi7B#1tR%TId^3JbZN#+)4*s>DZujDTN>eSsHS*&=p| z3x;~L%WPY)1KLh;jP-}ZKV2-3Y^opR_GXLOaX;+U@7#J47~$2%NM9ki2$i;_hSDl)U*hBbnH43NFO5Ye^MGpK$C{!u7~&kSX24mpMKvXK2bE&Pr}8 zS}3(IW)~N}gaR#Ny=BHv%d8w<Ihr9bXfUh@zj-o?10!S~kBdk^U^t}5%F{A5gc z7&$h|JP^_yHa#R}>!;woj7(X4H~}N%`nls;fy8)cv0G7tKjc)+S zWebPWRShn?XKf3C-+U-UKq1&8VWx7vSm4gY*Z~oB{q*O(GCk5g((g?KzcF% z8@rFjr_YRaWdFbE;VEiz4ce>OuAuHSF6$nL8 zsqEApcV9r~HkwAOS1Pfsd|@nmv^61an@=~F%mS**D;%kS=$LSd{(SAJH=t971%~{G z9Yz_mSut|hN5KhIQVm+7w^I`{IHog)f06PHfO{;0Y5z-#y*dZ0oBTra(XeRVLG}+D z7%I5SisL?Fxyb;|g)@<8koLTKfF7!W1!7^x1-h3cbagQ1bBI@k z2HUy63uf!V5mG^_rlQ4!fY&#!~Ov@|5GGLko(dD>(_Gy*ZdN?-gE zMoJ<)qpd~^EM8mA7jW3I0eh1A>!?g?Je zv3Ae+$=0#>mQm4%v#Ai=9u?LL_LoMLuTI$Cr{Zilj+bgF{l@S&{I^ZOi%B(x=#0VM z+^H<~NAHk6p!ENHAF;G7$HhbwH71h|tF|-jSpV8&B&!fkq+s6r<&XVplfU+>>0qk@ z&(QV}vWTj!&Dx1rt-|jEjy{dSSZ#iDZiH=zTo9#0l?6zoJ)Ut@vY#k>fLLg~dWP!k zSG!oHXXBh^J>c5oQ{&lkqRru&oUWH8k)A$A@kPKkI&HVd1Ex=-*Li?XEydQOCT*?vq#Ubu=uf_Xm9;AE!L;1>K0Vf!k$`+aA)*Uac~fB zs&5t;6e@`l-pMdqLFpynVfZqyi!rnt5aWuL?|Td-_wyLBjy>_|^c#kI9@h(wkCGUC*l9dojVchtt_ zs^ne{9oh5hk0k+-NvXghH+J9dygfdNu9x;+1qD_1PEIx`?jG+=AtX7=_=8t1_^m>i zq0XBi=J*7(9(P}xyg6iMQ6;fiJi0qO=U7gf5=*yO2v%7EM3t*u3-eXf(LQ#gLkgZq zD_`!CQC74gxv9kyw02&($7&ilCa&P_WEO_FxYxVPemm-~RTl~viz^g6NQCDnF77PR z9JIW5iGYFY^8EV&A0^{WRZUeEb~gky+0JM&Fxg?RAdu?cy<4H!9=-vinqp z#e9=k&2X3ICQm_kx=<6DAdHSjibKKWMi+0#>^Rv)eh zxPRsei)U%rqm3{p(3xONMu2#^U^)@uqIc6gXe!ccGp%M&$lCK}rnCv+E2z_>IsfLZe#o3Q{XPd7z8>1cPor_l_f^Cy z_)pqz0K@9HQ}3?ctJ$(b$40n@W7@NW{XXqvumY7ekIDITujTW0ymoL8v4-kK z;3JZs<74lBJ_=kYZ8;dZ0jxR%xb#b3ej(X?*JL`m>KbcT-=E1d{+>$E-gnBtmj#a& zc>R=1DY~N0YgWLSYJnv4ghHw86FOvDeWg%B@*n^N&;Y1x z2FbG4kG|;_-$9vKgTIq%WtD-clQlg=XT`X|U_%Q5dR z&5Ye)WfKuwqBFhOk17i|U^9vY2r3R2GXX0=Y0R@LEixYam~4*@PZu%!MWKjZE(!Z0 zZbo~7c0*=Oz(L4ssO&($b?3O!l<|;7GR{7FNFY5>bK_14hB@ipJroJObh)mBtL7^&n=jaI@dUDNkX+I2eJ8iVZ8J9%}Zrk1f^7RA14&4AQ4PZl7kFBQ*hhshMAC$#DZaA`m z@a7`OsZjNL8_UM8A2Emi+Kmodva)o%cwFXhWwjLb>FLt7wV`HA?7{4n&c&0&C+9iT zSAH+g^~di1sI$8PEYRKnWXjoUXBCL*;B(LvyhdSeoR5YQCqGSsbA;q!NRH=A zvpI+qN*~*g-2j>duV0ybwm*aV)6FtoYR#B6+0{6hP+Ke6X;fgeau}Pk+zo-a#ka-S zNj2-7Vh(IcT^(%ihcK$pXkqLW11|*! zYBDmPQ8C_it&?juS(S@-_pBW-^Tln))rpvBQ6k?u*xU!R)lr*u;=y-sjX(6wWfGdRvX56AI=Ts!B{kr z`mHUY+;?RqQT(qy)k!XQFu)rg3+KV>xI=qF1L`Xt`)(-cLmVZmQArfJ(fcd!6yn^5 zJod`GhfB8Ss_MTTO|(9EP>5Q=$5ksfp40x!4d!4^@g>c&G87ASi1byQq?mhA#;q1* zNc_%=Lx&=*-;PLh5%;5mu`xa%2+>P?U+z8jL@vk+`&B}kf#C#Sh;gzw*`lWf)xl1` z;RG6MT>J3j*uE=M`{UY3tl;mFk7n!@3K$?DWYUUOa5?*P7z3sGW2UM^BM0LAdi&>) zRL7)rM^N_zIFPjI{cHj*CTYA0m%;Y*8KZ3p+1|8)*APEHIt>ZLmlJFQ(4>?!z@!0u{tVzz_p8@&PPs%bEXF`7l-2i5Uuu1nz?Nz=u3?fY* zzJdBq@2a*+Z8gjVaD`u&?I??X+S`40u=~JFVVbbR`}VmYJyra;v0?E}COH)3PGxD^ z@a&`wmrN_&#nyzk9r?xwHWORMoRGH%QVashAP*X*Du4T)4nNTvui$%q7CUd>-ab{4 z+muT0+k+U&lg01DLD6g|uXme;2ydSC7aE7m_bg=JLfuJOnGhdj1+CxsWgDP+um}6 z8#QY?E70X*xJhL11{27f#2m$J^$VT5OWK?md5c^1D&{~fh5J3wDZ50rlI~}pkOmWf zqv>F*%{Tq1X+NEjpfJ7Hn@*)5^(i_1{rEu8VYHKOhfG{}vfgD1V20t$H22`S43Rx;gG9sFM`s~B=y zt)@D8`mGJ8%`vqWRnj{x2<(OPLBB#AxR4DOgB4YWjBkwx>J5L$X%>%oHZvY!!)v=6 z=YaJBgh@DbzBO^OH-J}+A>Ob1CMow}({qiHepb|QYS?{xC7i+mxh#ghJs`XEvz5C2 zq&c9JZ`+ROh)LD9iUqCas#herz8YV#K%A;h5_xS#i!$t9w7u5W!p0K=L|1Ravjfse8QhY4Yb$8D zihh#HhoW(6)6d{(pRVE8#(cQly1OwYRW0VIcxHz>r`|I#;_Xd$x8BhBsvLb9HW2)l z_Op$z-k`=<;ZU;?t0t;upR^Jm9LXlXR|XU=0_c$C!xEBm76&$G$L-6A-_GjiiL-6G zMSH&VKRmbtQ*r~5u;NKktD@XbgR-t6Gr5rQjHavnY=0$_@|0c6(zw0GBTEm}C}jKA z$W96#7sK22-7=LqFJ6>WlX^e;GVvCjt&i_nF2X4*4x0k!p%t-NQw_U)N*dL9k$R|K zIAuP1mT95fkF2W@E{s&Wc811d1nVJ-oscN0Fr;zYi@Ep3 zF`Ud?O_0VxH10@wk_(gY?)#Zfg-!-geqKHv;R+%3*(^W#PvYO60VPMAuyC2V6AUV! z;fn6(PCI34)-KGYoILX>VB-DmHDR>Qj<4DocL(}~6X%izcs4O9oLTfGo+w$>k3Qix zf=5Q#vT!@I1Cu0^I52XQY1k!y7*ZoYDi&IQnzMdASp%2NbTz-U^k8ZE2;b023N9jV ziDS|h73-Ad;f2Fxnrqx^=V{`fA%|_>wltJzJ_^{7vjpLDd5md6# zHB~^ecKE9Jsf6VPB53Y(u9`D`7 z-1<6~R372Cv3+_;6!7|aYbTaT?Zhm&CNr6@t^bUvaD!RFnDCY0%ZYsT_{g7+-@lu< zgR!jz6<{zkwJHv<0zdoGdl`fEn9)|*zTsk^FMaR+i89%4{F*`>z+IwG8!~4gW<@$H z#-yMJ4IAM}Ii>K_wdy0AAb1l?>EHu{hraYD#VJ{O?WbbAx^Kg`Q4G~aVf0O8S5WpD@}qaP2LL}5Qv8Wi+x0N? z?uQ`_`4rgqLov(J*e8`YPi-2)O4JM4qESPF0#cnzbxpS7DfHGuNePHG3T9SR`$dTu zV{-EJy`|pCgnL1=BFtzsT&~4s&%Xj-#}D7qVqP294X67NC&3lL2H$Erb~%^QG2_i@o7 zOFhX&fb>-*^{1TC#XihCO0uVP-?fr~K-eArAVt?FEAq~FPc8y4&G@pV!-*OK$F^?( z`_J1vd*6Qb#Gvf>Q`t38c=-mPS#$%aTlq0IE3v?pwl`*TsfJ3OTHnboVmF3P(W;A} z4GP}>=$s6E<1+`$!Yq+!&B+OnYceE}IJf~&YC+u>NN3PayRBV{-cJbQzxi=C9#Q}C z)8&V5z2(h-FSpChNz5EigOsZ#+L(M6t~rbTR?h?u{Y`g0Zu@$uk2RqG7kaJ=Z99hK zC(bXwE9kz-J`ap2=EB3feO#3>jq(JDt^yz*x;Fl^vNWavieOo0D{s0#mnXNkrQQ8e zQY8I7?RGI--zVn}V=8n{!e$ELvrL&t<(fLmAN*`MgVqZ}(I^T1SGyIl^o3sEE>5;1 zQ^$U$FU~DDKY6pTI6>#xH9WM!NN;&wc*A3Zk(9<{QAqrAYX@xgzzG*EfL7y+yV_=XZ}{*LN;SW)_}IItDi0#qq5W zJgL0_jB}yWJ&!e6<}7%_x+#J}rdCOv|gNJ!1@)X}`O! zpA<@X2lr>4%OBla^1DJ2{svlQW&z;o)7uTD1^+b)EdqSgh)K8@iZacO4YEQa}Uc-+cIQuEpydwTU5>UWo1g z7oX#`%>HYMK5y-tIIR`36%u^j(Jnm-&vryM%qrpGOZEGgt=beb@T0mHsLeUoH$6)1TNj;{I3a{)_%6|NB42&#*J3OiV?LMvO7P_|M_; zsa2N8!=^t6xwP*8IsUIfdJQXd8b{-I-r2`s>1!=I@y=&JP*icKB=j&DF_mHSxuWx^#kn*M6$|TbSq{9Hk9^RM4P&=l&%`7d_{ns!O9d9Buw+ z#;@@Q2WQ3a3Zb_1^~)6PMKRHfbcYdce{kr#4qX=hyjSOWdjTo-Qdq$rAzn6OuxUsT ziLG>AQ?R)Bd9C}tu=8IvW&XWk{~k?$Ad$uOaQy>rA(o`4^S7xe-QTv9{Wk44D)+&^ zLCX4rL*L|&3PrzBdG#Ac$_fVr+(p}e-wUZuAHEq1(M z>)<`~($!w>Q91;%az2E?{Q4z>DzWy-?L5xX5#*jp7^GTTj~>6hD6h|vKT4d{tUr2c znA+>s^xRaaXjd~c;B$XqA~d_XRU$KOX7tkw+wYcRtIJFcFGKQ|X`ew}?*o)xHdMC! zHj>m*z@plZ?K=0m?@WL354a7B-gG_EL;dqks-XSsvCU}3^8ORBNffI$#N;*Ynb-~VqI2>%IK{r@-0|F;^ar&M9! z1N*i=5WMs_b;FW`waH}T89 zpV_E&RY$d4y|Tp^9!!vl*^z2aPKgZguw%EJ&@_3*(U^D>{$S2lqa(+vL@oBaK8rhp zZY+mq<_t)KFMdrh2msK_(IsG5UcI_qMqfKo@K#(|cF1P?LM3TqvfDREazfxSq9DQ$ z0|(ch+N($T)huj4}vMOv@@e9d*x0N;hRl;%5_xV66~juG+PinYXrA0r#jb8XN+m7U9$C zHBa&yW(N*E$Gr>DpZjZj%15D4K{=_UAMJHI1`_o0@3`ua$p|&3WqqWxXl!$2IxRWH z&WIP$YxU6UE~%dL!R1lbRMMhKrQ?A3B)|}2`1E}`5EYuuF$c7Qlc81~qOEEntbyyE zZ_5*&TjRSN7(1Tfa%%4u&Gh%#YEILKmWxl4;GAHH&g=d_H3H{`hCcmHPaDf^Tn+41 zY*=nGLK)3Ll|@l`8m;|q)>J0F!D_f+)oZi7i)TAaChxZkv-$y_0+`Qi!AdU)5JeUe zMAatVLX6SMj?TL0UbJusjaO2R`T@6x>H^^CQi1LV(KXfc96___#`W~{!*78K-iFRw zdzy*!(&Rl(T}mYg{z6U%=~fZ3tVcsD2aL0LLmZpe2&vPVKgCZskR5A* z&ZO2!YGO<43Ry@&&IZ@K&}XDm)i7UmYkjY0JE#IJ*Omyb$dpBPWX~>DFVs zjYSEUm3<0fABd4TzTKj$K@u%^oH{YYGM{b4^bQSik^8(y<3#*yGk3Ms$9Wc>)c2e2 z@LPO&C|_o%e)l<#Vs>|K*hEuq_7Fp``inX4Y?wqK6*S-?0Kvy8w4VHdZL!N!pexKY zZouF%7YGlZM(3?ohk=eB7Xs=nMmEE)iCoxNTF)6crx~tWjFz2&R-j*29GpaI^CGNV zET3;Oyw^JY)ZqlxP57{!J-ht^S$mZ8+R^En=&b`YjGIc%pf0E&xQ>H(-lonq-Ab6< zt@TMQkZWPbYe0UA`H5X*0sov^%K2MaMuKOUJS<$av}p38Vw4{YJ`~grE%2cAxt~&g z)Iw5V5SzsHc_pC*MQ_Y0-EJ>T!f%C5cIbGBvZmbrOAW>U>YtrtSHBjVb$VWx&y+KY zJx9(t&X#_3xx*)5(Y?j@&3VfMoDwc`EUU5zsggU+icgxdnXdRSPFFosdUDmjHN|z7 znmAFQT{U$~pP0>bWEc|vhRl`GNiw10<7W)6+Tn00@{N16ceHoow%Vm*1$nAJd0sk6 z*OQa@NfqW;WBurjiEU=sro_u6R7y5UQSS7ft8u;YvM`;6gnWX`X@YNpKO{yKn4l_T z3#M^bC#VZh=^aH~mh*k;ZLueC%&bv#;vczR5$v#6w}p|E2bObp*C0~`9+%aea%a=+ z9}s;p-Qn7wxRah%{NLDn@2IA>u3s1xL{LzwbU{D}7*u+f4hcQ<(5r;r5fD+DAU#r~ zw}c`cLg=9Mj)a~Ndap`Vu-rW7ocDXr^E~&x-@Rje-x&9gcZ~hVg2kG9t~uvk>^;kG zwn2tPnLq)Elj8$|I%>a}cvoFDZ1J+&Jf z*hargkn6>W?`^&!T8HizTSEnhqoZsVMb^NuCzH_)FgScd9_At3?>lu}w>3Pw_grr) z-mG+TE3DuwE~p(g;_2C;ik{7^H7n}2(eP(UPQ)>u%&Xi1F^p0N`~oip3c>E;Mq}!w zN|zTXw)z7Ep3wg$dN7KHuk}%rXTsb_^7$9!EY52C@Qn{j&t@+4r>cIvFbPabJ45<;+ zSTBBuHW$h{FZ;%%M`Fwdr6q9o23PBn%G$_#ztXEM?49O~9x0VwBjO?=agmapTb{cV z%22*oeePzuo4m<3?@^iJ#0MSBgpun?zd>jb5m^!O>pn?!U=9X=hm`-GY^vN8qHlDb z^eZ3o0XTF~(9xQ3N?tPzE-53tB?$dd_VM?zQW`EfjCzs#3mOMRBO{tU!k3=$92pU% z)Q>1IpxA1Gt-Byz3Iz}3ac6UR7Roy%+>a&jV;|BY435o--*q&0bpolGXq{0?dG1zF zx(zVraJ&(y%u|e?bim+lka_ar*(E9cd-bxs_BpGwW-AdFOdDt{Wm)|q!)pfRNP1N3 ztmhm0dUPV$=oTkNf{4;~=JjZ836|u%xLVnaWbCEhkgq#5P;h83axgI&*h9Ro#D6LG z3fm$ml-}&p&VBGDCieS{!k##(>-f7W8lBYY#9?c0lyn^r5*Y3#tRgQy zCLRCm{YSc9EXjb;*4x0HO`{e`DFy=+8iO_SC}>+D(=y{=;2YKAcWm5S7Fu|pj7RoH za5x1#F(t#prhqpgy{fW~1ou37%6^YdMXKpF(P`{f<`Xm5KE#cRuhiu9DY1=2oZOt^ z`;06f6eb&>hQMsuAwFvbvr`|b_c!0Kgf=MDw7uJY_Iy*Ok#UbMxnvv+dHwH~j$4PJ z*tf1<*NgR;tES?9Jto9}%UCdGG23*3DU?%)z3tYvqJ3!SMNjSK^Vs&dUIqw#WgKP5 z&g9PLNQJ&97%%pv=?4F5xmonIsOxQ&{?wsBxbxX%O7!*^NOAlf+7!9mmb>o>Oc)Ne_Cl_v|)Zsu0LRxYu#UjKK zyeX6y-J5Ibv4}M{Zg*fvC-HfFs-1l|5nHA%JuK?r|_!b4Md53=k+rFRr6zr>$6yd*m+zHu1j zzm&@t`rV12KVX|2R)o=SVDUi$uA$)C9J=PsIO7DmbAdR~?s{&jdHp=MM-;|IY(zTO zUn8!5f4i5-Q@cU(xjp|rK7R3j)$pBAX96Ut1B*jp9Y?n?=IWh9n*J-Vr1%Fr6!FYG z+ATY(ki1Bu$j(oFMRJfYA?*0CMkN>b{-M$;{@Sz6jZ^oX%}Uqv4gvADd+OM|k=!re zf}a%QbieBdm%iX^+g}WQQQ9fBfd9rbSm5*YANC{H&&e@!<2TWr8Pt!tf0dR(KWk;v zF~v>xkX_dAk^d8Mk4mck*Vd%;Fo@OXo}L+s0;Y}m zi>0hTIs9P23wiXWwBm+9Eo7T(%eDJgFY#tOPxK z7Kxv#mvnHrhn)XsL+B6=iH{5Tw)p%fQ2#&IyPr8n{jv+fTVg44mvX&NTK@UqGTKP{ zI#xwE*|TR7Fe#Q#{~roJGZqQU#{R2S317#no-!ZaLw?BZQ)&I--ug22!^;uRd&3C< zm-2N#XbxVc{bk?zp@w=G+0|%b)`?eStl6P|W7D8+Z^(^lJuYv1O{(>i9 zj>+E{4;>Vtw!;<7v8zn;>9yD}h5--GqQqsqD`K-AEdAV@)?*qgPpu;glR6KyC|08dC^8SOnqsMqDk1nxs)`$-{SSNTORe~%6Cdlum0l36q>@FI(?L_%cl#SM3%sj z1XoEk4=Mv+J7DQ%(`VCYrfxq@DFl~%?Ag``FMoEl(uybi7v$Sy>fXmXyf)QkoT&D- zMYa^sDmN>>b!T_G)tthi>RtW%^!2($+WT(jaIuyC8-7yzrNzv;Ylsr7un9-5t{B|kUI!wBHE`C4tOTnBoV`zkp_h#-^W0h>e)QCti#*0h+pm;)4 zFbUq z5;D4bv$1sTy8<$2BSni@-TgDvSmHT$=^c+yYH~|1;#*hZFcg8Sx+Xw^2f?Gx>~lK( z_dNmW$c91x#Qadf8=Oh|bS3pQGHjzG2KiKsC$sv?!IooYD>r)<-nUG+yC0t9qx&7*#6M}P1IY1wbX{99J0{H zE_+4Xl{i)^n`(N4Ml2Db8v@r6D+2D!zJh76Me>pB`tlG>8hr1Vp8K`Z@vCfIfc!@C zCtW-VAEQkAy1q}zCG6W|s0DaBtqsHs*_XT~V5uu=qk5g8>G79a05@UU)N}#qDrFLD zGWX{sck&*SRY7)%<8rYZC1#sl_2%>w{lS?Z*vMRgp`Y&GC#PxE%|2NQFS!a5KXEz> zC}M3k+mvvQTupmG#ir*3V~*$MgVw6Z)(G(P%nZ#LKl1}1)?oegq|F0<#>%5_sUJ~i zxXL@<#CsQd%J70%nl<;l&!!8U$z%mY^6f2(oo{Mp*!SA`eJ*iStPnjW;Wu+mG%ayc zv}XsQNZpJD6J1MqNSfBiSU?cKrqgQuBBd^Gb?V zXeMV)Il|kj-@%~wzO^uVwYvK>T(q9MJFOO^>p^OGHClXquT0ej3-Ifr8Z!39R2+MH z=@kW7;#L}(%@2p#zDw~9Z(wS)tWo@x$cEk0)UF5n^fXlLN(uha?n&P7ivpFUFV~~u z_%kL4nNHw|GMqyIQtJx=C(zw&rXsHS${7-LLu%4f?STgLtRU;??kGCBS^Hr)TrZZz zNE@{sMIRLUp#HuhhK8(!l`EZO3V@=PhoqzcOmqjM2g;fqx=2BDCiS17u@1>A6PR*< z33RgF`^S#RBU)ek-N8lm3frEJ;uDlFoeza4oanT|A_n$jiR9j;LK}E3GD9J?K_{8Z zuB3#BF5HH*=O zMY+32bL_wS=F1==3z?!8efR&qHSah|^s{P2zC`fzgx$mGXd-*@uqvvF^F z*sUP|>|ECkjy9H0!aTyb-dt34R-m0()`Qpccr2T&c}|9nBqgGfzVDI-VnkzpY;uoi zgO8RgZ;M<_MkA4SuL9iNIY^&g-fuH!;&j(=jMshB=_#$!&L3h))%3LOgTu2KA$Re; z%C7>%nJKmQ%SBx8&y_oT(A-*2DO}o@g6k$)SMK1Pt^i9lc>&DncwNT20z0SYfsmo% z&7By`JNFvEAlJ7w-rlHNW^6-n#RPRD}vHnmj`x6;5*RxQA&nbcS{8uEn zO1P!ZYm834d{eVOGpUyI%vTAR3w&X1PXC~C;zJ5Ysk+`0O@SlbxQ)sC72cdx2l@aL zVZ5UxgHbKLtAJRH0LBPV#B}4v4Ol%q{1&A>fOu^!qmq6Qb&6)<=<)LQ$ZUM#A|qUJ`6S1#QEcoe+JOer`3 zay#o)0YG~3k0ph#7F>J#vG3u9bcj5MrNP{8v7bfA^aFZ~P{#>o<{>VIaS47iWR{WL(x(l&}Tu;&E@lsEic_nHVX&laZR9QQ<-#P45rs zK#?{iZt6b;c;%RmNt^o;0P!nKAkl=*7ubWXxvAooz$Q!dj~B=5x^J`%53+SXppL8T z1HR3#ZrwP67rfKIUW!^Dwtrf`@M@?H#UGg=ZOx@NKL7aCCP*VrK655ezsuFz;T-bv} ze`)GyC`SJh_?7d>ZNuS7MpQywRjw@z`-3eLG`I2fmYQce2I{N!YE5~0WJ)v(h@#^L zxEyZ|XN~5sJl?opIZjimRpq!-Y*^ju$2+TY96J2m@kvyCq4u_ID!f??$perl+g) ze~z-gZyS+qd|5b?NNAcs@rEzB#r~mU{X(T6%T)E}MI)_ch6RBv08oQIQ^z=P+EGL=y=hAE514_rF9W6>c2BtHPPWfYV;Ql9+ITn2y(Ev^XYK7>wHL}M zwBmv~2n4?`r$1bdWe4BGW#c;9v37!S!en?lr@M6YHsXCwQv;u=!|iZID*E~-$|*iD zipJPdl3EZM*h6j8qPxfzy)==Ipi{rb$D zEGL9703^nOyZy(lXqO5vBU`W5A5c`)zEH_xwzT^e2UT~GrVa6!d8PL!*_lSs_JOZV z94zN%frK zIf?W9pM*UW>`g1h!}({6ABU`hfpvc}Y#_#36t9E56I`lB_rVAKo9N}t=+BdF*Iz%Y zvM;xl(Np;XJiah;ww_y4?SD`u6s!F;iNXE6w3Ir>#JK(w$5YLwAsx<}FE9Rs+NmRY z;!jq@|B!QqnFLW5DVFDl<)YCy(U*kXC7W9RDT=H=wr<$=ss2nmY9h>eFP92QKh>VZ z6diZ3HnVGl1C7+sCLV+*QkwO1y)?(KLM=ZZD#mCfW|u6gaN$|E();)9**cDOjlvDX zPO_V1N>1zFsN&>Jqw%^fyvy2>{*c=n_jW`G2sp?USQ$zCbf?#(3goi{XVA6u-$X)$ zlYMn70pZzPe=<(DpSCrx)wEaj&eTlW){>^I|F5??`%jiw^{3XCu$MgDFl96q)hjPX7pmPGbb3=6Om@`zybmOH$@ zhDxKp-9XIL8$#sMx3XS*Lf_#~_%~Z{_AE2ZQW_E}*4VJ-`F5@7>0RK{y!uPwUqssp zJww`Cz?UN`f7AZd;qEVh)<*7fclkaepx6yrXl!f>NY4f&^j5 zdq#Z)u{$Mx2x9W-wP6-{0STqnFQ85o1ntqCq1rx(t~Psqda#7A(?ar@&#jfbnYX&V zWxC$qsI@IIN=Yp!&mv$r-`#&?h^W1ED+PcDb*c-lwwH>Zvakd>P2yVly}vS~0N)>~ zdl$sxDv3+im$E0rupVUVW@l0~>kAL-fUN?#lX#1nyU*Y$fHbR%Qw|yYpBVICv|J5HAn%fNSx;a~5y7j1R2= z*8`xV^Q8~99rWOe5IFpCA|$F3+5M^(97Vo9t7+@wE!H3!@r6@KVR&1B2T zi7?iBDf01jm^aWFGB{DC)!Y%%${(WMANcdpLqPZSEYe5zGK}OFA?Osh*rd0a=r*Pj z(Gd~K`vU%Y6L(fetM|v(l8Kg#5K9*1a6z$i&!Ou`U)Ca$Gn|iWN>?ru#6nHaz8F&R zHFnKvsz?M_q$eXJ#xN=p&L1pr%i0M*h?Al6pRS*P8|LWB_AEvwYhGItGVZdpyaRN zc1pb;)VRkNueC-pYKdienwBz(6X3=q=+^0kWQArb6D0)jFSjmoXmPcl_Xqh%l_o$g zs>Zq};4j(*Y4YMk8CcO=1h?7g%j`O_XWKX@X3Uyqv$oFz2-bxbMNW!HrVGm+hbJfK zj#L&C0_Q0`qWA8Ih+-aKIr?%=Lq!H?Y=(m|Oa9|J(7Uh5dIJbeYs??;M%N}I?ZJ>_ z@_)Iy2!2T{6#>1+lHg~3`}dy=sRJY;XGLhu8Gsp)Ki#6e#?c#V65blM;}jC%47*@6 zc0kZ45Rv!EccWZ}I_Zr%Y-C9cO4QHb*_5A;84$h7ouOe&?D7 zSPRSPMd-BjWoF7=Ma4q7PyXq>s8lvTR|rUK*4drk7Xa>s` zqNCDftAuZVt(PpY@fH)>-MuwWQGl&vI@(4uL*B_QQVLHs7v#h_25WI&;gKGN8iov_ z6@jnn^IB&i*Y)A~$qBGCqjZtt@zBjOgp*VZy)#LHPmD|vFguk^SF>3OrX{(o5N7M+ zRzie`&9Nmiq*fj0F&b#x^y_Q!8dvt03&fpJrUQ(IRH^?99KZS$Gc>}&_vP# z8MSY}5+q;VhDIk1IeW;?xss9_e(Tg;7YW4#9^lGW<`K$}bT^7o%~Ja#n)Y3OUR`Zj ztE9n3x55h%JT(2`td)solJ~w4zFK-GBhKTY;IVuD9{cN7B&yn@V&!vxg}~1f`DwAJ zuQ(bG1@s4lWi1oe+6;A#`=(o-NK_LQ+Z7NPESSs&mY>#4$ov~UOe4p*6-uO zx!qw+9yzifeV7^Z!54h9PK*L6dG-2Hvz5ZP_4|Y&|$K1h@ zH6s~>p0oPWOKa5=>q1xB`z0}vT!n0Xbkf{1)B?D7eO|HTCZUWuAcAX+X-zUrr)$D5 zeBJ@Im(sfGnqQR# z;Dl94ZNly!8+pHRr9lXAQXNpDyFMgFBm)yu?t!f3VtJmvV}FZe`gIK&y{p+dBlx*! zwe3-C7NdsO5LnGLbHF3H7^5vh{F|t8x}iu^f?s6;crImj>1DZPVdLV@ey4X8E@|6^ zYoCZsI%lZ}D{HYzWr-g!A24LI$Uq-Ek9W4qVbH@JLwKEv4Wc(xYntFfx>OygKi-T*jWhFUaP|5?O>+@Lxb*CWmBO&2~E!HHyPNt15b{BzI_+2MPX6+ z2yyCK1%0q%^@QUp{_&^h>aj)ohRllTWICii$nHcn#>(QNWFxznRuDVY*eu!A#*H_a znF^`E$=U-wE3nO>R6O{8HF^?r@gM`wt0U(0FOknPZG^H?=&7f6tD8m;&}rzs}y%%)8`45ygvhm8#>y7-NWv$?>jG4yls7`^=nLgcax z0DGFnMf1yGo1fwr8?xbRHH(1iwz+Kp2yl`KO{@vqgJ=ef*#`+V&lSRk%FAwzC_CiR zc64^7;;yr#NRr!$r?I#IW$tL*;DdOSa~)<3J*{OWfV@@&`Zl;4ZAZ5iXyk|!T^ki| zT*R__1MmRiLfJ~+zK^b23*AmrV+O+)>!%%jjd&JI*e-7FH_>e;^vMNk^{^p-+v0`4 zLq_PScPIz!GADh4ugP>qVC=yjcHY=MYK4u^CsmA=eh{p1p4I|S+HENE^Q~Cg%|w;W z!-OjK+dPaP4^j(wnn4>Kd!{r8)nPy@ABynQs6`2Gi&*7$S^KR(Bc=-#7~cBBLr);q zVzhL?3}n}Df5P#twt<>6`6L0A2nQc^bON{modGE~Twp)F$`GbLIN|Ll7HvI^gUz0M z*J)T(z^snN$ZhgwxpD-jPgtV1{e9pw$|*{hpjbP#XpU0dnd})8V<=r%Sg3?w=+nWf znan-YM{O`1Ne1>TrqK-Ofz4$#oHgRzw}C8xGt%F2u;-y~u$+&Necm)ZEBHKfxsWzD zQt6za?MKlmmDXpHGJX>GfPcW^s1}Y&qn%~9%i3fZrOM!;jDFFkJb@1Imly(ey`A%& zLUJo{`5m#=KeZRk#56%rPvYfCW)j`jQ5Fd;!dZye!(9j(D>Xzf2D{zCyu0* zlC?S~Hf|Me@bnuQs-$_EfX}baq8H`m67?vSBR#evrAS(g06hdxPT}0?)1sZ__ypHr zi$wR~R{YFXjhC;rPy!-YTrBK4==+b5M~c zs&O{;gSEY01f_~<_{7~F?V5JAGiG_}kh^{psilTr57Qd;r5F{8?V323k#hFa%mX!m znnfGffa>6NSz6rC74Aw^47r6wu}rPx!{sd|qs+rhLbrfRif-7qIz7*T%M-(YM9J-V zY}M{0l4pIqvT)DGuTc72#aZxIzdQYI_grfKgx7E;OBD0D2mk=N$76Uj=~>#s&7pZfZ*6tpKRZDEceTs zL7NM%MHY2%crhrt2}tv54+T-u5nH2mki6MFM?_g%SynV=I8~R8T_>zX?&Mu5{#rJT zo5^CuUxDSl{An0L!<*>$(gf;G&3SITXybXwy@__!+-ER^ih;;6lbEH-?F%O?RX%tIQy zgx;`S8gy*Wp3&L4+Ev!0)_ywCy012;7$i$*2M}3(GQfj`z z;8mi63hPg@@I6AnmVWGnG(O!{gEVxw2x!z*2eZfyPKNekJZ2vSu~*@0ih&h(`R=@ghwRt<8RzO zRz2ZXDDb>}r7`5=Mbm;7Azz&MmeHfR_@Vmvly~KY0-Mym-7T>uR1ZGJro|@TrvWw= zr0KWXJ4*0%zJ(Xg=9{82DSBlmq3sQ~1|z3lZ+-B*>B{FPA5hQDz~xQo3>t9D@9o=R zn7O;-bX2OVTmSZbai%0T7oO$zaZbPjY>mT;YM8|;zSBuc3N+z06`~}S*d%2GF>S1O zw;#|Ftjl8OK$TRyh?-a9>+cnUF7*5S+?n#8oTy9^sIi@AwRX9A5;ZnDr~|1+szQ>+ z6I4CisRhf=UMp{3wiWB`Q*TYxdVf9e%on@;A=kP|fEK-C227(*MuPZ>=@Q8ZV0Pv$ z=ChRdOB^%a2t}gVFf{L%Z#i8-4axqd4JG6yMrJTC^uFz;jVa ziuimXb_J4>+&;)4AhcD=$9i8cF;7K)#QW4`NM~%$3@3z6SJ~N17rHMcYs$Tp!_k15 ztQRGm-rFD$MBx={F)Ay0g#W|gXWv+qiFuvOhl9uWn<%y)-Yxi~!0e#>Emh}{tr%_Hi`gMdoZit%DJ9`)c)>bln;K2}45!>+ZZ*%{;+)vh6{>x7s}7y`+vKUdF3ESauVV0mYj$vK@whRNw^5l z%GDdC^+kK1$j#f&A9JY1h<*ip`=rP`a(HlfWltBJERz?uTQOMh|LW%dyUYL2FMi0#ZE|8wJEF=`Q7mWtZT-DLJXUlA}S4ls>;*r6P(oZt?U1Dz3y8Tk7)K zv!T_h|1Bo5>lTm?YHD$Cj>tnUox!h&U!$z{N$qjOX=OC``v5ohKX;qZ6wbR*MB1vy zcVgaT!|aa1`Y3N5ErrlX`4L?j=U4&Jtj5v$0hGz3O7Y2ni3Al>UCzjD9Xa61zye_5 z6R9uP)jWRHcQx>nq-YJcWc{8WVK@M z3mXlo_fEt}rPv>8kJM>WQ_oYG)p?;!00GKj>)Haj5{;h^e17cHX08+ZV`cJt0=(u+ zwE9f7OCuN6!H>9EOMHnRUWL=%5U&7heLT<;IY4L9+aGJI3YuoRH7l6imDKyhT``R7U9CzQvm>o(pRvh^;oGE%D~u}d4}j2PX#KBFjVJ^5w6wIYUnig^zjpbY znyLRw0yX&3*j|HU8!{iDnXg1ge8_vS=_|pR%duAxnR31At^?Ep*lfpX9|K5lTuA}4 zO2MDZ8rH9a`^pHIxN&=Z;3&`4r`BN}ZjxX|gHK5SS42sf4`5`4noOMWV?{wSQ+Ay_ zlfn>Pq!kS3BgWt&I$6ZYK}|Kp{OPk;XwA>^jlvL`K)HHWJ`{&@0&8P=b1}Uc?1nA` zY>H!ihyEoUDtSc+Q3FS%!A)}@Bdt%BaaD-AV zddS(X4@zBc?V1!C;k|L=lxY#5vB2RjXgVySIeeYa z5zJqQws3eU$XNnxkY4@o1+j$sSpM7ozlj`Ge-n{~eART|zbD8nB%p!Z=z1kwaOKB# z85uC}&^GS*mf^B*^h5U(>eVXG4KN&1kU}{es@gS80;_bntzyRfdPa29m+QUQHE$dH z1BLbe+iu~xO(9V{OzwNKx z{e2p>JyFbLovl}9=wQ5K_^-2-jHR}vCTxXa_=&HwK0D$v_@^|R>Y>PZq+Q^j1SbBX zCqSMmSv>|5zb)B73c0rQv-FAL&&>1XQCkN%7~uiG(+&7}IbsvVN@=M(?{?{)9SfU!4Bh3r_MD z*qczZMhIiCx#n{K6jZLt?OiP^Pxwo}f2q3)y!=(jJN^Ga4=G9iEOehnzJJ#(X6gS# zwf{}>F9ZH>S^oX_`2WC&2q^(ms^6l16X}#!{bl$yqT>{il1h)MQ5D22ITnd!9{ROY z;gi_T8H6sC5l`wYe9OK)4k_*xU>6?t8h$+(5#&txQg_YJ#cMmFzMyNo;oalOG;z~0 z&I_B(<$>}n=bF|$T;G7^5B6~kjV(PD zJ*dPr9-PY2wFt#vHL^7(@0p+P%sRX3Ok*TNAYEGTJ=2fiSQ{BOSVDM-wHl<8*>DLM z!3LJi<$wqujBR3ln+2*=a%>;h55Q9Mxxw3p_sG?J!Q_ULzSiLV^`yaio%pJ|j)8vm zBeN9dg?#GPq|B@2i~U;Lr&)-j<8lo=+Hu!=f+@*6;AYgf*z;pDV(!j*G!aKDOKE*< zY`kSgeT$M*xs0Xk%9^HrdSs4+>Cw_P2NGAXmtoewe|Kp1!=mM{xdwt_C^~&&f;5Jdy4x7I$^rmT5BXE; zXFsP#_bnCUq)5LBryIeH=*zW$F5DaN?2}UaksjKlexTB?osEg(;KhZFo!z(v0tVet zAPL`s8t1eS^z!0Y58F{DWYfkecNrpi_on?m{vZZHHou79(93!~MwdotaEPu2?G6$q`KNVOg)kJdBXdSZtiF-&wo|8Csh6IYg7^#- z)M1=FeC$`uo{R|$AAwt4Kd@HLN%=;o>-}o^%ew%lLud=6}&D_{7mG-;Ox<2M5owuFoV}*PtE?f z%t0fiqih9?fA8T8p)psES+B%!^WLaNoYt?r1`FNmE2X8Wi<&e=u7G7F-+a(bkfGcH zF;yt(eL*qvaJRh0HRk0_?SDw7^HsGzYn6%bgz%Gm5jT7E5@6CV8ClJjm@>T zwX~j;R0ZjtG|YS3Az;csIB|H2>Hbgm6kpMxQ|wyVTQ`ph4BvftHRFv@LCD~p*rUzX z5bg(6H%8)6TPe8vW6nCNN%VRg-`2x{J+kmag~V_InmQ!w1d>ReEdNnl#7%|JES8Ky z*Ns0#&?ESXbphx4IxvN}IR#DCI=vEgbCGO*)Zmp66!Ek$T%9&Ed6#vutWw$qW*8xH zN3y4_hB=CL)>3cPh~*-b!~@Cayuz5vMTq-cYs@2q6-YSu>S4^BUOcge)2j1t@-hDRY2Xfa|gFW)uj=V5t4=?kP zL=e-xLcrtpmvgL)#HOAq4>=%`0`&Y#?=ZaYem>6Znpa3tO-K*)G@aud)44SD|BWRi;+6#_8?_j9HODWJEh1mzj`i$=mIXE9A{pfKTCeO_wwcW zj9+|JzMm1rN7d<>-$X`64NRQBi5NOyU`QKj|Gzs6gk_BCaWav@J<35bezZk}119#L zy8;3z#3ik=J5lbjdAucTN`^}NikeXr>Z=wyzNft!b_&6eX8l_g+^^!3Y2%*()*fqf z8XNiz4|CBBkycJep>JMhWx4ecz8vzt@vuGKl`il#tpC>(UsWa^_neKQXXaD6^(vM zKJ~Jf?xbYB&)9%@=RMg^v8by33U0YW4rd`8;*M`}q z)BCZ7wHL}J?DLrxJX8ewr{(&Uxiq2KY#*qST zIk(+1S4LF!dr(rINMufKphkLNl8_{9q}}=cf0oM7C?Y-iy6Y&%ASjQ|>okJT=JN-u}rLb&O1<_81!|yR;(wlhP;T|qkeo97A8h9l3famzQZ-_wqZEzi;B3y zia$*YDcBlsXlOP zfnIw|Wkh~|4pc14c0ySzA20)JC>WEE#bYym`Nmu!ctM=<)!#%;f@{p=-GN+e#3 zQ?@InctcOFWAv)-)LRG0Y*JS_=91)>7)8B-RNqR(#d9Fus9U=M034_kBC%AtfnI0U z62C`mr3L6l8IMbObu(Q6b2eo6(LgG|>z{z(7~nJuBSb zkeSkh`=S1>sw;&oc^(+!|2a@mJM`=JCy&BHJaaZf?+v2(5WQ+T#%iwqb}?@wC7c+s znN91Cr2?yI6op$q$lV_SAyqR!f#hJwc*{-1(&Q!sk3J#7dn^Q8O(&>%Gmf&!I~f<} z7yDU$-oH*NYm4O*8G{A7eO`_0Z={pN#S?0u`h3?B=;YEK+I+49E+F&JMWJ+m8PlS} zm->-~K0(u#3f$yYpo9xR5Nk|~s(kSZ%4AuUL5i-&S@a$-dCfVwjg{V+8kII$R?n0q zy~8cK(>Yo3amC+^_!d7O+hmS$(zc1ci0q6jePiu}b0QwIOy?0(!NCvAZ@MiUUeb7d zYtVc_ZA_ZQkHzXV5^vHlWL@!;xq?CTdm7!amvH=RM{9ZJ5~%6CgsyD5ju!Qk?32mp zAkv1x3211y8zsYCTX%ADXFf$wMMa;)e0cSYW3N~XC7%LX@xF4s&bsAra=|#?5Np>h zZb$1rW<7fXr~rbyI_p*T!TdFxO@)wVqyy$;zkR7u(2b-CnB8|HekVgQGxH~v2Uj5i zu)#d~l{K_14izUN3QAx*=o$-GvRDy|#wV-A2R&^FOR(DT;3xO9=3Owmf3)}|8246S z)O1+7-HyjUF$D3^rIbTihS8|(Ii*3&gcM0wk=wLHh1tB1>Al4Es9K@`q+8TJO-?U0 zp_lHo&|F56xuJWZf9tNgXik&jYmZc0wNy0DG1UxsDBjlFnMKAc5B*6SLDd>?E2B>y zi%xF*pso1ar9kSDn&%9&Cj|i#)fbae%E32pcr_FR;CjYb5#pH1oK)p2tRCI*rFt&# z+7ITMn>{yUc%P=GV7NFDy*srjY#{bmH=a7UXr`n2eQW%m-Ffd>tV)^xiahoW^^zfZ z)Csw&K+qyQ$>5#n7>pvXcN!Xbew~S)6PLtldMLO`nIcZBrsu}Zr_Bx*l!&-Ct8Dg5 zSa^DG@kid0zQU}j1LvonleZ9NAg6c0XC${AedlZT*ZMGud)~G9s=KB=>zU=3?_5Od zsHxsg$mivWLCidk7X}6sq%W$kN5d{;N9&^xno9D`AM}TJ({^~)i*G1um44i9gr@)} z+D1SSB2IS7L7NHTt>OLa-9H*Qqun@MYPw|`Y+55%IlV)^3_-3!4G)pcwRG{%%lR*z zCV0JOqYkvQN+*<-Nt9zu-O6$&YR4%Vr@8m$IAymedc_2_z`4QBtAV&c@oLgpH1Z%Ap))STCs0<|ko zneU~KWSzl(0%NYQhp)2Fn}`{^Pg5AUPA;FCTKFkyl=-yUHsYK&R7|08>ZN{JIN80_ zdb<7E&V#B5m$)Y>xV!GzlA1k}73jqGyY`F5XigNTk*ahIIV@a2=H~wAH-u^KTO>pcY|dHLVb%; zi)gX)CeW}LRm#_i$q~18BpxUWm=*?UO}NRuv}D<#9_o*8Jm?Ch4pffK=ZIhzFUTKZ z=URKlSen}A>eyx$d&8|i5UfL6ZQYuT0xLAIn8<QjYz130j`htKTUa8Mr= zu=4de^I~v(G)H0{HdV0Ri}QG@Okn1Hc5pBx!w;DKEj7)?XE?bTyKy&EvGpfQvS@C< zL4vt?lS?|@=tas?Jv=T4TOj3T9pfWQLzNZKK>sG$%%fRbA$-m47Tq#?8Rv><0O$W; z@4dsC>biYVnuV$$MVf%rfFK~fNQV%52sMC8HDKsPKopQ(LkYb@=m9AS9g$u{dJRSC z0YRlIsPE19`t7~-JLjHro_qJb&pqd!=gA*yuBbd6$bP=DEK1IN(P7huS2v$dz@6d9Vf%W@L?fFZ0cqV#;^@ki zT;0UFDnfW+i+koZL(55iKrD2hF%6}#%cc$eCM&BfOMX+K-+5-l&hvyXzfPlFD{lA0 z$Ug$kTvVqzFKT2KNpj%I>(5BgtE0ML$2ke#1(EZY#*1siSR5|gm-@Eg&9wSGtsBe^cG->~wtlg%&yD>ixm8OLdkAvc@8ar0i@7IJ-Ti&F|DERqVNEb#`h#kK! zOT*1e$e40MFw7l1m|^&_@uJ)$tDVQ%K`<4q^O)sF$MtNTt8vlkl+`J8+#*BjzQf%V z?~YfBhhryOeD++J&`j)^=XnE9VhK#h^=0TY#8J7BA&?7$jX$vW)aE~A z!F%zExkepV7)Z0+6A+O(sw7vXBT#A{5(UBrw>m&G%!{Q-No5-kSf;OAVYJ*OKBl%H z!t;8Qa487o0kL-tvw?5fRh}ezV09dyF_W)4tNVl(+AUr!$OiS+nphHYnBF}`d&cn; z$_g5gZK3#SMXzM>ao_Wh|RyccYmwggqmcLkFSZw{(6jgH0{r#C!r+d2gVRaO8H-Wc$gYQ`gzUyBdh2tj_{vx_x zqd&8*)F`w3?zUK|6YYmmu~NmP>yfh$d{PbxytHz^s;Nk==^0YnyW2YS?L}vJBwrJ~=BV|JBb>~&{-QcR{R01QT~fE|nVL0S zF5YOOjI;LX!-wyWzDI;A{nH8D@;*qu|B2?@v(XDIdZk2&hfT%30Kof@#5nI^Kno-= z{Y2x1KgTY8h+y;CBntqVC1>1W6-B$<%G35VrsDcIQp-}=o1e8HnaI?gM(EXykgAmm zXp%l<5T(XBDXNf*E`&Onf0e(-Ld^sasmI-!zh05@;JY9>^?RDQhh9d5qzVJ@01g*M zykAh(E>M({GNzmBt2vw+f?KXSt8Vd)IIAX&N#H~l{W z`M%lLIpY)fP5Bd^-{xKkKvJ{7Xj@amy`P;8ZJ$&Xf0Ub&8)(ph2!(f; zE>NcvzPkI&ln_FT*!2&atjxJU`tIc1=?-pzSvKksC8~1g)^`C2Sy8SlUY%b{l#<}`1vnyfwOBD_a0npEqa?yX^+i= z4dGcgir;mxg47R>@5W5)r-VD9RH1$z@;h3#`m!P41o!_7gynw$Tox33y%_%&QE2T6 zAL_R~b#s=4Ig}!kPJJmmR8Sm2k!_^S?enbV`6J7;?Chrj3(Tr`F^z6DIBIH;L{s*s z^GE%9+|IHGxrbs~6*GgH`-024)a3jr&qK2}r|CnZKjj;p5+-`}GjhcknAlpE3^vYW zo=;3P^gQd`Us_(xusyLs*=aw%z?|Em>u#-*54p|>WE zf03y6k{L%m7tuoa`rrc}h<($lV0AK23RFI&J&uanMGqCd2Di)uT>PEo(QYMT-h1|Eq+E zd!?t^8bi}vj|DvqZr_q`aeomg31gL<6kgn)?ZrcH!)LcC#|vYVp`k%g~SnJJyI(Upb9cxH`eGvHla&6J)b}c4_t@~~UwPv&Vj!^`r zqZ67$5V*~a=)bm7R_Vj=)laiz>*%$p2Q1SG-i3uQGP3EWqO(Jp1^v6KA3-a5iJ6JD zj}u(@`X?lLs;1-~i%i`5(y#l-Pui}fyMjt;vVTue!1VlOPrCLXms6e?^vlF7sZWx( zlsv2jNPbQC11mn4oU_dT>ChVxaA-sM)C`ABe6?C6&q&zHv$B)hL9C0fN$oPezex!& z)#v&;LAiISyNF&K$rIMJv&sMT6yhk^S=hJ-U9*4J z@HSsiI9bBy6`L+_ORc^`vY=VJtN$w4?{j`tp0(3gjz@dxB0scU_nc_Vl)~J~%nFP~ z-@0*VfDGIoale{=+NL-;4xilr(m0c+Kn}Z_#86JnTb4}DDa<8fIGpOAFd5X?T&^Ac zm{HNrd#X)(5&q+>$m+lpW(K&r6Z6%lB0@D(6%-Bf%!94S3^6NWnD?uk_jJDjnl5?S4264;K;4_CO^DLvuxWcQ`()^l~P=@BKVp&#MfA^nCgrkKS(!gYDKq8RW$2adf&;=cdU;pk$;SfRN}B zf@=@LxH5)Kna-=S=_xwLQUUs|epH1~hc>2GAB3$7YI9zwa87>qiHJIwBs|=8(V^+F z#>5$XD3SN7SL8zw&7lXczk9)}f~x|nf(AS$3#{Na<@uk@Gc*|2bg!w3=7?VSPv#R% zie(-KI&tH-PU&JPakKbJ36nO+qN9p`X>s&pcvG&fDZ2<4S!$uS52jF?S%yNpUti4h zcq^n4)^TSmU)@^|+c9}%F>R4PGW4jEJ{S={Nh7VQ>M?S&%3Y%ms@lA7Y=S$>Eep4IP}2o&`>3Nb`PAQ^pJdU$SDDs4?81Jwh4fcu9q23nl=Y8{jy|jN?E++EWlPVKC z!xfGNS)?ubkhX7G|4a!)JaM?hne)k{4%@bbOMUA5RL>^PmnFbsQJ`*|R+zE^V6$Ew=L*3#Tn za@LB@UX@?E+`6B9&ghrBtMxhP1A$cMImeKs{9AvRS-kF9$o^-44~TO zMZHKZS87}PqRjkU(~ebsQZ2d0_Xek@vbf7k-uSA#tKvg$gE!LRo+L`@7#SBa-yic} z=j{~y8ZOn;l&OuUk8@@4+%j&>xv>zEFkBxm?BEu))TnGgJL!$9L&(`6!eu)~^1cvN zDVC5pFIl5(v&cq^A52MsKa|eB5%bkZy1v2N!|Pf>&b3zXio~N z5Odhe#(#XSJG1q~bpDgXp6O1!-0%{i?}vqRxw17Ecnp@GCgJuf=o)Bz8arx06LNWE zHy_6fhwv@+ZZQs@t4Aldk_pIm(j_bM61Uc)ClzV&rkwD6LnxYhxkeP7ppi-O;kFR% zxq1bv-(R?-xgH2GH2u=8d2nrZzox=-Qc7raC^o!%#5MQ64A2phfo|S=Bt!`WO_Sd= zq`0XNbp=}+ujE24wQAbCgHwwyWG8V?Z5iziNl>w=+dG*QOh9uu%~2LFURa3v zjF-J4u-GvU*K1yuJ*i?$Z-DtyGPd3O@4 zcFVGMJj|%;-ItH$MyJs=Zf5MV9N&<+Ez+iz;>*~U2_1*QWOm^&!2811(#TSV za5v6i(UQD6CM|O=Yq@ud3AkIE<55d?Myxu^*KbmP3dtQOVr>}^Oz0nzsCI#-&3cdK zR(aJ%3q?gy^CDZXa&-siFpyk9#T{1NVAR#^6SHJ8Tlxfcov77op!-07MWZ8Qgdj9B zEDwR!ek$btL7V&}&g@Pm#%2CBKG%SE#BPEIUxgf&tmm#WP^Em;O%|T4+T_>48&>9h z{F1Fc5S^}TaXg+Z%$Oln$lHCiMsI&_vih_FTHwYp>^~C~z^{)h)6Qn>c@i&fpglF{ z$okl0`McXE0e-=iWqq%IOwLK7Xoep}yEmo%B#uTJUi(@NCS|rtjYLHSUl_>Z8?6L| zfcC+N_bd48o;kC&GgXB2g8hY06k)w)v@Hz%w`J5fQLiv)n&T4bm+f-AJURUG+md-s zDzJ=G{wMJ;oQ|mzvVF7Vx+U#wpeF&`n;)^y;EO9dKFL}iz@}KZ+wX8msWr7Si)VCk7)Sue@*9AnA!+z-n>q>CT$r@=?)4#D>w7eULeBZzMM#*~bj zMyOk{YATqR*Sz02s~9XPGFP}i&a-lmTx2bBmhq`jQ27#h>Q5D*PguFYk^HdC7?({b zfzEYZeDiI*vn@lnp|`x8z)p!!31{{3%gG_mTPx<94<7AMYIITw)Qw+gNAP%yL{p0J z8YEjAS;z|Vs&AZ+iU?FRkuGQ~FIe_y4(UdJxM;SEgQl!J&`qL&R%mN`sF523x`x6o~j)hs~CsDqTydZd8ct*1uS`~L=(-ykgm zjEO^mkq+O0@ok;yG=Xwbsjt71#`L&Mm*+Z;%_p7T%Wu$caQX=R!92E51x`x`MbAF@ z5zSF7dm*tW^}IK6BIidIA^Cdr3#Vn&{WTR|-V9^z63q3lYURTn8K%K45P^8gl zy6ijR)P9xRCoewjhT9!))rMEvFGGWukdm&+<*})Iywq~L+Jx`59O&kf)a1+JvW^X@ z>i2(>2QmzvxfYSW`T_#@yLtxk)Vlw>-p0L{N*^AEox z$M?{fi$#p<)Aa=obVq{Ra~Bt;yrO=hy>HkHH%;!;`(gOKdWKuXyQ0g9!Kj zP~4#^&L4G&7p53SyD0eM&L8&GMXN4jSa}yekg%zUot3@jD#z8O((T>^8yPTKEM`|YJ#-CH^2O7L7CpT!A|x~R%2Q!)Ble^D+@LSALe(B?9aL~* zClzbRkh1$TNrFiR7Ib!^nj_J9ab9Q)(1H9eEoAuHf)4HPSD1ce8f;A)W85YOK`aDT zpP=PS2zz6H5eW{0|IiaMJYadZ?ygH-MkP3o85D7P=n+Xr2fQ^APUBtY_uuS@4O;)Pz3 z(2E+tWU9s7#A+CPBNAu)S)9qqJ|-Vv`ad z;Jt;LSXK+rWZ_QvDTf1;5J2ic2yzu)VBZibNMuy=REriKyms6cLUzlEs{Ajaj8dcY z->vIhfuqUN+r)WiUh4w0`4`$)3)H5R1PMbb+{R5(4W1F&C8X|hs$9k%%xbCZW} z_p0VWV_<#7L?zdAf4nVwnxJmX$2#}7D}eYZm zE+1NKiU9|BRu58L75E^X<1)!#qQBFnaCpy}6;FE{wK$tQ%QVVGX|h;2kZ^I0KmpIU z%KS54TW!$b!vVP|1%$2|D;)vcP(k}S-O5+WdZ46sOoH4tD3Y4>*_!8GIS45I;1sY* ztsOTV7)a%v3m}GWKG6x1>DRkRn8?c=XqK(b8o2(5lF^eUGfQD0UL>V@>UN*9x#rQx zBbf3E4>6whZBon{OZFqVaJk8WnJ*~5uH{xz+6PVU!@d(@O|u)BWbM0GjoU^)$<2GX zTm?g}_MeqB&lB+cZkDlftYX#~2AFO<5BP31l~JpB|Jprg>yYI~Q106^rQ9`?O@Zy1 zv$!XZLC`)trI>wc>z-Z(@W~->;LZwbF?VY0yY3SZnq@`ZF(=zQ+Xo}u#49(IxCDQz z78*?zpmSCKqPnd;8~|`HrjV~34bb{sMcu@E4aYCq^{1wwNO;CK{p{u@=V*_hFhR02 z`-=G;RTdvO7P>i6A;{I3J6%p3GwMv8?VO#>G7)CZh_>*4)B~SYH73%6=@u$*KG2@3 z*QPXhKW>C~`{dYm_P1X2>vk8X?Y8JqBO~7~(eu26!=XIGUnoi5)7m9U?WsRvf zC%-MRUDm|}K5$32{6(};q%@3`tNSI3%-=SyDl_oYg(V~i?=M3yvYLF3X6`9D{}3Gb zy@?t>JV2TMYOS0nFP_iE8U81jp0(kU~ z*Cf(-g5O*keJ_3q6xpB$LO&|g=$@^L1FB?99!$f6gj3*=uxZWK{ePlJ3w|D3w>Pc!&fyZWQys%HdG`R91m-e3L z$bsMT;%1-hA%;J%^Zv}zXSGbiUt!Vr>g;%1PIAetft}d5-WkV+&vL6=iF8S#+xCW( z=12oTP_43VMo0m=FLyJz^C>6Mz4_mr0DTK6GDng}qXQ#w+P{8J2qIf)Bqkl z@5CfeW>Me`)4G^d5v)9J;S53yshT4`WZ4SkNgrt}98T^bM8ws%hT4aXt5Z7}kw`W& zIltv4bG+zfp^h&}4SBiM7k+)e`eTWnhX1B|-{`k5?zv7V^YODw+S%|>H0|tAU826y_Ci1cMy$QaU`SGq>sVt+Tl9E+=?;^lY6Js-sT9dD@G>TOH;dBa z%SN-CZ)p1O&W?(?IF68C7olttu6nH{ zAXQNUdh{;Dm1hIln2w{|Zf1%LG2Xur$n=@^oy(uH9W9Ap0GpNBAP9Ni&nV*=gt4=w zpPN8RmqSYqR}`iTcA3;Kt7P+NzLk zAP8Z}`Zm1$;Gl}6mGwfjt*2S96DOS1Uu6Ab0S@wcpIlxUV|X1!ide8YFjTY)WM;1E zcGTqOJ*`&^T>CXLO)@iqQIUBP{i8k*KBvm(86G3IH{K*4 zpipD^b%Jc3n+0DcFd4NgPbC2uR3h#(Q{pYyT=t~zZ5$V@7F>YMyJi(sSem1}RW$l*iqv15c+c|Vo z4eYk)hf90SX~)SCxvo3I>(x>rvsq_`hRakCS z<*OlurS`S-hehTu1Kp8m6-uLia?4kRgE?l(zx&O4Vka9W26N62xYQ##>f8bbp{bbD)-YPphiBk($WYHnf^`9(lxokDQGNK~y11<|dgf^|y;s_7Z;ZVAaY`JKz&v zj%T)nLml#d_RS0t;@=>Xte|g&^%SPnNx{|E+#IjjqUTy^Awu*}U!b__1he z^`-j(D)?|Vj<2>Imq3ur`r9%810<}<GXf3B$_kHNGbL_ zC)TGuv47*iNT7T_jPA>z4_TE@wtt(3yoBC)?a!@lG0yfRU2=4sK zI1+W-*Z;#4Z~4+KYGa5s9*Gt%yyV<%oUBj0Aq3H%=k8z`=f;n`9m$<&D!JzzXDfGT z*9c}jPfH(BVfMJ$6RuuPl&nG;|8}MMchRNv7vCFX^nC1ZRmC*$dHb|~*Bs`nyywOQ zUcmdMv|8{#Eu+{V-UswQc%D?Sl-EU6P7+RE zO_x(biWuC>VZHU?HBs5+fo8WZA&bd2jo2!mON%p%IK^ z>aDbcwO4+SYSRT3r5DBDzQ?<;->?$P80bDa zQlOd=$*-?gd@9)rAe#iYPQHu-+D`P;OL$8+VO4dd1Hq;x=?@*_Ly;XEB~G7%1T4(-CJ;tr6Be?^9vw-#8EJnJ{VsYV5}wzUy2L}K zV^U8gq5FJ|E+HNOg?W%)UByEn$TBj{GJfVU!6AG{v84}t-kygdFQyHgnmPK`*nXhr z(dZ@sgC=to5aQB0JR$?T$|e~P)&1V-(6X#x_&g$4GpAEfuPfy;EiAPI@GAarXQWU7 z$X?hTZXeQ?OdO)*nkHkE#LIZm-dqtDeQ1-&%%kC;Us2thTu_aAYzP1v-6W)uQKZm> z{2@Hk>71Lbj=PC%Cu{eruaXHWA{KA_1a+v5mllOuLTCuDdmA+-uD2G@mI-sWc*v5b1)DA$Z->SXerr{Fh=B`YbzUa(cHz+E4bb-&M=ACJpgN z3txY8rKb7M7p*8=6HU4=^)LIjh;ZN4f|UiU^-YFh-|K^3+N1duzTH}!b8RzegEd=5 z%*o*RcFSEO3XX-*QJ=y^*=d9+JU}2r>84l3h@?0MV(sRZ+ogrmzk6#_Qr+ECw^>%p zujPDa;!D##U!kYpRDN1OX-quj!k+=aNeu^NVNe&SPod$6h9FQOl$Wz+@>Mh7+LXFFUNM ze|5jl>l@UfgWDm1-y{-kRJsEH!mD^`#|_Yw>qA{$NE1o)i2K>Os~gSRQx=T0wukB7 z9|qQ*KJPql`yO~J-}Vk*H&(!45dosIc~+I0&%S-O==np18jrg$uMhi|vc1o4 zJgRSDk7U;99^utV-JQe;m%P;p3A^K3;(8r*BgYA8+qkmmu=?bN8;Lp_wnv(5kpOnk z6BUJuKjA&2r{yN+o`T6o8xEk7fYHg0TyrwN+1X^=leiYc@Go=K|H)kRYk^?0YxsX{ zD*I2meLL$DED`p@$iBgHiu$DGzL1&ZkkNP2yLUYRK(gnw>Yu1K$VxW6==ZcIai4yl z=GM|iJFRc{LM(h|Ys7OSrGO34Vwq@nHUjN7^?g)13i@Vr|9$Fveolew&QXC@L1`aT zZnn#oAE3_Wn=NeBJWAwD{aR&qJ;*Qh`%!=y3rr~*-o`nXH2WU>?seY~XgSq5AkM%` zrqkgDV(U#(AlCN-l#@ITltv0k$rP6vKj@#`G3Ax_pZ03hF*4?xquJ7NrykT+p|mW} zN|_9A#UhXi8KEg1qY@Q5ktI+6ww6&1t%W+cq%jOp@-Qs1Q7G;sfqs1M;%1_nRp4ez_&(UwJ9*035+fA zzOzhXVe_Oz9fY(O89Zbi`Wl1B4E;nviC#7ZN_Ip#)y%m!3A@De3Li&zcddd_k_Hb- zHaJXtW!y4z_mz}R0-9y=^WxDD#7(CgCT zQ`mnTx?KC7C;ozM@yFGlGfFA_2J6`m3ON?b{Oa0Y(5~z!Uh97QLcpi^i?|!U{72Sr zhXiIS{t4XR%&zXvfxc z!t#0|I-fv&y{wpW6wvAVBA~Rh>3)JW+8TY7_1sg*ckO4bNR80w>y5=XtAM%&y>-MC|XK5XJ?prg;m94b=9>4Sn^RtpGyZg-?$bE>sdfdWC_;^mp&GIP7}tNHtrrV2{ukeX!93% zh+kxUD<00#?cBQm_^{e@1TK!R7YfYs3kf?~Q}^ z&p)5)1^2jU3oiJ-wmwK%ZqjUuu2GnP@DM25!|EcEuhQ@c`A?%kT`N3PW;$yQXJSWM zNSVx_x6dXvb>p3f8i2F8ZrmnHzRk<73F;6Y4_mqGBOoySD>*)d8B!u8o#M>yOb zRq|SN`qIGYEyzFXARC6<=X>DAA>nWQ&p!&qk^gdyfG$ey&qso801UtWdb<9vyTM`q zb5`Vx{#Z&1N*xwSmsre8AGkfu<`U^}_%%pJW#_E_C^$K}KB>K$IICv8eqQ`Q#Ofch z)v=W*dER@4!bNLx>Qa%c#Qn3O;g_>z0Q>+l+L(nuo5c`@C!obLR&_LEcs)dtxYhkB z1D}?NqAHr`fbV{u4@dKCgpA-aCKmx6f>S`pZE-_w_iDK%-{dc*JD0Q_O85cfL=S1E za%S!RG@JONB7=F&yjExFgUOO5Yn|F5#KUyMo0QMxGM;X5Kij0_A!%rRUUONc^IiXk zBA#Q#7ab$ncX|~cUc@nFilst^l&Q%)SGL<<=G*A&x&E`Omi=>tq)@xkynX+sv-G_P z6O)}ZV2A)@?$OCyA+)mUBcp7i4St8W01EQ(n3(-Yw~!P+At^(h1?-oj?~`;V>VS&Ni(K%O`D{o38ip_I6BsqDzCSs<6|E zs%kkYWWBui$hYEjzAnGrnzC;Cloz|CnFD55R>c9-h;Me?Gz6NT2-weF{Btb-bHrte zPB=Uf>G9C@bsA~b4`*2tu3Kv^O-_UEOs;&kWYvi=rK6$8@Cao9WzZ3Q1@9M*H*j|% zbJ}{d7^5#k??_pzrnDNWQDbvl56@e+-_APy+2(xPnKw74lj<9rC~j!aFnthNDdHRx z(K7y$C9ot+RS#}NT-J%UtS4R>B(u^SdIHCxR8>)hnNr74D@6f8VN_qs>*V__#xXp3 zOQ)t&e~MnCD@w7@QDNC(iD=?3-yI* z)U49Y_a8+MIm<_T)YocdMJo^893107(M&WmuI*?Fv3q8oLB-0;vTz6YF+=NRIhZCaYNE!p(4INk#=j|`F5s%sU}^1_bW_^fPFa^ zY=|e0W%*BALRgG1m@pkQ&s2QdB-u~d;+e@kiiG!N-h1@kOM9o9nuH)w19G*B%0kLc zZ5-6Y2$^X6epf-}vEIs2BOeh=p)=+AJgB>Jmtqne&>1t!9X> z4i|RuQ#w?fK}vV#_4QX}jAbj(8qVW*VfnUqLN|$(iN|KWmVP<;e;YZ{3U5`Hv-)V> zJPLOTU2vw7*@+vbh~^>(0=EM7ufWhI`$=#7fR+-KZcL8eA1MQUn;vpKIT%d{BO_}s znlK;MB!>gv>B0Z3wQ{4!dRks4v3v=>?Z#xDZ1_Oco{$VAb2HGdB!OI|$H_xsp!6!4 z0L!4tgztsJjMo~IUCYed#YGqeJDV>QJnVN__QvbC$zIL*P73LNw!=%C%m!>ty3n&& zT=$#fA&x^mcEx9$@%_*K)sS_P} zRR&opFJGbkW|>q-(3C{eod~`Drv=4r!ChUonEzEKloTp{;{@!cJ16C59B~N-)@-9G-KK;l++@t4uQHt z9~5o0qz~~hA8~xUgH#ns31TqX}{VND+JfbH&AtGuFi(| z@H(VT58J;U=Pg(}m*n}2=%G`-*l^RzpX-33NV&;2dIFj?OjbK*GR5AB##N%@n@EI> z13#}lFW22#1T#M~U#sH3#m$!e(%j^h*P#gqWz6;DMh9z7nd5M)x0_;YnIJGSEyis7 zFQUnpc=qb$MW1zp1-E+=KPtz+#iim_j78)ce>k9FTR3zN8bJ$LG}>jeZfrXX7i`9OXDx!R6}Y@C9Ghg_*pZorD>7!2;_p`?D9*9W%$!X16_t@*jL;v`IhOB1C9KXbaP6Y(9&I=;czc& zOlHlCdtf7h=2!DOL_-gnj6JRfEwuXRC8_j3ppMYl7xL%TkC>*kBdg%48fP22$&Nwk;uy9R zF$2hMO?Ahu+L$Ejao*_919_GiU!g38>$mefg^f)@ZeR z+!ZtTF`SGGKv2kvk<4bw7y=!z-8izG1^&7haND~=7{k6bohs4MOOH0V(Q2GrUriPK za?zi`G5Nd|DJWdyB`x&K{|?X$UO&>rl&)4ey>(P1qnm*to36V&kuFuOJJ0!ZnVnW1>#yX`OW7v(qT0$&~g~gysQVvdWvG+i1t+FLXP{HL%9zk|po(<&4kC zZ(yP(l0n=;e$OK5g2O9#E*pm$0Bfug7bKn68)Gq*Uq3R#>u4)zgt0b=VMNpik+ASj zHzHM@AOmE&n~ZQ}+P zh``P!|Ni1P6T*YIY`|qX8VQs0Gj`TNMXY+L&AIqeeo3V;Y4yPKD_JNEl!(hFK1O@t z$?azXUOJlH%u+Q_aaUM@E93;_u%C|Cf)nqKg(?zgxYJZycB$`{pvPmDW+oT}7B{u6ciiO?a3+m#s|He<9MuVYiSQUDy9v?rX>B zPIUCa+~+l?j>^A?MiO#GTpi^b_6gCwh6iTASyqMY_;)%UYif@Sh7dJCRk|Fs);tl} z+~sUcY&|xfgu=(!Jnbh+st>P1i?m%GM+1|R6H?s>iS60AF!phA^4frV5?}GX(ASw? zQf|bEv(UOTOd;dn2Ku!L{vhN|YxqY*r*L%2wh3wAHr1vQd!sl36T-a;P`kn*0d|%I z@;B~IY47zlx3L|%?VQW#~_kO?bj{I5J*zY z>oUv%`n3zSyi1d_lgMI_+tQN8hm5+jS}HAd6A3xxRZU`Ojg%D}KtYuH{(V@V^)WCz zfCBmEXMCIXOq)uI@ZL#3J@)P82S{R(y57ZY00_^Wwg@zSOp4H1aHnT0e;Kj@5YtFS z2?G8HXxT#}ZQx;Ua%)g+E?EmA`yAq*j#V|J<&wNT5M0f~hO-#U*O^VMX3h@FPupP; z>caG4zW@_APTs&iXbfJK#v~~lT`QH@J@ItL)dH<+<iS9$LWYe_;pyq`Ocxvb2!!mBpnUW?Q_|~#< zv|6-fw&_|oke8q6{RPccr7gkMHKuqsIUM>f+CaG+8jU8Q$4|Ji=i1|2b!*wD&k8B;Ilian*eSyGFL>9y=Mij z$Bo^y>QUXE@#z|N{>Nw2Rw9>T-v@le%`uk1-Z2+YjNmzptFNzSl z;kU@x+&DPp9{(W>nCTSn;i%WuA2-@opO-3l-`8xigB0#GVYMnZw^lp{C(NXInVb6;~wwa zd<0+VLBw_I>q$4Iz|zSj=_Wi>bmYt6Hqb8*7kuIDuNz5c4N^^=H(VjwCQE$u<||80 zdrz60^t@$&YcXcGf{9#epWQk2qNcHDFZG-)&4j&=jEF@49706n#B8dvvbyfl*3gO< zJ^v{30xD0vEH*Cv^`3Z%{RoT6U9i#bqNRk}io2VX1+}#ufkdcwOSs|~NL^E2x{ciezfTomOKJu#mtv9Qk zCfEXg;vFSOz63V4wh;cHp+dM+%qA!viZ#JLa0W>)c+eCGl=)MT+0$L!+#|dGLZIeK zA-3x2v;g+ri)sAScyDxdgZGa4<(+k7`YAoP=2flac~F!p9qN-Cw>OCptLt=Dn+kr8 z&yq)EC%OT1SL3lK3nU`P)hZI|!OJOAoLU7C zA6kXx_HeOw#1Y*s_sgMai!aGR(xDB(m)#4liKVL;J(0f1M}U;TP|}G zhzsxcVie{Do=B%w%6TA)!s+PELIuwp_hvrnp9=3_I&nI1N)G{O7>XPmEXYZxe0oS= z5JVa?+WVJ-*E^TO2)Lyn*dt{Dw(A~ySCSaEak_g|OclQACwFi;>*WU?bN!Oco)0ly zgHRFah!D3xa?W+{1m%YaTT99C0p-PG#pG4LAbmdJI@g}$x29i9fR^A7SJk*&maec4 z2`qCmAa9IXb>toH{^;B;!P^8i?_Q)8PKX$!*YH}neq1W5!Mj97jj{?o)89pOoTe5a z@@8a!Hd~*&wXZxKm+Ll}vss0U-_7;BshR^5onarGz)=#Gtf`zbxL&r@YDBd_36U81 z_Uy7?DoW#DP(J?KML{8HnPd^Vhtgb_w{a0m{=q?flO6lrTCWa-IF=>fNNkizJTIA< zXe?$_wD!u#4qItx#GT6;qU1#x*G9dC1HZ2raTtaT$QnGV4uz+WFnluM8SJes7ynFaNjZljwSNSTMUOy*K_`dC8K;vCw*aj>?#NR(8}*5s zMs5_Y*cU(Lo*?FDreJvftz?14riYOV;HNKs;!h)W%XmaUJ^f24Hm_^rUf9t|mMLX` zui_$oORbu%vHp<5RgZo|0VJ$eKJ2I3%f!(K|CW|#mMLXS>1F)!KnGiTo4Jnyx+Z8? zmdd4T*}<9miM|5cp>~rk!ow!JgD2STPi;_LqIct`=pyAj8gOufGS6El+Ag~4)z9xu@#xA=Zr>+@mfa*G9s&?J zcP|3Xa=Bi*(I-O4c%1Vj@M<$5 zi-9M8`9v7Nle!&9TysS?7Ea4Wk_2xwq8kw}_#HslkD+639qR2Z zeBOI)H*`-%Q+JFLo9!~*-zB1+#<~-c5~>4u8bcx*0a9u8+JK>&Cu+ z+K#+r*LY{U9xdcp$JPW4YeV+w>Us?DZ%%K)%s~(~`SyriRregvO=vj4dFGQG15;oa zbLifE?pJBM>k&GE=uap>7YL!nnM}OTy1V_Z)}~QiST*hI*@nWpZXq^jg$Uc&oK9Ly z%DM?u`8ZG<_b}%pq@2^@&c}H;y8Dri2RV4w6hEAXsz!s9QY#2hZ@JDFjtiIba5ZDD z0rJM;P4E;|2%CLaYfrSSS`-hP0LK~Kwc9R!C>Q;TsY*#$PPeXRdBw)(nxPjPMU$-e z7s(Vgp3O{2S_xb$)zz_l&nGTDSzT6YdVa2al`UBRg|y&l&i*~R=P1krb`g)XS$okz zPzPOltK#=Wi|=%Vk16RWFEnnXl*9UT&t+QE0C0Zg91tkLU%LgE$^+09@EI3_oyZ)59)RocPH}i4QZ7Nf~0UZ}V)}iEr zGI8boOPrzdg3a=bbv@23I+NB`W;Q-F*B8k}L`$#AM*%sxoCoe~Y&D%jj;SQ{glKLh zx#>$;y=RT=3N^tW2WIDXE}7o{(9q`dW-G01Q({$l+GzpaMazqQXUH@%?X?iM5wi$0 zJ%U-Y_#8Qk`jBi^nsl$p%rel~LZ9+zCzxU`r|dT(m%;94$h4xt^*vK1m4FG&Z*UhXOMq3=g`hd79@!E#=1wvvun@cJ#(-C-TKb z#a~s<)o5!!*bFQ;CPw$wrnoxQpGb54H2^f+~1dff^pNroE$Jna$LBHRV-EAV8kp zDd>+I^8a`MWuj-KCMNLNDQK>*bNDmrMK&bnPwC*zy5qMITcV%jPs#6(&P4d2#XonO zMf93{`RV_(X;76v53T%nXUrditT16X<==sk!3A#pDgD`P`2TYckWlISl0&!8eZug? zvUoVa=RG9+Cbw!Crtw7q1*GA>3lGg^Ci524@io8TMzrMEtv9 zHa0Z>dO392Z#*M&P=`iqQtrblFKFTb%BZ2Drn#m|r71I9v-Z=ZDEjA3{MT#10W0Ud zVDE2%KMZlv9ktQoc9#NJncQ>bX+Hghwd3c7a3$S+icj4yrnup)a8#KK%z^gum(SmR zJV&azLCmruUh-knEMj}vk>&bRG%60vLbe`8F|8|%>`Nd3yFiOB- zMJ2aZ$+S%t)0bC*ABBe%A5y$do(guuAU#z^m!(py;wA7UQfJY5X*0a4!ctM59znos zXo}Ns=<(X;YT4RW?l@uTo4RT(quLof_mN16Wo1newd_dp-pH(`LKpg6F}oD!5R)I` zyWWv!IUva1zR~t>t)*?Hsb6iwNfkNk)I8y-aB5Jou@`{waaeiNC2VojAK&Ndw@_fJ z!luLmMU@VbkOB)vUh>=QM|F52<&vi=gs%uINYIQsX~oKzv$3t`qz7IVQ?J7!`E@kr zgXQPs9`j(E1dd$Mq!rc^o6Ls-Dv$=67XmCVB!|y`c{LB8G`*k8G0)l9g}^Vvd$d2= z-K>f|P3Cn!mgr6eyym7LL`gBcpSU!ts=YfYy{gN@87POWL0r)Ix59*P-1Jw~%{im4 zUFDUU-tZ$3Bax+J*MSvWbOrXfxcOzjfXgU~4QzZq(z7i8rnYBUeDc~ZtP6Ezq?UG* za6;Veg;Y+^7*zB>mRJedgS5%hlg4agzi(9uwHgPQv}3L&qAD)mtVTB^8HvMIo!0VO z*1E=zr! zdFWDN-_xTdu-^Cds@nBuIcZPk+k+wCX6uvrQMpx;V(}fR9(^FtA@sh&(5<}9)~82d zu7_h;>GdWW3+UnK?e;h9mTU2*$#C!%g+lDs5Vs)22qj@$K3y9*QsLIcTU+1ZXm2Rb zk!*jXTv%$IZ->)RZN(p^%wp=kJ&_34{y@w2LWndcs0(y>7JA&nZ*Kb5(#e8y8kq1> zW55DW>_gySQXS477X|=DM!I&ps9U zgmwz=jLV&$IHxcsX6&$fOYkiQs@ZP@ZGB-uV7l?BDZ0D#J|HhUOB|&~uH5to3I9x0 z91N@Q4h{N#;nNXgVd_ksyQhjAeE$F2`1=t_leS z+TYeha`r2Gr=@d~cZ_JvE&Kg@rcbNf5?7KmRj!Y}ScwwUP`;%f!Q7aZHz$>!VuO@R z&^hxixy*##VTbcMRC|G?Zxrnjq(w@u+(T;tlPyZ|wN3yp;UvuYdf|f9-b}5@qf9=a zw-8nuu*sVMrE7sj5`m?Fyn*AZXMC9OWK# zY1V*=0kbQ&`%>f>&qc;6^d6WSTOvU87}>na7)EsjTcj*A&|T%N+$;C6qY-hC{b*oI z`zBfD{#a^XQYAlmAeUHCk;cSB#gcwMu?@>*4)g`rbN52#vzNIM;Qo~mlw3xh@#98K zo(y^U!0CHawIAs%Ckn&GBnJ!-=iQaKtGjgN3AV*AXg~)0@>d$>OWB+PI+Dv&e{6z{ zhq?VP#sn=AEEE~-KX0r<0RfYGVfm}PcgPFbI-fpuE(I1b1wr^9?LMQ)`1Yf=aiFt&>U?K?ZcZ7fY>DAL?K*!#rTg(06Jfsx zp$@hgZF}~gk%9iF9ire-q?+i2$j+7G$(8s`!Z^>P?0S~QenQ+;bhYaF%5!pVg1NHG z3E4p|y&|IA-OwDNn?mx<6Ks%Hb)jE=p0xa_Ic0M3` zuIVa28{7YR`+bkNv~}R;Ox+(Gi0%rjl~4Cw4=}P*)d8#vO6Xm-vf!1rNg-!R#CQop zt?rubU6|R7oEez{OtI+jWhI7!KUiQST<@;w?Y86QY1r97IcpscSdXTS_W~Q2z`Io= z@`bF7ip5LAJRp?G<5~n%V|^&5346V^sArlw)2%rHNP+!9xup9bFeWdw{{}G#D2|uD zmO{Ea4^Ugso2})Mgpo?KpO=ahlk8>FlbF;K;6^Ry$0moYk$fR1mzAlCTgZVMqpYY( znse=EF(oc}3o0e4kTB~D8WKR@hn_lT??r{B9Q!_`lOgqqg&nwpVKc#-eKRW^C1lGa zfg6SldN2pF4l6I|On9_LRtpccs0T{xDt--71<$8mrx_X|0&wt$_LfC4F;H|8G@^q3o4*{^Wm;6AU z^1wR~O|Lr>@Ab-7QjEM5)r;;f!}K6Cjkb-!kB{7SI;*Z?T4Q^}ILxJ)GqfJ{MS{GL z$EYn&e-q_=TtY&kwk1fvc$MS1@VnKLB{L<_1klhRrby4(j=f2_qoq#b?M1eUTKxq9 zoMvp1CVAaL2v@v}t`DYB7`z?oCwXJkEhD)_wO0TedM!9vYgowl(XjQ8fB>(<3Be@9 zT9RvxYR_Tci8*GK?J-3(SBL2CO)qVUQ^@+mW?J#-C}0`xdOH<^m`{6+>S~euEerGe z^keO-cB?j5UrSZ6F+A^Xm_fWCo}vxf3hX=-kN@4=w$=Dv_Qy7^O-E9ld*(2IDZvb) zR&pmP(98ogBfZcP*h-jQbtAyPu;Ps?#R}Z=jT+o*`q0NZW0}PZKG*UJ_-x!xr_zDI zm12ZQ$2(NPH{Q5DB{;-A3Ktekw741gifJb&g*USMM7(;)wm+zNB*$cc*Si6U zZRC^XBsT~m`|=jo!o}bCvKXQ?G{g6Fb&ynVTM^ z{!s2<1p;k&sC(+@e~~J9^T%B2K%2nz)Em>lX6c;NmAG%f{(kmpjiEd1Jk8FRvLvhe z7cVM|i<({8lGd4ecRjTpCB8SXTfO&%V98A$=z`pOD&J0HP8+XwA)w8XY~`Tix2+uw zB~0&;)XUs3*>O1d9s?~rAwSrKBt&?WEmT2_&Hv*Q&A)5iSRJgSt1a8WHQJwy_n^T3 zLy)Vj$cM#1_qJBRK?c>SYY!AQWGpq<5^Qz+<;+)Z8JXNq&3;@?!C_mU-K}_o(8`#8vndvt>vi!EddP8SyG(KLJHST3|$W46yReE4@WA3A( z_sh|cBt>LCEghTB9xRkcR~>esbwu&3qqPi=*}Y|gYhlvfSpp){p4y-fMYor@AD z#bQ(65DSPCKKbX-D%ODute)WZL^Ngiu<0aHR1Jwkgf>f8-JL2vm&kf|402a~HY2hN zDIpSXa0{~oc_|{q^XJ&_x@?dwEd4l{+S{FC)a{8zh!C1 zEiVQ@Ni&JG-tw(m>$>QZc^o`F+uB-K%Xd6>_%rVk?*8&5W@B~rvkRVTtSa8pOb+f?oKQ5Yzh~_~Qz@Z~PT+>tFC=LJc4nl5 zDd&L??!7QHnJ3fs4}($aoSemO7$3S0Xxyn+vjV5p8!s7u#$MWlAhbJ|fS%mt4!_=eB$g%%z!2{LVWP}zBw!zm-F70~5eWmp|nOCQ3Fd0LrDDnr)!>EMiaqy_)V zPdBs4^W_}a%F;MrXI%X4fJ2Gb+ytCdC#i3bY847)2(-ZmK6M?<-Pxr5mQ;L>&14c24y&i(>P~vD&P!(hJ4KmEk26h zE;%tVO>KT|rP|z=Am`BgI)nXA-~t;Bf|3GkTPg!h&AYx&Mk1QCqI#gZ#)`y(uLZpv z4UJMh^yV%rH%H8My!2%oe0BunFPZqz6;@5)^M{u&B?!w9Zm?xcvxIbUFEDNb4%RdT zpmAFS=HSprzmgbrtdE=x6s2!vXKcs0p$AwD$`mV zk;xMjGNR&R!N%rkHE;+|AR9qT`y(2LGI{Z@orq3Kj8?xOb6_DRny5Ndm6}C`l zuri1#Ze59h?vy_;(;W=FB4p>Pg~bZ$zS~mbBH|D(OF37tb&CdEy)h`VY3-h^$2@&t zvDcT+`D5qDZiz|KrYyj<-x%ugE(+IWR4MuRS(D|7@uC5f7{C#<2%S9$7~aaClS)@) z=bVWgEkxQqPuAoB#;$2hd+6$^wo|tG!~GzBeL(EEdNqRLFQrI1 zOD+pjzo$}5aQbSkI&Nw~!8M3s34oNJgyDYbi<`9}Ntyd?v01?0AoFeTWf;z>`w)w| z8_EHg#gzvbLc-iDE@k!8HQeaJ>dbtJo^i=^if#ks*$WkLz9z^Zt^n9>P1zRZuU4tj z_TsJv3uc+&vuj^#&Sc7eN$r3&dP=up@3*0e(avANQqxd}A_|OrJKH*`**Y{_uWWf@ z#J9Ij)VYmxp=`iH82@(0#z(m2J6koa+2x1+o;3nJTzLQid#S(*z^s2#6}p8}C`$+b z20G1>+6+Fss!0LXDazYz%8;FlXGQk8EJWG7Ha(U)TlBz=M|`b}6OTvi)n}%YT5nS8 z!vtt`kL5+m$Vaeu>rdqBEf5u!Vc-nGvU0;uRSz28hq8_Ny86k-?ac!^k0XkszPo?G z;^vx=@^F%Yuof9k#JjU>;qK#8+vtI>@lb+3BX-u=*OQ&W??X?Mm2A1NOREKPJ5C3W zH#f(~y@r>i5~FwxORgq{1pvs@E9r9V1kA^0+`YjIhNwkyJVX0|xRYAPm!EhG-!zA!j3V|85|>|upPHYvz#MW>dh>+h|SmZg4mBrz^z0h1yqeg-flX5`}FN5cZ*v|U8su0ndcjo@|$~c z^Xk#yX!3qys?_~X5=hwE-tfB2j-o(|%X0t0m*X5!-&L;hHwzYC6&@p-rMwk}F?ry! zC~i&Nz1?X#^wS@(ocRukl&TzIB||sAVed2$%o=%K2?8_sC?hU1BHO)EYp?j)zuiOWoU0sEb%zYd04f@URgE^V!M_XyCNvS4HhaRl2Tk0139Ol z)VDM8^Y_0BzAsE5RWl5v&4|+*^4monCU}0z7b-{aufX zE-Btd+V$)O=8Xat3n12IZ>7(b;cEFpI)sXZ2e{e2gwOVEv8mf{@4KJDWK%#OczN4G z-lnn3rM>cSC}pdrRJDzGT*ZE%hF#Qot{;87CrGh;o^=l>5?u(Z=W4YC30x_oWg9Ud z9avohrp2#ryU5cmPO7IN5f_}Os${7*29R~=W%vW<0;tBI^H#aswNP{31dsZSyvG}y zy|21it}P|8>zdGQS|bMmEehqz8Z?OUquIMzY-MKS?s`XH11Y!1b61v8*~Z4(@1?fx zFv)&slEpc~>C7E<4rI9A`m8G}L0L@}Kn;x|I%#`?+pM=Ok236pSNp5A zV~V;IBv21eZQyPY0>f!MJsiZvKWivLvq3f7Od#2UdHPwL5bg{a4vwK(!=TIQKZuM7 z`LpS%fFcqg!-M#wZxELrRs~zZ@Rj-q;-lb~RAJJsagAu}is! znJlz0(w(t!IuUR~TGovTXifA*GHF8cRFFd<(yi(kO@4A7S|U26K?TxAzwWb3z6ZXy zRYE9J)BC8gE}kx;@72Q~)dsY0xS+WTYYG!8Dh-!zmyT5QEp-&2)IMxpoBw449)?5v@d!nQ<@iBn2u&?=IxteGC8zTCQkbnSh2GObo zf~HC4GiKj`lCmAvy$wHM!QeWZ*`qjV(aTbHHNp+=9@)r)LQ}#oSQRqmMP_zmZIG_IEdS_R3i2YPRE2^rh?+^1iCi4pJinz5_-^N;*QE=L6? z4=8J4?qteeafwaxdORJFfB#We^D7agvF44~u_}nG0Hb4^u!Q*cl-kfL^_@EOCFl9e zp(78@dc>T58S0Sz$jitda@VUH4WX|Hq4X#v2VG*1GH{%Fe`FGTH1c5hhS?2Y{7kQ{ zvWmGUX4Rb+MDFWbb2KcwYu?EV?@xZ^r=Vzhmf^T=ZRNt6ay2rCS&0Aq(pU3+|M&`rhGr1;gUs-=^p(*|nWgPidxQ5>IvDBG zO=}lqrR7bZdA4egN5X@_%&*Vba#S82BXcI}Gvve8WHW|LEuc!}(zZc{`fc9+NR*zD{Yys;kT3 bHTI!uwk!$+A=<`&bw&Nt^8c;|ep~u)T4>TCfgmM7KtOsY zQbGwG>Ag2aZxDUU`ObINy6gP+u6x(|MxFA^%+u$Y*|T^2(f?x%a8XrJMG-(j0RT{t z{{VhWQ1q+ZyJvb|M@vyf9dy*u1R$pqe*gduj;;tDrMuVk^bM|^9y^+G*!R#J>3mrJ zn?%OD-Ft`~0O%I{H)H-M@L3B>q&a!OD)}ELf=ryuEFHN{XMI$^aacDys>>eMJzSk# z$z$#x))5e$d*r$Wxqid?Z*{Z3)ykaHKUM$| zpaW0>+&z?^+$JZd6ae7n765Sk?O!mnSOB2J4**~y{RQKE4**=um{(}9b6X%DB zhdguU z2MCdCqJTdDA^?dWgMfR0tx#96F4KiJHhv76)WaRFX%A zEaZwx?z+^0LFiEt4g8qllRSJN{0Tw$e6aGV%BOA}ldJ_43%7d|`b0PNs7NP7`Mz55 zJ${r|On7NfdS%B)7)~PhajZv$(4)f5TRy9-KXbMf+7=sdyZS#lLQW+me;s{#l&^Ru zg6}56tvP%5Sik2g9TjRx0tXjHzMVeG$*iF0Ho#KcB~3G1@Lz?|p7pHn&Y0BCTxNvl z-RDeHY%{P@=@v%&sL+{dy>)M=isC3AS(DB1lkMsFr_}QL`&TP=&mZMGfm7nV(-{a2 zr!c~N&#wZ%{!<@ETJiX(#rq$n%=i39DgW^la_ory&+_?OObL$)nldU6f1;b-`ma$k zUxF1VfGkMKEfW`Vcq7{%RrXQ>ysO$q}dij^a3%(d*C6FE0N*>V;z!Z7ix^|2^O~!#Ko6A2b{Vw4>Yt6NdUP zG)19J?dM`d;{O)RFY`@Wy|SF{8N>-QU@{a}%**I9di3n(Msf|Ts|d_+(4yeb--b)9 z=DRqjbTax=&ZgkY{)f3T=oS4Z^8O!MPsC_{zMW1CIGs^!IY?K1SREln*)d?` zk}gu%EidTxk3AA>eoSV|1hQi@-6#8xZCv6Hw*5Tb;ncVcwByV(!fK{uFD#ES&Yj@{ zY=Ua&8>lQ)QbQmpXc3iYF@hOWxF_ib&*ioqL-57C)_|whF)`0LOt+_Pl3Mu+Tek4mU~vzRNH{pGO13HLdhDd9;QCG%B)16$IZb%M#2prG0aNWOQ%T(S;^TtClvu>PH}HXNu{`gc>*rH`016lAyNZciK?Rl16H^S0 znj>aMc1bgLCx^rHp@!{a?6EQ7S;6u$?Zje()6mMqjO<*W%k?@61i`f>k-m~ahs-;p<^32LX6?8U3mU6O#W|TNDxd98Wff4N7051E zI&N9eJH#DT5Vjeqt*zpemFC0O>xGy|wU>e;s^kW|_UXFZmP#E?RlC=LNo!nV&zEzaySkdaA8(sb= zyjumH6;$9Es|Gz0Q4qPP5+7EX9ULyElggSE{PyE=)dE^-aH|u4wS-k5%7FhRLbpdv zA1uhJ+ix?nUytI1Mg~)Ir|Gg=;g{9B)5VI%KPoxZ7xVGgqm%h^x5C!mwghCOkbHbX zn-Fp`p3TbgBKAVlA1mH^(xNWsl3hoS({)X&#R0p^5^dN++E9`CnA9&f{qgj(ZILXf z46n<~{PKJ?Mr;pjAU!1|6S20_Wf18oM$YVWxK@GH0xOG#9v2-vO)kW!yJD4!u+Sgp zTiKUVJ#E)sLRoeW$W?OA%-xO-^S_*fdsD7+hywxLT|8yP2>Iul(jXZ>b8HE7dB{3H(lTtlSwP4qCMQ_3 zQo^d2!ytGhU(zCoTTSlV1=)umX^kIOjt6<8sM?rrm17IRokW*47uS}iQh)W}h!~=` zT)X+YQ5da|>rF%Zt#9dfn;fG1iv!uFOrZ!A<-2qPP2j+cbmpbmD1k7hAWWiRA&606 zr0=zgl6fvprw6k;7^#H3la9}*;9EL48v$v#0enU z#|lAu3h*~G^P%9o`A`~qS`U%I7ibZqUfBy4=_RJT`6r`VUD9&1u&IgC?!BBh`U@kM zI@$TV#S#2r+Fng}HQ;?RPwrX}#n}jF`&S0pu>R5KMw7fV9-q|clAdR-%R*VH#@nItw7T)k z1?U{_LM3j~b|KHMVU^H`vxYXgFe_4)u!v}Kfp!m=hjyp~A>^pw9(s@q9j=nV9@OVa zmgMVpW6E^y6Lr8n5K-Rwk^E|w=trT7m`K`15O`j8aGfS%_vAa2{#JyW}ke^ zAIEUegwHsbR2Uh4k~tk4Y`R5QcQW+nN-Z{BV(^MQ7(l8dXr<#zHZ_!P0aqOnm%)l- z@j$m?vyazlmv&AWE|pMCYFBzr>Q-w`!EXo6Ac0^c(s(v5WKvz@GvMs+XXXEA%3}#| zQb@*15vGCC&$vaGq&IJ}wU~KU@B8oyUVDT+aGwGvWaz^115Q5OL87f9+OQ`!ADr+~JZ7J)zz8*vy@v ztR<;QKP&r~;EJxt;y@}b8IK+8P}YOh4z{!!cRm>n%C>p9E(h~`ps_&c2EGFdg^GrN zkYTh5Fivi>wF)ZU8h!l}TH#6@9Olpt#0EQ$bo|=}GykS}3^v97a5Ardv| zpyFT+BJnK9v8>{4bFy`Int_c;TmjM|f*UPl8}+SE-u#w%V{zP)1w^~j+b+02&p^7& zqi(Zxh8vE8f^Qwm(-PS$Zqdkp;1JO(vFB6N9w0tsVT81M#$n@|X0=)}H`8-1Wd%@*3b9gQIf2}-J877+ z(`nEFQ3WMFDT)i7q%mNWS3pLUt+MOH=-mMh3J zR$ML0J}he*tA=tkm0E}OXJKJfZf*(&^A0OMbEBvXKcs8k4zKlpK`M2b>(wdqU+Bt& zQoM0H)XJi2=g&3#Y8=VP{MI6h_3|%)nPu$eS*g{&r%A4O!(`zsG{3Tg1O zpn`~+uV0V+OT4(&G?-gVa7ygbgq_KS0_cb^^z)!YQI0-$cESKK@CRV( zQFDbBy~F8sluqZ+bVhZIVfh>f0*C9Pfr0eG5A`o*UsqsUCNVo7zZ89Zk}|* z(tMK6%ruzPZ%s?ej|>Xmu*CX*vB#LHP^+JB$7=lmP)!@p%4p?Fd~b>gDZC({b#_?f zmi;5@H%0Eol|1ErUT@CRE=S!5I1gX@KYakXhzaM*6J?`D*)oS(sDCc+HY73%W4Mmb zo>nY-L(iCFI?OyT3o*%m;(C77)h=sI+q?dt6PsEOT43C{z4HIbK@c6x(6n6HmEk@1 z>7YbQncL*|Mp)Y~8G&7D9A+Weafw?B3j0K@py7Zem=_xxbw_xC;zq$)(w0^}T@e^k zKOxC(#qJ>5^prB~Z`$5lnD+_w&v;Zgk&0a34}-)6wgw$7}C8&k8Ant=W*EjNYiQEwQQwBJD`yr(2ymfOnECjLv@aio-i6$T5mivXVvX|<;wV0_4l>~xjjKQ zZ8bKqQDB1y@UEo#+m^PCRqPKyHdq|IRQc|q`8JQmV%(KRF-?YV8!Q=CoekdYGmEo( zqh=Ei-9v}p6ge=UegNEgY9v>;9d*|_YTQ9JZ!CzVg;oNoY4}f#Wr^5P=9<2yy7q@C zfe3+a4^cj=;A!LQpZFc!2IWc84-9kf?NM&9*RzUZ=ix=ux|{G;)y$sC<6S-i0~Ifx zTA%*9J|c45k(MAHiBO%MRhg#`XaLdDBaPd&R*ai`Z3kVrK-^*(RZ1gJB@ znW4Hjpby2NgNqdybJDb3&2YH&V|3AK>{Qv4ZTDeMTK1s2-hyk*wAFjSoJP*xD; zf*)r10=^{Sb!9uv^_yV=YB~pwwT+6DmwLqzShWy5D^}U-pw9c$g{_a4^d4KgU+Ob5ZepEiix`}9~7f8U%p3vN%dwx?D^I`csB<@cCG-X|%kw++Ab;TwlH+#Y< z&+cYpL#X?^ZoHU@x$T1y>Zlz0KpQ8>ZaLEscNzj;)W_1A@oX<8L(8nh+ey%np?XW^ z*1&nHT4<<}u7UmLJ0!-dv_YY_N?0OIXVwgjk4;!m032uj^lP5<`Ec=` z&g>1rzP*uw-q`u5*aG_iJRjfG*eVL$tgoeC-MgiRDAnnduvhq?(E4(|w;~4sEC?5Al?6iE*TXx|5d`XD;DAQj|joI^15% z$GaW?P8wcbVsCvWE-|~;rO%WXbklZ}y{WvDIVdnH9WoS@p4M}7y-}ibJ6FR$$qj$s zKybTw2!3f~Tk`wo!m0D{X(_R0tm%ijZQoN2@{->bNCAPd+e!oSGVx1QER3g5e?3!y z6rov4DrWe0701sYk>voaAZqunsR+CyiW5TCtOCl6Q5L~fabi@4C2{oUpoNvAUT*ZP zE19VLcS3sFZN9HOhM6NczQh4CS_E%q(i^UwoS$)4l|oMjEyyBj#dCryke&Is)^H$= zC}hayv~g}UU%DHJI?SgScU8{GrH|8Qreu~1)-BK}I+`RhFL9;0-<`BjSKF@3 zYWRv}4SiE-;7gZ@lf#lmp?S({S@rxt$uBjd?i@rSU4%VVf;TfU- z<`gO@BcCt7aLr!1kPrONR@?8H0w^^e&WiGM+$rvk&VCS<+ST;Eq(gfIBW~B6ip4*B8)AeP5%yX=LU2#`&J5Z(N#hRq9@sgn zfu1RBSLiZ%o88cxf0^To=d)Gu0A48{+yK8XHD{GFggVzUQhvM9(xFk?UGH8FBZ@}m zJ_@Vr!%xTk$6r6OO-?HxqQ49h@6w;f6Zr0ErGGRk09Pux7?}^vh_D7^)mgvNp1(%^ z4kdgkiMrmSa?p*a2DY{vz5lkmOaZAE@`XMqN#<@jk1L-{vZi}T{`g=$D^T5FUXK&) zA7N}s_jdMCXIdCPj>o%?9Y@nzF3FC}o*c|LlT+rvyr5xGQrBx@=V+(UPDio`F5_0= zG#AIMC^Q=d%wXtUXYfsqa_`L4hJ&pZqsoX>jvhWe?FP-fmu~7cG;^dUdT_o`Pqol& z)hY27$?iJUQS5*@W4sn=#gtiIQzGsDsSMf_64{j#@Y-zGC>~s>B~2T!-TmT2U7J64 zPkG-DZOQqzUJSY>Wtwa>J%a+GE7o_Z&uZD#(SNgdgM2r!$Mfn#y=)QA)BpUW1^ETnHg3%&QbdJ$ zwmTw{78xHx5QiK$ACk!Tn{`;#ZA3cMoSoMR3wDBcV1!<&q-*1+M)F&$-q#pP`~dhZ zhe=yCBK%Ke8MvZUd1evw-P!bF!?C$5Fv{S{+Eo1oo4j_sG2i3G8`{m0ZsGXs zUGw>D*1Gn*5V0=&h7-`eMX--mCeR#Pp=YW^Pp`#Q;IqZWpEVwGrO(FcQp1|)w~_BK zDHwM~I;81>%X4Ntj$SHK_Qca0EDD3ZOap$sqmC7=vTi`EnPM0yn}!vufWX?8OXP_> zkzJ_aKMozuA4ZR#1VY__Y>CAayO%bML6mA*$(K>J>_EoNmaGsvqcWHzpW|^01BoyG zD^Qs7d&}yal6z2X*>xQ`4n=U&>=eZGyq0;Vv@2H0$k3~19n4uEjSXBdR8jZ9r-4EuJ>?-3kNziF2iq$P^V$UnI9d zy6-f4G8h#jAm*RD?Ne^!0u1UlEAEDLj$knGrO$@%Gcx^8i{$#~>eJ+frv|~TCSe)r zt?swIqiL6JQ9Y!zM9n>Zp8?HugSb>C2h5qP@8r{aOlM^!B16xULKz#wzzE=cC^p6O zQZlc30yZ)P0$M=@;Vlz4)JUuJ2mR8l_iPPi1lPxbX|6D)s~lmM3<9YNeaZ24W8l&trqXd#A`6J z9p^H`6{b9z3~9x;#9C#=b)IW}FW|XcTiEx7N|U3a);Q1>W2P!C1viLhZp@{YRo-R7 zmA7W&SW^eA4zF|ydTqW;j z;BQ&}DgD+$fGDmr~lfS}KFW2YgGLLE3@> zc_}yz{+mt1$+MvfBLS1G;zQP}zGA5jJ(qg#cf-Kdtie~@im=a&ve3bqFnQIW;Ap=K zVk6id#>;u`H}csS&V9V|q+DzLxq+W0NE`vVbt9IUk3U=i0)-ldi9%Mw=p{Sq^MM&( zpWnz@3MuX`>#s|T3hmQwyu%RRiS9$rA-K~Bz6W17j(r0hW1S@5)yNV8@aUbb`-=i0x7Ek1a-PT!wkbV9RoHQhjvq4-%Z?cQ^Dz^@~ib#x5SA1vtN;`7z%GPD7M+@_HAWm(sC&0SKT#gs9POmzkHVw-Vsf@M)PJkl6= zcW7=Jof#@&@tseCBXNeKpjiybQng2bdup+@KTLNED%-IyHe3*-g^xjfJo4?3!)RJa7CIml zIfBpd%1AnPjph45QL9_Wo)a#etp#QH;>x~0+Lnz~CzU08KjNO&5w<(7p4#(5eKtE$WM(Et1u4U)6r5CB?vly;(HZSgQ@PLJzeCYr?amrU+l#V_&S%m7=madZ->L%rG~PC%Yp64V;vWs4+}OZO&MyP5STDG z8jm#jXf~5u-YLnw7+w4M)yQupAC;%oo`h8GpZ(6~!6x{fyjRZh>}C%Ej?)I~CjXH> zXAAVu(blM&-6QRm*tQZ9L0OqVZ7m=?K3{aT@@Cy`uz%F>G81#HZ+QJv0q_!LY#=*Vma(Ve$oc(T`;vj6h+v|(u8zIv^+`^T!GQN(Z5;l>MDE)w31Q%C_#In*Y zfdM|Ad);3ly3(uzRxF^lC&S->sg@d@=?0DTy-K;56^y;dub`@xYd%Zy$sdIT{mz(NX`uC~NTdQ4C>_J8@>ubli#CL$(W6YW%?ePMUx*w6Y z_2+{+HCQD^plRmUpG$jA_q@gEVRefk?b@)oCn8l2oAD(@MyI)opD)p)2=KgwEtr*7 z1IMJRwh~K{yc3dm2_}>dZO+1UygFsZcEj2sP8m<^k;9eJUL@JZ(oQ{wY9XaU6mc+t z)YuNFQGt3ye40v4SqrJfitG6-3D&Tq#o2X-?%lEi9ET0*vPMlWZZsQYS4b|8@B|CR zn9>UNu_zRk`rSY~Nir6rDB*RXdP)52-N~_hQj7_VzGi~F(W#>)Mq*0-wocKUc{m-2 z;vNlO)?|B#T@e~@5UQZQ7cDpUap{wx1$8KE)p*}T-3M(0OaH>?1#H9}E^x-1VC8V!GDx>TN@jM)Vso@jlDZxWJ4r1wlNI~+ zZu>I%eWbx?E(D@`N9h8KY-cTl4Qdhr6oWQP=Bb7m-_t^-qQbEZ5U_f^MVRssX**Av zj}9Z>#t=TES;C*TW2b>9<+|_f6rWs`TKL{+*hcga_1XzzSmRz81H61?v|5~UmfhQ` z5oeG+4QX0D{`A{)^^=H_OA|wC0dNQcu}HcJIpZ|3lJoq!z`f6lGRvFV^cF6I2m%Fdb;z*=1kE>2Tr_Cp-xdh zWU%V$NietK2_lMV6!svBPYV(o5ua=G(n?6MTIHi z*tDItq!UEw=9hKX2?4>!9|*6oX9FEB5$~}1u=WIbGGjHI6bL+$0(&Ky@dwPusx*DH zWNo@N=oAbE6junt7U0>D#wa&#G&<>W!Vf^6!?5dWx}2;Fj~H`^R=y~3*!0x!I~Y_= zTf6YJu)sRrWRK1Eg`|`RI?t!W{^_Ij^(}Wh;r3t)+aG}KZpt5kr9XV<5T#=ndeXR6 zZVvfFh@OG?ypl@gPM^q@VZIzq3!@V2vDM2TgvBP_0&f2mGik!EooS~1q)djK=|V*! z)cX+G*Uk%@TbSXNlz`3QxMma|t52e!&39f@cPt4kzRLDAu0`;?pt)X;oi=mNw1WEE zNs6;)AZ>2}uOr^SJ;Z#%vg+2^Ylk%ehd>}XKhw&7oBU~c2jKhz07V-a003OfPLjIz zvqsJ+&IAO8v$MONu+ydyRgXY$D?H`kB*>O*hW{P`xHvrz&s4Q|wAL)3jSJS?;Rg$l za+`gi7t$y?hc9`FSL7Uw|0=(H{Qnh*Qm(5^NyNOJh6bI5IcW4Gi~Ml-#iDR+^Fue( zvuy=y#^#O;v+lXs38FapUgTBH*DIfPxyNwZtX$SbeL}Xw;zim83ZNMU1h-A1g%F@W zB$XKjPvMV)HVDfceC*GpI&Z~?GFz+lI9;n|QSy0c!BM7L@{ly_yCaqtRj9<(Po|M8^lopkDfzGcg*)KGQ&{dUu`K9Og5w0*QTx) zs^{LVi)o4f_h^VnEqg$ikd)L=U`Q-GI~oo$-m@l* z3+>;xy=Q)$As3ai>%zD|bL(YYttks-LP~s0bcssLt~xM!)hoDa#PTcg&ezmkx%$x+ z(S!N-2i;b4cg2&dzN*>$0B8-R{s3Uqw%t$fR~|e$i22Ajh`m~<1k!hn+LJMw5wtYd z=0xyK2b3W+hK3qUqu~2tWXW%mt$g122?+`$vdk=g<3MIA*ml!_Wm1=R@$2oTIzP4W z*HB7}%fL4D$=6#*`kDSpE9$xsm- zzs4ho)`u=aHtMrO5)rM?#3LWT$o}+!F2zF;t+QLBjsT6n@Ws0(GvALVyGPGVtJ{%Q z41a6o%%LNMkp=1a1wst}ROyV#pCbOh(u4Z99Q>o_r%M09Th9DFvUx)dkIG+Ww#LLI z3HG{OC!1Hwv9P}OF9$g4@hp8zb`7H=_20B%CL8ARJlSU=j}#is|5KqIzZ8wfuTTBd zyysL4;c#(o_#dr8tm7lR{?BD8g*YL=^vfA{A%E-#2S<$QcdC z|3ap8IG4;|yg1ozw%z17BWe9Nbk6(yE7F_f2&b8-2*;Nnl0Fm=LFVxl**p5kp3d{{ zyO>?q#h~p2hoW93$B#o%5B;{{hWPt#a%d!*fz0tC305*U<75T8&D}{q`|rC>C9u9G zBHe#d&p*N^7yOIA&_DU>uUu=zB??`s-%0Q$uaHb74j@_H@*@gT?nla-|13@|Ep-_^ zy*Nesspp?_$m@H?$~-$)QkM~kHIy$!P~LX1cj zl}z|BNJQHmS+#;gxjXfz%l|^cO6KD*wz`o+)%&Ba5#!vWuv_s<3FII|mh^Deh{-Sg z{OWQg>*rV3@_*k&4muu)t)JHMoSG*vULbo=CONi<&mMZpBH-eOKMxn=6H4jN-$EWi zuARMRrT9S07I5BPAPi&wUQOMqjy{{jD7;~2S4LlXNN-oFz(8hD$6 zBzEl@;Az&+=J5ppQ|#@d9&)bm^v^w*e5N|wkoB(p>^vI%v-&$zw0Nt0k>VPF^EcpM z%4=jHFFz%t`IV@CSN<$d0IsY2#SHl^{@>XFefsOvNC8l}e6H!NDB#4)pOfkTMDb_k z%;mQlFW*x9&kUWQ;Cp$l>Db}f^ChckzepFfiQv+JmG@eaE(;4Rr?I zxq2B;#|*TPVT4D_mdIpM3;BSZiFvYpAh{9UlYKZP7ruDQ$;Y<(!^9)0*Zd59 z0tI)8m^#{q19hW>a^Pyz%<7$SuB#mlS)+M$n*PMG0Vt4ha;GkmZbL;&*2O5k*$jbe zH&&~lmrO~t8=LtMw{lihW=cb|%k))haWkE!!=rG`Ib3SAI4JBR>B+~wR+h3t6OY_z zO==de0;L8c_vkx!t{P-AsW7|a@6XpfVs&45a~&N=y(Hm(KrIjt=^Au-6j2~Kl^ChZ zm*K^mpZfz4A#*-0=xbD;c{ag2UG9WSo?WKL?v-a_E7f@?SKaV^x_bGM4lRZ+d8rlX zBc0cTAx5N0b8VT)(eUYy^9DC!6V`SyK&2NR4=E{Ib?FK_jjlHIfxAF-E4_I{b`hG^ zY_FY}kKdX}9+bxT635ai%7^M2Nc4noWRWXF3)LgB))Xm|5Od}*{)7m_8Kbuc8n^h} z&3^#4u8`k4eJfu04m`OgTz1_+)^hM0zjN(6EAj_`)cOOk@Mhf5|LWT>N#A=-e*laQ zG)~MBQ0i;VdzSwkmN+{W>)q&a@J5HY#4Ph#FBU#+A5zx2!#QNYXVW1^JxmCnbi8kX z;*PB#qVmQ7=YpR8atgBFkRLK`AGHAF75}^TySzvq0RcSyJN$GnKt%Kq@Dy0utM4d2{tt`d>)nvaXMYPC>snLfBYiiq>Z@|Ua6Q-OsWgqJ@6 zr^>5N73B+R3b?IA-fSz)kQWkGG!`=2Xd5;Z{FcNxxRgp|Mi{5auLrXM8}Yh{APA`I z;PS#(F#*$e>y3!YlH&-6O7xfdT*b;szpgogrv4nLX{)17Z{GOLgHkJyzs(X|?jrmME18+uRf0N#1vO zBLbB_Qw?jo1KBR7a%07jZ?#%-;bY?_+3-rmOStHagRDA(8BWMxo)|IFWlbj zkE?Fto~Xv?CUlA(+y-3k|Mc6oXUIR|)*{^=k$igXH|W@(oxdRz0KkaiYVM~qKLgNj zC;;&I!ExFf|J+{yw->M4Qt&Qb1H65DIPlo}!~7qK;>1B3Eg%h`{MWbe#}%Dx`rW?1 zJk`qU+{=b8>4McGgKz}A>h8qn{z>u!AJY~=GV?{I*T-sF#$Xj%+wu+h7wfJ|Wkk() zn|ck^O2_!npp&Hh5Ri#m^R=e^Tb)}aNrUB*-?xlcFLXUM>q>vL{;lr^K&f}+iR0qv zSE9$KtF!xqgr(uQ9{_Lc?&-PP%#hfr6p6SG>S{&u)KN=`je+dNCJ$4e^PzJR1b~|4 zkG)u@6x^ewXxuE5;y#&`(yk&)?N0-SXfG;Ji3oOO6SD1vRajWnq4o|+Jo2`2Rr_I5 zpEn-}M+v(jHF5w{?|^elkl+98}$UUty;#6U<*_zOBSZx)c70q%Nw zWXqg!wrk2PoG%sm9g$&zSnlH>g~C&CU~$N5n9H@yB`rH9Cv!bxm-D=s@X+1}(UVa( zuJSE-h}y#WcF+3=lM>km8R*~8jC*JE3u;BK7VAHB)$AFqu>)}|+eLb{oMrcZ+Zt9mP9uCxYo@h#@G}Wi& z(~X|xI(mY@66wA0ME{`EJp|Ddw5SBoXvT3APXj?O5*%*w*h6qoCYg)ikpZ>TH})Dn zR~d3P=gmod+}^MRSxVtCp-*{V+Po|VG8l5}^n!A29O!IfkSk>4$Xsfr-rp|nLzIIC}Nb%cZv}`~{;~#b(uh!67 z$NT^=X!tvS=gGj2K2%{)y)hAqg)8E3`Je*vkEZKrB*&a7>enSC=6%AlyD(vW!_J-h!$F=?LF?Dl zjfMV_C7;Jpro$XS*|x&wDpmhpiCFvoZHh9p!8&@V#DceoWl&(QE4F7lUpzIwX9nS^ zVZOJuXt-Tb*($so4L_&yyi{?q<_oZx?~(p1IQ^=0V)`2kq}mltMe=*dJ(9=kPFc>P zM#h;p=NvDU%Z)Q?yyCN$7#zlKImC9hf{isU0ehC!iL?q>C_;)xTatTgE^2WZ7-%`(nlC=RwB_5(22qP3*zv<6mLRTz&hJP2Gw#GRWE0#Dso;7Ra_ihgYs zjkc`Pab+p|`(T)}&{$@{Qtsm2=!<3_#TOEWx4LWQUzS2@2bvq#pO2?8PV%0_5+N<5qg*& z@FV1tMZ${=Lh&sPugn_v^XmR!sg zL^4n#a?;KB%4KiBq{57u$u9}4$2DeBp703u3X}*4${$P5sbgeGr{gyjkCsp%=`mv! z*C6{A$f6Xq@Z%_-KF(>;A$rf7iHYr{{eRu1KUaF-!XJTB*b#W_%wNk}x1djT;^s7}aT2(%-tnX=)djdhzA!0^@)lWr*CjP3`&7Sh54mdLYEV>#XN_+rg9tw#r{} zr--{%BiJdrg#4ql>^RNu>^yd$&pisQoC*Ztv$?x+t8H~G#|L_DnhiX=UVayM30hX5 zZ)u)}TwmpYiYh2(_8aW#5=TolSA%Mp9NV=7Vl>Dv5$vEX7rMYiAo-kan`PcDflZJ` z5Zqr91lsprc&dK})rhhMbd3ewuo3L49H8&y0l5b}fU&PVGW~Y>(ZmqEVxOb-d1}5> z%jG8|ccT4G={!JJw_tBN?`}VT;+stM$PRB@o3U~T$fyK~Ow1hA>~Z+YI)GcC8@ke? zstgMVY^}77cD2`x?-MQ*Ylvs~1KP;wOltJ3S+K6tZS6@CCL zb2W_jZf|!7cNKp=Y8u?tfggQaAYyU`l*(aschvN9DJik(i06m157-Oa4O7 z(lG^DA-_xE0?SlKJe6l^l;KLNi`izu$#sokQHiKTUyk4xu|d-qDAFj>H~nrHZ5i)& zt{BU5mn@@1tDn1hjm|>w+DX@aLGFrw{U9 z-CEATF6p7))^b(abPjOajY?$&;xBZ3KR0-x)(|$%h^+SL!59k4@EdhZ$0<~$_^P)L z2){^jSs9L9y*^Ofyq0k4Mn2cDkgMucaG9Ns@=i+1^e}JYl;Eql+UNW&gw>pZL~c@V zGv6QIFJUa{$G4o@1}w5%&nBbGBETNI_g8^=&mD)PN6Ue2)B zAVoBq-IHEhzI)5!TU~uGRn=+il|OVJmOr*M7BsfAD-ZQ&RLx7z8Wt1BF^X$wZm_4g zGSmfpRL3X~P9+aUwMj%&Z_nAiuV;F0t>xmkQ9A_Vwa^fBk0i(zjAXQ=jae6$#q*x$ z@V$|ifs+s6@3mb4O9uPt=YdNco6CDjze9+`&_CESMF*{oe*k)uOG+V2mr72oN_`ky z>e&*`sx;)ik(S65KYi~49Wl&kQaujkQNFhUzM>Zxw=0Ztpcbo6deLz!$#&Q9Qg!HJ zzkjo1ZV(#F$FeZbn$NznB09RAl=dp(2Y@H4&a|*1s*2v|yP?@mq$gAmGUZ>ib}f21GIi562ckdtv*S%Hvc3%Ko8HCb&f-(h%CUT|-1;mTiKDgB z`8kTWF4kVr=b5uLQ_#w$7ZtH;hfZG>B?(^IbUII+xnyb?<2dO4sb_QT;qIi8k)uds zLyy9whj$+k%-!&@WwQMdW%hWPv+1~$<@kk{gB-fj=d=~;)I#!&D;VQmTdOgC#lKtI zYBe7cTGKeu zV^3TqJeE67r+PPk8>O_47Ll3{%5!LHrDgD~I!^P_kytV$Xj?jz+eH$hhjg}T-w{oz zOa#ICW6%jyrLA#))ByhVe(M_G1mItv&&cPh>$k({E}kX zn;DpHd37omOy=T~YWx z6JJVaV3#9%MS}2#cRDJ33e568106y#srTnm#=>`H=D?jmV}iq=3hArMN$yJnQzvmQ z9GpSpa{ixp2s+Jdail^Sot;)fc!p(!wG3d;|73NakKc4+M)QjsOOXW{j%chR2XPfo z@9B?5W8uN0>`3NLnTfV;)}Ggl4+rz8)C9Xm@A;OR#aWgqCxJDPWxyP};cUIEa49j9 zt)T`a&0_aU(^DFW@|AY&?##6pHG6Y(7i#IVIfJ9TnHG1@$S6m^qifDWb-W^RB9Y_0 znMS6n^A-*Qg?5UZs460Ez%pBEUn96y($BZe9rs~=QO<9TYFX0Oj(5tQ3M8wK!?w3( zFDiUrqq&SU^%9fBl}O$KymolfSbk0|k)xbAf@MvWecWxx*K}|rJKU=B1h0(47|rwL z{4e${Todzy@4}$N3=H^qQ>GB)`ZPh{bDOWGbimwANflXaHyw;Qx?=cDEMIo3J*$kw z-BL-Kfg4V49)^C#)F6(_O82}>zRIjDR> z)o{tpfmZNCBd3~TE(n2d2~M=BIQ`yJd-Xw)y~lH|j?o9%80_<5pUiT3)ttyV%uRYp z9?3av-`du1OrBcp)!4j?knV@mrf8uLjkK3k)BW;o5m2|34;jqxxg$!#Zn7YyGci-Zel1nNtyeY{8{R(z@qE!BhvzgP?g4pj)gXkqWY}YGeMn5l?F{y^NSy1}>Y z;!!Uf>2Hg+^jBMggn^QC{Y-*pq_hL$!f2Cf99bHVo6zVr(hQAd*Ig>;A>sA4z;0D? z`U1*ZinkQtTk73h%?80$G2MESFCK#yUbgvgSB~hPRE4NfYc%);^Sg;Ms-k;*BaPNu zfvrg~^$-*D=>jF({*!9Lxp7vO0yR)7r5Uq5%o3+^zX`imDaDM{XWf~co*pIMP2WKm`72}K>ity9U4uQa zdtLM^)oH&7yFjEvU(hwu`IUb(fs2qTzPu=A@x6xv5=cQGT>s zNkSop^y~DP>(v&bepT$Ydl-)kz>Z?<3#OWL-#sbIT6U2sv7;9?oZ}k zF+RTk1l29x2Gtl3WjDjf)_&kyN;ll4R}*SwG5Dsmj z{zSf3*6$mgUw$&3yzdjMdgG&u2Gqb)Hl+3{?GmXm@Q+C!e*ZnL6Yn(L5TvCXG+ZrP zv2f>8yAw2QU+ZaC=W&rk)d9-Lq(Q`h~fW*gH239V)6 z|1hUurTPJDn<+Tex}N!DCWq=E?$%zG55e+Da}@3O=JX!`-i#ZmGwR0S@L^i(88wM# zwO1s7uT$&W&B2^@&fk;+{x9algcSLYX62p;`GVGRm=1=jF9)6A~Qu3RPfhK6Z!#|MP<%PR0vm zN$Q-rP+v~qWDwfoV9;*UL)g;K+pSC7%@yUKjR@zqe%nGVQ)KGe$Cmf7i;K(t~%1I4cVU^vRHFo83Hw#Ma8jVhU zOJ`K9j-#_DUVPTEIZ3fnqS;$&%xkBSVU>PP(f5(Wk04Zr5z;gfLJ2-nnADVkIN1kQ zNr69|ANdC@mUx&00PW9$pI!e?metK?%zW|0L*Dz{nHyhz9QGF(o}GOKxN%+Kd+gsh zO3yAmyZu+fq!YBOcQltJ6ZJwg!BRI%-V`oUpy~%Ah<##|U^vHJK@k+0z+n*;|E7>; zy#BtfVqLLu$f?)&kHy=wQf5KioGj6lIt`8xG8p$h)#Hb2JFQZz`ayVx7NM7pvrX07 zqgECP@;3xkbhhUbH&-1F-gXi>C@q=K&{_`i_x&KI30p-#ZJ6QX#n%z>yUlqrDvK%P zgYUAEg)lKVn9#c~O&?lApD(r8Wg=SXKKCQF``yx^K=plBEPKJSZ&a~K$!=>~W+xqH zeOsW$IWsqJ>RmWE+6luXD|Q!GH!C4|sg0^gK*{i_BUOsN1Ve}uV)Oc_&S0uKy`J7~J2 zZiaSp)pil}4?L!=Y98wtmuj@A?m+ogV5pR^4#DiWEuzMLi~m}3LN8Znf)4Ei%VcIr ztLP9}WOkx`>^UPQ)`hBeEdZ5tgIn*0Hy&}uHdfixqq+;{Zv&%-^yxBBYd@ZkmV`@? z-__fgi2oqfJzZ$n=Sq6qV>>Q!?=pyDLszh>n|Eeyu%dCxG_txtQ=g+uq>T6FeuLFZ z+f!e&YIn-xO0DHUcJECDKDEjlc}NhypM7!*4vi_K8UQ zzy2o&?)Mx3F@tz{Cpyv+fRP^25BYSp_U5aMXQzMrp89X>{4@Llw-Yb5|6&3BDEE)Y zkFA4^|9@r{iUG_7nD0}T|JC5`@9muBc-C(A@Mq*MahJLLY$+uFMM99X2R42{@1}fzr6S4!#`G1^;@LM*J|HxH-Fdql z-Ub6w-hik{a5-jHjOB8;6(Yf`^1sxR)+Rr(04yNC;c}%EI97 z>|LExP=-@H5HTRIBH;G(^%F$uxLPbcu5-aLwAAaI=3f4`5=s9hw5I6r*v&Mi^^v4_ zjO&UfWL(vPo4ew244`qld@9t60gh-2xIv|G?~j8H|)!${qv z?JyZ9i=)Hk6Z+s>zG}ev)W$@YeeMvedzR^C16|$r6unI>j!#l6SUgyjLGVRcjJke` zd|(2hJTNYd{dK$z08TH5;Zm}xGBhe{c!DY&xu=1}^CTFU!>6VoDy_RfNw;P~e7l4@3?^jKk}8%?OHLqnKQ?k$>kpB;Oo6_^b5a?kE^)XcoNI~AkpIF>m& zA8#5f8q#eQlQ`siKL4d<>_W2X`*iXrqO7CtK#K4VFz+?J zDUQH#9XFoaz4w=UvQkM6Gs=h26x10A5C|EG_Ul^jwhv?w152gM!UV)C#*KyogkoF4 zJv#7ZYPjdu`DFD5yn4PEn32cu6b1DCND4z>8R#zVKkpvs-{K5MO0Pb43}U znt$%*yOPqJdwbao`rJ-&hz z^>_w?yOdJXYDC&q$|8cZ8JXfp2}Okdkm`bYc7{g{s{|QLQN%SjXIa;&9!7^?0q5jQIjv)k zbofbzvfN$qp$2_wIm-1auY*@@BPQ>8-fhC{?Too6OsY@{mQY_%FAkEC@zi)Qk?Y{u zD?Xl!ZA}aw5_f$^lyRV)IxH1)4yXNW6S2)x#uq8AH8+eL{eVk!t4bM+Mq5SbCB~+5 z&lub;Hh!r)#ejZ1DUp$xwhA3qQZ9A-`qTm3yjuZN-a+!jAk!@2j_+eD5mwi<&HP0J zZLVp*H(7F)KqAdh^zj&HexUNknq&4WnI?*1*mX;#-HRN)Z^4gsOma+=Y^NukA(0qJ z@7>oyc(@!&vCJhyIRduC9UF=SKmH=BG^IxTF93vAl9n^CO`#EsOfo@es|TkjVIgF~ zS)Q*TR9MR|A74Vg99KR)#0=b!bF7DYh{AOmK_GMyD{5Mc*)di^TZ|36kYFae~ zC^R6yT<9YO|ALaXc}D1pGl(lcqQKGB0-iR5hP3c6XgGq=9YYa`33Wg1+X$JZ80?xQVjC^ui?}T+P0y$rc~>-;zppQ& zSkpS7T}WVpCFrgyslHH%h&nzC(}826U6`=H+8K%veo+{}d}D%?>*DY|pf0!Fc^`gQbK9gICT{234P|aP{H5I{526M~q6@xZzWDwO-y^dvzA+o(IVeVeGlXiIMEg zn)t@K;6w);u!YR^67qIQn-h`VxeCz=ChZ=z;&#*~q$4o73OZ|4r=VX5^zg@i(8z=Nnny z(SFUzoRUgmz!hRFm?yORTZjXpvtrtTuoLI|rFG$C&0Z>7Y3<59Q&%E}wHr~!CE}l7 zx-*(tf1?j7qG6S9l}mtGKx7M__^Io>4u^D*^Q442wNP0zdX_^qK2zd%TpUdJMi)6) zr>%^;sbXq`$|U!}Ldcn%91l)1qA45|rFuN`bVRcpkU(k0Pe?i)CX&V%8KdFGWSmR1>UYFTYD- zZ(%FG6l=(b6d+#|A!!Jn#xYt+VQ>_g(<$R{&CKl>8&=q+5Vbd zh@tu@bRS9!Uo7jm;>4%dCyQL!&q=I0U!I$dLY0r$l*e@YtvDFu3yeDVcB#(PhN=TcmidcrK6=g3Q+72(xw(shmiK3YC@Rj(7&`r{U2Sxc5xqc2 zXLR#NAYHq4g}65&kNEYVP>7dv%E(0MRLDaL6=+fFPEz}=35luwMqIjNbu?xRX5}LY zTvk4YIr5T*D^@?riguC6GGEoJrfXw3Pnq$)9ym~@AOUI24J4&|e5`O)9-E<_#>A|f zvb~1fM9#$P@H(5ui{^5}AkIw+^Juz0QN!=nZka*I7s?=T?EnCSdv>Nzy*w z-s+Wk2i@c_re#CJ_Rm zdPyO(gEi^~+H*YTb%W;G>ZmzOozQ|l(JI=K5+|k`Q=pf4zy^xk+C^*d3<}}hW~r6V ze90;Qt<@s|sjYt%7m7soe}$)|*&3Lx1uGj&3eqNPYgm{rBI;Lugc3MdL4+HVRviojrhDGJ6qExTuznFtw9we&b@d(;xh6Z$ zaVOloAX(k3u{YrS;KJHO%Z|=MWP4$rl1v^bw8Oxw+a2$|slUG}se22zwC96Qk#jI$m$j_)A`Y4*YSo*CvkK8O0YIE|gC|V|GG_=c!*lh2&mE+QhR=<^t zrF3O=pTW`zMO_}TD2NrSx5L^9Q^`?m*2sxC@lnz?rlD1Aw)LX87m+TLRZ?<()|$|u z^VKCz_8IETW4X43x=AnOR`+#DlGbgeFgBTab=ipEC~7O8J;OK z2?_7JI~%-$wxP}O9JY`{VFs76RPE(*RWFeS90ZXw{a_23yrIBf4Z*>r#PJY_*If`f znN46q+U_ELr}C^;tc4H~zN9)TKR3kcMPA=bdvFt2AL{5<(xzdaz*%v%A%)X>;%>&a#Xgcvi?coP*I!&A&ldmn z*EwX2B2{C!T%QC^fxZx<0qlV2CGMg(W{ZYLybl?SICK&qrsODX#}CbDO_T1r&N+ud zQQE?^*{&_Hg~10<(Ki508w`!gz&I(`Mn6sNCS1e#$P=|U_3*BQj&jHoD;7-`7uOpP zV*=9Wp~)+OPh0p~D3QULV2897!%GysRz6j}k!Ch{zNPgot#1JG?#V$5GbFrgjQ;D$ zY(#jxN%AmW3yVx!Xp?9TRec#lYSVq9XUubFPq)G|Ew#d}QF9&_q+DWx-E`Vh`K%JK zz|M;=d)<9Jq9Od zX(Z>mR>%a0&Lu4*$kyXB7p--(TG#L^Sv4L9H#to6a}dg%I`KNlokE(?Xf&vWHfeOE zuL1pOjXBF2W#Z7<2TkuUO&0_al=o_5d<{mj*6I-{fxC457fRKKa&3z;H8(d*m5*f9 zLf@|1z>j%)UKhZ#Ju17e4?yWT?V_lrFZP;Iv5%3z!>b3B|o7j%1 zo-5W`5yY1`;le!Ns#7oZa1qSG&`g;=R5dKwec9J7LIlDx-Lu)*=Mk$K?}&^!Pr(B-*7u$bBGw`!VM2TYK4JiB9Vrv=tdSu!TI; zzPrSVtZO}Trg!8^BUBz~#c+d3@CuE|Vl`Til;jiREZnfM;qin#PnGNV+1^kTseP zB91L&VDX|+<{?ZB9kCr&m8&kJ{W1O`g1s`cMumD2X^uz{nO;v;j-uQTIycRVVR}d^ zCxfM!ir2P<)X=#i!g8D_<;)=oQL&%DNH0dcJh>Dz0;F4x#1>n@S}__{9j!>zW)Z>QrG(p13Qav^4XTg0 zKAP>%T33&PDbND3vj;%^o2!I2Nt;(p?1|dK!DFDTl(43fDoz*`Q`GfZXe&w?6V*W< zOplgrqodS%ow!%V#HJYgwe#NR?x%~Dc*%Y}`UGNtL~q#>^5J&jE$GIJX27%lq+e{rvR(5I)*{b7>IV>8EMIZI$ zb-TrdLHB%=K+w2&!oeN}dcVW^@Ha%w-4}RDwB%QhVqi|bOEjdfa>K-;uSM9k(PKzr z{K_0KGc!9Q^?D}v-I8eM;q)LyYTyA)1sMb?yxvloDu~OdGRnJR+elT8z!yWMWt*5& zDOr@>D|C}{->}^8m>aD=uefURV$~|euSZR>C4DLeE&^rOnbPLM> z$T>XD0|lXk`HGDA?U-8CZd2e&`^V{2R2V5quI_5jWrfC@gvyU^D@TY_Tnut;Z?&zs zlQm|OS7_aILu=_R)sBvcL0U4N62Id2R5K!hEMI=S0LGh4Eg3m!QP2=FTrbc+l-Jj* zsbiGs%*jjY6?nzWGscW?8gB3nz%7Q2^4VCw)?ux#cPG#52JS8}a-gxr%%L%=CC0Wp zC0ets8UpG~nC&!058Axg)wV$){q(`Mv0>TK;ZTvVHWuaHWJ+oe$9q}v!OB~ETJc2N zl?{XtnFH2k*)VjsM*{=`!DGN`3@lh=VF6XBY<)qX031kGiFc5})oJDHy&gkCCR1jh zla@K@r4P3w+{&ObC-Y8<*j0ygJS)l{&kQz6aDh!zwNEx(r0DCa<@hjWRHe;)#Ud!>==vYe%dN&x4 zO3aqJ#0SdX`34YF9{&bFtAN8117B;n3+oOz&owTq8KYv5qWvwS`S`n*NWmTgeAU&Q zFb+Qi(^;oG?-bS4q%?sZS%uB2NsBh2*=m`9hgf`p?8>}DN?gqoPwyT0VnCa=0t}|w z3d5`SRj||18RpXB)e;kkl6#^&n`A15A)IQkt1Is#Ge0y?sAT6P;{vvznW@r331^^`@VpbD&73q>XzB=aq@BxJ!iKs& zsY4L$53cH}JlEG{pa==%QSee-Sa}QTpUF9Kk z#iT{Fv3mDs@L@4C$bTR^@SmD9-wQls$oN_Aho&X5n}baN?b5rm z-xJS0{F(Ka=V<}JoAzh_Df;zik?%FzLp~7+AgA($)=Pq8I_@`6I-~F1&CNl|3*^AD z`6@gv?o8R5*}{~|_+>d(>QW@p9&eH_4Pk1z{qMqhyv64!P1}|YuikjcC}Jrz;zS&b4{Y$z)xn~pA(80dUi9U z_GiM`XMi}ssoQ|>%Coo6=)WZf{$-W|F0umr{!m3F7Wu>ZfnTP)zD|gz4fPCh`TFvM zX2FY^ReFrdXvtPiQ8o2@hq*(=tD}5tZyh+bt*R9JP8^K`$vh2Qg?#Fl8M2c0v?!Gw zF*Q6wQr0^@9Z+RGxRJZzhR2!5`0ZPsM;jlq7d>(Bsp}b2pcKby?psWI{sS#F{RdSi zjETCl)TRe}M+UF(+a!kSgpWAR%Rbpv2V`E4_Gh1}_3ftq#`{`QWv2{JSUc+82rfGI z^5Ojre&An_vpGSC_LBmSdta=%{g*yi4|Nwe_k7G-dD^zu_o^?U7HMnJalXI0e!1CN zmzujO`O5IRSK_iQ3OBi*dasgqplNFBRzVK@AY9VNT?@P|LFfveBE|7uKKTjFhApP1 zKq_F)D{bkcA!GOqvG9o*(ES^rYLh^O!}q_zVYv?)@~M$xY?&AT2H1~l|55rU)OVtb z?MiBeMRXbDTt^)BBJlUq%9f$Xw*}oR_%s1k9IWXJhT-E-bf;ER_T;+FQf=S|BToai zG=x5~=87NxH$chr`5Co>^kx3nOP%N3oS)(a2dLuKVqJn3>a#eqY;SaX@k(t-5Un&x zwYpbX`UXoMpZx|nBWG9j4bZ&fyG%h8?3r87JmIF^B0st&d10^J)>Nuv*;D%HWie46 zDs=t*ut4*d-E(KPhr@Y~UMIzu;>?4?a}LI0=Gc;30~{}yHw<&M`KioK zq0k9T+e${9Z$d5LW}~Gefe|d2Crc9kw_SF4?v%HP2PN~E+)IFUB!owe-l-}u^FdA? zHWD1NgkhmFYKLh))Zq=`P;)=>m7@`J9TE$;?VrUy&+J~bymiw$r;7A9$cR#+* zg+&%PT7P|%PolEn`ARr?35VB{a`Q@=B$@RT*oKjgGyKm5lmA;m+76Ws*Ix~WM^*g+ z>}Txc!GYxN7La2DvVzDr*3R`&UGv|ljz2&JtdjosayqoJGpoPg7ZutxIp#JEK5r!7 z>rrcwpOX;*ZZBqj<0@+I{9sG@8=zu>PHqa0pO!gHw4@JGK;~7tmSv9932|Wb?t>N~ zy$lkQuLrYSWgOYuR{9M2N|>YR6W`(+hYDTXU)N_1OtyMQ)%i5c*Jpi9=jqADgwKo+ zMSfcpPRX!t(I4NGAQhNLd&7W+0m@vTV>;TXN?8wn@WDQbZ{}|R5#r5l1~q+;;r-*6?sJ`8|9W~Z|2QZCKUI!@ zKco+yov*z2Z$c1fZ#-fGJpPNu>Cb)XG^K**_|ls@jSBn+;mnB5SNi(nMJUpzL1kcF zV@Oz9#RZdnyvXnXn}F8>Zkx{W6mqKkzo} zI{94Jl;v`qmRPTn8#w7UEfqC89<$TWdbRO$aYvTXwzB<1os=N9mB2x~wQM5aoZ6vo zd3J^B?56>SG^6uF%Qnw3s{RVwni4woRvN<`WHluo%m~nz2=Y@pOObRY4!kf%_c;i91Q^&m>5ki2zNBAz5pO5rZOAM z@pa0w+Gr|oWh(|gjl4t|S=e1IGQ8FD*nrn$*sUCJ>ESgm@{O^_k(?ID@wXUd zoMYT<3-wn}i!ae4LC98A;r&V_GZa-2f=GR4ORi$ucZfxAd6wFXiCJV{t|;)+95#+V zGTz?|NGsQ?6el%5=~mlFv87>%=921CTN8v`k%NEQ@g+U}k~%9sQg-x6$!{$mF@+hv zH-B#e!k_&-#0Wbv8ZcL#5LjT_m=!m{C%5T?TBgmP{{}E`-!H5Yyyy9-!F351%rvxe zF}h?RFil}4*3TwOCpX%mFR;C+(gP@oS)&s(t7QK?!cmk-5)-x6!&8BJ^%b3k1`)i9 zcy>8<*q-Is8P{!gSs(LBbf%DAxqa;q5Xm-cX!Ka~I zh3Uqw^bMX)Jnm@u#7&urj`)}mj&&wvPH+RC`vUdrk2OY=NTel(5H?E`7}6qIhtPn0 z$}{e0ryj^Xmatx)n%DEf$&8_wm(R4#MwsWANv%Wj2u{{XQvG{!ha+dhD0Zm=4J-^xU?2jwVFaM5aNXzY?u{T8T*R7xN zF<{`kOx8c#Wmyw5`N)4!nFZW&4-3?fF?S44F)-y;qvUWm_BRC|7F_L}tI{YkpCy3c zzWd^R9+iGOjNWDpC1dGuEIXT%2CuY1;>T*zd#Py>6zGIXw#)2mRcfW1QXOlI3iHU6 zzWRhJ1mDUtfCnL(RDI2S$b__f*d7tN%FrE_o|}nP$L7+%jS>aQvTCowx4r?^1#Ox+ z?{9y!q*4rlkh2u>Lv;LGJFNG^deF9hWWz*(Cy$+Ui$uCWZSGh~@KX;W4;NM!K~&}ptni@5uQ-nx zRDye`lcxZKv3^M3)1Y~F4r|Pc4tv6^Gkth|j49mS#u^#;5ei3E9b&_28B#ut6z+^A z7+ZWsCgiRo`NwxBsY1JVR!a9{Im=wQNTP1BnWN*g3ND-NV9Go8a6>hRX}6MxGPCeD zwR`Z$1~s1;We}6lw0-t_e_5vV?ke5wMcGC?#2~s2IL}GKz}>$%KJ|V#W(>)EroSGa|?! zWUfF;$BFWmY26*F2H*f)gY50RT!s1JH^Y(awD~#m!~LivR5sCe*?yzejUH3S+^Ff=z${Xe2-F5-h*43gyJbS zge*rRFvw&N&Y(14p&+JT9Kh zvE}j!Ps0P>>rZImmpT19FmIi0a}g2)>?vCwKxBXODDhigV%|TiV<>KGxo;T=g5t!M zC%s3?>vfoYNs}kd`n!i|1PWCPxz~IH=rLw(;Z%~u z-^*HZVsK7?MxpH%~YECu^q2;sezsT)MyYz6UA0i}@Sx^pK3A@GJ;TRPh zC?GSMATCz+UTb(9_?c!3r=lwd)@Nx<5%r+eQOuh8&_FA}$efh?HAZG?AW={cCpy$YbfPU*6EZZBvm@X*F`jqbJ>&PlC1ND0L|A?bDrEt!Qr= z)HR>BwjD#uUd;o(4`6!R&~JdXQK1T&{FqkJ2V^%cyJ=swNFrJpCX>RF=439J(=>&b zYWwqsxMcTrH8ipL6=5n&KnF=d`qz9U8QiomlT+UJ;sbZ;%ljady_Dd(ho_!9tHB?n zY`?TqsoQ)~c$8kQVHp)Q!b`Ql+?Qm8anKtL?NApNxg{~Qdr8M)r?(^#O;H3fqMKun zB>w-jXGSX)%%m9`>Z0_XrZQT2_X-zdSz>>mXZC3|4^-hz$o1) zp>W-9E>CN{fIDpq4^KN{vOFG9-4Ca0iW@0SvTSGr0cF7A1>U{X;l5$3gafrJDf7lo zp|bM%AV+ma8NjF1LH-@!2X}{{xyeN!@L^P#;?BB6z(XYNfexcJPkA2W3;ltWGQ(hHo{8-&#brQo7 z$M%OQWG?22Wp-5IN#T>R61`d2y!`T=y_FU;HwV*6!8#Tec|6%+PVrBw9#^EDN1Em2 zhlZPbClESakR$Ub>I)jVC8jlp^(zwc66oLuHpCSDz2?W%Mx_#C)L zpX_Z(S@XLDt5W*BuzRzP9CsWcxV)Zao``lRh6ah{@r1gGA7LPd{w zhCU}2qJ`R2EwC`2M39rKN=PZX4yRDXhhGyA;JZa;5kF0$cU5$a;7$IyK&o{jM_b4^ za7C(Cpa2$cR^ZGJRU%Pd?&enEa!{ymo@sMTrqih!*~Ha>KP>m&(9YRG1fgkO6ippL z)UOJ2Iw6xgsGUA77|Uw~j@#SFmWl6XxvvU=7_M=aKi23mFfNo+;mq`}xhyF%zvyhH zB9C{PS;9MZQ1Z`ll*3Hj{Gab>40%*^!JZ!7+j7P}30=<3h04yq)81TF*J|SJz-grM ztQZH#vYu7thM0!*3fPF{L+8gjN4X~@>B=~=ut7$=6;cfSslnC&QqlQm48Umh$2}@w zfu09XjWaxxuuMR5poS5z(DQettbLjFx@u|R&JsKvIo8DeAS{TjU>BxV8U{!17}rjJBhcL?X%_{0q?8|&!pppFuar1sgRK5a>0D#@whr^zgL z8iZ0CX*{WY@WnXM1)_NY$lk9@r8Y(dLcCP6{kT+i>gMyiWO09_aRE+0{38sw&<6kx{EGb^xOg|bB<}QgD)zJAQ~vZWy7Fx{ zC%!#UjeUPV4>{OIqG8l_&N&NHk&8@+it)GosTD^uJfxlI8!3;1hlp| z-Q(W0>6R)lc0)|#bpfdhi@0bL`-vLhrIXRgmBBEa*i2CypF-W63WUI8RT|MFVuc z0bYa{9|9eBTuHxV)0}kZ9Va*9yobCIl6yzPV>rugXU|N{a|L_sjZ(!hDRaA%%l6?* zc-+van)guk_d=b7l7ous9^~nPBZ_IpAyjH{x$n6LO*?SH(h9?-*>=g{mlaPn6l#p) z9k*|-D&KJe_E0Qf4IJWYhW6>4oDj0rRjax7|4<_>!!FdJ=nYIrVTU4Ke--5*6F7|B zxuaNm9QnEf3gly43GeW5VL)Or48586_wGUbNved{aYP5wcGzP`306lEQ(Y?oaia`L!WoHL4x%>1NFZDZV)CcQI^ zm-ycSS|z_kr!T=$J1=}=Ha&WZMYTS@Oa0;{ecpGo@c8XFfbhI`4fpr2>2H8ax@l>( zCshwSUMzEDUmG(V9pt6JS7hOJj=R+dq`cm)8knUI)WFe8n`)?T3v8BUyH%dyEAF_~ z7KmkKkDTr!jWOOJnI+A6;(zLM+pRoJr!6Y4K{bjZ_0zwU@5=VehgPZ!AQLKwh66jQ z8MfWLStZX&WLqn;GE+CQA}DnmSzSSTQ*P^lRYOyD>uhHG7|_DnLhsO;#{=4+fx2Qd ze15OKM--`M)&hHhQ;~)wa311dX;=uk8cX0VD%gD)aEwjajDndV`Z}^t7gS0`IS3_P znxJf|q2r?M-HaK69oBw68O6CB&6Yw=*6h9k$^^z?J1!%O@WbMLwyG!l(-R~yB$oSa z)~MqzlivU~=2bopBOKoVZV!A%;#V1qODDjvvDD^O8cDa^&!#VR{+63m+WK+)8spBK zXVcPhSs#Aad_$~}G(}=<=t+O-Uo!uMb^TBbu?_SdItp8VNeK56(gJ4xCG&4!bdGqd z7`pf}|0|GtEA;H8`qH!&;<^eBt}i%s8KJwpzuiS>bPrWJgDpN4tl(x;dtWksytRjL zlt!%5K#Z+|UBG^eQp*EW%mw&$9!uNzhA9n(H#uB$#=W9povD<)aH1tEt7|&+)W*5B zLhNEg_6!Gt2yo)$iiZiL#e;tP@H)nN)vUBSWyB==y#kyA&e#MQz>DdF^iZAqjFW~d zYNz^#N_9L%bDD-(*=|UNiX$Y+Vg4(8YIN%*_Pq^_t7K86^Lx0BaNOvJL9VTSF@2VE z^a>;_Tq^$eMvnS1{4yugT&v_L|tHdyMzG`SBMC(;r=XLVab3@q$v1+GcXWiDC^ufP|3r=YC8K z-3CAyEq|t*efCezh~&Fd=<>f62gq5BUivS)JCU*1(Cl!EGL1GJ=*QPgbrV!NbuQ+Xz7%S2^r5n5wyD49N9)|6D>qq`cw#fK0BZVf`wEU1c zm>E-;H2nD-q`epnO5pUXGkw8Dw1LTf0`6E)ID#^M-%o`7hE1OnZPolpomKjoN^ecN zUVhT*n>ex)uz%tAy(BR{zU#or_1VEAd|+|okbnLMbv>z2^;+<;6T)@x{n}ALkx$mU z9W&2hp|{MB68KPA+~gb`Wa3YC_DM~SRUR$1?~=aRQS0lw)sC|_?Ghv}2%JmxAX=u# z_xht|K(i;?BAnFByu4_h_IwRng7;jqcyz%Zwd{U1M5TY`;XwVST2*g$Bxd7Q%R$1+ zq4*WXJ4>m#ITGO15f)m0K@Z>1E2dS zJF^po9I1lDlXN(3!C&Te?FV1Kx*fUN?yaaJf)$a9RuJ;+j62~kbMxXT;N0z>@xPJ1 zekT1V@yqY7WY53fQhwGrbvZur;r0AG+H)dT{kkTz+XeT-w6CkwTW*p_8a*rzW$${a z=yd0{p6uKCN13A6-|#$GP<@`=?r?fK>=o^`hmoD4=U<5cv}qr|f$_0Q7EgMHR*b>a zk<*3(PhZ6F9DravI?>{n2pZ7?s`O?^#MQkAG?gF^H^B~YtEl}h(Dq7}$RKT3PEgKB z5C@V?cz)dFQQg2rxvSQ?h*cpBnZt`);a^n-ARskEYD(}~2pJ(IP%BKeL6SFe#}+Rr zNt`_$^+Wd4xALs%W}o!B9KrtOHvn`(LPm;W#Fu4h0%_qLpE9{&&~cCz zK%BF*Vy^k&mB*@w^Q(QR6SiZWsLk?PViUG0X^cTqVj+wNrTdF__OeFydCiW&(%%=+ zA84@a9;vqWo>+99kn_njT$ftxOHFPhJ=XQwzJ8>>Kl+4dDX0vmPTC+e(c&q*n7~L} zR$g(%J~{7+Yt%n%?5pw0HvqWiKx~_MBnA>;%=n3+2_lBVf1!=N^yCi|)qBPM54AJ@ z#Bap@$Rz1s`wsfvcX$-l&)4?{++oK*#>{)H6T4Yf$B44^Ar4uNVEal6I)3~EU94Px zpvwsNH*~2_c+(Sc-S7j~E_>43h}9ps{#)H|fZuWbkIF<`6NtDb_8awQzfbwfE>-ac z-j5z0P&H}?X-NG!do=P>5k!iny;1K; z>SEi=VT155M&MxC2K|M&xRc&S;eDOMm(uhdQM%hqS&quN365FCmPxm>gJP%}t}6Sp zu2?a{fw|jE^qpbnslq59z^c>k%pNLPAHOf1M%b$q7amG_@ zB1PRYoE$g}!a)u^N>LFIYF|HU_ewYB;mT~fF)O?Pf}4~oxQkGY^>x&9<8Y}m=o+z` zs_rKLJXC$4L|0hK3p!>TMh%@fJId4#h?~L3sAj2?yBUM~Z=adxZGZCnKu?{019;&` z)Np$E@b{EIz0*W{4nJb2ze^!q`ZME?(BJmlvo5C}{W1dnM~T1r&j6>z07~u0B5n zIQyTnEEHj%{;+}l9s8$#ukf?l@6R)f0NSt5enifa$6wC)s|D=875o`F3lIQ21YG=; zQ}QEp0cT(E0{=)|8_Wb!J8Kgohjoy}kOS2PEUL1II<-NT!R?p}m-5rHsO$2JG?cPt zok3P5=)Es$Ngr+by{pe(c`@D|^TKbhbn+TlGp$KxfOV&-op6l8inpY`8lXaQj9>SB z8so0MU^CDtUvn`xq57*=Yt@8K?vjqEd=C4atmlCg*9)1AY2%r70weFtmFk$=4kL#b z4=7f$H;;YU`o+wtBxsksBtO@tE*BaLacu-1Ps}a_Zi+p-km>aY<>DE@>>>2u1l^eL z6LWqa@~J2PxmWzm`n6-oJ^9`K>+eGN zom>_jfz)7JHSyx8v3e*zw_IQDA9Y!aDC&>vR#Ldpd=fnmcPC1u9^gcl&Mr!u2DXqu@8{D=uT2; z-MCDWO%88L#Osaw>ZQMO0-T4w{yx-4cL7U)Xa8!;?e>Ky|0477pZGc0D)=pwvn$Jq z{@5TiUowv>dR{%#gQD{}s;hUg*3$L29TJ{-gU_Pp%cPZ1XEMjG49dnj@jgvG`_)OX z;sY)0z32Jpd9WlgTMc!JSkcnJPvDD{liu0S0ybXC8yAdZ;oI%}Ff>2wUKSiFEG>Dy#h^P_^%~UyGVXO)(dOz!x>=hC4W;?XL)3a3 z@iC-23g{IX0;_73$gvx-kTro{eKdd{XQv*o)>iWEarco8y9~J`N#V@Tg5NBNDuNn7%VVXFDw0_MX^Rv5QL=+nTLP65C zbs`9P-$6M4y;kw_aZw`9LVvvlJF%v}gK)u(xFDc{V#w;3-&@iC^uOxzOx5>Q@*V!4 zZ{dsU|1BT@&L=+k&wc5CCq;Ze%z3%Znxb4W8M0@74a>pfAUxM5tnMk8{S9FFsJOH; z%;(XQ)`W8M&q?2uhVEkPsjgrIS!30UU}lihzVRfCLFmIwTOJ1PEPv=wd=I(u|?^ zCg6>pW0^bm{{Ok_p1c0L{&lieR=%CJ_x|>NzUO^+`S$z1(JR=H>xWb!+L^ch6ynYn zTJcq})$Kj6^!f9iFBbyjtCo7u^#kdvLR-`C-8Zqca}eR>K^T9jb&U=_2QNGa7iSdA zk`}KF@oP7o-+a~Y|48fqD|wioo%ykc{<#2iKJ+_v&d`7Eh0a?}q|?BvTz94Voze!) zjjj3IP+Q@Mivzt4uRQ#MTJlY*Gm%aOZcBTt?uzBprt%FjkF_KFbfPv2hw~dkB=h>h z#jfdwtu3bN!lE9yk*_T~ZDzrVv=gSr6_GYvFN~)ltgN|@RHaZT*{D?U#4Cxt*t!R0 z!uh$ZESj|C;wq|l3a|A#oH?w9=}DKqcML_wFRe`UdnjjQFZ7?@+kd=5G-Kn(y^Fto zzGH<8yuo~rjTV~?PLCbBBIs>6Gubh>CO(QYYyo}%{Bi{!+jcG?ba9?!)A<4LM4Q=X zW8tD>Pz&9~HotZb%4KfNp|1S81X@lnc3Y{;9q^%=^r3T#R~mx3#X7J$V#Icx;a_F` zzrX-H-30>fzE3@%o@@q*qfl`SVoG=NV2Og`n54RZ`L35aD)ib`M0bX8%+Z{{r)H5RI7;OVkM0Fzx5#&DfO|aY=|Ktf9tfm9D5rZjkowc`bvc?%azt!!`?E|5=0_LzLLJDg zm`DCYc{jB=>w+5B`%YZVvy*W29CbVnNY&igH3b;~yJX{?+OoEx2~pP6Hy*2Wrq@mk zG!J8n%XZRUCGA&TX1UBPBjZ=`E@mj$q$rag`f`L%)mNaWa+Gg;WtR=*jM7b4>z~B< z@-j_pR&?QrRYq^P=D^`w@{dw0ubk&e0d}K6ZczitWeB+M(LP>h~+Ri@cr*N?IsQr0% zc6$B?JQjXanC*-@;1VYn*|53C_%1Ot>z6mm=L*FRAY`$wdkLl3?27K#?qk7o;Zrro zeNxL~rBtaZ>|^|+#FOK&7fn)p)vc^_1^KaT@&;CX`86C9ADK?JAFZ+leI zhi;p`aPb6PIi=t>ZSb7N^lvmo^N(q>h`6C^cxG zQaH(zVmkijdE7x!J~|ffX7e^)OYghhVe-DKE}HZ@45B0?&<@xg*R6Uvj zwEk2^m(i5SU2LW;l?~f4lfy*B<*6Qav$0PH2N7uRqwhY?c*MJu8PelsLh}jrbU>Hi zS{nB(1qT%e5m5$kzjiw8U^NZ$mmjfLiEx|tiY#$#=A3XdgJ@LUY;|#j8}ZnSfr=51 z8mq%|+L`sjLOS6nJ#@NcO~|A)wq||F-{_?w6}N0?xnA(Mue^Fs`jmmS8^sA_X7>v@V(*4!xVzhTz9** z1?1T|cFe%9hpmI64}nkB&%lt@A%! zs_z;YjdPg!)zUmGFhWeb&bU;Zn*e*OX;j<|ZF{ZXIz3hVqU6N_(MrRJS290Q=g?^H zJVN$r6r9hY0PP{(O^Wo$(e#ComC9tc8d@`tkwFhDY5qxf=4q?eDyT7^I(O1G=qzp}d^}F4 zq~W>}Es(gs)bincTk1gdkAqJ5cyVK!L7Yp(w8kx^kMj6QaOMDCuxXCfZ6DqU6uvcl zJNl|&PT;`;=Uf!4K-80JLMgNZ9iJ|!|9kb9f~0%*aW;}WoTF&yP1xh7_(dK3EkeQ1 zo8iS!S8!JB2th{E&@Dl!JFR0U$$RvBJPhKoB4Qt2s-S}2!6qWCCT`{v{0N(L4qs&W z+ot^SALFJ3Q2d|C-dF?`f`%uO;Ie}g>#5bRAle=cK;X;J5yOSfy?nCC&HAHr9SBX$ z0h-_b$)rpNPdA8QfQQQMS8aZpl%*JsdG3ALox0rAczQ4F^m&K-Wz*c0c-X6nh00=T>Kw6o>V6;c z%9;zV;lJ|M|7{UfQ{8vW+DBkrZB(SdDUK?~Vp7mSHj@^W26`0f9cY#L3qiIvQxc7< zzGz6-lslE;C{A2f6bT3{FKdgd$j#}~h=oH7-Fvu_Q;#=$@!_G@SoO|%}k(2sp#HfUr;GQXANQ)bwa)Y2VkcNqsuz_h@X zjQz^`?j;LK{_gc9YsZQ9&(i*wU|a;UahQA%WLN8XZ~E6tix>C@qP`r>UVXB^Xt%u! zKcljXMQ9+#3#O^WAJY1)TRbyIrTe8BSjIg@W^2`@n;PNJEpfD0PmX_Fp z!N!9n-$leO9_83o*Pec~k~peNZ)L4cw;&L*>E)VH1Qw&jQF?kP!cFhiOS?X+oMtoI zo!4^l=X-9umn2+j!^PAFR1GPK+8U$pRZFkRz9Sbu@N+;0`np$=?HCmv;3_wGK$>Za zn7la7A=MiRH>+e7szBe&RQnNg~B+u7FdQOi<-1FQC$LBq+lL4j|+FR}Fo zo)KhWYO2-+%FV>XoO80#Vo7Iyic3xEC14Q~LS90vDMP>ga;oGr7)>!DI|)J}HN*U& zG&6=(c{6M3f~~wKv7@!Wu*+AM&$GLZ1;!uor728mkE^~&e0YuI0Zr${8oW8O**YUZr!RdgDIvrLuWx@*OxW zoh^?+8CkIR~9m zuNv0IJa`FkYttt_a zedW|gOJ>v^_h>13HzVECtdb!8UVAYFWi@N{S(-bwY6n%hm$UT;EC^|<%T)1lw8qv6 zBvS@%$7ol7hM_qn~0NYF*jq#>ys(fZM;NvyZPlD}nJ97F6*q zL>owHdPAdodNnn=OmIPO{IM=oCnw66xb8NCoo|la_hfG?WEwc$(CSFKIM^bgJT;Rz zpk8z=dSBr3@`HO=afhr@cHO2A8}}{4QDUtP027BGktF<=oUmOjqqt z$84ITj;+Za4^x-}vVhkJJ{R6QZ&LGU@BDOi;Lo}at$(!oAaENtSl0^yH7E-Zrent1 zdNV8fsw7m(YGf{y77FPD;B0-|DbIC1>aRW12S;!ZFTV($H3c_$*P;Mr4D#g<5OM0f zkAYQ@y9BGtq_oH^j2-Bg>koo7?2nPE#`gH$V8jLo34(D&J8y51l-~M zg-18~lI9fSxIio^QA|{1K0(!=A6-OB^QIj1_bh&VIP{AHRt!i$tQz>j$%M%hv?6OJ`t?3yEimXDhIe!2N2YKUP)2lQ!g4 zY9%yc_k|yV*c-!geLhwk{OKObNYwX@+vn_7ky2!`8@9?;{C&>2DX0)BRKjuAHC>hF zL5~WO61Mq@Az+E&+tfl;(Ajv}QLr-&5dDO5#A^)J#C(Di^P7x*1Rr4`Nx~v;FwFMM zl#n4w6r_}_s5oliqy3N#72q9BAnA{aa`BrVl6m4m1!*)xV&YwH6L||kTq-nM)A?MB zR>?UWSlk;SMl-*qH1xWu{#vh}ny(nTFYTKVc0JJ~0K)6EUw=OS#}=TyaaTb{@{j~= z0y_1#3FzP-c=o+&-;A*$>T%cm{vHE)c!`&f3eWG0-;&O^hUI?K*6yTIU+lV`08d{* zJ^iBg=6&}Y%;3^7%{H{lqEI0gWf{F-k)3Kh?u4pT-4)m`)Y^1L-B&_QeB|wgzg2g_ z;^iKbgfQ6j)@VCf)g_tDFse{1Q>m90g74n&lkhi^+TViqX9%g)#_)vd+(Z+Z=%`l< zW?8W^`enp>qUsTL<;*s5lnI>oa#FkukkL26jk$Sx&5`qB5^xy(RcPtXhZgELqfo~3 z&ZGvbcs-RQC`y;NI3Q7yn4D4*|E!b8|C`J&{+zjdtIBMAJR#J9Jx0WZ1Zt9&E^Ys^RKNjaugh4y*iRS;OGAUOdo4^8-UgZ|&9n(W58oZH#_mhWMN%uEbWxj2f4)oVcm zYHimyf=>(!w_)|N;U2Yr+|DYVnR3$!IkT=}XQ{PVmU1Utynwc_I|tPRA>9fijKR`M zv6=jqm`KeYI1Hh=mQnt~gKb|a*hM12Bf2a+7@6d~8z)97khZ1YrpSDQw=x&lW!Bx? zD;TI@%C*csyVJd1+r61obHjP~QiYptYpxSCbtw7nd^LYaCjZmx88^!E#sVIS7K1L8 zhVYL1%8P83obOF(Q*E#Zg39qv3kg74R7Il+xV90u*Fq{!0WW4S6XR6#3j6?uUcGqm zl_E+W@wC{Bbn-ic${6Lm8!}Y>oP>3)*g`aD0q2{bPxj&ST)SMyVBpl^PQEl|!2?EA zho*_}2Z#=1lX)rF&92nWmG^l9^77v(jmF!N9Bd2~M0EqOBvcc6^1u09& z&i?MCNHb};lF4cbkJ-wuGOb60qTs%Xe3^4~SGq&L_JbiLh6}@bMY4i4BUf|J+RcwF z801x|F%yu(Z$4mE+9#_?3%0dk6%$wK0K^wXTU=ykw>ndTk29<54wy%n7e--_6Uvqm z$;K@3Rf@^>r!klFBl2ouiBYMg?{5)do?zj^TQr-vIJp#kT)3SDI)D7tzEWq}_aN)` zmjn;Uz^ahBP{;AVb4 zX89(DfWSF6d@~^3$-m@SL54u{v!iaimxjd`WI%T#!|I6AoBW)i-ruU%3xC$O|F>jV ztATTNc6u~YL5_;fr7bXIC`4Ac(Tjs`#w&)~%G+N@uYZ@ivq5|J!x6J^{K0_?m;O2v zRJ_s@xRHLNR=7{(?ES2u>58dO+mv-Cv~9}Lh;q+E`LUxsy<;dl#whjTAj5!G_Kk=k ziTLdK9A!Y1i(H11@O8%cjDPMW-&@^5Yz{S1I&Rmois|Oub-4^>V--4qcWV4JM=6H7 z{lWjb{NIbinP^Q#eOhP-_#DY#0sBVI&5HC{JFLolb%YDyMhCG>_tt#`Kh` zCSUS*R;xalD9GzGJ#@O3e{zM{pLpjvqLfsUiPMd>k4QQ>c_5+~fd9u=Vr+G*Ztr0KDDuEY0L-RgzgLT9?Za&Rw|3oZ3=H-|j zrgb_pnVBgR;*mo}dlK9P zuv)6*s}Urs?4OuCW$iecb|G!^$@7}&1umbO(_)gHIPu_I1Sf5yu+W(eT{9#cq9SNZ zzlP3lQ9yEX*2<{-bT?F#>R|sWe}@3KriI*gN*RCOICWv5BKl0Z+>q1jK3KlJnD_wd z$1IP6%ossKX)VgMuk|7Vs5$BJ)avymfxlzL08GEy;1ZvzT7Kqm=P1+0`=Fnx|F1ay zH~!)AIY|oXJS=x?P<4y44&I3=8&{7=hnBuH;2D(j4Toq94`>>tX6$>eCG+ln00h$} zm?ktsSKvC^%WJ5Ji`%r5ZvAhr_AvA)LxUpQw|V*lU+3oSbqC#I?dJLbNGzHn|LX9O z5R5cJRQzLv5CItP&@8e;$AUgBakoD%aU1DMZBwR-Xq`VVI687_fei7?2Me&?t^bKN zBEFn}Art-Kw*W=vJN6zWHYW3|?tz=@D;R`x04pxR-h3Ow&+;Hq%poH2S(z18kQUa* z=#*8tbjO5bK?rAYl)tHonDh2?fbokRBIfS#g~A#IcI$;X99ju`C-o1P0<+8Ko-Ac) zY7CGJccttk-k&KO6EE~JA%TEIm&$f^qo>8;>^I0AyZqc}!hWJGfYGrm_(-rKcZ$zu z!67=j1RojhLQO_zZD)a~oVuu>b*`_jK9qCzm)~pDQfasM{TUI z@YEHGySPoH1#La@6uzQ=0Nh~R-G~NF=e|~^Ik`S?C`kA<+|M#~G_966Nl6?9aDnfP zi>w6LwsqaXHu{*u1#;rDYMiQGUO_Cpd_rGQ=8^Rm=}W0~_L e0BbD=jh*f^b~;8R0qE-L={}!D1ptCRjQkr5_m^4# literal 0 HcmV?d00001 diff --git a/assets/screenshots/market-installed-skill.png b/assets/screenshots/market-installed-skill.png new file mode 100644 index 0000000000000000000000000000000000000000..2968472cf7d297d4800f32d703b602f5b938bad8 GIT binary patch literal 80489 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 Ri{N0~tNJrm)*tu#{|`kZ^0oi~ literal 0 HcmV?d00001 diff --git a/development/ACCOUNT_MANAGER.md b/development/ACCOUNT_MANAGER.md new file mode 100644 index 0000000..267fda5 --- /dev/null +++ b/development/ACCOUNT_MANAGER.md @@ -0,0 +1,216 @@ +# account-manager 接入标准(账号 / Profile / 租约) + +> 本文是浏览器 RPA 技能接入账号体系的统一标准。开发者和 AI 编程工具在编写 `account_client.py`、处理多账号、排查登录状态或验收浏览器任务前,必须阅读本文。 + +## 目录 + +- [1. 为什么必须依赖 account-manager](#1-为什么必须依赖-account-manager) +- [2. 核心模型](#2-核心模型) +- [3. 强制规则](#3-强制规则) +- [4. 标准生命周期](#4-标准生命周期) +- [5. 账号选择与多账号](#5-账号选择与多账号) +- [6. 创建后去哪里查看](#6-创建后去哪里查看) +- [7. 登录状态与 Profile 验收](#7-登录状态与-profile-验收) +- [8. 租约与异常退出](#8-租约与异常退出) +- [9. 工业级测试](#9-工业级测试) +- [10. 排障顺序与提问证据](#10-排障顺序与提问证据) +- [11. 安全边界](#11-安全边界) + +## 1. 为什么必须依赖 account-manager + +真实用户会长期使用同一个账号,也会在批量发布、矩阵运营、多店铺、多租户等场景中使用多个账号。一次打开临时浏览器、成功操作一两条数据,不能证明技能可交付。 + +account-manager 统一负责: + +- 账号身份及平台归属; +- 一账号一份持久化浏览器 Profile; +- Cookie、会话和登录状态的持续复用; +- 多任务之间的账号租约与并发互斥; +- 账号状态、最近错误和运维排查入口。 + +业务技能只负责编排业务动作,不得再造一套账号库,也不得随意找一个浏览器目录直接运行。 + +## 2. 核心模型 + +| 对象 | 含义 | 业务技能应如何使用 | +|------|------|--------------------| +| `account` | 某个平台上的一个账号身份 | 通过 account-manager CLI 获取,不自行落库 | +| `profile_dir` | 该账号独占的浏览器用户数据目录 | 只传给 `launch_persistent_context`,长期复用 | +| 登录状态 | 目标站 Cookie / Session 等实际会话 | 由业务技能打开同一 Profile 后检查;账号已登记不代表已登录 | +| `lease_token` | 当前任务对账号的临时独占权 | 打开浏览器前取得,所有退出路径释放 | +| `session_status` / error | 会话健康和最近故障线索 | 登录失效或账号异常时按约定回写 | + +关系必须保持为: + +```text +一个账号记录 ──绑定──► 一个稳定 profile_dir + │ + └──任务运行前取得 lease_token ──► 同一时刻只允许一个任务使用 +``` + +## 3. 强制规则 + +1. 所有浏览器 RPA,包括公开页和无需登录的页面,都必须先经 account-manager 取得 `profile_dir`。 +2. 一个账号只能绑定一个稳定 Profile;不同账号禁止共用 Profile。 +3. 禁止使用本机日常 Chrome / Edge 用户目录,禁止每次运行创建无归属的临时 Profile。 +4. 获取账号时必须同时取得租约;仅 `account get ` 不能代替租约。 +5. 业务代码只通过单一 `scripts/service/account_client.py` 集中调用 account-manager CLI。 +6. 禁止 import account-manager 的 `service/`、`db/`、`util/`、`rpa_helpers` 等内部模块。 +7. 未取得非空 `profile_dir` 和 `lease_token` 时禁止打开浏览器。 +8. 账号均被占用时返回 `ERROR:LEASE_CONFLICT`,不得自动创建重复账号或换号绕过。 +9. 所有成功、失败、取消和异常路径都必须在 `finally` 中释放租约。 +10. 密码、Cookie、Token、短信验证码等敏感信息不得写入业务技能配置、数据库、日志或错误信息。 + +对应机器门禁:`POLICY-RPA-004`、`POLICY-RPA-006`。 + +## 4. 标准生命周期 + +```text +解析用户指定账号 + → ensure / pick 账号并取得租约 + → 校验账号平台、profile_dir、lease_token + → 用 profile_dir 启动持久浏览器 + → 打开目标站并检查实际登录状态 + → 执行业务并记录结果 + → 回写会话状态 / 最近错误 + → 关闭浏览器 + → finally 释放租约 +``` + +参考结构: + +```python +account = pick_web_account(platform=TARGET_PLATFORM, account_id=account_id, login_id=login_id) +lease_token = account["lease_token"] +try: + profile_dir = account["profile_dir"] + # 只用 profile_dir 启动 persistent context,然后检查登录并执行任务 +finally: + release_lease(lease_token) +``` + +浏览器启动、登录门禁、业务执行和关闭顺序见 [`RPA.md`](RPA.md) §0.2;CLI 封装边界见 [`ADAPTER.md`](ADAPTER.md)。 + +## 5. 账号选择与多账号 + +选择优先级: + +1. 用户明确指定纯数字 `DEFAULT_ACCOUNT_ID`:先确认账号存在且平台一致,再通过可定位该账号的 pick 流程取得租约;不得只 `get` 后返回空租约。 +2. 用户指定 `DEFAULT_LOGIN_ID`:使用 `ensure-web --login-id` 获取或登记,并取得租约。 +3. 用户未指定账号:按平台 `ensure-web`,有则选择可用账号,无则登记一条。 +4. 多条记录无法唯一确定:返回 `ERROR:AMBIGUOUS_ACCOUNT`,要求用户明确选择。 +5. 匹配账号均被占用:返回 `ERROR:LEASE_CONFLICT`,等待或由运维确认租约状态。 + +批量和矩阵任务必须以账号为隔离单元: + +- 每个账号独立 Profile、独立租约、独立任务结果和失败原因; +- 同一账号默认串行,不因追求吞吐提高同账号并发; +- 一个账号失败不得污染其他账号的 Profile 或结果; +- 设置单账号批量上限、单位时间操作上限和任务总时长; +- 风控、封禁或权限错误不得自动切换下一个账号继续试探。 + +## 6. 创建后去哪里查看 + +账号创建成功后必须可见、可查,不得只相信命令没有报错。 + +### 6.1 宿主数据管理 + +优先在匠厂宿主的**数据管理页面**查看 account-manager 数据: + +- 账号记录:确认账号 ID、平台、账号名称 / 登录标识、状态、`profile_dir`、会话状态; +- 租约记录:确认当前持有者、用途、到期时间和是否已释放; +- 凭据记录:只能查看类型、引用和脱敏值,不应出现明文密码或 Token。 + +宿主版本若直接展示数据库表,对应核心表为 `accounts`、`account_leases`、`credentials`、`platforms`。不得在数据管理页面或 SQLite 工具中随意修改记录。 + +### 6.2 account-manager CLI + +在 account-manager 仓库或宿主提供的入口中检查: + +```powershell +python scripts/main.py account list --platform --limit 50 +python scripts/main.py account get +python scripts/main.py lease list --active +python scripts/main.py account open +``` + +`account open` 仅用于查看指定 Profile。业务运行仍须走带租约的 pick / ensure 流程。 + +### 6.3 本地用户数据目录 + +实际数据按宿主注入的用户隔离: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/ +├── account-manager.db +└── profiles/ + └── {domain}/ + └── {platform_key}/ + └── {safe_label}[_{login_id_suffix}]/ +``` + +- `account-manager.db` 保存账号、平台、凭据元数据和租约。 +- `profiles/...` 是真实浏览器用户数据目录,保存对应账号的 Cookie / Session 等浏览器状态。 +- 实际路径以 `account get ` 返回的 `profile_dir` 为准,不得在代码里拼写固定盘符或用户 ID。 +- Profile 正在使用时禁止复制、移动、删除或同时打开;需要迁移或清理时先停止任务并释放租约。 + +## 7. 登录状态与 Profile 验收 + +“账号已创建”只表示账号记录和 Profile 已准备,不代表目标站已经登录。 + +最小验收必须完成: + +1. 数据管理页面或 `account list` 能看到账号记录。 +2. `profile_dir` 非空、真实存在,并位于当前用户的 account-manager 数据目录。 +3. 两个不同账号的 `profile_dir` 不相同。 +4. 第一次用该 Profile 打开目标站,按平台要求人工登录或扫码。 +5. 正常关闭浏览器并释放租约。 +6. 再次取得同一账号租约,用同一 `profile_dir` 重开浏览器。 +7. 验证登录状态仍有效;失效时返回 `ERROR:REQUIRE_LOGIN`,不得假成功。 + +## 8. 租约与异常退出 + +- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。 +- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。 +- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。 +- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。 +- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。 + +## 9. 工业级测试 + +除单账号冒烟测试外,至少覆盖: + +- 指定账号 ID、指定登录标识和自动选择三条路径; +- 账号不存在、平台不匹配、Profile 缺失、返回格式异常; +- 同一账号并发抢占和 `LEASE_CONFLICT`; +- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放; +- 登录过期、验证码 / 二次验证、账号风险和权限不足; +- 两个以上账号轮换且 Profile、登录态、结果互不污染; +- 关闭重开后登录保持; +- 批量长时间运行、限流、有限重试和断点续跑。 + +不得以“单账号成功操作一两次”作为多账号或矩阵能力已经交付的证明。 + +## 10. 排障顺序与提问证据 + +开发者和 AI 在群里提问前按顺序自查: + +1. 阅读本文以及 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)。 +2. 在数据管理页面确认账号记录是否存在、平台和状态是否正确。 +3. 执行 `account list/get`,核对账号 ID、`profile_dir`、`session_status`。 +4. 在文件系统确认 `profile_dir` 是否存在、是否错用了其他用户目录。 +5. 执行 `lease list --active`,确认是否被其他任务占用或残留。 +6. 查看运行日志、任务中心终态、错误码、失败截图和浏览器当前页面。 +7. 判断问题属于账号不存在、租约冲突、登录失效、Profile 损坏、页面变化还是业务失败。 + +仍需协助时必须提供:技能 slug、平台、脱敏账号标识 / 账号 ID、任务时间、错误码、账号与租约检查结果、`profile_dir` 是否存在、脱敏日志和截图。禁止发送密码、Cookie、Token 或验证码。 + +## 11. 安全边界 + +- 账号和 Profile 只能用于用户或企业明确授权的正常业务操作。 +- 多账号管理用于账号隔离、矩阵业务和稳定调度,不得用于绕过频控、封禁或平台限制。 +- 禁止破解协议、逆向客户端、伪造设备指纹、验证码破解、养号、撞库、代理池轮换或自动换号继续试探。 +- 风控、封禁、安全警告和权限不足必须停止或转人工。 +- 不手工编辑 `account-manager.db`,不在账号使用期间操作 Profile 文件。 + +完整合规与风控边界见 [`AI_RPA.md`](AI_RPA.md)。 diff --git a/development/ADAPTER.md b/development/ADAPTER.md new file mode 100644 index 0000000..3d6b019 --- /dev/null +++ b/development/ADAPTER.md @@ -0,0 +1,133 @@ +# 适配器标准:真实/仿真 × API/RPA 四档模式 + +> 凡是"连接三方系统"(ERP、CRM、SaaS、银行等)的 skill,都应采用本文的 **adapter 四档模式**。同一套业务逻辑可在不同档位间切换;**四档都保留**。 + +先遵守 [`AI_RPA.md`](AI_RPA.md) 的授权与技术红线:`real_api` 只连接公开或明确授权的官方接口;不得通过抓包、逆向或猜测私有协议构造“API”。没有授权 API 时使用正常界面 RPA;正常界面无法闭环且只能绕过安全机制时,停止并上报。 + +## 为什么要分档 + +对接一个外部系统,工程上无非四种方式的组合: + +| | API(有接口) | RPA(操作界面) | +|---|---|---| +| **真实系统** | `real_api` 真实 API | `real_rpa` 真实界面 | +| **仿真/离线** | `mock` 纯内存/fixture | `simulator_rpa` 仿真站点/桌面仿真 | + +### 四档定义 + +| 档位 | 含义 | 默认策略 | +|------|------|----------| +| **`mock`** | 纯内存或 fixture,离线 | **单测 / CI 必过**;mock 不通不要往下走 | +| **`simulator_rpa`** | 仿真站点或桌面仿真,可半集成 | 按业务场景选用 | +| **`real_api`** | 真实 API | 按业务场景选用(有官方接口时) | +| **`real_rpa`** | 真实浏览器/真实系统 | **模板 `.env.example` 默认**;业务自测与交活用此档 | + +- **mock**:纯离线、不联网,给单测 / CI,**保证可重复**。**mock 通 ≠ 开发完成**。 +- **simulator_rpa**:操作仿真平台(如 `sandbox.jc2009.com`),按场景选用。 +- **real_api**:有官方接口时按场景选用。 +- **real_rpa**:真实浏览器路径;模板默认,须真实跑通后再交活。 + +> **口径**:业务配置默认 `real_rpa`;单测/CI 用 `mock`。不得只 mock 通就交活;须 release 到匠厂宿主验收(含用户体验)。 + +配置读取见 `CONFIG.md`:**bootstrap 之后业务代码只通过 `config.get*()` 读 `OPENCLAW_TEST_TARGET` 等项**(进程 env > 用户 `.env` > `.env.example`)。 + +## 目录骨架 + +``` +scripts/service/_adapter/ + __init__.py # dispatch:按档位返回对应 adapter 实例 + base.py # 数据契约(dataclass)+ AdapterBase 接口 + mock.py # 离线仿真,给 CI/单测 + real_api.py # 真实系统 API + sim_rpa.py # 薄仿真平台 RPA adapter(委托 *_playwright.py) + real_rpa.py # 真实系统 RPA(占位,谨慎实现) + +scripts/service/ + _playwright.py # RPA 主流程(厚);与薄 sim_rpa.py 并列 + account_client.py # account-manager subprocess 唯一封装 + browser_session.py # async persistent context 启动 +``` + +模板示例(均在 `examples/`,**不在** `scripts/`): + +- **仿真浏览器 RPA 完整案例**:`examples/simulator_browser_rpa/` — async mock + simulator_rpa + `simulator_playwright.py` + sandbox 页面 + adapter 分层 + 测试 +- **真实浏览器 RPA 完整案例**:`examples/real_browser_rpa/` — 登录/验证码/滚动采集 + `account_client.py` +- **真实 API / 仿真 API**:`examples/real_api/`、`examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现 + +复制 adapter 分层时,以修正后的 `examples/simulator_browser_rpa/`(薄 adapter + `simulator_playwright.py`)与 `examples/real_browser_rpa/scripts/service/account_client.py` 为权威参考,在新技能中创建 `scripts/service/_adapter/` 并按 README copy map 选择性复制。 + +> 先判断属于四象限哪一种(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`。示例是**参考架构与边界**,不是业务代码原样复制。 + +## 档位 dispatch + +由 `OPENCLAW_TEST_TARGET` 统一决定用哪个 adapter: + +| `OPENCLAW_TEST_TARGET` | adapter | 用途 | +|---|---|---| +| `unit` / `mock` | `MockAdapter` | 单测 / CI,离线 | +| `simulator_rpa` | `SimRpaAdapter` | 开发/演示,操作仿真平台 | +| `real_api` | `RealApiAdapter` | 生产,走官方接口 | +| `real_rpa` | `RealRpaAdapter` | 生产,操作真实界面 | + +```python +# __init__.py +from jiangchang_skill_core import config + +def get_adapter(): + target = (config.get("OPENCLAW_TEST_TARGET") or "real_rpa").lower() + if target in ("unit", "mock"): + return MockAdapter() + if target == "real_api": + return RealApiAdapter() + if target == "real_rpa": + return RealRpaAdapter() + return SimRpaAdapter() +``` + +`get_adapter()` 实现应配合 `tests/adapter_test_utils.py` 的 profile 策略:**默认必跑测试不得误设 `OPENCLAW_TEST_TARGET=real_*`**。 + +## contract tests + +每个 adapter **必须覆盖同一套契约测试**(复制 `tests/samples/test_service_contract.py.sample`): + +- 至少覆盖:**timeout**、**unauthorized**、**invalid response**、**empty result** +- **mock 不允许真实网络** +- 使用 `FakeAdapter` 或等价 stub 模拟异常路径 + +## 兄弟依赖 + +依赖 account-manager 或其他兄弟技能时: + +1. 在 `SKILL.md` 的 `metadata.openclaw.dependencies.required` 声明。 +2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。 +3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。 +4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。 +5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get ` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。 +6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。 +7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → 校验指定账号后带租约 pick;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id --lease`;皆空 → 按平台 `ensure-web --lease`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配或无法确保拿到指定 ID → `ERROR:AMBIGUOUS_ACCOUNT`。 +8. **运行前校验**:`profile_dir` 和 `lease_token` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。 + +```python +# 普通兄弟技能 — 走 sibling_bridge +from service.sibling_bridge import call_sibling_json + +result = call_sibling_json("account-manager", ["list", "--limit", "10"]) +``` + +```python +# account-manager 账号/租约 — 集中在 account_client.py(ensure-web) +from service.account_client import pick_web_account, release_lease + +account = pick_web_account(platform="target_platform", login_id=None) +try: + ... +finally: + release_lease(account.get("lease_token")) +``` + +## 相关文档 + +- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式 +- `ACCOUNT_MANAGER.md` — 账号、Profile、租约、多账号、查看与排障标准 +- `CONFIG.md` — `.env` 里如何配置运行模式与目标地址 +- `TESTING.md` — 测试 target 与隔离体系 diff --git a/development/AI_RPA.md b/development/AI_RPA.md new file mode 100644 index 0000000..c161d6c --- /dev/null +++ b/development/AI_RPA.md @@ -0,0 +1,234 @@ +# AI + RPA 技能开发总纲 + +本文是技术人员与 AI 编程工具进入本模板后的**第一份架构说明**。先理解本文,再按业务类型阅读 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)、[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) 与对应 `examples/`。 + +## 目录 + +1. [一句话定义](#1-一句话定义) +2. [职责边界](#2-职责边界) +3. [标准执行闭环](#3-标准执行闭环) +4. [合规与技术红线](#4-合规与技术红线) +5. [工业级 RPA 风控](#5-工业级-rpa-风控) +6. [等待与操作节奏](#6-等待与操作节奏) +7. [三端统一与选型](#7-三端统一与选型) +8. [结果契约](#8-结果契约) +9. [开发与交付顺序](#9-开发与交付顺序) + +## 1. 一句话定义 + +**AI Agent 是用户入口与业务编排者,RPA 是确定性执行器。** + +- Agent 负责理解用户意图、补齐参数、判断风险、选择 Action、发起任务和解释结果。 +- RPA 负责按已验证的步骤操作浏览器、桌面软件或手机,不临时理解模糊业务。 +- 业务规则放在唯一 domain service 中;Agent、CLI、Action、定时任务与数据管理入口复用同一内核。 +- RPA 必须返回结构化结果、进度与存证,由 Agent 用业务语言反馈给用户。 + +本模板追求的是**稳定、合规、可控制、可追溯、可交付的工业级界面自动化**,不是与第三方安全机制对抗。 + +## 2. 职责边界 + +| 层级 | 负责 | 不负责 | +|------|------|--------| +| Agent | 意图理解、缺参追问、风险确认、Action 选择、结果解释 | 不猜界面步骤,不临时改业务规则,不直接绕过安全验证 | +| Action / 编排层 | 参数校验、鉴权、任务状态、进度、暂停停止、日志、人工介入 | 不按入口复制业务逻辑 | +| Domain Service | 业务规则、幂等、防重复、状态流转、结果汇总 | 不关心来自 Agent、定时任务还是工具栏 | +| Adapter / RPA | 调官方 API 或按确定流程操作界面 | 不理解模糊意图,不自行扩大权限或操作范围 | +| 宿主 | Action 展示、任务中心、数据管理、共享 runtime | 不替技能实现业务规则 | + +Agent 与 RPA 的边界必须保持清晰: + +- 把不确定的理解与沟通交给 Agent。 +- 把可验证、可重复的执行步骤交给代码和 RPA。 +- 不让 Agent 在运行时自由生成高风险界面操作。 +- 不让 RPA 根据模糊文本自行决定付款、删除、审批、发布或发送范围。 + +## 3. 标准执行闭环 + +```text +用户自然语言 + → Agent 识别意图与参数 + → 缺参追问 / 风险确认 + → 选择已声明的 Skill Action + → 编排层做校验、鉴权、预检、幂等检查 + → Domain Service 执行业务规则 + → Adapter 选择 API / 浏览器 / 桌面 / 手机 + → RPA 分步骤执行并持续上报进度 + → 验证码、人脸、U盾等转人工处理 + → 返回结构化结果、日志、截图与可选视频 + → Agent 向用户解释结果与下一步 +``` + +### 执行前 + +Agent 与编排层必须确认: + +- 用户意图、操作对象、范围与必填参数明确。 +- 当前账号、数据、设备和目标软件属于用户或企业授权范围。 +- 查询、准备数据与外部副作用已经分开。 +- 付款、删除、审批、发布、发送等不可逆动作已取得明确确认。 +- 浏览器 Profile、桌面窗口或手机设备已通过预检。 +- 同一业务键不会被重复提交。 + +### 执行中 + +- 每个用户可感知的动作都要有中文进度 step。 +- 使用可暂停、可停止的等待;禁止 RPA 主路径裸 `asyncio.sleep`。 +- 同一账号、Profile 或设备默认串行执行。 +- 遇到安全验证、权限不足、频控或风险提示时,转人工或安全停止。 +- 批量任务记录处理位置,支持失败定位和必要的断点续跑。 + +### 执行后 + +- 先验证业务成功标志,再记录成功;不得把“点击完成”当成“业务成功”。 +- 返回成功、部分成功、失败、停止或等待人工等明确状态。 +- 保存必要的运行日志、错误码、失败截图和可选录屏。 +- Agent 根据结构化结果解释完成数量、失败原因和下一步,不直接粘贴技术日志。 + +## 4. 合规与技术红线 + +### 允许 + +- 使用用户或企业合法拥有、明确授权的账号与业务权限。 +- 通过正常界面入口完成用户本来有权完成的操作。 +- 使用浏览器 DOM、Windows UI Automation、Android/iOS 控件树等公开自动化能力。 +- 采用真实点击、逐字输入、自然鼠标轨迹、分段滚动和合理业务节奏。 +- 遇到验证码、人脸、指纹、短信、U盾或动态口令时暂停并请求人工处理。 +- 在授权范围内保存必要的日志、截图、视频和业务结果。 + +### 禁止 + +- 破解、反编译、逆向第三方软件或分析其私有协议。 +- 调用未经公开授权的隐藏接口,或通过抓包拼装私有请求代替正常界面。 +- 绕过登录、验证码、人脸、指纹、短信、U盾、设备绑定、签名或权限校验。 +- 注入、Hook、篡改第三方进程、客户端、APK、IPA、安全模块或通信内容。 +- 窃取、共享或复用未授权的 Cookie、Token、Session、密码或设备凭据。 +- 撞库、暴力尝试、自动破解验证码、批量养号或伪造身份。 +- 通过换账号、换代理、伪造设备指纹、提高并发等方式规避平台限制。 +- 识别到风险提示或封禁后继续自动重试、试探或隐藏自动化行为。 + +“采用 RPA”不代表任何任务都可以自动执行。技能仍须遵守账号授权、数据权限、目标平台规则、合同约定和适用法律;边界不清时停止并请求负责人确认。 + +## 5. 工业级 RPA 风控 + +### 风控信号与处理 + +| 信号 | 标准处理 | +|------|----------| +| 页面或软件加载缓慢 | 等待真实状态,必要时有限重试 | +| 普通网络错误 | 递增退避后有限重试 | +| 登录失效 | 请求用户重新登录 | +| 验证码、短信、人脸、指纹、U盾 | 进入人工处理,禁止自动绕过 | +| 频率限制 | 停止当前批次或长时间退避 | +| 账号风险、封禁或安全警告 | 立即停止,禁止自动换号或继续试探 | +| 权限不足 | 返回明确失败,不尝试扩大或绕过权限 | +| 页面或控件结构变化 | 截图并停止,交由开发人员重新验证 | +| 提交结果不明确 | 查询业务状态;确认前禁止再次提交 | + +### 运行限制 + +- 同一账号、Profile、桌面会话或手机设备默认只运行一个任务。 +- 设置单批数量、单位时间操作量和总运行时长上限。 +- 只对明确可重试错误做有限重试;禁止无限循环。 +- 采用递增退避,不以快速重复点击或刷新解决延迟。 +- 关键副作用采用“准备数据 → 用户确认 → 执行 → 结果核验”两阶段流程。 +- 风控策略用于减少误操作、系统压力和误判,不得用于规避第三方安全控制。 + +## 6. 等待与操作节奏 + +区分三类等待,禁止只靠固定睡眠碰运气。 + +### 状态等待 + +页面加载、控件可用、弹窗消失、保存成功、下载完成等必须等待**真实条件**并设置超时。固定等待不能代替状态判断。 + +自 **platform-kit 1.2.11** / 本模板 **1.0.78** 起: + +- 统一使用 `jiangchang_skill_core.rpa.poll_until`(或 `poll_until_sync`),内部已接 `interruptible_sleep`。 +- 先有界等待就绪,再区分「加载慢」与「页不对」;超时优先 `ERROR:UI_NOT_READY`。 +- 不要把慢机器症状映射成配置错误、网络错误或频控,除非有明确证据。 +- 详细表与纪律见 [`RPA.md`](RPA.md) §0.3 / §4。 + +### 操作节奏 + +真实业务环境中,两个正常业务动作之间默认随机等待 **1~3 秒**: + +```env +STEP_DELAY_MIN=1.0 +STEP_DELAY_MAX=3.0 +``` + +- 输入采用逐字符随机延迟。 +- 鼠标采用自然移动轨迹与小幅随机偏移。 +- 滚动采用分段操作。 +- 提交后等待业务结果,禁止重复点击。 +- 测试 / sandbox 可使用更短节奏,但不得把测试节奏用于真实第三方系统。 + +### 前后缓冲 + +- 浏览器或软件启动:先等待会话与目标页面/窗口真实就绪,再额外缓冲约 `1.5~2.5` 秒。 +- 页面导航:关键控件就绪后,再缓冲约 `1~2` 秒。 +- 普通流程结束:确认业务结果、写日志与存证后,再缓冲约 `2~3` 秒关闭。 +- 付款、审批、发布等重要动作:确认结果并截图后,可缓冲约 `3~5` 秒。 + +缓冲时间只用于稳定界面和改善用户观感,不能作为成功判定。RPA 等待必须使用 `interruptible_sleep` 或共享可控制原语。 + +## 7. 三端统一与选型 + +优先选择最稳定、最受支持的执行方式: + +1. 本地确定性逻辑可以完成时,不启动 RPA。 +2. 有稳定且获得授权的官方 API 时,优先 API。 +3. 只能通过网页操作时,使用浏览器 RPA。 +4. 目标为 Windows 原生软件时,优先 UI Automation。 +5. 目标为手机 App 时,使用系统控件树与正常设备自动化。 +6. 只有破解、逆向或绕过安全机制才能完成时,停止开发并上报,不改走违规路线。 + +三端详细标准见 [`RPA.md`](RPA.md)。当前浏览器 RPA 已有可复制案例;桌面与手机仍须基于真实项目继续沉淀,未验证内容不得包装为成熟方案。 + +## 8. 结果契约 + +RPA 至少返回: + +```json +{ + "success": true, + "status": "success", + "message": "任务已完成", + "summary": { + "total": 10, + "success_count": 9, + "failed_count": 1 + }, + "items": [], + "errors": [], + "artifacts": { + "screenshots": [], + "video_path": null + }, + "next_actions": [] +} +``` + +状态至少能区分: + +- `success`:全部完成。 +- `partial`:部分成功,列出失败项。 +- `failed`:未完成,给出稳定错误码与阶段。 +- `waiting_human`:等待人工验证或接管,不是假失败。 +- `confirmation_required`:等待用户批准副作用。 +- `stopped`:用户或宿主主动停止。 + +具体字段可按业务扩展,但不得只返回控制台文本或把技术 traceback 直接交给用户。 + +## 9. 开发与交付顺序 + +1. 填写 [`REQUIREMENTS.md`](REQUIREMENTS.md) 中的 Agent、授权、风险、人工介入与结果设计。 +2. 判断采用 API、浏览器、桌面或手机,并选择真实 / 仿真 adapter。 +3. 定义用户意图、Action、输入 schema 与确认策略。 +4. 先实现 domain service、结构化结果和 mock 测试。 +5. 再实现 RPA 会话、真实操作、状态等待、进度、截图与人工介入。 +6. 在真实界面上确认 selector / 控件,禁止凭经验猜测。 +7. 完成仿真测试、真实业务闭环和宿主多入口验收。 +8. 使用 [`CHECKLIST.md`](CHECKLIST.md) 做第三方交活验收。 + +AI 编程工具不得为了“跑通”擅自采用破解、逆向、私有协议、验证码绕过、进程注入、无限重试或未授权凭据。正常界面无法完成闭环时,应停止并向负责人报告边界。 diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md new file mode 100644 index 0000000..720c6b4 --- /dev/null +++ b/development/CHECKLIST.md @@ -0,0 +1,201 @@ +# 技能交活验收 Checklist(第三方视角) + +checklist_version: 1.0 + +本清单用于**旁人 / 测试 / AI 编程工具**对任意业务技能做交活验收。 +它描述的是「验什么、怎样算过」,**不是**开发规范摘要。写法与实现约束见同目录其他文档。 + +--- + +## 验收输出强制规则(凡执行本清单必须遵守) + +> 面向 AI 与人工验收员:只要开始按本 Checklist 验收,下列规则**全部强制**,无需用户在提示词里再重复。 + +1. **必须落盘报告**,禁止只在对话里给出口头结论。 +2. **报告目录(唯一合法位置)**:技能仓库**根目录**下的 `acceptance-reports/` + (与 `development/`、`scripts/` 平级;**禁止**写到 `development/`、`tests/`、临时目录或其他路径。) +3. **报告文件名(唯一合法格式)**:`YYYYMMDD-HHMMSS-acceptance.md` + 示例:`20260722-175000-acceptance.md` + 时间取验收执行时的本地时间;**禁止**使用 `report.md`、`验收.md`、无时间戳的文件名。 +4. 若 `acceptance-reports/` 不存在,**先创建该目录再写入**。 +5. 报告**开头**必须写明: + - 完整相对路径:`acceptance-reports/YYYYMMDD-HHMMSS-acceptance.md` + - 技能 slug、当前 git 提交短哈希(若可得)、`checklist_version` +6. 报告正文必须**逐条**对应下方条目,每条给出:`PASS` / `FAIL` / `N/A` / `NEED_MANUAL`,并附简短证据(文件路径、命令、观察结果)。 +7. 报告末尾必须有「给技术人员的修改清单」:只列出未通过项(`FAIL` 与未通过的 `NEED_MANUAL`),按 P0 → P1 → P2 排序。 +8. **禁止修改业务代码**来“凑”验收通过;本清单的执行结果是报告,不是改代码。 +9. 若用户只说「按 Checklist 验收」而未指定路径:仍必须遵守本节省略规则,不得省略落盘。 + +**结果取值** + +| 值 | 含义 | +|----|------| +| PASS | 已满足 | +| FAIL | 不满足 | +| N/A | 本技能不适用(须说明原因) | +| NEED_MANUAL | 须人工点选/真机;报告中写清已做或未做 | + +**级别** + +| 级别 | 含义 | +|------|------| +| P0 | 不通过不可交活 | +| P1 | 限期整改 | +| P2 | 体验建议 | + +--- + +## 0. 技能画像(验收前先填,用于裁剪) + +先根据技能实际情况勾选;后续章节不适用则整项标 `N/A` 并注明原因。 + +- [ ] S0-1 交互形态:纯本地 / API / 网页RPA / 桌面RPA / 手机RPA / 编排(调兄弟技能) +- [ ] S0-2 目标站登录策略:required / optional / not_needed / 不适用 +- [ ] S0-3 是否依赖账号会话(account-manager 或等价 Profile):是 / 否 +- [ ] S0-4 持久化:无 / 另有业务表 +- [ ] S0-5 Skill Action:无 / 仅同步短任务 / 含异步长任务 +- [ ] S0-6 是否需要在任务中心查看进度或结果:是 / 否 +- [ ] S0-7 外部系统账号、数据、设备与操作权限的授权来源: +- [ ] S0-8 是否包含提交、付款、删除、审批、发布、发送等不可逆副作用:是 / 否 +- [ ] S0-9 **入口定位表**(见 `PRODUCT_SURFACES.md` / `REQUIREMENTS` §0.1):已填写 / 未填写 +- [ ] S0-10 本技能宣称的主用户路径涉及:对话 / 数据管理(执行或仅展示) / 定时(可多选;与定位表一致) +- [ ] S0-11 **业务行生命周期**(见 `references/WORK_ITEM_STATUS.md` / `REQUIREMENTS` §0.3):有处理队列且已声明状态→角色映射 / 不适用 +- [ ] S0-12 **HTML 页面报告**(见 `references/ACTIONS.md`「HTML 页面报告」):有侧栏可预览的 `html/*.html` + `render-html` / 不适用 + +--- + +## 1. 安装与可运行(所有技能) + +- [ ] A1-1 **P0** 按平台方式加载/安装后,技能可被识别(有名称,能打开详情或执行入口) +- [ ] A1-2 **P0** 健康检查(如 `health`)能跑完且结果可读;缺关键条件时有明确失败提示,而非空白崩溃 +- [ ] A1-3 **P0** 版本查询(如 `version`)返回的版本与对外宣称一致 +- [ ] A1-4 **P1** 若有用户可见配置:说明可读;按说明改关键项后行为符合说明 + +--- + +## 2. 对外说明是否自洽(所有技能) + +- [ ] A2-1 **P0** 市场「说明」能让非开发者理解:做什么、给谁用、开始前准备什么 +- [ ] A2-2 **P0** 「说明/教程」不以开发目录、内部实现细节作为用户主路径 +- [ ] A2-3 **P1** 「教程」可按真实顺序跟做(准备 → 分入口步骤 → 失败怎么办);结构见 `TUTORIAL_SURFACES.md` +- [ ] A2-4 **P1** 「演示」结构完整(有演示信息位;无视频则标明暂无,不假装有) +- [ ] A2-5 **P0** 「更新日志」能对应到当前交付版本 +- [ ] A2-6 **P0** 平台/Agent 侧说明中,常见用户意图的触发方式与真实能力一致(短操作 vs 长任务不混淆) +- [ ] A2-7 **P0** 「说明」Tab(`README.md`)**不含**分步操作(1/2/3)、Agent 话术块、「入口一/二」专章;操作指引指向「使用教程」 +- [ ] A2-8 **P0** 「教程」Tab 覆盖 `REQUIREMENTS` §0.1 / `actions.json` 中所有「挂」的入口(数据管理 / 对话;结构见 `TUTORIAL_SURFACES.md`) +- [ ] A2-9 **P0** 无 `cron` 时教程有「不需要定时」+ 理由;有 `cron` 时教程写清如何配置 +- [ ] A2-10 **P1** 教程主能力对照表与 `actions.json` 的 `label` 一致 +- [ ] A2-11 **P1** 若有数据管理主路径:准备/载入/导入类能力在对应表 **toolbar** 可见;教程**不以** skill-detail 为唯一主路径 +- [ ] A2-12 **P0** 说明/教程正文**不以** `# 技能名` 或 `# 技能名 · 使用教程` 开篇(宿主页头/Tab 已有);主分节用 `##`,不要把原 `##` 升成 `#`(见 `TUTORIAL_SURFACES.md` §0.1) + +--- + +## 3. 主能力行为(所有技能) + +- [ ] A3-1 **P0** 主成功路径能按宣称完成核心动作(至少 1 次完整闭环) +- [ ] A3-2 **P0** 缺参或非法输入时稳定失败(可读错误,不假成功) +- [ ] A3-3 **P1** 失败可诊断:能从任务记录/提示判断失败阶段,而非只有“失败了” +- [ ] A3-4 **P0** 同一能力从已声明的不同入口触发时,业务结果一致(不出现入口分叉成两套逻辑) +- [ ] A3-5 **P0** `REQUIREMENTS` 入口定位表已填写;定位为「挂」的入口在 `actions.json`(及必要表元数据)中有对应声明 +- [ ] A3-6 **P1** 定位为「不挂」的入口有书面理由(不是空白遗漏) + +--- + +## 4. 调度与长任务(有 Action 或长任务时适用) + +- [ ] A4-1 **P0** 秒级只读/短操作:不进入任务中心也能完成(若技能提供此类能力) +- [ ] A4-2 **P0** 长耗时/浏览器/需进度或取消的能力:经 Agent 或技能动作触发后进入任务中心(或平台等价异步队列) +- [ ] A4-3 **P0** 异步任务在任务中心能看到进行中与结束状态;结束有成功或失败结论 +- [ ] A4-4 **P1** 进行中的进度/步骤信息对用户可理解 +- [ ] A4-5 **P1** 已对外暴露的动作参数覆盖主路径关键输入(无“命令行能做、动作入口不能做”的关键缺口) +- [ ] A4-6 **P0** 若 Action 需引用同库另一张业务表(话术/平台/账号等):参数为 Lookup 下拉(内联 `lookup` 或绑列),**不是**手填内部编号;可选「自动」可留空提交 +- [ ] A4-7 **P1** 若 Action 有互斥采集/操作模式(如「按关键词 / 按主页」):用 `inputSchema` 字段级 **`visibleWhen`** 联动显隐,勿只靠 `description` 灰字说明「仅某某时填写」(见 `references/ACTIONS.md`;勿与 Policy L3 行态 `visibleWhen` 混淆) +- [ ] A4-8 **P1** 行内/勾选打开的 `form`:有「已选择」时参数区不应再重复映射主键(`inputMapping` + `readOnly`);需要强制露出时用 `revealInForm`,**禁止**用 `visibleWhen` 藏主键(会不提交) +--- + +## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项) + +- [ ] A5-1 **P0** 未准备好账号/浏览器档案时,主路径失败并提示,不会默默用本机默认浏览器身份跑起来 +- [ ] A5-2 **P0** 登录策略为 required 时:未登录不能把做不成的事当成成功 +- [ ] A5-3 **P0** 交付联调默认能看见浏览器窗口(有头);不能把无头当作唯一可验收形态 +- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入) +- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功 +- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活 +- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在 +- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行 +- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放 +- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录 +- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染 +- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理 + +--- + +## 6. 结果落点(按画像裁剪) + +- [ ] A6-1 **P0** 宣称可追溯时:跑完后任务记录中有对应条目(成功/失败均有) +- [ ] A6-2 **P0** 宣称写入业务数据或可在数据管理查看时:成功跑完后能看到新业务数据;仅有任务摘要、业务侧仍空 → **FAIL** +- [ ] A6-3 **P1** 重复执行同一业务键时,结果符合宣称(更新/去重/追加),无莫名脏数据或静默丢数据 +- [ ] A6-4 **P1** 「零结果成功」与「失败」可区分 +- [ ] A6-5 **P1** 若产出文件:落在约定数据位置且可找到,不丢在不明临时路径 +- [ ] A6-6 **P0** 有业务表时:`sqlite_master` 中业务表 DDL **不得**含 SQLite `FOREIGN KEY` / `REFERENCES`;跨表只存业务 id,关联与级联在 repository/service;界面名称用 `lookup`(见 `references/SCHEMA.md`);可跑 `validate_template_database` / `init-db` 后自检 +- [ ] A6-7 **P0** 有「处理业务行」类 Action 时(见 `WORK_ITEM_STATUS.md`):默认只处理**可操作**角色状态;成功/失败/跳过/进行中再点处理应拒绝或跳过且不产生重复副作用;重做须人工改回可操作。无处理队列 → **N/A** +- [ ] A6-8 **P0** 同上适用时:开始处理后业务表进入**进行中**;成功回写成功类终态;失败回写失败类终态且**失败原因**可读(任务成功但业务行仍停在可操作/进行中 → **FAIL**) +- [ ] A6-9 **P1** 同上适用时:`REQUIREMENTS` §0.3(或等价)已填写本技能状态→角色映射;处理类 Action 确认文案写清仅可操作态 +- [ ] A6-10 **P1** 有 HTML 侧栏预览时(S0-12):预览区横向铺满、无横向滚动条;重新生成后内容与页头时间更新;布局符合 `references/ACTIONS.md`「宿主预览布局」。无 HTML 报告 → **N/A** +- [ ] A6-11 **P1** 业务**状态 / 启用类**列若用户需要筛表或顶栏搜中文:`searchable: 1` 且 `options_json`(或 `boolean` + `options_json`)的 **value 与存库一致**;仅 `searchable: 0` 则宿主不提供列筛与全局搜索。见 `references/SCHEMA.md`「列筛选与全局搜索」 +- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A** +- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`) +- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`) + +--- + +## 7. 可观测与安全观感(所有技能) + +- [ ] A7-1 **P0** 失败提示或任务结论不泄露密码、完整 Cookie、明文 token 等敏感信息 +- [ ] A7-2 **P0** 统一文件日志(运行日志 / `jiangchang.log`)能定位失败阶段:可见 `task_start` 与 `task_failed|task_success`;失败含 `error_code=`;**不只** `cli_start` +- [ ] A7-3 **P0** 故意失败一条时,文件日志先有 `task_failed`/`warning`,再有 `ERROR:CODE` 或任务中心失败态 +- [ ] A7-4 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束;RPA 宜有多个 `stage=`) +- [ ] A7-5 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL** +- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话 +- [ ] A7-7 **P2** 数据管理技能按钮(toolbar / row / batch)中文名对照 `references/ACTIONS.md`「Action 按钮中文名」;不与宿主「查看 / 编辑 / 删除」抢名;打开类成功不弹成功条。行业专有按钮名可自创 + +--- + +## 8. AI + RPA 合规与工业级风控(外部系统技能适用) + +- [ ] A8-1 **P0** 已确认账号、数据、设备和业务操作均在用户或企业授权范围内;边界不明不能以“默认允许”通过 +- [ ] A8-2 **P0** 代码与运行路径不包含破解、反编译、逆向、未授权私有协议、抓包拼隐藏接口、Hook / 注入、验证码破解、设备指纹伪造、撞库、养号、自动换号或代理池规避限制 +- [ ] A8-3 **P0** 验证码、人脸、指纹、短信、U盾、频控、账号风险和权限不足能转人工或安全停止,不自动硬闯 +- [ ] A8-4 **P0** 付款、删除、审批、发布、发送等不可逆动作执行前有明确对象、范围和用户确认 +- [ ] A8-5 **P1** 页面 / 控件加载、保存和业务成功由真实状态判断,不以固定等待代替成功判定 +- [ ] A8-6 **P1** 真实 RPA 两个正常业务动作之间采用约 `1~3` 秒随机节奏;输入、鼠标和滚动符合正常人工操作方式 +- [ ] A8-7 **P1** 同一账号、Profile 或设备默认串行;批量、频率和运行时长有上限 +- [ ] A8-8 **P1** 只对明确可重试错误做有限重试与递增退避;风险、封禁、权限和结果不明确时不盲目重试 +- [ ] A8-9 **P1** 流程结束先核验业务结果并完成日志 / 截图 / 可选视频,再保留合理结束缓冲后关闭会话 +- [ ] A8-10 **P1** Agent 能区分成功、部分成功、失败、等待人工、等待确认和主动停止,并用业务语言解释结果 +- [ ] A8-11 **P0** 关键元素均有真实拾取证据和 element map;没有真实 DOM/UIA/XML 时未由 AI 猜 selector +- [ ] A8-12 **P0** 不可逆动作定位器在目标作用域内唯一;没有用 `.first` / `hwnds[0]` / `instance=0` 掩盖歧义 +- [ ] A8-13 **P1** 页面、窗口、弹窗、iframe、Native/WebView 等作用域明确;列表先定位业务行再定位动作元素 +- [ ] A8-14 **P1** 重启浏览器/软件/App 后关键定位复测通过;动态列表顺序变化不会误点 +- [ ] A8-15 **P1** 定位失败保存截图和 DOM/UIA/XML、候选数量、作用域及目标版本;结构变化时停止而非在线猜新定位器 +- [ ] A8-16 **P1** 所有 fallback 均经过人工验证;图像/OCR/坐标仅在控件树确认不可用后使用 + +--- + +## 9. 质量门禁与交活底线(所有技能) + +- [ ] A9-1 **P0** 仓库默认测试套件通过(执行技能约定的一键测试命令,如 `python tests/run_tests.py -v`) +- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免) +- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」 +- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节 +- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求) + +--- + +## 10. 报告结尾必填 + +报告末尾固定三块: + +1. **结论**:通过 / 不通过(任意 P0 为 FAIL 或不通过的 NEED_MANUAL → 不通过) +2. **统计**:PASS / FAIL / N/A / NEED_MANUAL 数量;P0 FAIL 列表 +3. **给技术人员的修改清单**:仅未通过项,带条目 ID 与期望现象 diff --git a/development/CONFIG.md b/development/CONFIG.md new file mode 100644 index 0000000..dc64ed2 --- /dev/null +++ b/development/CONFIG.md @@ -0,0 +1,200 @@ +# 配置标准:`.env` 规范与首次落盘机制 + +> 解决"每个 skill 每次都要手动搞一堆环境变量、账号、地址"的痛点。统一约定:**模板内置 `.env.example`,skill 首次运行自动落盘到用户数据目录,之后从那里读。** + +## 核心原则 + +1. **三层优先级**:`进程环境变量` > `{数据目录}/.env` > 仓库 `.env.example` 默认值。 +2. **`.env.example` 进仓库**:带注释的全量默认配置,是配置项的"单一事实来源"。 +3. **首次运行自动 copy(bootstrap)**:`scripts/main.py` 启动与 `cli.app.main()` 均会调用 `util.config_bootstrap.bootstrap_skill_config()`,把仓库根目录 `.env.example` 复制到 + `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/.env`(**已存在则不整体覆盖**,保护用户改过的值)。 +4. **技能升级合并缺失项**:`merge_missing_env_keys` 会把 `.env.example` 中新增、但用户 `.env` 尚未包含的 key **追加**进去(带注释块)。 +5. **敏感信息绝不进 `.env`**:密码/密钥/口令走 account-manager 的 `secret-ref`(见下"红线")。 + +## `.env.example` 注释 = 配置管理用户文案(硬规则) + +匠厂宿主「数据管理 → 配置管理」会把 `.env` / `.env.example` 的注释展示给**最终用户**(英文 key 可保留作标题)。因此注释必须按**产品文案**来写,不能按开发备忘录来写。 + +### 行头与行尾分工 + +| 位置 | 职责 | 要求 | +|------|------|------| +| **行头**(`KEY=` 上一行的 `# …`) | **是什么**:中文说明该配置项干什么 | **每个活跃 key 必有**一行完整中文行头 | +| **行尾**(`KEY=值` 同一行 ` #` 之后) | **怎么填**:可选值、单位、推荐默认、何时要改 | 有枚举、开关、单位时**必有**;纯自由文本也应尽量写清填法 | + +宿主当前会把「行头 + 行尾」拼成 key 下方的多行说明。按上表书写后,用户看到的自然是「先懂是什么,再知道怎么填」。 + +**不要**依赖 `# ── 分组 ──` 装饰线给整组做唯一说明:宿主只会把行头挂到**紧挨着的下一个活跃 key**,后面的 key 不会自动继承。分组可用空行区分;**逐 key 自带行头(+ 行尾)才是硬要求**。 + +### 必须做到 + +1. 每个会进入配置管理的活跃 `KEY=` 都有完整中文**行头**。 +2. 枚举 / `0|1` 开关 / 带单位的数值:必须有中文**行尾**,解释每个可选值或单位,并给出推荐。 +3. 业务专属 key 同样适用本规则,不得只留英文 key、不写中文说明。 +4. 开发向细节(四档 adapter 实现、录屏管线、路径解析 API)写在 `development/ADAPTER.md`、`RPA.md`、`DATA_PATHS.md` 等文档;**禁止**写进 `.env.example` 注释。 + +**机器门禁**:`POLICY-CONFIG-004`(`tests/test_development_policy_guard.py`)会在默认测试 / `release.ps1` 中校验上述「行头+行尾中文、禁止开发文档指向」;不通过不得发布。 + +### 禁止出现在注释中的内容 + +- `见 development/...`、`见 ADAPTER.md`、`见 RPA.md`、`见 DATA_PATHS.md` 等仓库文档指向 +- 默认假设读者是开发者的黑话:如单独甩 `adapter 档位`、`CI`、`account-manager`、`CWD`、`workspace` 而不解释 +- 只写裸枚举(如 `mock | real_rpa`)却不说明每个值对用户意味着什么 +- 多个 key 共用一个行头,后面的 key 没有任何自己的说明 + +若必须提到账号体系:写成用户能懂的「请在账号管理中登记密码 / 平台标识须一致」,不要只写内部模块名。 + +### 正例 + +```ini +# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 +OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock + +# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 +OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) +``` + +### 反例(禁止) + +```ini +# ── 运行模式 / adapter 档位(见 development/ADAPTER.md)── +OPENCLAW_TEST_TARGET=mock # 见 development/ADAPTER.md;单测用 mock + +# ── 好看视频 / 百度账号(须与 account-manager 平台 key 一致)── +TARGET_PLATFORM=baidu +HAOKAN_VIDEO_SELECTOR=video.art-video +``` + +问题:文档路径与开发黑话;行尾半给用户半给 CI;后续 key 无行头/行尾,用户只看到英文名。 + +## 标准配置项(`.env` 分组) + +每个 skill 的 `.env.example` 至少包含这些通用项(按需增减)。**注释写法须与仓库根目录 `.env.example` 保持同级清晰度**(行头 = 是什么,行尾 = 怎么填): + +```ini +# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行 +OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock + +# 目标网站地址:技能要访问的网站或服务地址 +TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可 + +# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名) +DEFAULT_LOGIN_ID= # 留空=按平台自动选用或登记;有值则按该标识筛选;不要填密码 + +# 默认账号编号:仅填账号管理里的纯数字主键(一般留空) +DEFAULT_ACCOUNT_ID= # 留空=自动;只填纯数字;手机号/用户名请填上方登录账号 + +# 是否显示浏览器窗口:网页自动化时是否弹出可见浏览器 +OPENCLAW_BROWSER_HEADLESS=0 # 0=显示窗口(必须,便于介入与排查);1=后台静默(勿作日常用法) + +# 浏览器兼容配置:仅在项目授权与平台规则允许时使用共享兼容能力 +OPENCLAW_PLAYWRIGHT_STEALTH=1 # 1=使用共享兼容配置,0=关闭;不得用于伪造身份或绕过平台限制 + +# 是否录制操作过程:用于留证、复现问题或向他人演示 +OPENCLAW_RECORD_VIDEO=0 # 0=不录屏(默认),1=录屏并生成带说明的视频文件;需要留证时再改为 1 + +# 失败时是否截图:任务出错时自动保存现场截图,便于排查 +OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败时截图(推荐),0=不截图 + +# 步骤最短等待:两个正常业务动作之间随机停顿的下限 +STEP_DELAY_MIN=1.0 # 单位:秒;一般保持默认,页面不稳定时可略调大 + +# 步骤最长等待:两步操作之间随机停顿的上限 +STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组成 1~3 秒节奏;状态等待须另行判断 + +# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间 +HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300 + +# 浏览器启动超时(可选):限制 launch 最长时间;注释掉或不设=不限制 +# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器时可开启 +``` + +四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。 + +**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_OUTPUT_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。 + +### 数据目录子路径(输入 / 输出) + +用户可见目录:文件浏览只有 **`input/`(上行)** 与 **`output/`(下行)**;另有可选 **`html/`** 供宿主扫描 HTML 报告页(见 `DATA_PATHS.md`)。技能写入的业务文件产出默认落在 `output/`;整页报告落在 `html/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` / `get_html_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 + +- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md) +- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法 +- 可选 env:`SKILL_INPUT_DIR`、`SKILL_OUTPUT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`) + +### 录屏开关(`OPENCLAW_RECORD_VIDEO`) + +- **模板默认 `OPENCLAW_RECORD_VIDEO=0`**,降低本地开发 / 调试成本(不启 ffmpeg、不生成 MP4)。 +- 需要**生产留证、合规记录、客户现场复现或排障**时,可在用户 `.env` 或进程环境变量中设为 `OPENCLAW_RECORD_VIDEO=1`。 +- **默认关闭不代表可以删除录屏接入**:RPA / 长任务代码仍须保留 `RpaVideoSession`、`video.add_step` 与 video artifact 写入 `finish()`(见 `RPA.md` §5.3)。 +- **已落盘的用户 `.env` 不会被模板整体覆盖**;老用户保留原值,技能升级时仅通过 `merge_missing_env_keys` **追加** `.env.example` 中新增的 key。 + +## 🚫 红线:敏感信息不进 `.env` + +`.env` 只放**非敏感运行参数**(模式、地址、开关、超时、账号标识)。密码、密钥、动态口令、token—— + +- 走 **account-manager** 注册凭据,用 `--secret-storage env --secret-ref XXX` 引用; +- 真实值由宿主/用户通过进程环境变量注入,**不落任何文件、不进 git**。 + +`.gitignore` 必须包含: + +```gitignore +.env +*.env.local +``` + +## 配置 bootstrap(模板提供) + +模板在 `scripts/util/config_bootstrap.py` 提供 `bootstrap_skill_config()`: + +```python +from util.config_bootstrap import bootstrap_skill_config + +bootstrap_skill_config() # main.py 与 cli.app.main() 启动时调用 +``` + +内部委托共享 runtime 的 `jiangchang_skill_core.config`: + +```python +config.ensure_env_file(SKILL_SLUG, example_path) # 首次落盘 +config.merge_missing_env_keys(example_path, env_path) # 升级后追加缺失 key +``` + +业务代码**只通过 `config.get*` 读配置**,不直接 `os.environ`: + +```python +config.get("TARGET_BASE_URL") +config.get_bool("OPENCLAW_BROWSER_HEADLESS") +config.get_float("STEP_DELAY_MIN", 1.0) +``` + +### 空值、行尾注释与假默认(硬规则) + +- **留空即未配置**:`KEY=` 或 `KEY= # 说明` 必须读成空字符串,再走技能自己的「自动 / ensure」逻辑;**禁止**把示例说明、`# 可填…` 当成真实配置值。 +- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明不低于该版本。 +- **需要 platform-kit ≥ 1.2.11(本模板默认)**:RPA 有界就绪等待 `poll_until`、可选 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`、health 中只读 `machine_power`。新技能直接声明 `"1.2.11"`。 +- **网页账号配置语义**(有则用): + - `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;校验指定记录后仍必须取得该账号租约,不能只 `account get` + - `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`;非数字 `DEFAULT_ACCOUNT_ID` 可兼容为 login_id,但不推荐 + - 占位串(`#…`、`none`、`n/a`、`default-account`)一律当未配置 +- **用户隔离必须一致**:调用 account-manager 时继承宿主注入的 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`;不得覆盖为开发者自己的固定路径,否则账号会写到另一位用户的数据目录。 +- **不要**在 `.env.example` 里塞「看起来像真值」的演示账号(如假手机号),以免首次落盘后被当成用户配置。 + +## health / config-path + +- **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok`、`ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`。 +- **`config-path`**:输出 JSON,包含 `skill`、`env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径),便于排查落盘位置;业务 skill 建议附加 `list_resolved_data_paths()` 中的子目录路径(见 [`DATA_PATHS.md`](DATA_PATHS.md))。 + +## doctor / setup 命令(可选) + +每个 skill 可提供自检命令: + +- `python scripts/main.py doctor`:检查浏览器、Profile/账号、`.env` 落盘、设备等。 +- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`。 + +## 相关文档 + +- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释) +- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收 +- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释) +- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定 +- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范 diff --git a/development/DATA_PATHS.md b/development/DATA_PATHS.md new file mode 100644 index 0000000..88a28b0 --- /dev/null +++ b/development/DATA_PATHS.md @@ -0,0 +1,193 @@ +# 用户数据目录与文件路径黄金标准 + +> 本文是 skill **读写本地文件** 的权威约定。凡涉及输入、输出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。 + +--- + +## 1. 根目录 + +所有技能持久化数据(配置、数据库、业务文件)默认落在: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/ +``` + +示例: + +```text +D:\jiangchang-data\12500\download-video-baidu-haokan\ +``` + +实现:`util.runtime_paths.get_skill_data_dir()`。 + +**禁止**把技能自己写入的文件落到: + +- Agent / 宿主 **workspace**(进程 CWD) +- 技能安装目录(含 `.openclaw/skills/...`) +- 未在本文声明的任意相对 CWD 路径 + +--- + +## 2. 标准子目录树 + +用户可见 · **文件目录**(数据管理「输入 / 输出」文件浏览器只挂这两个): + +| 子目录 | 方向 | 用途 | 默认 helper | +|--------|------|------|-------------| +| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` | +| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` | + +用户可见 · **HTML 报告页**(宿主扫描挂到技能下,**不是** input/output 文件浏览器): + +| 子目录 | 用途 | 默认 helper | +|--------|------|-------------| +| `html/` | 技能写出的静态 `.html` 报告;宿主一层扫描 `*.html`,侧栏预览 / 右键重新生成 | `get_html_dir()` | + +内部 / 可选目录(**不上侧栏**): + +| 子目录 | 用途 | 默认 helper | +|--------|------|-------------| +| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` | +| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` | +| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` | +| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` | +| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` | +| `{skill_slug}.db` | SQLite | `get_db_path()` | +| `.env` | 用户配置 | `config.get_env_file_path()` | + +复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。 + +**开发者硬规则:** + +- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`) +- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`) +- 可浏览的 **HTML 报告页** → `html/`(`get_html_dir()` / `service.html_page_service`);**不要**把整页报告塞进 `output/` 指望侧栏预览 +- 禁止再引入业务「文件浏览器」第三目录(如旧的 `downloads` / `imports` / `exports` 三分法);`html/` 是报告页扫描面,不是第三种文件浏览器 +- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」 + +宿主约定(与本模板对齐): + +- 工具栏「导入」**UI 一律**打开宿主「导入数据」面板(默认从本技能 `input/` 选文件);「导出」默认写到 `output/` +- **Action 表单**里 `display_type`/`format` 为 `file` 或 `directory` 时:宿主原生对话框默认打开**当前技能** `{skill_data}/input`;若字段声明可选 **`pathRoot`**(相对技能数据根,如 `input/库存数据`),则打开该子目录。禁止在技能里写用户 ID 或绝对盘符。契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「本地路径选择与 pathRoot」 +- 若表声明了 `replaces: import`,确认导入后由技能 CLI 写库(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「`replaces`」);仅有 `replaces: create` 时宿主可按行调 create(兼容) +- 数据管理扫描 `{skill_data}/html/*.html`(一层、非递归);`` → 侧栏显示名;「重新生成」调用 Action id **`render-html`**(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」) + +--- + +## 3. 两类路径 + +### A. 技能拥有(skill-owned) + +技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。 + +- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。 +- 用户可拿走的业务产出 → **`output/`**。 +- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 + +### B. 用户显式输入(user-owned) + +用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。 + +- 技能**只读**,路径可以是用户磁盘任意位置。 +- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/input/<相对路径>`,**不**相对 workspace/CWD。 + +--- + +## 4. 配置项(`.env`) + +可选覆盖项(**留空则用默认子目录**): + +```ini +# ── 数据目录子路径(见 development/DATA_PATHS.md)── +# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace +# SKILL_INPUT_DIR=input +# SKILL_OUTPUT_DIR=output/videos +# SKILL_UPLOAD_DIR=uploads +# SKILL_CACHE_DIR=cache +# SKILL_TEMP_DIR=temp +``` + +| 规则 | 说明 | +|------|------| +| 未配置 | `{skill_data_dir}/{default_subdir}` | +| 相对值 `output/videos` | `{skill_data_dir}/output/videos` | +| 绝对值 `D:\nas\output` | 高级用户/NAS 挂载,原样使用 | +| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 | + +业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。 + +--- + +## 5. 代码用法 + +```python +from util.runtime_paths import ( + get_input_dir, + get_output_dir, + resolve_data_path, + resolve_input_path, + list_resolved_data_paths, +) + +# 下行产出(A 类)——视频、报表等 +dest = os.path.join(get_output_dir(), f"{vid}.mp4") + +# 或带 env 覆盖 +out_dir = resolve_data_path("SKILL_OUTPUT_DIR", "output") + +# CLI -i urls.txt(B 类,相对路径 → input/) +manifest = resolve_input_path(args.input_path) + +# health 诊断 +paths = list_resolved_data_paths() +``` + +**反模式(禁止):** + +```python +# ❌ 相对 CWD,Agent 从 workspace 执行时会写错盘 +os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR")) + +# ❌ .env.example 里写 ./outputs/videos +DOWNLOAD_OUTPUT_DIR=./outputs/videos + +# ❌ 再拆 downloads / imports / exports 三个用户目录 +``` + +--- + +## 6. health / config-path + +`health` 或 `config-path` 建议输出 `list_resolved_data_paths()` 中的路径,便于排查「文件写到哪里去了」。展示路径须与 `run` 实际使用的一致。 + +--- + +## 7. 与 RPA 存证的关系 + +- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1) +- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3) + +业务 `output/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。 + +--- + +## 8. 测试 + +- 单元测试须用 `IsolatedDataRoot`,断言路径落在临时数据根下。 +- `tests/test_runtime_paths.py` 覆盖 `resolve_data_path` / `resolve_input_path`。 +- `POLICY-DATA-PATH-001`:禁止 `.env.example` 中 `./` 路径;禁止业务代码 `abspath(config.get(...))`。 + +### 卸载技能与数据保留 + +宿主**卸载技能只删除安装目录**(如 `.openclaw/skills/{slug}`),**不会**删除 `{JIANGCHANG_DATA_ROOT}/{user_id}/{skill_slug}/` 下的 SQLite、输入输出与日志。重装会复用同一用户库;schema 迁移须在 `init_db()` 中幂等完成(见 [`../references/SCHEMA.md`](../references/SCHEMA.md)「表增删改与退役」)。 + +--- + +## 9. 相关文档 + +| 文档 | 内容 | +|------|------| +| [`RUNTIME.md`](RUNTIME.md) | 数据根、共享 runtime、编码 | +| [`CONFIG.md`](CONFIG.md) | `.env` bootstrap 与三层优先级 | +| [`RPA.md`](RPA.md) | rpa-artifacts / videos 存证 | +| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | POLICY-DATA-PATH-001 | diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md new file mode 100644 index 0000000..f6bfd09 --- /dev/null +++ b/development/DEVELOPMENT.md @@ -0,0 +1,1046 @@ +# 技能开发教程 + +这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到**业务技能仓库**后,可以**一步一步开发出一个新的 skill**。 + +本文默认你开发的是当前最常见的一类业务 skill: + +- 有明确的 `scripts/main.py` CLI 入口 +- 可能需要读写本地 SQLite(业务表按需创建,不是默认必有) +- 可能需要调用兄弟技能或外部 HTTP / RPA +- 业务逻辑主要放在 `scripts/service/` + +发布、对账、代发、报表分析等场景都只是业务特例;模板提供通用骨架,复制后按领域补齐实现即可。 + +## 推荐 AI 开发工具 + +建议用 AI 编程工具辅助搭目录、补样板、写测试与排错。团队宜统一 1~2 个主力,避免协作习惯发散。 + +| 场景 | 建议 | +|------|------| +| 一体化 IDE | Cursor 或 Windsurf | +| 终端深度代理 | Claude Code 或 Aider | +| 国内生态 | Trae / 通义灵码 | +| VS Code 插件 | GitHub Copilot 或 Cline | + +官方下载与文档以各产品站点为准;技能开发步骤以本文与同目录规范为准,不依赖某一家工具的专有流程。 + +开发中常用的**公开公共仓**(skill-template / account-manager / jiangchang-platform-kit 等,可 `git clone` 学习,目录可扩展)见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。 + +## 1. 先理解模板的定位 + +`skill-template` 不是业务 skill,它只是一个**新 skill 仓库模板**。 + +开发任何外部系统技能前,**先读 [`AI_RPA.md`](AI_RPA.md)**。本模板的统一定位是:Agent 负责理解、确认与编排,RPA 负责通过正常界面确定性执行;只做授权范围内的正规业务自动化,禁止破解、逆向、私有协议、验证码绕过或其他对抗第三方安全机制的实现。 + +你不应该直接在这个仓库里开发业务。新技能仓库有**两种合法来源**(详见 §4「第一步」): + +| 来源 | 适用情况 | 怎么做 | +|------|----------|--------| +| **A. Gitea 克隆** | 项目经理已在自建 Gitea([https://git.jc2009.com/](https://git.jc2009.com/),**不是** Gitea 官网)开好业务仓并给你权限 | `git clone` 到本地后按 [`REQUIREMENTS.md`](REQUIREMENTS.md) 开发 | +| **B. 本地模板复制** | 你本机已有 `skill-template` 源码,要新建尚未灌仓的技能目录 | **优先** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(见 [`tools/README.md`](../tools/README.md));也可手工复制但必须清掉模板 `.git` | + +拿到仓库后的推荐顺序: + +0. 按 [`NAMING.md`](NAMING.md) 确定 / 核对 slug(`{verb}-{noun-phrase}-{platform}`) +1. **先填写**本仓 [`REQUIREMENTS.md`](REQUIREMENTS.md)(范围与验收),再写业务代码 +2. 来源 B:在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`) +3. 把占位内容替换掉;**尽早**配置 `developer_ids`(见 §6,开发期宿主自测必做) +4. 再实现 `scripts/service/` 等业务逻辑 +5. 本地测试通过 → `release.ps1` → Gitea CI → 匠厂多入口验收(§15) + +> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git`;`git remote -v` 必须指向新技能仓库,不能仍是 skill-template。 + +## 2. 新 skill 的标准目录结构 + +复制模板后,你应该保留下面这套结构: + +```text +your-skill/ +├─ .github/ +├─ assets/ +├─ development/ +├─ evals/ +├─ references/ +├─ scripts/ +│ ├─ cli/ +│ ├─ db/ +│ ├─ service/ +│ └─ util/ +├─ tests/ +├─ .gitignore +├─ README.md +├─ TUTORIAL.md +├─ DEMO.md +├─ CHANGELOG.md +├─ requirements.txt +├─ release.ps1 +└─ SKILL.md +``` + +各目录职责如下: + +- `assets/`:放示例输出、schema、静态说明资源 +- `README.md`:市场 **说明** Tab(`readme_md`) +- `TUTORIAL.md`:市场 **教程** Tab(`tutorial_md`) +- `DEMO.md`:市场 **演示** Tab(`demo_video_url` + `demo_md`) +- `CHANGELOG.md`:市场 **更新日志** Tab(按版本小节同步 `changelog`) +- `SKILL.md`:面向 LLM / 平台的技能入口 +- `references/`:Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA) +- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范 +- `scripts/`:放真正的代码 +- `tests/`:放自动化测试 +- `evals/`:放人工验收材料、评估清单、示例场景 + +## 3. `scripts/` 内部如何分层 + +`scripts/` 不是乱放 Python 文件,而是按职责分层: + +```text +scripts/ +├─ main.py +├─ cli/ +├─ db/ +├─ service/ +└─ util/ +``` + +每层的职责要明确: + +- `main.py` + 作用:唯一 CLI 入口 + 只做启动、编码兼容、引入 `cli.app` + +- `cli/` + 作用:参数解析、命令分发、用法说明 + 不写具体业务逻辑 + +- `db/` + 作用:SQLite 连接、建表、repository + 不写页面操作和业务编排 + +- `service/` + 作用:核心业务逻辑 + 比如任务编排、调用兄弟技能、外部 API、可选浏览器自动化 + **负责业务关键节点日志**(`task_start` / 外部调用 / `task_failed` 等,见 [`LOGGING.md`](LOGGING.md)) + +- `util/` + 作用:常量、日志、路径、时间工具、通用帮助函数 + `util/logging_config.py` 是 `jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger + +公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP)从共享 runtime 的 `jiangchang-platform-kit>=1.2.11` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。 + +## 3.2 开发 RPA 类 skill + +若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地: + +1. **先读总纲与四份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`ACCOUNT_MANAGER.md`(账号 / Profile / 租约 / 多账号 / 查看排障)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。 + - 写任何界面操作代码前再读 `RPA_ELEMENT_LOCATING.md`,先完成真实拾取、element map、唯一性和后置状态。 + - 浏览器读 `RPA_BROWSER.md`;Windows 桌面读 `RPA_DESKTOP.md`;Android/iOS 读 `RPA_MOBILE.md`。 +2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map): + - 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`: + - `examples/real_browser_rpa/scripts/service/browser_session.py` + - `examples/real_browser_rpa/scripts/service/human_verification.py` + - `examples/real_browser_rpa/scripts/service/task_rpa.py` + - `examples/real_browser_rpa/scripts/service/account_client.py` + - 若是 **仿真浏览器 RPA**(自有 sandbox、表单批量提交、可控 DOM),**必须先读** `examples/simulator_browser_rpa/README.md`,再按需复制到 `scripts/service/`: + - `examples/simulator_browser_rpa/scripts/service/browser_session.py`(async) + - `examples/simulator_browser_rpa/scripts/service/account_client.py`(subprocess,对齐 `real_browser_rpa`) + - `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py`(RPA 主流程,复制后改名为 `<platform>_playwright.py`) + - `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock) + - `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排) + - `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill + - **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess + - **网页硬规则**:未拿到非空 `profile_dir + lease_token` 禁止开浏览器;一账号一 Profile;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」 +3. **只用共享库,不在 skill 里重写反反爬**: + ```python + from jiangchang_skill_core import config + from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass + ``` + 上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。 +4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。 +5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 全链路验证。先读 `examples/real_desktop_rpa/README.md` 与 `development/RPA_DESKTOP.md`(含 Name/Value、最大化激活、同名叶子父容器),复用窗口唯一性、UIA 作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。 +6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。 + +**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。 + +--- + +### 3.1 `requirements.txt` 依赖规范 + +技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。 + +- **公共依赖**(`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。 +- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。 +- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。 +- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。 +- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法: + - `pkg>=1.2.0,<2` +- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限: + - 不推荐:`chromadb>=0.5.0` + - 推荐:`chromadb>=0.5.23,<0.6` +- **不要**把系统组件(VC++ Runtime、浏览器安装包等)写进 `requirements.txt`;这类前置条件写在 `health` / preflight 文档与错误提示中。 +- 若技能无额外 Python 依赖,可保留空文件或仅含注释说明;**不要**为 platform-kit 或 playwright 保留占位行。 +- 独立本地开发环境若缺少公共包,可手动安装;**生产/宿主运行**由共享 runtime 负责。 + +### 3.3 公共能力不要复制 + +以下模块由 platform-kit 提供,技能内**不要**重复实现或 vendored 拷贝: + +| 能力 | 导入方式 | +|------|----------| +| config | `from jiangchang_skill_core import config` | +| logging | `from jiangchang_skill_core.unified_logging import ...` 或 `util.logging_config` 薄封装 | +| runtime_env | `from jiangchang_skill_core.runtime_env import ...` 或 `util.runtime_paths` 薄封装 | +| rpa | `from jiangchang_skill_core.rpa import ...` | +| media_assets | `from jiangchang_skill_core.media_assets import ...` | +| video_session | `from jiangchang_skill_core.rpa.video_session import RpaVideoSession` | +| runtime_diagnostics | `from jiangchang_skill_core import collect_runtime_diagnostics, format_runtime_health_lines` | + +`health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`。 + +### 3.4 发布打包约束(PyArmor 与编码) + +release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前发布链路可能运行在 **PyArmor 免费/试用模式**,因此有两条**发布硬约束**: + +**单文件行数(仅 `scripts/**/*.py`)** + +- 每个 `scripts/` 下 Python 文件必须 **小于 1000 行**(`>= 1000` 可能在 CI 加密阶段失败)。 +- 推荐单文件控制在 **900 行以内**;接近上限时应拆分模块。 +- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。 +- 正确做法是拆分到 `scripts/cli/`、`scripts/service/`、`scripts/db/`、`scripts/util/`。 + +`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。 + +**文本编码(UTF-8 without BOM)** + +- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**。 +- Python 文件**绝对不能**带 UTF-8 BOM;否则 PyArmor/CI 可能报:`invalid non-printable character U+FEFF`。 +- Windows 编辑器容易保存成 UTF-8 BOM,提交前须确认文件开头无 `U+FEFF`。 +- 不要只写「UTF-8」,必须明确 **UTF-8 without BOM**。 + +本地可用 `python tests/run_tests.py release_packaging_constraints -v` 自检;默认必跑套件中的 `test_release_packaging_constraints.py` 会守护上述规则。 + +## 4. 开发一个新 skill 的标准步骤 + +下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 + +### 第一步:拿到新技能仓库(两种来源) + +例如你要开发 `disburse-payroll-icbc` 一类领域 skill(目录名 = slug)。下列两种来源**同等合法**,按你实际拿到仓库的方式选一条。 + +#### 来源 A:从 Gitea 克隆(项目经理已开仓) + +1. 确认项目经理已在自建 Gitea([https://git.jc2009.com/](https://git.jc2009.com/),公司基于开源 Gitea 自建的代码仓库,**不要**访问 Gitea 官网当作业务仓地址)创建**本技能**仓库,并给你拉取/推送权限。 +2. 克隆到本地(示例): + +```powershell +git clone https://git.jc2009.com/<org>/<your-skill-slug>.git +cd <your-skill-slug> +git remote -v # 确认 origin 指向本技能仓,不是 skill-template +``` + +3. 若仓内已是从本模板 scaffold 好的结构,直接进入「第二步」与 [`REQUIREMENTS.md`](REQUIREMENTS.md);**不要**再整仓复制 `skill-template` 覆盖,以免冲掉已有提交或串 Git 历史。 + +#### 来源 B:本地已有 skill-template 时复制 / scaffold + +在 **skill-template 仓库根目录**执行(推荐): + +```powershell +.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc +cd D:\OpenClaw\client-gdcm\disburse-payroll-icbc +git init +git remote add origin <你的新技能 Gitea/Git 仓库 URL> +git remote -v # 确认 origin 不是 skill-template +``` + +跨平台可用 `python tools/scaffold_skill.py --slug ... --destination ...`(见 [`tools/README.md`](../tools/README.md))。 + +脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。 + +##### 禁止方式(来源 B) + +| 做法 | 后果 | +|------|------| +| ❌ 资源管理器整文件夹复制后不做 Git 清理 | 隐藏 `.git` 被带走,push 串到 template | +| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template | +| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 | + +##### 若已手工复制(补救) + +```powershell +cd <新技能目录> +Remove-Item -Recurse -Force .git +Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue +Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue +git init +git remote add origin <新技能仓库 URL> +git remote -v +``` + +##### AI / 编程代理复制红线 + +| 禁止 | 说明 | +|------|------| +| 保留模板 `.git` | 必须先 `Remove-Item -Recurse -Force .git` 再 `git init` | +| `origin` 仍指向 skill-template | 复制后必须 `git remote -v` 自检 | +| 保留 `.openclaw-skill-template` | 仅 template 源仓库可有;业务技能不得保留 | +| 复制 `.pytest_cache` / `__pycache__` | 用 scaffold 已排除;手工复制应删 | + +目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。 + +### 第二步:先改关键标识与占位 + +拿到仓库并填好 / 更新 [`REQUIREMENTS.md`](REQUIREMENTS.md) 后,优先改下面这些地方: + +1. `SKILL.md` +2. 根目录市场四 Tab:`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md` +3. `scripts/util/constants.py` +4. `references/` 与 `development/` 下的文案 +5. `scripts/service/` 下的业务占位实现(优先改 `task_service.py`) + +最先要统一的是: + +- 技能中文名 +- `slug` +- 平台中文名 +- 平台内部键 +- 日志 logger 名 + +**开发 / 联调阶段务必尽早**配置 `SKILL.md` 中的 `metadata.openclaw.developer_ids`(完整目的与取 ID 步骤见 §6「关于 developer_ids」)。开发期技能在匠厂常为不公开;不加本人用户 ID,**技术人员自己也无法在技能市场看到并安装自测**。 + +## 5. 哪些占位内容必须替换 + +复制后,至少要全局检查并替换下面这些内容: + +- `your-skill-slug` +- `your-platform-key`(若 skill 涉及外部平台对接,可能会用到这类占位) +- `技能开发模板(通用业务版)` +- `你的平台名` +- `openclaw.skill.your_skill_slug` + +如果你的 skill 对外 CLI 需要自定义文案或别名(例如发布类对外仍叫 `publish`),通常还要替换: + +- `run` 命令中的中文提示与别名策略(见 `../references/CLI.md`) +- `../references/CLI.md` 的命令示例 +- `../README.md` 的用户话术 +- `../references/SCHEMA.md` 的字段映射补充说明 + +如需浏览器自动化,不要在模板里保留空的 `platform_playwright.py`;请按业务新建 `xxx_playwright.py`(命名自定),并在 `task_service.py` 中按需引用。 + +## 6. `SKILL.md` 与市场四 Tab 材料应该怎么写 + +### `SKILL.md`(LLM / 平台入口) + +`SKILL.md` 是给 LLM 与 OpenClaw 平台看的技能入口,**不是**用户市场详情页。 + +应该重点写: + +- YAML frontmatter:`name`、`description`、`version`、`metadata.openclaw`(含 `slug`、`platform_kit_min_version`、`developer_ids` 等) +- 何时触发本技能、CLI 入口摘要、运行契约要点 +- 指向 `references/`(Agent 调用)与 `development/`(开发规范)的分工说明 + +不要在 `SKILL.md` 里写大量用户市场文案或实现细节;**不要**把演示视频 URL 写进 `SKILL.md`(放 `DEMO.md` frontmatter)。 + +### 市场四 Tab(根目录,面向普通用户) + +发布 CI 会把下列文件同步到技能市场详情 Tabs(有文件才更新对应字段): + +| 文件 | 市场 Tab | 同步字段 | +|------|----------|----------| +| `README.md` | 说明 | `readme_md`(frontmatter `description` 作列表简介) | +| `TUTORIAL.md` | 教程 | `tutorial_md` | +| `DEMO.md` | 演示 | `demo_md` + frontmatter `demo_video_url` | +| `CHANGELOG.md` | 更新日志 | 匹配当前 tag 版本的 `##` 小节 → `changelog` | + +**`README.md`(说明 Tab)**:能力、场景、前置条件、结果形态、注意事项、概念类 FAQ。含 frontmatter `description`(**不要**写「如何开始使用」)。**禁止**分步操作、Agent 话术块、「入口一/二」专章、逐步排障——这些属教程。**禁止**文首再写 `# 技能名`(页头已有);正文主分节用 `##`。完整契约见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) §0.1 / §1。 + +**`TUTORIAL.md`(教程 Tab)**:按 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) 写三入口结构(数据管理 ∥ 对话;定时有则教、无则 N/A)+ 主能力对照表 + 常见卡住。步骤必须与 `REQUIREMENTS` §0.1、`actions.json` 及宿主真实 UI 对齐;**禁止**把仅 `skill-detail` 的 Action 写成用户主路径。**禁止**文首 `# 技能名 · 使用教程`;正文最高用 `##`。不要写 CLI / 目录 / 开发规范。 + +**`DEMO.md`**:演示视频与补充说明。frontmatter 必须含 `demo_video_url`(无视频时用 `""`);正文可选。 + +**`CHANGELOG.md`**:每个正式版本一节(如 `## 1.0.46`)。打 tag 前为即将发布的版本新增小节;用户可读的变化,少写内部实现。 + +实现细节放在: + +- `development/` +- `references/`(CLI / SCHEMA) +- 代码注释与 `service/` 实现 + +### 关于 `metadata.openclaw.developer_ids`(开发自测必做) + +#### 目的(先理解再填) + +开发 / 测试阶段,技能 `release` 到匠厂后,平台侧**默认不公开**(`access_scope = 0`): + +- 技能市场里对普通人**不可见** +- **若不额外授权,连开发该技能的技术人员自己也看不见、装不了** +- 看不见 → 无法安装 → 无法在宿主做 §15 的多入口验收(新建任务、数据管理、定时任务等) + +因此 `developer_ids` 不是可有可无的装饰字段,而是**开发期自测通行证**:把本人(及需要一起验收的同事)的匠厂**用户 ID** 写进 `SKILL.md`,发布时平台将这些用户补进可见 / 可访问范围,使开发者能在「对其他人仍不可见」的前提下完成安装与测试。 + +#### 如何获取匠厂用户 ID(完整步骤) + +1. **下载并安装**匠厂客户端:[https://jc2009.com/product.html](https://jc2009.com/product.html) +2. **注册并登录**(使用将用于开发自测的账号) +3. 打开客户端左下角头像旁的**设置**(齿轮) +4. 在 **用户信息** 中查看 **用户 ID**(正整数),点击旁边的 **复制** +5. 将该 ID 写入本技能 `SKILL.md` 的 `metadata.openclaw.developer_ids`(见下例) +6. **必须替换**模板里的示例 ID(如 `10032`、`12428` 等占位),不要原样留着模板作者的 ID 却指望自己账号能看见技能 + +#### 在 `SKILL.md` 中声明 + +```yaml +metadata: + openclaw: + slug: your-skill-slug + category: 通用 + developer_ids: + - 12345 # 换成「你的用户ID」(匠厂「设置 → 用户信息」复制的正整数) +``` + +约定如下(原有规则保留): + +- 只允许填写正整数用户 ID(来自匠厂宿主,不是 Gitea / Git 账号名) +- 推荐使用数组,即使当前只有 1 个开发者 +- 发布时平台会把这些用户自动补写到 `skill_user_access` +- 第一个 ID 会同步到 `skills.developer_id` +- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权 +- **首次正式 release 前**就应配好;配错或漏配时,CI 可能成功,但你在技能市场仍找不到技能 + +## 7. 文档目录分工 + +当前规范 skill 的文档按读者拆分: + +**根市场材料(四 Tab)** + +- `README.md` — 说明(含 frontmatter `description`) +- `TUTORIAL.md` — 使用教程 +- `DEMO.md` — 演示(`demo_video_url` + 正文) +- `CHANGELOG.md` — 按版本更新日志 + +**`SKILL.md`** + +- LLM / OpenClaw 平台技能入口 + +**`references/`**(Agent 运行/编排/调用时渐进加载) + +- `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description`) +- `CLI.md` — 命令、参数、默认值、兄弟技能调用方式 +- `SCHEMA.md` — 数据库路径、`_jiangchang_*` 数据管理契约、日志表结构 +- `ACTIONS.md` — Skill Action manifest、宿主入口、参数与执行契约 +- 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充 + +**`development/`**(开发者 / AI 编程代理) + +- `REQUIREMENTS.md` — 需求与验收 +- `DEVELOPMENT.md` — 开发教程(本文档) +- `TESTING.md` — 测试分层与隔离(详见 [`TESTING.md`](TESTING.md)) +- `LOGGING.md` — 日志分层、必打节点、敏感信息红线 +- `ADAPTER.md`、`RPA.md`、`CONFIG.md`、`RUNTIME.md` — 技术规范 + +## 8. `assets/` 应该放什么 + +`assets/` 不放业务代码,只放辅助材料。 + +建议放: + +- `actions.json` + 宿主 Action 入口 manifest(可选;见 `references/ACTIONS.md`) + +- `examples/` + 比如 `version` 输出示例 + +- `schemas/` + 比如 `skill-actions.schema.json`、机读 JSON 的 schema + +不要把正式研发文档放到 `assets/`。 +用户市场四 Tab 进根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 编排资料进 `references/`;开发规范进 `development/`。 + +## 9. `cli` 层怎么写 + +建议保持一个原则: + +- `cli` 只负责解析参数和路由 +- `service` 才负责干活 + +也就是说,`cli/app.py` 的职责是: + +1. 打印帮助 +2. 定义 `run / health / version / init-db`(及业务子命令) +3. 把参数转交给 `service.task_service`(或薄适配后再进 domain service) + +不要在 `cli/app.py` 里直接写: + +- 浏览器自动化 +- 子进程调用兄弟技能 +- SQLite 逻辑 +- 与 toolbar / cron / agent 来源相关的业务分叉 + +## 10. `service` 层怎么写 + +`service` 是核心层。推荐拆分(**单业务内核、多入口**): + +```text +cli/app.py → 只解析参数 +service/task_service.py → job_context / emit / checkpoint / finish / 错误转换 +service/<domain>_service.py → 唯一业务内核(同步、处理、统计等) +db/*_repository.py → 事务与读写 +assets/actions.json → placements / bind / executionProfile / entrypoint +``` + +通常还可以有: + +- `sibling_bridge.py` + 放通用兄弟技能子进程工具(`call_sibling_json`);**具体调用哪个 slug 由业务在 `task_service.py` 或 domain service 决定**,不要在本文件硬编码发布类专用 helper + +- `xxx_playwright.py`(按需新建) + 浏览器后台自动化 **不在模板默认结构中**;若确有需要,按命名约定自建模块并由 domain / task_service 引用 + +- `entitlement_service.py` + 放鉴权逻辑 + +禁止:toolbar / cron / Agent / CLI 各自复制一套采集或同步实现;为兼容旧命令再写第二套内核。 + +### 日志要求(`cmd_run` / 核心业务) + +`service` 层是**关键业务日志**的落点,须遵循 [`LOGGING.md`](LOGGING.md): + +- `cmd_run` 及主任务入口必须:`log.info("task_start ...")`;失败路径必须 `log.exception("task_failed ...")`。 +- 外部 API、兄弟技能、RPA 操作须记录**开始 / 结束 / 耗时 / 状态**(`external_call_start` / `external_call_done elapsed_ms=...`)。 +- 长任务 / RPA 须配合 `activity.emit` 或 `RpaVideoSession.add_step` 输出用户可见进度;`cmd_run` 须 `job_context` + 各出口 `finish`(见 [`LOGGING.md`](LOGGING.md) §2.5)。 +- 使用 `from util.logging_config import get_skill_logger`,**不要**用 `print` 替代 logger 做排障日志。 +- **禁止**在日志中写入 password、token、cookie 等敏感明文(见 `LOGGING.md` §敏感信息红线)。 + +### 一个很重要的原则 + +不要把所有逻辑都堆进一个文件。 + +推荐流向是: + +`cli.app` -> `service.task_service` -> `service.<domain>_service` -> `db` + +(旁路:`sibling_bridge` / 可选 `xxx_playwright` 仅由 service 调用) + +## 11. `db` 层怎么写 + +如果你的 skill 需要记录日志或本地状态,建议: + +- `db/connection.py` + 只做连接和建表(模板默认只建 `_jiangchang_*` 元数据表;业务表按需 CREATE) + **业务表之间禁止 SQLite `FOREIGN KEY` / `REFERENCES`**:只存业务 id,存在性 / 级联 / 删除在 repository/service 处理(见 [`references/SCHEMA.md`](../references/SCHEMA.md)「表与表之间:禁止 SQLite 外键」) + +- `db/<domain>_repository.py` + 只做业务表增删查改(有业务表才建;模板不再默认提供任务审计表) +- `db/display_metadata.py` + 创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`) +- `db/display_metadata_validator.py` + 元数据完整性、editable 权限、options_json 等自检 +- `db/timestamp_columns.py` + 标准时间字段 trigger 与维护约定(`created_at` / `updated_at`,Unix 秒级) + +不要在 `db` 层里: + +- 调浏览器 +- 打印用户提示 +- 拼接业务流程 + +## 11.5 Skill Action manifest(按入口定位挂载) + +先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md):对话 / 数据管理 / 定时任务**平级**;哪个主能力出现在哪个入口,由本技能 UX 在 `REQUIREMENTS` 入口定位表中定义。 + +若定位表要求出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json` 并写入对应 `placements`。 + +- **产品入口与何时走哪个**见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) +- **运行时金标准**见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) +- 字段契约见 [`references/ACTIONS.md`](../references/ACTIONS.md)(`placements` / `bind.tables` / `executionProfile` 正交) +- JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) +- 模板最小示例仅暴露 `health` / `version` / `config-path`(均显式 `executionProfile: "sync"`;**不**自动加 toolbar);CLI 另含 `init-db` 供宿主静默建库(不必做成 Action) +- 含 `toolbar` 的 Action:**必须**声明合法 `bind.tables`(`POLICY-SKILL-ACTION-003`) +- 含浏览器 RPA / 长耗时的业务技能:**必须**增加至少一条 `"executionProfile": "async"` 且 `placements` 含 `agent` 的 action(`POLICY-SKILL-ACTION-001`) +- 入口定位表确认无任何宿主结构化入口需求时,**可以删除** `actions.json`;删除后勿保留失效引用 +- scaffold **不会**移除 Schema 中的 `bind` 支持,**不会**生成平台业务 Action 或业务表名;仅替换占位 slug + +宿主执行方式: + +```text +python {skillRoot}/scripts/main.py <command> <args...> +``` + +CLI 必须提供稳定退出码、结构化成功输出,以及 `ERROR:<CODE>:` / `HINT:` 错误契约(详见 `references/ACTIONS.md`)。 + +### 编排范式:单内核 + 多入口 + +长任务 / RPA / 数据同步技能推荐拆分(勿把 `job_context` 塞进纯业务内核): + +```text +<domain>_service.sync_*(...) # 唯一业务内核:读外源 → 写本地库(或核心处理) +cmd_sync_* (...) # 编排:job_context + 调用内核 + emit/finish(async 时) +CLI / Action / cron / Agent # 全部进入同一 cmd_* → 同一内核 +``` + +RPA 批量亦可: + +```text +execute_single_<biz>(...) # 鉴权 + 业务 + task_log;无 job_context +cmd_run(...) # 单条:job_context + execute_single + finish +cmd_run_pick(N, ...) # 批量顺序:job_context + for-loop + emit(progress) + finish +cmd_stats() # 只读 sync:JSON,无 job_context +``` + +- 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。 +- **顺序执行、不并发**同一浏览器 Profile / account lease。 +- **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。 +- **HTML 页面报告**(可选):需要侧栏预览整页时,写入 `html/` + Action `render-html`;契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」(含**宿主预览布局**底线)、[`DATA_PATHS.md`](DATA_PATHS.md);薄写入 helper 见 `scripts/service/html_page_service.py`。建议从 `assets/html/example-report.html` 复制壳;`{{BODY}}` 内数据须转义;样式升级后提醒用户重新生成。 +- 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。 + +### 队列 + pick(可选) + +有「待处理列表」时:队列表 `pending → processing → success/failed`;数据管理录入或 `import-*` 入库;`run --pick N` 消费;失败用 `reset` 回 `pending`(不自动重试)。Manifest 上配置 `*-run-pick`(常见为 async + toolbar/cron/agent;有 toolbar 则须 `bind.tables`)。 + +## 12. 如何接兄弟技能 + +如果 skill 要依赖兄弟 skill,不要在业务代码里写死绝对路径。 + +统一通过: + +- `scripts/util/runtime_paths.py` +- `scripts/service/sibling_bridge.py` + +来做调用。 + +调用哪些兄弟技能由具体业务决定;请在 `task_service.py` 中使用 `service.sibling_bridge.call_sibling_json(skill_slug, args)` 或 `get_sibling_main_path(skill_slug)`,不要在本模板仓库的 `sibling_bridge.py` 中堆积特定技能函数。 + +调用原则: + +1. 通过 `get_skills_root()` 找到兄弟技能根目录 +2. 再拼出对应 `scripts/main.py` +3. 用子进程调用 +4. 机器可读输出优先 JSON + +## 13. 如何开发一个新 skill + +不管你开发的是发布类、采集类、分析类还是知识库类 skill,建议都先按下面这个顺序推进: + +1. 先把目录结构搭完整 +2. 先让 `health` / `version` 跑通 +3. 再把核心 `service` 骨架跑通 +4. 再接兄弟技能桥接、数据库或外部系统 +5. 最后再补浏览器自动化、复杂流程编排或高风险集成 + +不要一开始就直接写页面选择器、复杂接口编排或深层业务逻辑。 + +推荐先确保这些基础能力正常: + +- CLI 入口能跑通 +- 基础命令输出稳定 +- 关键依赖能取到 +- 日志或本地状态能落下来 +- 错误返回值格式定好了 + +如果你的 skill 属于外联型 / RPA 型,可以把上面的“核心 `service`”具体落成 `task_service.py`,再按需接 `sibling_bridge.py`、(可选)`*_playwright.py`。这只是示例路径组合,不代表模板绑定某一业务领域。 + +## 14. 本地开发的最小验证顺序 + +建议每次新 skill 开发时按下面顺序验证: + +### 1. 验证入口 + +本地验证请优先使用宿主共享 Python(与 Agent CLI 相同契约),例如: + +```bash +python scripts/main.py health +python scripts/main.py version +``` + +**禁止**对技能脚本使用 `uv run python`。 + +```bash +python scripts/main.py -h +python scripts/main.py <your-command> -h +``` + +### 2. 验证 CLI 路由 + +```bash +python scripts/main.py -h +python scripts/main.py <your-command> -h +``` + +### 3. 验证本地日志与数据库 + +如果你的 skill 需要本地日志或数据库,再继续: + +```bash +python scripts/main.py <your-read-command> +``` + +执行一次 `run`(或你的主任务命令)后,应确认**统一日志文件**已生成并可检索: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +``` + +其中应能看到 `cli_start`、`task_start` 等带 `trace_id` / `skill_slug` 的行。路径与分层说明见 [`LOGGING.md`](LOGGING.md)。 + +### 4. 最后再验证真实业务 + +比如: + +```bash +python scripts/main.py <your-command> +``` + +## 14.5 测试驱动的开发顺序 + +新 skill 不应该等所有业务做完才补测试。建议在每个开发阶段都先把对应测试跑一遍: + +1. **目录搭建后**:跑 `python tests/run_tests.py -v`,确认全部默认测试套件通过。 + - 此时 `test_skill_metadata.py` 会校验 `SKILL.md` slug 与 `constants.SKILL_SLUG` 一致;如果你只改了一边,会立刻发现。 +2. **改完 constants 后**:再跑一次必跑套件,确认未引入回归。 +3. **写完 service 业务后**:从 `tests/samples/test_service_contract.py.sample` 复制一份做契约测试。 +4. **接外部系统时**:写在 `tests/integration/`,并配合 `OPENCLAW_TEST_TARGET` 显式开启。 + +详见 `TESTING.md`。 + +## 15. 发布到正式环境验证 + +### release workflow 通道 + +`.github/workflows/release_skill.yaml` 默认引用: + +```yaml +uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main +``` + +约定如下: + +- `jiangchang-platform-kit` 的 `main` 分支即稳定通道;kit 的开发、测试、验证须在合并 `main` 前完成。 +- skill **不需要**锁定 workflow 版本(不要改为 `@v1`、`@v1.0.x` 等)。 +- skill 运行时最低能力要求通过 `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version` 声明。 + +在执行 `release.ps1` 之前,必须先在本地确认 `python tests/run_tests.py -v` 通过。测试不通过不得发起正式发布。 + +**POLICY-RELEASE-001(硬门禁)**:发布前必须将 `SKILL.md` 的 `version:`、`scripts/util/constants.py` 的 `SKILL_VERSION`、以及 `CHANGELOG.md` 对应小节,调整为**即将打出的**版本(与 `.\release.ps1 -DryRun` 打印的 `Next tag` 去掉 `v` 前缀一致)。`release.ps1` **不会**自动改这些字段;若三者与 Next tag 不一致,脚本在跑测试/打 tag **之前**直接失败,避免市场「最新版本」(git tag) 与安装包「当前版本」(本地 `SKILL.md`) 长期错位、技能市场一直显示「更新」。 + +建议流程: + +1. `.\release.ps1 -DryRun` 查看 `Next tag`(例如 `v1.0.104`) +2. 将 `SKILL.md` / `SKILL_VERSION` / `CHANGELOG` 改为 `1.0.104` +3. 再执行正式 `.\release.ps1`(可用 `-AutoCommit`) + +`release.ps1` 会在 **auto commit 之前**、推送 tag 之前,对**当前工作区**自动执行默认测试(`python tests/run_tests.py -v`);失败则中止 release,不会留下失败状态的自动提交。仅紧急排障可使用 `-SkipTests` 跳过(会打印黄色警告)。`-DryRun` 预览发布时**仍会实际运行**版本对齐门禁与默认测试,便于在不打 tag 的情况下验证。 + +新增或调整开发规范时,须同步 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 与 [`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py)(或注明由既有测试覆盖)。 + +当本地开发、自测和联调完成后,还需要把 skill 发布到正式环境做一次完整验证。建议技术人员严格按下面顺序执行,不要跳步。 + +### 第一步:在 skill 根目录执行 `release.ps1` + +在当前 skill 仓库根目录执行: + +```powershell +.\release.ps1 +``` + +发布前 **`developer_ids` 应已配置**(见 §6)。本步触发的发布工作流除了同步 `skills` / `skill_versions` 外,还会在平台侧自动补开发者可见权限;发布后应重点确认:用本人账号能在技能市场看到并安装该技能。 + +这一步会自动完成标准发布动作,包括: + +1. 检查当前仓库状态 +2. 自动提交尚未提交的改动 +3. 推送最新代码到远程仓库 +4. 自动创建新的语义化版本 tag +5. 推送 tag,触发后续 CI 流程 + +如果你只是想先预览发布动作,可以先执行: + +```powershell +.\release.ps1 -DryRun +``` + +### 第二步:到 Gitea 仓库查看工作流 + +发布命令执行完成后,远程仓库会自动触发 Gitea 工作流。此时应立即进入对应 skill 的仓库页面,切换到“工作流”页签查看执行状态。 + +重点确认: + +- 是否已经出现最新一次发布记录 +- 是否由最新提交触发 +- 是否成功执行完 `release_skill.yaml` + +下面这张图演示了在仓库页进入“工作流”的位置: + +![Gitea 仓库工作流入口](../assets/screenshots/gitea-workflow-tab.png) + +下面这张图演示了工作流成功后的状态页面: + +![Gitea 工作流成功示例](../assets/screenshots/gitea-workflow-success.png) + +### 第三步:确认工作流执行成功 + +只有当工作流状态为成功,才说明正式发布产物已经正确构建完成。 + +如果工作流失败,不要继续做平台验证,而应该先回到代码仓库排查,例如: + +- 发布脚本是否正常推送 +- `SKILL.md`、根市场四 Tab(`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`)、`scripts/`、`references/`、`development/` 是否齐全 +- 工作流文件是否存在 +- 发布包结构是否符合模板规范 + +### 第四步:进入匠厂客户端(用于安装验收) + +当工作流成功后,就可以进入匠厂客户端验证最终安装效果。 + +若你已按 §6 为 `developer_ids` 安装并登录过匠厂,**直接使用同一客户端、同一账号**即可,无需重复下载。若尚未安装: + +匠厂产品下载地址: + +- [https://jc2009.com/product.html](https://jc2009.com/product.html) + +打开页面后,下载并安装匠厂客户端。下面这张图演示了下载入口位置: + +![匠厂客户端下载入口](../assets/screenshots/jc2009-download-page.png) + +匠厂产品页可从这里进入:[产品下载 - 匠厂](https://jc2009.com/product.html) + +> 取用户 ID 写入 `developer_ids` 的完整步骤见 §6;此处侧重发布后的安装验收。 + +### 第五步:在技能市场检查最新 skill + +使用已写入 `developer_ids` 的账号登录并启动匠厂后,进入左侧“技能市场”,搜索或查找刚刚发布的 skill,确认以下内容: + +- 技能可以被正常检索到(开发期不公开时,**仅** `developer_ids` 内账号可见) +- 技能名称、说明、版本信息正确 +- 最新版本已经同步出来 +- 可以正常安装或更新 + +下面这张图演示了在技能市场中查看已发布 skill 的位置: + +![技能市场中的已发布技能](../assets/screenshots/market-installed-skill.png) + +### 第六步:安装并实际启用该 skill + +在技能市场中找到目标 skill 后,执行安装或更新操作,确保客户端本地已经拿到最新版本。 + +这一步建议至少确认: + +- 安装按钮可以正常执行 +- 安装后状态正常 +- 不会出现缺文件、缺入口或安装失败的问题 + +### 第七步:按入口定位与声明做宿主多入口验收(须看用户体验) + +安装完成后,不要只停留在“已安装”状态,也**不要**只在本地 mock 通就认为完成。对话任务 / 数据管理 / 定时任务在宿主上**平级**(见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md))。先核对本技能 `REQUIREMENTS` **入口定位表**,再按 `assets/actions.json` 的 `placements` / `executionProfile` 对「定位为挂」的入口逐项自测(字段契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。 + +- 定位为「挂」却未声明或测不到 → **验收失败** +- 定位为「不挂」且写了理由 → 可跳过该入口 +- **禁止**从未做入口定位、却用「未声明所以跳过」交差 +- 不要只测对话、忽略定位表中已挂的其它入口 + +**用户体验(与功能入口并列,后台通了不算完):** + +- 失败时用户能否看懂原因(对话 / 任务中心 / 提示),不是只有技术日志 +- 长任务(async)是否有可见进度,能否取消或等待说明 +- 网页 RPA 是否有头运行;需登录/验证码时是否停下等人,而不是静默失败 +- 数据管理:表名/字段中文、按钮文案是否清楚;结果是否写回用户看得见的地方 +- 市场说明 / 教程是否与真实行为一致;**说明 Tab 不含分步操作**,教程覆盖已挂入口(见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)) + +| 宿主入口(侧栏 / 界面) | 技能侧如何挂上 | 建议验收什么 | +|-------------------------|----------------|--------------| +| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 | +| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 | +| **数据管理 · HTML 报告** | 写出 `html/*.html`;提供 Action id `render-html` | 侧栏出现报告项;预览正常;右键「重新生成」成功且时间/内容更新 | +| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 | +| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 | +| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 | +| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 | +| **技能市场 → 技能详情** | `placements` 含 `skill-detail`(详情直调) | **必验**安装与四 Tab。详情页「技能直调」按钮随宿主版本上线;**已声明则须在具备该 UI 的宿主上完成按钮自测**(与其它入口同等对待,不要只靠 Agent) | + +补充说明: + +- **进不进任务中心只看 `executionProfile`**,与从哪个入口触发无关(既有正交规则不变)。 +- 数据管理还依赖库表元数据(`_jiangchang_*` 等,见 [`../references/SCHEMA.md`](../references/SCHEMA.md));仅有 Action、没有可展示库表时,侧栏可能看不到表。 +- `row` / `batch` / `toolbar` / `cron` / `agent` / `skill-detail` 均以**当前匠厂宿主已实现能力**为准;字段与示例见 [`../references/ACTIONS.md`](../references/ACTIONS.md)。 + +**新建任务(对话)最小检查:** + +- 新任务中可以正常选择或触发该 skill +- skill 能被正确唤起 +- 主要命令或主流程可以运行 +- 返回结果、日志和行为符合预期 + +下面这张图演示了安装后在任务界面中实际使用 skill 的场景: + +![新建任务中使用技能](../assets/screenshots/new-task-usage.png) + +若第五步在技能市场**搜不到**本技能:先核对登录账号的用户 ID 是否已写入 `developer_ids` 并随本次 release 发布(见 §6);不要只反复重装客户端。 + +## 16. 发布前检查清单 + +每个新 skill 发布前,建议技术人员逐条确认: + +- [ ] 目录结构符合当前模板 +- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform) +- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致 +- [ ] `SKILL.md` 中 slug、名称、描述都已替换 +- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID(设置 → 用户信息 → 复制),不是模板示例 ID +- [ ] `scripts/util/constants.py` 已修改 +- [ ] `../references/CLI.md` 示例命令已改成真实命令 +- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现 +- [ ] 没有残留旧平台名 +- [ ] `health` / `version` 可运行 +- [ ] `.env.example` 注释已按 [`CONFIG.md`](CONFIG.md)「行头 = 是什么、行尾 = 怎么填」写全(面向配置管理用户;无 `见 development/...` 等开发文档指向) +- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去 +- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag) +- [ ] `.github/workflows/release_skill.yaml` 存在 +- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`) +- [ ] 主任务入口有关键日志事件词:`task_start`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3) +- [ ] RPA / 域模块(`scripts/service/<domain>/` 等)非零文件日志;`print(ERROR:)` 旁有 logger(`POLICY-LOGGING-006`~`008`) +- [ ] 本地跑一次主任务后,统一日志能 grep 到 §3.4 最低可验收串(不只 `cli_start`) +- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md)) +- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测 +- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行) +- [ ] 源码/文档/配置为 **UTF-8 without BOM**(Python 文件不得含 `U+FEFF`) +- [ ] 没有新增默认必跑测试访问真实网络或浏览器 +- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀 +- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`) +- [ ] `git remote -v` 指向**本技能**远端,URL 不含 skill-template 仓库名 +- [ ] `git log` 首条提交属于本技能(非模板历史)(来源 A 从 Gitea 克隆的已有业务仓,以该仓历史为准) +- [ ] 业务 `.env` / `.env.example` 默认 `OPENCLAW_TEST_TARGET=real_rpa`(单测/CI 仍用 mock);**未只靠 mock 交活** +- [ ] 发布后计划在宿主按 §15 第七步验收:已声明入口 + **用户体验**(进度、失败提示、有头 RPA、文案一致等) +- [ ] 网页 RPA:`ensure-web` 取得 `profile_dir` 再开浏览器;`.env` 默认有头;REQUIREMENTS 写明登录策略(required/optional/not_needed,会话依赖型勿抄 optional);用户 README/TUTORIAL 区分「登记账号」与「站点登录」(`RPA.md` §0.2 / `POLICY-RPA-004`);依赖 kit 空值解析等修复时上调 `platform_kit_min_version` + +## 17. 常见错误 + +### 错误 1:只改了目录名,没改 slug + +表现: + +- 数据目录不对 +- 版本输出不对 +- 日志名不对 + +要检查: + +- `SKILL.md` +- `scripts/util/constants.py` + +### 错误 2:平台中文名改了,内部键没改 + +表现: + +- 兄弟技能筛选账号失败 +- `run` 命令走错平台或 task_type + +要检查: + +- `task_service.py` +- `sibling_bridge.py` +- `../references/CLI.md` + +### 错误 3:把业务逻辑写进 CLI + +表现: + +- 文件越来越乱 +- 不方便测 +- 参数和业务耦合太重 + +要改回: + +- `cli` 只做参数解析 +- `service` 才做业务 + +### 错误 4:保留了旧模板结构 + +表现: + +- 仓库同时存在 `docs/`、`optional/`、`skill_main.py` +- 技术人员不知道看哪一套 + +现在的新模板原则是: + +- 不做旧结构兼容 +- 用户市场四 Tab 在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`,Agent 资料在 `references/`,开发规范在 `development/` +- 统一走 `scripts/main.py` 作为 CLI 入口 + +### 错误 5:平台放 slug 最前 + +表现: + +- slug 形如 `erp-download-settlement-report`(平台段错误地放在最前) +- 语义校验或 `test_slug_naming` 失败 + +要改: + +- 动词放首段,平台放末段,例如 `download-settlement-report-erp` +- 见 [`NAMING.md`](NAMING.md) + +### 错误 6:noun 段过长导致超 48 字符 + +表现: + +- slug 超过 48 字符 +- 中间名词段堆砌过多 + +要改: + +- 精简 noun-phrase,保留核心业务含义 +- 见 [`NAMING.md`](NAMING.md) §3 硬约束 + +### 错误 7:只改目录名未改 slug + +表现: + +- 与「错误 1」相同,但目录已改名而 `SKILL.md` / `constants.py` 仍为占位 + +要检查: + +- `SKILL.md` 的 `metadata.openclaw.slug` +- `scripts/util/constants.py` 的 `SKILL_SLUG` + +### 错误 8:复制模板时带走了 `.git` + +表现: + +- `git remote -v` 仍显示 skill-template 的 origin +- push 到错误仓库,或 log 里全是模板历史 +- 新技能目录里仍有 `.openclaw-skill-template` + +修复: + +```powershell +Remove-Item -Recurse -Force .git +Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue +git init +git remote add origin <新技能仓库 URL> +``` + +以后复制请优先用 `tools/scaffold_skill.ps1`(见 [`tools/README.md`](../tools/README.md))。 + +## 18. 推荐开发顺序总结 + +如果让一个新人照着做,我建议他按这个顺序: + +1. 按 [`NAMING.md`](NAMING.md) 确定 slug,复制模板并改目录名 +2. 改 `SKILL.md` 与根市场四 Tab(`README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`) +3. 改 `scripts/util/constants.py` +4. 改 `references/` 与 `development/` +5. 改 `scripts/cli/app.py` +6. 改 `scripts/service/` +7. 跑 `python tests/run_tests.py -v` +8. 跑 `health` / `version` +9. 再做业务联调 +10. 最后 release + +## 19. 这份模板的底线要求 + +以后新建 skill,至少要满足这几点: + +- 目录结构统一 +- 入口统一为 `scripts/main.py` +- 用户市场四 Tab 在根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`,Agent 资料在 `references/`,开发规范在 `development/` +- 业务核心逻辑统一放 `scripts/service/` +- 不再使用旧模板历史结构 + +如果做不到这些,后面 skill 一多,就会越来越乱。 diff --git a/development/LOGGING.md b/development/LOGGING.md new file mode 100644 index 0000000..8c208ff --- /dev/null +++ b/development/LOGGING.md @@ -0,0 +1,359 @@ +# 日志规范 + +本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文。 + +自动 hard policy(`POLICY-LOGGING-001`~`008`)只保证底线;阶段是否够细仍须对照本文事件词表做 review。复制技能后可用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample) 做契约断言。 + +--- + +## 0. 验收铁律(先读) + +| 通道 | 给谁看 | 能否单独当排障依据 | +|------|--------|-------------------| +| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** | +| Activity / 任务中心 `emit` / `finish` | 用户进度与终态 | **不能**替代文件日志 | +| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 | +| 业务表 | 用户可见业务结果 | 有业务才建;**不是**默认排障通道 | + +硬规定: + +1. **没有关键节点文件日志 = 客户现场不可排障。** +2. **`emit` ≠ 文件日志**:任务中心有进度,而 `jiangchang.log` 只有 `cli_start`,视为交付不合格。 +3. **失败出口必须先写文件日志,再 `print` / `finish`**:凡 `print(...ERROR:...)`、`finish(status=failed|partial)`、队列 `mark_failed`,同一出口须有 `log.warning` / `log.exception`(含 `error_code=` 或 `task_failed`)。 +4. **域模块也要打点**:`scripts/service/<domain>/`、`*_flow.py`、`*_driver.py` 等业务文件不得零日志(见 `POLICY-LOGGING-006`)。 + +--- + +## 1. 为什么日志是必需的 + +OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览器、ERP、微信等外部系统。这类任务具备以下特征: + +- **耗时长**:单次 run 可能数分钟甚至更久,stdout 长时间无输出不代表卡死。 +- **失败点多**:网络抖动、页面改版、登录态失效、验证码、U 盾等都会导致中途失败。 +- **环境差异大**:客户 OS、浏览器版本、代理、杀毒软件、屏幕分辨率各不相同,无法复现开发机行为。 +- **必须离线排查**:技术支持往往无法远程登录客户电脑,只能依赖本地落盘的日志与 artifacts。 + +日志用于定位: + +| 维度 | 说明 | +|------|------| +| 失败阶段 | 任务停在鉴权、取号、打开页面、提交还是等待结果 | +| 输入范围 | `task_type`、`target_id`、`input_id`、`batch_id` | +| 外部系统状态 | HTTP 状态、RPA 步骤、兄弟技能返回码 | +| 耗时 | `elapsed_ms`、批量 `current/total` | +| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 | +| 存证路径 | 截图、录屏、`video_log`、失败 artifacts | + +--- + +## 2. 日志分层 + +技能日志分三层,职责互不替代: + +### 2.1 统一文件日志(开发 / 运维排查) + +**来源**:`jiangchang_skill_core.unified_logging`(技能内通过 `util.logging_config` 薄封装 import)。 + +| API | 用途 | +|-----|------| +| `setup_skill_logging` | CLI 入口启动时初始化(`cli/app.py` 的 `main()`) | +| `get_skill_logger` | 获取带 `skill_slug` 的 logger | +| `subprocess_env_with_trace` | 子进程 / 兄弟技能调用时传递 `trace_id` | + +**路径**: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +``` + +**特征**:每行带 `trace_id`、`skill_slug`;适合 grep、离线打包发给技术支持。 + +### 2.2 Activity / Run Journal(宿主 UI 与用户可见进度) + +**来源**:`jiangchang_skill_core.activity` + +| API | 用途 | +|-----|------| +| `emit` | 推送用户可读进度(不写 stdout);进入步骤前自动步骤闸门(SRCP) | +| `step` | 结构化步骤 | +| `finish` | 任务结束;**唯一**写单行 result JSON 到 stdout 的场景 | +| `job_context` | 包裹 `cmd_run`;未捕获异常 / `JobStopped` 时自动 `finish` | +| `rpa_step` | RPA 专用步骤:闸门 + ▶/✓ emit | +| `interruptible_sleep` | RPA 等待(替代裸 `asyncio.sleep`),可暂停/停止 | +| `checkpoint` | 长循环内 consult control(一般由 kit 自动调用) | + +**路径**: + +```text +{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl +``` + +**约定**: + +- `emit` **不写 stdout**;长任务应持续 emit,避免用户以为卡死。 +- `finish` 才输出单行 result JSON 供宿主解析。 +- RPA 类技能:`RpaVideoSession.add_step` 会自动同步 activity(无需重复手写每步 emit)。 +- **禁止**自建 `scripts/util/progress.py` 或向 stdout 打印 `type:progress` JSON;进度只走 Run Journal。 +- 宿主通过 `{job_id}.control.json` 下发暂停/继续/停止;技能侧由 platform-kit 在步骤闸门自动处理(见 §2.5)。 + +### 2.5 步骤控制(SRCP v1,platform-kit >= 1.2.0) + +**权威行为定义在本文**;platform-kit README 为实现细节参考。 + +| 通道 | 方向 | 载体 | +|------|------|------| +| 进度 | 技能 → 宿主 | Run Journal(`emit` / `rpa_step` / `add_step`) | +| 结果 | 技能 → 宿主 | `finish()` → Journal 终态 + stdout 一行 result | +| 控制 | 宿主 → 技能 | `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.control.json` | + +`control.json` 的 `command` 只认:`none` | `pause` | `resume` | `stop`。 + +技能作者三件事: + +1. `cmd_run` 外包 `with job_context(skill=SKILL_SLUG):` +2. 关键步骤 `emit(...)` 或 `@rpa_step` / `video.add_step` +3. 每个出口 `finish(status=..., message=..., skill=SKILL_SLUG, **fields)` — **不要**手写多行 JSON 结果 + +**单内核多入口**:`execute_single_*` 不含 `job_context`;仅 `cmd_run` / `cmd_run_pick` 包裹。批量 pick 循环内 `checkpoint()` + `emit(..., progress=...)`;部分失败用 `finish(status="partial")`。完整范式见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。 + +RPA 等待用 `interruptible_sleep`,**不要**裸 `asyncio.sleep`(否则暂停响应滞后)。 + +暂停在**步骤边界**生效(当前 `@rpa_step` / `emit` 执行完后、下一步开始前),与影刀「当前指令完成后暂停」一致。 + +Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`),供宿主任务中心显示状态。 + +### 2.3 可观测性三分法(禁止 task_logs) + +| 信息类型 | 去哪里 | 技能怎么做 | +|----------|--------|------------| +| **过程**(步骤、异常栈、排查) | `jiangchang.log` / 宿主「运行日志」 | `log.info` / `log.exception`,关键节点见 §3 | +| **进度 / 终态**(任务中心) | Run Journal:`emit` / `finish` | `job_context` + `finish(status=...)` | +| **业务结果** | 业务表字段(status、产物路径、统计等) | repository 回写;见 [`WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md) | + +模板**默认不建** `task_logs` 等通用任务审计表。**不要**用业务表冒充任务进度,也不要用任务表代替业务状态机。 + +### 2.4 RPA video step / artifacts(用户可见动作与存证) + +**来源**:`RpaVideoSession`、`rpa-artifacts/` 目录 + +- video artifact 是**标准可启用能力**,**不保证默认每次生成 MP4**(模板默认 `OPENCLAW_RECORD_VIDEO=0`)。 +- **`OPENCLAW_RECORD_VIDEO=0`**:不生成最终录屏,但 RPA / 长任务仍须保留 `RpaVideoSession`、`video.add_step` 与 Activity 进度同步。 +- **`OPENCLAW_RECORD_VIDEO=1`**:`finish()` 字段应包含 `video_path`、`raw_video`、`video_log`、`video_warnings` 等;CLI 可打印录屏路径与诊断。 +- 用户可见的中文步骤(「打开登录页」「点击提交」)。 +- 失败截图:`{数据目录}/rpa-artifacts/{batch_id}/...`(受 `OPENCLAW_ARTIFACTS_ON_FAILURE` 控制,默认开) + +详见 [`RPA.md`](RPA.md) §5 存证与录屏规范。 + +--- + +## 3. 固定事件词表(生产代码须出现) + +日志消息推荐 **key=value**。下列**事件名字符串**须出现在 `log.info` / `log.warning` / `log.exception` 的消息中(可用 f-string / `%s`,但事件名本身须可 grep)。 + +### 3.1 长任务通用最低集(所有业务 skill) + +| 事件名 | 级别 | 说明 | +|--------|------|------| +| `cli_start` | info | CLI 入口(模板已提供) | +| `task_start` | info | 主任务开始(含 `task_type=`) | +| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 | +| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 | +| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` | + +### 3.2 RPA / 桌面 / 浏览器额外最低集 + +当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求: + +| 事件名 | 说明 | +|--------|------| +| `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) | +| `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) | +| `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 | + +**不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。 + +### 3.3 其它常见节点(按业务选用) + +| 阶段 | 建议事件 | +|------|----------| +| adapter 选择 | `adapter_selected adapter=...` | +| 账号 / lease | `account_acquired` / `account_released` | +| 外部 API | `external_call_start` / `external_call_done`(含 `elapsed_ms=` / `status=`) | +| 兄弟技能 | `sibling_call_start` / `sibling_call_done` | +| 重试 / 跳过 / 人工 | `retry_attempt` / `skipped` / `human_intervention` | +| 存证 | `artifact_saved` / `video_path=` | + +### 3.4 最低可验收日志串(示例) + +mock 或真实跑完一条主路径后,`jiangchang.log`(或宿主「运行日志」)应能 grep 到类似: + +```text +cli_start argv=... +task_start task_type=add-one target_id=... +entitlement_ok task_type=add-one +rpa_start stage=open_window +rpa_start stage=locate_control +rpa_start stage=submit +task_failed error_code=ELEMENT_NOT_FOUND ... +``` + +成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。 + +--- + +## 3.5 必打节点与 Activity 对照 + +以下节点除文件日志外,长任务仍应 `emit` / `video.add_step`(用户可见),但**不能只 emit 不打文件日志**。 + +| 阶段 | 文件日志 | Activity | +|------|----------|----------| +| 任务开始 | `task_start` | `emit("开始…")` | +| RPA 步骤 | `stage=...` | `add_step` / `@rpa_step` | +| 批量进度 | `item_start` / `batch_progress` | `emit(..., progress=)` | +| 失败 | `task_failed error_code=` | `finish(failed\|partial)` | + +--- + +## 4. 字段规范 + +推荐在 `log.info` 等消息中使用 **key=value** 结构化字段,便于 grep: + +| 字段 | 含义 | +|------|------| +| `task_type` | 任务类型 | +| `target_id` | 目标标识(平台、店铺、报表类型等) | +| `input_id` | 输入记录 ID | +| `batch_id` | 批次 / 录屏批次 | +| `stage` | 阶段:`auth` / `run` / `cleanup` | +| `system` | 外部系统名:`erp` / `wechat` / `1688` | +| `operation` | 操作名:`submit_order` / `fetch_report` | +| `adapter` | adapter 档位:`mock` / `simulator_api` / `real_api` | +| `elapsed_ms` | 耗时毫秒 | +| `status` | `ok` / `failed` / `timeout` / `skipped` | +| `error_code` | 业务错误码(非 Python 异常名) | +| `artifact_path` | 截图 / 附件路径 | +| `video_path` | 录屏成品路径 | + +示例: + +```text +external_call_done system=erp operation=download_report elapsed_ms=4523 status=ok +batch_progress current=7 total=20 target_id=store-001 +``` + +--- + +## 5. 日志级别规范 + +| 级别 | 使用场景 | +|------|----------| +| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度 | +| **warning** | 可恢复问题、重试、跳过、人工介入、部分失败、鉴权失败(业务可预期) | +| **exception** | 异常路径;**必须**用 `log.exception(...)` 保留 traceback | +| **debug** | 低频诊断;**默认不依赖 debug 才能排查问题** | + +原则:**生产排障应主要靠 info + warning + exception**,不要把 debug 当作唯一线索。 + +--- + +## 6. 敏感信息红线 + +### 禁止记录原始值 + +以下字段**不得**以明文出现在日志、Activity、`finish()` 字段、stdout: + +- `password`、`token`、`cookie`、`secret`、`api_key`、`authorization` +- 验证码、动态口令全文 +- 身份证、银行卡、手机号**完整值** + +### 建议记录的安全替代 + +| 替代字段 | 示例 | +|----------|------| +| `credential_ref` | vault / account-manager 引用 ID | +| `account_id` | 账号表主键 | +| `masked_user` | `138****8000` | +| `domain` | `erp.example.com` | +| `last4` | 卡号后四位 | +| `hash` / `short_id` | 内容摘要 | + +变量名可以叫 `token`,但日志里不能出现 `token=eyJhbG...` 或 f-string 拼接明文。 + +--- + +## 7. 推荐代码示例(service 层) + +```python +from util.logging_config import get_skill_logger +from jiangchang_skill_core.activity import emit, finish, job_context, rpa_step, interruptible_sleep + +from util.constants import SKILL_SLUG + +log = get_skill_logger() + +def cmd_run(target=None, input_id=None): + task_type = "your_task" + with job_context(skill=SKILL_SLUG): + log.info( + "task_start task_type=%s target_id=%s input_id=%s", + task_type, target, input_id, + ) + emit("开始处理任务", skill=SKILL_SLUG, stage="run") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason) + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED") + return 1 + + t0 = time.monotonic() + # ... 外部调用 / RPA:@rpa_step 或 video.add_step;等待用 interruptible_sleep ... + elapsed_ms = int((time.monotonic() - t0) * 1000) + log.info( + "external_call_done system=%s operation=%s elapsed_ms=%d status=%s", + "erp", "submit", elapsed_ms, "ok", + ) + + finish(status="success", message="任务完成", skill=SKILL_SLUG) + return 0 + except Exception: + log.exception( + "task_failed task_type=%s target_id=%s input_id=%s", + task_type, target, input_id, + ) + emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") + finish(status="failed", message="任务执行异常", skill=SKILL_SLUG) + return 1 +``` + +模板示范见 [`scripts/service/task_service.py`](../scripts/service/task_service.py) 的 `cmd_run`。 + +--- + +## 8. 错误排查顺序 + +客户现场或测试机排障,建议按以下顺序(由快到慢、由用户可见到运维细节): + +1. **CLI stdout** — 是否有 `ERROR:<CODE>:`、`HINT:`、模板提示文案。 +2. **任务中心 / Run Journal** — `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`(进度与 `finish` 终态)。 +3. **统一日志文件** — `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(grep `trace_id`、`task_failed`)。 +4. **业务表** — 宿主数据管理中该技能自己的业务结果(有才建)。 +5. **RPA artifacts** — `rpa-artifacts/` 失败截图;若 `OPENCLAW_RECORD_VIDEO=1` 还有录屏 MP4、`video_log`。未开启录屏时可能只有截图或日志,需要完整录屏请在用户 `.env` 中设置 `OPENCLAW_RECORD_VIDEO=1`。 + +长时间无 stdout **不代表卡死**;先看 Run Journal / Activity,再看统一日志。 + +--- + +## 9. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [`RUNTIME.md`](RUNTIME.md) | 统一日志能力来源、`util.logging_config` 薄封装、数据根约定 | +| [`TESTING.md`](TESTING.md) | 日志相关 hard policy 测试(`test_development_policy_guard.py`) | +| [`RPA.md`](RPA.md) | RPA step、`RpaVideoSession`、video / artifacts 标准 | +| [`SCHEMA.md`](../references/SCHEMA.md) | 业务表结构与展示元数据 | +| [`CLI.md`](../references/CLI.md) | 最小命令与手工排查入口 | +| [`CONFIG.md`](CONFIG.md) | 敏感配置不进日志;`.env` 边界 | +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | `service/` 层日志职责与发布前检查清单 | +| [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) | 任务中心 / async Job 与 `job_context` 边界 | +| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | 可自动检测的 logging hard policy 索引 | diff --git a/development/NAMING.md b/development/NAMING.md new file mode 100644 index 0000000..d057f68 --- /dev/null +++ b/development/NAMING.md @@ -0,0 +1,237 @@ +# 技能 slug 命名规范(verb-noun-platform) + +> 本文是匠厂**新业务 skill** 的 slug / Git 仓库名 / 目录名命名权威说明。复制 `skill-template` 出去开发新技能前**必读**;Agent 在开发者询问命名或 slug 时也应先读本文。 + +--- + +## 1. 适用范围 + +| 范围 | 说明 | +|------|------| +| ✅ **适用** | 从 `skill-template` **复制出去的新技能** | +| ❌ **不追溯** | 已上线历史技能(如 `account-manager`、`reconcile-finance`、`scrape-contacts-lianjia` 等)保持不动 | +| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 | +| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 | + +**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。 + +--- + +## 2. 命名格式 + +标准形态(约 95% 新技能): + +```text +{verb}-{noun-phrase}-{platform} +``` + +| 部分 | 规则 | +|------|------| +| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) | +| **noun-phrase** | 1~3 个词(按 `-` 分段),描述业务对象 | +| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) | + +**展示层分工不变:** + +| 字段 | 语言 | 示例 | +|------|------|------| +| slug / repo / 目录 / `SKILL_SLUG` / DB 文件名 | **英文** kebab-case | `download-settlement-report-amazon` | +| `SKILL.md` 的 `name`、`description`、根 `README.md` | **中文** | `亚马逊结算报表下载` | + +--- + +## 3. 硬约束(新技能强制校验) + +| 约束 | 值 | +|------|-----| +| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) | +| 总段数(按 `-` 分割) | **3~5 段**(标准型);scope 型见 [§4](#4-三类合法形态) | +| 总长度 | **≤ 48 字符** | +| 字符集 | 小写字母、数字、连字符;kebab-case | +| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx`) | + +实现校验见 `scripts/util/slug_naming.py`;`scripts/db/display_metadata_validator.py` 在 `SKILL.md` frontmatter 自检时调用。 + +--- + +## 4. 三类合法形态 + +### 4.1 标准型(95%) + +`verb-noun-platform` 或 `verb-noun-noun-platform`(3~5 段) + +- 第 1 段 ∈ 动词白名单 +- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单 +- 中间为 noun-phrase(1~3 段) + +示例: + +- `download-settlement-report-amazon` +- `add-product-shopee` +- `enter-sales-receipt-kingdee` +- `fill-export-declaration-single-window` + +### 4.2 scope 型(少见) + +`verb-noun-scope` + +- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default` +- 第 1 段 ∈ 动词白名单 +- 总段数 3~5 + +示例:`generate-article-all` + +> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。 + +### 4.3 历史豁免(不校验语义) + +以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`): + +- `account-manager` +- `skill-template` +- `your-skill-slug` / `your_skill_slug`(模板占位) + +--- + +## 5. 动词白名单 + +词表文件:[`assets/naming/verbs.txt`](../assets/naming/verbs.txt)(每行一词,便于后续扩展) + +| 动词 | 动词 | 动词 | 动词 | +|------|------|------|------| +| scrape | receive | download | export | +| add | fill | enter | bind | +| withdraw | reconcile | verify | submit | +| query | process | reply | generate | +| draft | review | confirm | track | +| upload | match | triage | ship | +| reprice | | | | + +--- + +## 6. 平台白名单 + +词表文件:[`assets/naming/platforms.txt`](../assets/naming/platforms.txt) + +| 平台 | 平台 | 平台 | +|------|------|------| +| amazon | shopee | xinghang | +| alibaba | kingdee | icbc | +| pingpong | worldfirst | paypal | +| lianlian | single-window | export-rebate | +| etax | | | + +scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`、`default` + +--- + +## 7. slug 与展示字段分工 + +```yaml +--- +name: 亚马逊结算报表下载 # 中文,用户可见 +description: "从卖家中心下载结算报表并入库" # 中文说明 +metadata: + openclaw: + slug: download-settlement-report-amazon # 英文机器标识 +--- +``` + +同步修改: + +- 目录名 +- `metadata.openclaw.slug` +- `scripts/util/constants.py` 中的 `SKILL_SLUG` +- 默认数据库路径 `{slug}/{slug}.db` + +三者必须一致。 + +--- + +## 8. 与 account-manager 的关系 + +slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对应**,但**不要求字符串完全相等**。 + +| slug 末段 (platform) | account-manager platform_key(示例) | +|----------------------|--------------------------------------| +| amazon | `amazon_sim` / `amazon` | +| shopee | `shopee` | +| kingdee | `kingdee` | + +新技能若需账号下发,在 `REQUIREMENTS.md` §0 填写拟用的 `platform_key`,并与兄弟技能调用参数对齐。 + +--- + +## 9. 复制新技能流程 + +1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符 +2. **复制目录** — **推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(`-Slug` 与目录名一致);勿整包复制带走 `.git` +2.5. **清理 Git(若未用 scaffold)** — 删除 `.git`、`.openclaw-skill-template`、`.pytest_cache`;再 `git init` 与 `git remote add origin` +3. **全局替换** — `your-skill-slug` → 新 slug;更新 `SKILL.md`、`constants.py`、文档中的占位 +4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识 +5. **开发 & 自检** — `python tests/run_tests.py -v`,含 slug 语义测试;`git remote -v` 确认非 template + +--- + +## 10. 正例 / 反例 + +### ✅ 正例 + +| slug | 说明 | +|------|------| +| `download-settlement-report-amazon` | 标准型,4 段 | +| `add-product-shopee` | 标准型,3 段 | +| `enter-sales-receipt-kingdee` | 标准型 | +| `fill-export-declaration-single-window` | 标准型,平台含连字符 | +| `generate-article-all` | scope 型 | + +### ❌ 反例 + +| slug | 原因 | +|------|------| +| `amazon-download-settlement-report` | 平台在最前 | +| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 | +| `reconcile-finance` | 新技能禁止 2 段无平台 | +| `download_settlement_amazon` | 非 kebab-case(下划线) | + +--- + +## 11. 反模式 + +- **平台前置**:`amazon-download-...`、`shopee-add-...` +- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称 +- **只改目录名**:`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步 +- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读 +- **仿历史 2 段 repo**:`reconcile-finance`、`content-manager` 等 + +--- + +## 12. 演进说明(当前不启用) + +当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。 + +--- + +## 13. 历史形态说明 + +以下为代表性**历史 repo**,新技能**勿仿**: + +| 历史 slug | 说明 | +|-----------|------| +| `account-manager` | 跨平台账号管理,非 verb-noun-platform | +| `reconcile-finance` | 2 段无 platform | +| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) | +| `skill-template` | 模板仓库本身 | + +校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。 + +--- + +## 14. 相关文档 + +| 文档 | 内容 | +|------|------| +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | 复制模板步骤、发布前检查清单 | +| [`REQUIREMENTS.md`](REQUIREMENTS.md) | §0 技能标识必填项 | +| [`../references/SCHEMA.md`](../references/SCHEMA.md) | kebab-case 与数据库路径 | +| [`../SKILL.md`](../SKILL.md) | frontmatter 命名摘要 | diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md new file mode 100644 index 0000000..bd5ac4e --- /dev/null +++ b/development/POLICY_MATRIX.md @@ -0,0 +1,76 @@ +# 开发规范 → 自动检测索引 + +本文件是 **development/*.md 规范到机器检测的追溯表**,不是另一份长规范。新增或调整 hard 规则时,须同步更新本矩阵与 `tests/test_development_policy_guard.py`(或注明已有测试覆盖)。 + +| policy_id | 规则摘要 | 来源 | 强制级别 | 自动检测 | 对应测试 | +|-----------|----------|------|----------|----------|----------| +| POLICY-STRUCTURE-001 | 标准 `scripts/` 分层:`main.py`、`cli/`、`service/`、`db/`、`util/` 必须存在 | development/DEVELOPMENT.md §2 新 skill 的标准目录结构;development/RUNTIME.md §目录结构 | hard | 目录存在性检查 | `tests/test_development_policy_guard.py::TestPolicyStructure001` | +| POLICY-RUNTIME-001 | 不得 vendored `scripts/jiangchang_skill_core/` | development/RUNTIME.md §共享 Python Runtime、§明确禁止 | hard | 目录不存在检查 | `tests/test_development_policy_guard.py::TestPolicyRuntime001`(另见 `tests/test_platform_import.py`) | +| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtime;development/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py`) | +| POLICY-RUNTIME-003 | Agent/CLI 文档须要求共享 Python(python-runtime / PATH 上的 `python` + `{baseDir}`);须明确**禁止**对技能脚本使用 `uv run python` | development/RUNTIME.md;references/CLI.md;SKILL.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_cli_md_requires_shared_python_not_uv_run`;`tests/test_docs_standards.py::test_skill_md_requires_shared_python_not_uv_run` | +| POLICY-AGENT-001 | `SKILL.md` 须含「用户意图路由表」结构;`CLI.md` 须含短 CLI 白名单 / Action 强制黑名单表;用法回答须禁止无故粘贴 CLI、禁止为答用法列目录挖 references | SKILL.md;references/CLI.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_skill_md_has_intent_routing_table`;`tests/test_docs_standards.py::test_cli_md_has_agent_routing_tables`;`tests/test_docs_standards.py::test_skill_md_how_to_forbids_cli_paste` | +| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`) | `tests/test_development_policy_guard.py::TestPolicyInstall001` | +| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` | +| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env` 与 `*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` | +| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrap;development/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` | +| POLICY-CONFIG-004 | `.env.example` 每个活跃 `KEY=` 必须有中文**行头**(是什么)与中文**行尾**(怎么填);注释禁止 `development/`、`*.md` 文档指向、`account-manager`、`adapter 档位` 等开发黑话 | development/CONFIG.md §.env.example 注释 = 配置管理用户文案 | hard | 解析 `.env.example` 活跃行与注释 | `tests/test_development_policy_guard.py::TestPolicyConfig004` | +| POLICY-TESTING-001 | 默认根目录 `tests/test_*.py` 不得硬编码 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`(白名单仅 `test_adapter_profile_policy.py`,用于档位 helper 自测) | development/TESTING.md §默认必跑测试要做什么、§4 测试目标档位 | hard | 扫描 `tests/test_*.py` 非注释行;白名单外禁止上述文本 | `tests/test_development_policy_guard.py::TestPolicyTesting001` | +| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py` 无 `.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` | +| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) | +| POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4;应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg(`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` | +| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 进 `finish()` 字段 | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` | +| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` | +| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` | +| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` | +| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` | +| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` | +| POLICY-RELEASE-001 | 发布 tag(去掉前缀)必须等于 `SKILL.md` 的 `version:` 且等于 `scripts/util/constants.py` 的 `SKILL_VERSION`;`release.ps1` 打 tag 前硬失败,不自动改写字段 | development/DEVELOPMENT.md §15 发布;宿主市场「最新版本」来自 tag、「当前版本」来自包内 `SKILL.md` | hard | `release.ps1` 门禁 + 日常版本字段对齐测试 | `release.ps1`(`Assert-SkillReleaseVersionAlignment`);`tests/test_development_policy_guard.py::TestPolicyRelease001`;`tests/test_skill_metadata.py::test_skill_version_alignment` | +| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` | +| POLICY-LOGGING-001 | CLI 入口必须调用 `setup_skill_logging` | development/LOGGING.md;development/RUNTIME.md | hard | 扫描 `scripts/cli/app.py` 含 `setup_skill_logging` 与 logger info | `tests/test_development_policy_guard.py::TestPolicyLogging001` | +| POLICY-LOGGING-002 | service 主任务入口必须使用 `get_skill_logger` | development/LOGGING.md;development/DEVELOPMENT.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging002` | +| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` | +| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` | +| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` | +| POLICY-LOGGING-006 | `scripts/service/**/*.py` 业务域文件须有 `log/logger/_logger.(info|warning|error|exception)`;豁免薄工具:`__init__.py`、`task_run_support.py`、`entitlement_service.py`、`sibling_bridge.py` | development/LOGGING.md §0、§3 | hard | 扫描 service 树 | `tests/test_development_policy_guard.py::TestPolicyLogging006` | +| POLICY-LOGGING-007 | 若 service 层含 RPA 标记(`RpaVideoSession` / `uiautomation` / `playwright` / `rpa_step`),则须出现事件词 `task_start`、`task_failed` 或 `rpa_failed`、以及 `stage=` | development/LOGGING.md §3.1–§3.2 | hard | 合并扫描 `scripts/service/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyLogging007` | +| POLICY-LOGGING-008 | `scripts/service/**/*.py` 中若 `print` 含 `ERROR:`,同一函数体内须有 `log/logger/_logger.(warning|error|exception)` | development/LOGGING.md §0 铁律 3 | hard | 按 `def` 切分共现检查 | `tests/test_development_policy_guard.py::TestPolicyLogging008` | +| POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` | +| POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` | +| POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` | +| POLICY-DATA-PATH-001 | 技能拥有路径须经 `resolve_data_path` / `get_*_dir`;`.env.example` 禁止 `./` CWD 相对路径;业务代码禁止 `abspath(config.get(...))` | development/DATA_PATHS.md | hard | 扫描 `.env.example` 与 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyDataPath001` | +| POLICY-SKILL-ACTION-001 | 存在 `development/SKILL_ACTION_RUNTIME.md`;schema 含 `executionProfile`;业务技能(非模板占位 slug)若 service 含 `RpaVideoSession` 则 `actions.json` 须有 `executionProfile=async` 且 placements 含 `agent` 的 action | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md | hard | 文档/schema/manifest 检查 | `tests/test_development_policy_guard.py::TestPolicySkillAction001` | +| POLICY-SKILL-ACTION-002 | 每个 Action 必须显式声明 `executionProfile`,且只能是 `sync` 或 `async` | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema required + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction002`;`tests/test_actions_manifest.py` | +| POLICY-SKILL-ACTION-003 | toolbar Action 必须声明合法非空 `bind.tables`(snake_case、唯一) | references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema if/then + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction003`;`tests/test_actions_manifest.py` | +| POLICY-SKILL-ACTION-004 | `placements` 与 `executionProfile` 正交;文档不得把数据管理入口与异步执行方式硬绑定;Schema 须接受 toolbar/row/batch/cron/agent/skill-detail × sync/async 全部组合 | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | Schema 6×2 矩阵校验 + 文档禁止短语扫描 | `tests/test_actions_manifest.py::test_schema_allows_all_placement_execution_profile_combinations`;`tests/test_development_policy_guard.py::TestPolicySkillAction004` | +| POLICY-TUTORIAL-001 | 存在 `development/TUTORIAL_SURFACES.md`;`TUTORIAL.md` 须含数据管理/对话/定时/对照表骨架;`README.md` 禁止「告诉 Agent」专章与入口专章;说明/教程正文禁止与页头重复的 `#` H1(主分节用 `##`);业务技能教程与 `actions.json` placements 对齐(无 cron 须声明不需要定时) | development/TUTORIAL_SURFACES.md;development/CHECKLIST.md A2-7~A2-12 | hard | 文档存在性 + 章节/反模式扫描 + placements 对齐 | `tests/test_tutorial_surfaces.py`;`tests/test_docs_standards.py::test_tutorial_surfaces_contract_doc_and_checklist` | +| POLICY-ARCH-CORE-001 | 同一业务能力的 Action、CLI、Agent、cron、数据管理入口复用同一业务内核(文档要求已声明) | development/DEVELOPMENT.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;真实复用靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyArchCore001`;`tests/samples/test_action_core_contract.py.sample` | +| POLICY-DATA-SYNC-001 | 同步型业务须遵循幂等、事务、完整性与空结果保护(文档已声明) | references/SCHEMA.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;完整语义靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyDataSync001`;`tests/samples/test_sync_contract.py.sample` | + +--- + +## 暂不自动检测(软规则) + +以下规范仍须人工遵守或依赖 code review / AI 提示词 / sample 契约,**不**做成脆弱关键字扫描: + +| 规则摘要 | 来源 | 原因 | +|----------|------|------| +| `cli/` 只解析参数、不写业务逻辑 | development/DEVELOPMENT.md §9 | 需语义理解,无法可靠静态判定 | +| `service/` 编排流向与模块拆分粒度 | development/DEVELOPMENT.md §10 | 架构软约束;见 `test_action_core_contract.py.sample` | +| 是否真正复用同一业务内核 / 是否按 source 分叉 | development/SKILL_ACTION_RUNTIME.md §5 | 需调用图与人工审查 | +| 是否满足完整快照语义 / 空结果保护实现 | references/SCHEMA.md | 见 `test_sync_contract.py.sample` | +| 是否把系统导出与业务同步混淆 | references/ACTIONS.md;SCHEMA.md | 文档 + sample;勿做源码关键词误报扫描 | +| `db/` 层不得调浏览器或拼业务流程 | development/DEVELOPMENT.md §11 | 需跨文件调用图 | +| slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy | +| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 | +| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` | +| RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 | +| RPA 阶段日志是否「够细」(每个控件) | development/LOGGING.md §3.2 | hard 已要求里程碑事件词与域模块有日志;逐步粒度靠 sample 契约与验收 | +| adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 | +| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | +| platform_kit_min_version >= 1.2.11 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` | +| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 | +| form +「已选择」时映射 `readOnly` 默认不占参数区(仍提交);`revealInForm` 逃生阀 | references/ACTIONS.md「选择摘要」 | soft(宿主 UX);文档与 schema 已声明;勿用 `visibleWhen` 代替 | +| 数据管理技能按钮中文名(`label`)对照 ACTIONS.md 建议表;打开类成功静默 | references/ACTIONS.md「Action 按钮中文名」 | 参考非白名单;行业词允许自创;勿做 label 关键字扫描 | +| Data Policy 未启用(L0)时宿主不得拉 Snapshot / 不得改按钮与行列行为 | references/POLICY.md | soft;宿主实现契约 | +| Data Policy 启用后 fail-closed(禁止回退 L0 全开);写路径 domain 必须再鉴权 | references/POLICY.md §6–§7 | soft;`POLICY-SKILL-AUTHZ-*` 硬门禁可后续加 | +| `actions.mode` 仅 allowlist/open;`hostCapabilities` 省略=不限制;scope `valueFrom` 仅 `actor.userId` | references/POLICY.md §0.1 | soft;Schema 已约束 | diff --git a/development/PRODUCT_SURFACES.md b/development/PRODUCT_SURFACES.md new file mode 100644 index 0000000..1319808 --- /dev/null +++ b/development/PRODUCT_SURFACES.md @@ -0,0 +1,188 @@ +# 宿主产品入口(对话 / 数据管理 / 定时任务) + +本文给**技能开发者**与 **AI 编程助手**读:说明匠厂宿主上几个**平级**业务入口各自干什么、**什么时候走哪个**、以及如何为本技能做入口定位。 + +字段怎么写见 [`../references/ACTIONS.md`](../references/ACTIONS.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md);任务中心 / sync·async 见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。 + +**与其它分类正交:** + +| 文档维度 | 回答的问题 | +|----------|------------| +| 本文(产品入口) | 用户从宿主哪条路径触发同一能力 | +| Type A–D / 目录分层 | 技能有没有库、会不会调兄弟技能 | +| RPA 四象限 | 外部系统怎么接 | + +不要把三者合并成一个大枚举。 + +--- + +## 1. 入口是什么(先立规矩) + +技能跑在**匠厂宿主**上,不是独立软件。宿主侧栏里的入口,是同一技能能力的不同**触发场景**: + +| 今日常见入口 | 触发方式 | 底层 | +|--------------|----------|------| +| **对话任务**(新建任务) | 自然语言 → Agent | 短 CLI,或 `run_skill_action`(`placements` 含 `agent`) | +| **数据管理** | 点工具栏 / 行 / 批量 / 表单(UI) | `run_skill_action`(`source=data-management`)→ **同一 CLI** | +| **定时任务** | 到点「技能直调」或定时 Agent | `run_skill_action`(`cron` / `cron-agent`)→ **同一 CLI** | + +**铁律:** + +1. **入口平级**:没有「对话主、数据管理/定时辅」。只是触发场景与交互方式不同,各自有优势。 +2. **同一业务能力、同一 domain service**:禁止按入口复制逻辑,禁止按 `source` 分叉业务结果。 +3. **同能力可挂多个入口**:不是三选一;按本技能 UX 决定挂哪些。 +4. **可扩展**:以后若宿主增加新入口,仍是「新触发场景 + 同一内核」,定位方法不变(定位表多一列即可)。 +5. **任务中心不是业务入口**:只表示 `executionProfile: async` 的执行壳;与从哪个入口点进来无关。 + +--- + +## 2. 什么时候走哪个入口 + +### 2.1 对话任务——「用嘴发起」 + +**适合:** + +- 用户用自然语言描述意图,条件一次说清或还要追问 +- 探索式:「能不能帮我……」「先看看再决定」 +- 对象临时指定(贴链接、说一个范围) +- 需要 Agent 解释结果、失败原因、下一步 +- 用户不想打开数据管理,只想在聊天里办完 + +**技术自问:** 用户会不会用一句话说清这件事?需要模型帮理解和确认吗?——会,就为该能力挂 `agent`(长任务 / RPA 必须 Action + 通常 `async`;短查询可用共享 Python CLI)。 + +**注意:** 若技能还有「对着表运营 / 定时啃队列」等场景,**不要把对话当成唯一路径**,否则传统用户与省 token 路径会丢掉。 + +### 2.2 数据管理——「用界面发起 + 看着数据做」 + +数据管理有两层,**同等重要**: + +1. **执行面**:工具栏 / 行级 / 批量 / `replaces` 接管新增·编辑·删除·**导入** —— 与对话一样触发同一 CLI,走完整业务闭环(传统用户可不聊天)。导入按钮的 UX 始终是宿主「导入数据」面板;有 `replaces:import` 时确认后由技能写库。 +2. **运营面**:业务表、状态流向、配置、input/output、日志、HTML —— 看清数据从哪来、到哪去、卡在哪。 + +**适合:** + +- 在业务数据上操作:看队列、选一行重试、勾选批量、顶栏「处理 N 条 / 同步」 +- 已知流程、参数结构化(表单 / 确认 / 一点即跑)——**省 token**,不必每次过模型 +- 准备与执行拆开:表里先准备,再在表上点执行 +- 服务不愿用 AI 对话的用户 + +**技术自问:** 用户会不会打开这张表,靠点选就把主流程跑完?——要,就挂 `toolbar` / `row` / `batch` / `replaces`,并做好 `_jiangchang_*` 中文元数据。 + +**不要:** 把数据管理理解成「只给开发者看 SQLite」;也不要在完全没有结构化操作对象时硬造空表按钮。 + +### 2.3 定时任务——「到点自动发起」 + +**适合:** + +- 同一能力需按节奏重复:消费队列、定期同步、巡检 +- 无人值守:默认参数即可跑,人不在场也正确、安全 +- 与对话 / 数据管理挂**同一个** Action,只改触发方式 + +**技术自问:** 这件事明天能不能在人不盯着时自动再跑一遍?——能,挂 `cron`;不能,定位表写明不挂及理由。 + +**慎挂:** 每次对象都不同须人指定;强不可逆且须当场确认;主路径依赖验证码 / 人工介入。 + +### 2.4 场景对照(贴需求用) + +| 若用户场景是… | 优先考虑 | 说明 | +|---------------|----------|------| +| 「帮我处理这个……」(临时、口语) | 对话 | 灵活、可追问 | +| 「对着表点一下就跑」 | 数据管理 | 结构化、省 token、传统用户 | +| 「每天自动跑,不用我管」 | 定时 | 无人值守 | +| 「偶尔对话下发,平时表上运营,夜里自动啃队列」 | **多个入口都挂同一能力** | 平级互补,不是重复开发 | +| 「只看结果,从不在表上触发」 | 表可仅展示;执行挂对话/定时 | 由本技能 UX 定 | + +--- + +## 3. 如何为本技能做入口定位(必做) + +模板**不**规定「某类技能必须三入口都挂满」。由技术按**本技能干什么 + 用户体验**填写定位表(写入 [`REQUIREMENTS.md`](REQUIREMENTS.md))。 + +对每个**主能力**(一条业务 CLI / 一个 Action)填一行: + +| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 不挂理由(若不挂) | +|---------------------------|------|----------|------|--------------------| +| (例)`download-run-pick` | 挂 | toolbar | 挂 | — | +| (例)`download-video` | 挂 | 不挂 | 不挂 | 对象每次不同,须人指定链接 | + +**数据管理列怎么写:** `不挂` / `toolbar` / `row` / `batch` / `replaces` / `仅展示表(无业务按钮)`(可组合)。 + +**规则只有三条:** + +1. 定位为「挂」的入口:必须在 `actions.json`(及必要元数据)中声明,且宿主可测通,并走同一内核。 +2. 定位为「不挂」的入口:必须有一句理由;禁止空白「忘了」。 +3. 未来新入口:表上多一列,方法不变。 + +完整 ≠ 每个命令塞满所有入口;完整 = **想清楚用户路径后,该出现的入口都挂上了,且同内核**。 + +--- + +## 4. 怎么挂(最小路径) + +| 我想让用户… | 主要改什么 | +|-------------|------------| +| 能在对话里触发 | `SKILL.md` 意图路由 + `actions.json` 的 `placements` 含 `agent`(长任务 `async`) | +| 能在数据管理看见结果 / 过程 | 业务表 + `init-db` + `_jiangchang_*`(见 SCHEMA) | +| 能在表上点按钮走闭环 | `placements` 含 `toolbar`/`row`/`batch`,`toolbar` 须 `bind.tables` | +| 能设定时自动跑 | 同一 Action 的 `placements` 含 `cron`;参数适合无人值守 | + +细节与 Schema:[`../references/ACTIONS.md`](../references/ACTIONS.md)。运行时:[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。 + +--- + +## 5. 反例(不要这样做) + +- 只做对话 Action,从未填写入口定位表,也未考虑数据管理 / 定时用户路径 +- 为 toolbar / cron / agent 复制三套业务实现,或按 `source` 分叉结果 +- 把数据管理当成只读调试窗,主闭环只能聊天完成(若 UX 上本应支持 UI) +- 不可逆、须确认的主路径默认挂 `cron` 且无产品确认 +- 用「Action 可选 / 定时以后再说」当作「可以不思考入口」 + +--- + +## 6. 附录:定位表示例(参考,非强制) + +### 示例 A:队列消费类(对话 + 表上处理 + 定时) + +| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 | +|--------|------|----------|------|----------| +| 入队 / 准备 | 挂 | 可 replaces 或表新增 | 不挂 | 准备态由人或对话写入 | +| 处理 N 条 pending | 挂 | toolbar | 挂 | 运营点选与无人值守同一内核 | +| 单条立即执行 | 挂 | 可选 row | 不挂 | 对象临时指定 | + +### 示例 B:单次确认发布类 + +| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 | +|--------|------|----------|------|----------| +| 发布一条 | 挂 | 按是否落草稿表决定 | 不挂 | 不可逆,需人确认;不做无人值守 | + +### 示例 C:无库短查询 + +| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 | +|--------|------|----------|------|----------| +| 查询 | 挂(或短 CLI) | 不挂 | 不挂 | 无本地业务表与重复批跑需求 | + +--- + +## 7. 用户教程如何投影本文 + +入口定位表填完后,**必须**落到用户市场文档(见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)): + +| 定位结果 | `TUTORIAL.md` | `README.md` | +|----------|---------------|-------------| +| 挂数据管理 | 「入口一」专章 + 表/按钮步骤 | 仅一句「支持数据管理」 | +| 挂对话 | 「入口二」专章 + 话术 | 仅一句「支持对话」;**禁止**话术块 | +| 挂定时 | 「入口三」教怎么建 | 可选一句边界 | +| 不挂定时 | 「入口三」写不需要 + 理由 | 可选一句「不提供定时」 | + +准备/载入类 Action:优先 `toolbar` + `bind.tables`,避免教程写「技能详情点按钮」而宿主详情页不渲染。 + +--- + +## 8. 相关文档 + +- 需求里填定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md) +- **用户说明 / 教程怎么写:** [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) +- 交活怎么验:[`CHECKLIST.md`](CHECKLIST.md) +- 开发步骤中的 Action 挂载:[`DEVELOPMENT.md`](DEVELOPMENT.md) §11.5、§15 +- Agent 侧字段契约:[`../references/ACTIONS.md`](../references/ACTIONS.md) diff --git a/development/README.md b/development/README.md new file mode 100644 index 0000000..d869617 --- /dev/null +++ b/development/README.md @@ -0,0 +1,51 @@ +# 开发资料入口 + +本目录面向**人类开发者**与 **AI 编程代理**。技术人员与编程 AI **以本目录为主读路径**;`references/` 是 CLI / Action / Schema 契约细节,根目录市场四 Tab 面向最终用户。 + +## 新技能全流程(先看这张表) + +按顺序做;细节与约束见 [`DEVELOPMENT.md`](DEVELOPMENT.md) 对应章节(**不替代**下文深度规范,只作导航)。 + +| 步骤 | 做什么 | 详见 | +|------|--------|------| +| 1 | **拿到仓库**:① 项目经理在 [自建 Gitea](https://git.jc2009.com/)(`git.jc2009.com`,非 Gitea 官网)开仓并授权后 `git clone`;或 ② 本地已有 `skill-template` 时用 scaffold / 复制出新目录(遵守 Git 红线) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §1、§4 | +| 2 | **先读宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;为本技能填入口定位表 | [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) | +| 2b | **用户市场文档**:说明 Tab ≠ 教程 Tab;教程按三入口写;与 §0.1 / actions 对齐 | [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) | +| 3 | **先读 AI + RPA 总纲**;界面技能先完成元素拾取/element map,再按浏览器、桌面或手机读分端手册 | [`AI_RPA.md`](AI_RPA.md)、[`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) | +| 4 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md)(含入口定位表),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) | +| 5 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 | +| 6 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 | +| 7 | 本地单测(mock)通过后,按业务默认 **`real_rpa`** 真实跑通;再 `release.ps1`;看 Gitea CI。**mock 通 ≠ 完成** | [`ADAPTER.md`](ADAPTER.md);[`DEVELOPMENT.md`](DEVELOPMENT.md) §15 | +| 8 | 匠厂安装后,按**入口定位表已挂**的入口自测,并看**用户体验**(进度、失败提示、有头 RPA 等) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15;[`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md);[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) | +| 9 | **交活验收(第三方)**:对照 [`CHECKLIST.md`](CHECKLIST.md) 验收;报告写入仓库根目录 [`acceptance-reports/`](../acceptance-reports/) | [`CHECKLIST.md`](CHECKLIST.md) | + +脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。业务仓也可直接从自建 Gitea clone,见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §4 来源 A。 + +**公开公共仓(可 clone 学习,可扩展登记):** [`SHARED_REPOS.md`](SHARED_REPOS.md) — 当前含 skill-template、account-manager、jiangchang-platform-kit。 + +## 深度规范阅读顺序 + +开始定制 skill 前,建议按以下顺序阅读: + +1. [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) — **宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;入口定位表 +1b. [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) — **用户说明 / 教程契约**:README≠TUTORIAL;三入口结构;skill-detail 不可作主路径 +2. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环 +3. [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) — **浏览器账号标准**:一账号一 Profile、租约、多账号、数据查看、验收与排障 +4. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展) +5. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准(含入口定位表) +6. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读) +7. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范 +8. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关 +9. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**) +10. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 +11. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时的会话、风控和工程总纲 +12. [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) — **所有界面技能必读**:真实拾取、element map、唯一性、降级和 AI 编程约束 +13. 按执行端选读:[`RPA_BROWSER.md`](RPA_BROWSER.md) / [`RPA_DESKTOP.md`](RPA_DESKTOP.md) / [`RPA_MOBILE.md`](RPA_MOBILE.md) +14. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 +15. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读) +16. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读) +17. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 +18. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/) + +Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md)。 +用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md),不要写进本目录。 diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md new file mode 100644 index 0000000..59fde12 --- /dev/null +++ b/development/REQUIREMENTS.md @@ -0,0 +1,130 @@ +# REQUIREMENTS —— 项目经费进度播报助手(track-fund-progress-default) + +> 需求来源:《核心建设内容:各部门"Skill"深度需求规格书》§4.1 财务处 Skill 002(项目经费"智能进度播报")。 +> 详细设计见工作区《财务处Skill002-项目经费智能进度播报-开发文档.md》;本文是该技能仓内的需求与验收主文档。 +> 定位原则:辅助非决策、审慎非激进 —— 数字全部来自本地确定性计算,播报文本由规则模板生成;进度诊断仅作提醒。 + +## 0. 技能标识 + +- 拟定 slug:`track-fund-progress-default` +- 中文 name:项目经费进度播报助手 +- 中文 description:高校项目经费执行监测与播报:按项目台账自动计算实际支出率与序时进度,识别严重滞后/支出异常偏快,生成三段式播报摘要、暂付款冲销与结题报销提醒,支持月度定时播报并输出《经费执行简报》。 +- account-manager platform_key:不适用(无网页 RPA,无账号下发) +- 命名形态:标准型+scope(track ∈ 动词白名单;default ∈ scope 白名单;4 段 27 字符) +- 网页 RPA 登录策略:`not_needed`(无浏览器自动化) + +## 0.1 宿主入口定位 + +| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 教程章节 | 不挂理由 | +|---------------------------|------|----------|------|----------|----------| +| `broadcast-progress`(broadcast,sync) | 挂 | toolbar(bind fund_projects,表单) | 不挂 | 入口一/入口二 | 即时播报由人发起;无人值守走月度播报 | +| `broadcast-monthly`(broadcast-monthly,**async**) | 挂 | 不挂 | **挂 cron** | 入口二/入口三 | 月度批量播报,任务中心可见进度 | +| `import-projects` | 挂 | 不挂 | 不挂 | 入口二 | 批量 JSON 导入走对话更直观 | +| `add-project` | 不挂 | toolbar(fund_projects,表单) | 不挂 | 入口一 | 结构化录入,表单更稳 | +| `list-projects` | 挂 | 仅展示表 | 不挂 | 入口二 | 台账日常在数据管理看 | +| `list-reports` / `stats` | 挂 | 不挂 | 不挂 | 入口二 | 查询类,对话即达 | +| `render-html` | 挂 | toolbar(bind progress_reports)+ skill-detail | 不挂 | 入口一/入口二 | 报告重建 | +| `view-rules` | 挂 | 仅展示表 | 不挂 | 入口二 | 阈值日常只读 | +| `set-rule` | 不挂 | row(alert_rules,预填 rule_key) | 不挂 | 入口一 | 管理员在表上改值更直接 | +| `health` / `version` / `config-path` | 不挂 | 不挂 | 不挂 | N/A | 运维诊断,仅 skill-detail | + +定时挂载说明:`broadcast-monthly` 为 async + agent + cron,缺省参数(全部在研项目)可安全无人值守,无不可逆副作用;对应规格书"每月 5 号 9:00 自动执行"场景,具体时刻由宿主定时任务配置。 + +## 0.2 AI + RPA 协作与授权边界 + +- 本技能**无 RPA**:不启动浏览器、不操作桌面/手机、不调用外部 HTTP。 +- 台账数据由用户录入/导入;Agent 不得编造金额与日期。 +- 目标系统:无外部系统;全部计算在本地 SQLite。 +- 财务系统直连、企业微信推送、PDF 导出、大模型润色均为后续扩展。 + +## 0.3 业务行生命周期 + +不适用:播报为只读计算 + 本地记录写入,无"处理队列"状态机。 + +## 1. 文档目标 + +明确 `track-fund-progress-default` 的研发范围、交付标准与验收要求。 + +## 2. 业务背景 + +横向/纵向项目经费"重立项、轻执行",PI 不知道执行率,年底突击花钱或结题沉淀;财务处靠电话催报。需要开发 `track-fund-progress-default`:按台账自动计算执行率与序时进度、诊断偏差、生成三段式播报与简报,月度定时播报,减少人工催报。 + +## 3. 开发目标 + +- 台账维护(upsert/批量导入/表格编辑)与预警阈值配置 +- 三步计算内核:执行率(C1-C3)、偏差诊断(D1-D3)、提醒与三段式播报(N1-N3) +- 播报结果 JSON + `progress_reports` 落库 + 《经费执行简报》HTML + 月度汇总简报 +- 月度播报 async Action(cron 挂载,任务中心进度) +- 失败可诊断(稳定错误码 `ERROR:<CODE>:` + 统一日志) + +## 4. 功能范围 + +### 必须实现 + +- `health` / `version` / `init-db` / `config-path`(模板基座;`run` 为模板演示占位) +- `add-project` / `import-projects` / `list-projects` +- `broadcast`(主命令,sync)/ `broadcast-monthly`(async + cron) +- `list-reports` / `show-report` / `render-html` / `view-rules` / `set-rule` / `stats` +- 业务表 3 张 + `_jiangchang_*` 中文展示元数据 + 预警规则种子(4 条) +- `assets/actions.json` 按 §0.1 定位表声明 + +### 可后续扩展 + +- 财务系统直连自动刷新台账、企业微信图文卡片推送、PDF 建议函、大模型润色、科目级分析、多人项目聚合 + +## 5. 非功能要求 + +- 目录结构符合 `skill-template` 规范;入口统一 `scripts/main.py` +- 单文件 < 900 行;UTF-8 without BOM;Windows UTF-8 输出兼容 +- 机读输出 JSON;错误前缀 `ERROR:`;主命令 `task_start`/`task_failed` 日志齐全,无敏感明文 +- 月度播报逐项目 `emit` 进度;金额 round(x,4);不存证件号/银行卡号 +- 测试离线可过(无网络、无浏览器) + +## 6. 输入输出要求 + +### 输入 + +- 台账字段(camelCase/snake_case/中文键兼容):projectNo、projectName、projectType、piName、department、totalApproved、totalReceived、totalSpent、frozenAmount、frozenSince、startDate、endDate、status +- 播报参数:`--project-no`、`--pi`、`--as-of`(缺省今天) + +### 输出 + +- 成功:stdout JSON(`ok/batch_no/as_of/count/status_counts/brief_file/reports_brief`) +- 失败:`ERROR:NO_PROJECTS|PROJECT_INVALID|REPORT_NOT_FOUND|RULE_INVALID|ENTITLEMENT_DENIED|QUERY_FAILED` + 中文 message +- 落库:`progress_reports`;文件:`html/report-{id}.html`、`html/brief-{batch}.html` + +## 7. 依赖的兄弟技能或外部系统 + +- 无兄弟技能依赖;无外部系统 +- `requirements.txt`:无特有依赖 + +## 8. 不在本次范围内的内容 + +- 不修改财务系统数据、不做报销代操作 +- 不直连财务/科研系统、不推送企业微信、不导出 PDF +- 不存证件号、银行卡号等个人敏感信息 +- 不兼容旧模板历史结构 + +## 9. 验收标准 + +- 目录名 = `SKILL.md` slug = `constants.SKILL_SLUG`;三处版本号一致(1.0.1) +- `health` / `version` / `init-db` 正常;`python tests/run_tests.py -v` 全绿 +- 金标准测试覆盖 C1-C3/D1-D3/N1-N2 正反例与阈值边界;三种代表场景真实跑通(正常/严重滞后+暂付款超期/异常偏快+临近结题) +- `set-rule` 修改阈值后诊断随之变化;`stats` 与记录一致;月度播报生成汇总简报 +- `init_db()` 写入展示元数据,中文 display_name 完整,`test_display_metadata` 通过 +- `assets/actions.json` 与定位表、TUTORIAL 三入口一致;`test_tutorial_surfaces` 通过 +- `developer_ids` 已配置(79017);发布后 Gitea 工作流成功、市场可见可装 +- 宿主多入口验收:对话播报、数据管理按钮/行级、HTML 简报预览、定时任务创建并手动触发月度播报 + +## 10. 开发注意事项 + +- `cli` 只做参数解析;核心逻辑在 `service`(fund_service 唯一内核,多入口复用) +- 业务表间禁止 SQLite 外键;时间字段 Unix 秒;金额 round(x,4) +- 保留模板演示 `cmd_run`(POLICY-RPA-003 录屏集成守护);业务命令不使用录屏 +- 修改开发规范时同步 `POLICY_MATRIX.md` 与对应守护测试 + +## 11. 变更记录 + +| 日期 | 版本 | 变更人 | 变更内容 | +|------|------|--------|----------| +| 2026-09-13 | v1.0 | 开发组 | 初版:三步计算内核 + 台账/阈值维护 + 简报与月度播报 | diff --git a/development/RPA.md b/development/RPA.md new file mode 100644 index 0000000..261b58e --- /dev/null +++ b/development/RPA.md @@ -0,0 +1,444 @@ +# RPA 操作标准(浏览器 / 桌面 / 手机) + +> 本文是团队 RPA 开发的**统一标准**。任何需要"自动操作软件界面"的 skill,都应先读这份文档,按这里的选型和范式落地,不要每个项目重新踩坑。 + +先读 [`AI_RPA.md`](AI_RPA.md):Agent 负责理解、确认与编排,RPA 只负责授权范围内的确定性界面执行。RPA 是代替人完成正常业务操作的工具,**不是**破解、逆向、私有协议调用或对抗第三方安全机制的手段。 + +网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。账号设计、查看、租约、多账号和验收标准先读 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md);二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。 + +我们开发的各类 skill,本质上都是在替人操作三类界面:**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:可控会话(Profile)、有头运行、拟人操作、失败存证、人工兜底。 + +**开始写界面操作代码前,必须先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md),完成真实拾取、元素清单、唯一性和后置状态设计。** 分端实操: + +- 浏览器:[`RPA_BROWSER.md`](RPA_BROWSER.md) +- Windows 桌面:[`RPA_DESKTOP.md`](RPA_DESKTOP.md) +- Android / iOS:[`RPA_MOBILE.md`](RPA_MOBILE.md) + +--- + +## 0. 三端通用范式(先看这个) + +无论操作哪类界面,都遵循同一套约定: + +| 约定 | 说明 | +|------|------| +| **可控会话 / Profile** | 网页版见下方 **§0.2**(必须走 account-manager 的 `profile_dir`);桌面/手机用对应持久会话,不硬编码密码 | +| **有头运行** | 生产 RPA **必须有头**(见 §0.2);禁止把无头当交付/联调常态 | +| **正常人工节奏** | 真实点击、逐字输入、自然鼠标轨迹、分段滚动;严禁 JS 直接设值/JS 点击/JS 跳转绕过页面流程 | +| **步骤间随机等待** | 每两个正常业务动作之间 `random_delay(min,max)`,真实环境默认 **1~3 秒**;状态等待另算,不得用固定睡眠代替 | +| **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 | +| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` | +| **选择器纪律** | 先真实拾取并填写 element map;语义/可访问性属性优先;关键动作唯一匹配;**严禁凭记忆猜 selector 或用 first/index 掩盖歧义** | +| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 | +| **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step` 或 `video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止(SRCP,见 [`LOGGING.md`](LOGGING.md) §2.5) | +| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 | + +> 三端各自实现一个会话抽象 `RpaSession`(launch / login / act / screenshot / close),上层 skill 不感知是浏览器还是手机。 + +### 0.0 合规红线 + +- 只使用用户或企业合法拥有、明确授权的账号、数据、设备和业务权限。 +- 优先正常界面或获得授权的官方 API;只有破解、逆向或绕过安全机制才能实现时,停止并上报。 +- 禁止反编译、私有协议、抓包拼装隐藏接口、Hook / 注入、客户端篡改、验证码破解、设备指纹伪造、撞库、养号、自动换号或代理池规避限制。 +- 验证码、人脸、指纹、短信、U盾、动态口令必须交给人工;权限不足、账号风险、频控或封禁必须安全停止。 +- 合规与完整职责边界以 [`AI_RPA.md`](AI_RPA.md) 为准。 + +### 0.1 步骤控制与进度(SRCP,platform-kit >= 1.2.0) + +- **禁止** `scripts/util/progress.py` 或 `print({"type":"progress",...})`;进度只走 `emit` / Run Journal。 +- **禁止** RPA 主路径裸 `asyncio.sleep`;用 `jiangchang_skill_core.activity.interruptible_sleep`。 +- RPA 函数用 `@rpa_step("中文步骤名")` 拆分;或在 `RpaVideoSession` 内 `video.add_step("中文步骤")`。 +- `cmd_run` 须 `with job_context(skill=SKILL_SLUG):`,所有出口 `finish(...)`。 +- 暂停在步骤边界生效;浏览器会话暂停时不强制关闭,恢复后同进程继续。 + +```python +from jiangchang_skill_core.activity import emit, finish, job_context, interruptible_sleep, rpa_step + +@rpa_step("打开登录页") +async def open_login(page): + await page.goto("https://example.com") + await interruptible_sleep(1.5) +``` + +规范权威定义:[`LOGGING.md`](LOGGING.md) §2.5。金样代码:[`scripts/service/task_service.py`](../scripts/service/task_service.py)。 + +### 0.2 网页版 RPA 硬规则(Profile / 有头 / 登录分平台) + +凡**打开网页、用浏览器自动化**的技能(真实站或仿真站),必须遵守(`POLICY-RPA-004`): + +| 规则 | 要求 | +|------|------| +| **Profile 强制** | 启动浏览器前必须已从 **account-manager**(经本技能 `account_client`)拿到可用的 **`profile_dir`(及租约约定)**。只用该目录做 `launch_persistent_context`。 | +| **首启用 ensure** | 业务技能默认走 **`account ensure-web`**(或 `pick-web --ensure`),经 `account_client.pick_web_account` 封装。无匹配账号时自动 `add-web` 再返回;**有账号但均被租约占用 → `ERROR:LEASE_CONFLICT`,禁止再新建重复账号**。 | +| **拿不到就停** | 未拿到 `profile_dir`、ensure 失败、租约失败 → **立即失败退出,禁止开浏览器**。不得静默落到系统默认 Chrome/Edge 用户目录。 | +| **有头强制** | 生产与用户侧联调:**`OPENCLAW_BROWSER_HEADLESS=0`(有头)**。禁止把无头(`=1`)当作交付或真实站联调常态(无头易风控、难人工介入,且易污染身份)。 | +| **登录不全局强制** | **是否要在目标站完成登录**由平台决定(如部分公开页可不登录也能闭环)。由做该技能的同事在 `REQUIREMENTS.md` / 用户教程中约定;**模板不强制「所有网页 RPA 必须先登录」**。 | +| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 | +| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 | +| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 | +| **账号必须可见可查** | 创建后须能在宿主数据管理、`account list/get` 和实际 `profile_dir` 中核验;不得把“命令没报错”当作创建与登录验收。 | +| **一账号一 Profile** | 不同账号必须使用不同且稳定的 `profile_dir`;不得共用、临时创建或指向本机日常浏览器目录。 | +| **租约贯穿任务** | 所有账号选择路径(包括指定数字 ID)都必须取得非空 `lease_token`;成功、失败、取消和异常均在 `finally` 释放。 | + +**反模式(禁止):** + +- 未拿到 `profile_dir` 仍 `launch` / 仍打开页面 +- 用系统默认用户数据目录或随意空目录反复跑真实站 → **污染本机 Profile、加重风控** +- 把「本站可不登录」写成「可以不走 account-manager」 +- 真实站联调默认无头狂跑 +- 仅用哈希 class / 不稳定 CSS 作为**唯一**登录态判定(须结合稳定文案、`get_by_role`、URL/业务 DOM;F12 实页确认) +- 把「业务空结果」(如无评论)与 `LEASE_CONFLICT` / `REQUIRE_LOGIN` 混成同一错误码 + +**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → ensure-web → RPA → `finally release_lease`)。 + +账号完整生命周期、数据目录、数据管理查看方式、矩阵场景和排障证据见 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)。不得只测试单账号一两次就宣称支持批量或多账号。 + +--- + +### 0.3 工业级风控、等待与收尾 + +**状态等待优先:**页面加载、控件可用、弹窗消失、保存成功、下载完成和业务结果出现,必须等待真实条件并设置超时;禁止用固定 `sleep(3)` 假设成功。 + +**有界就绪等待(platform-kit ≥ 1.2.11 必用):** + +- 业务技能的状态等待统一使用共享 API:`jiangchang_skill_core.rpa.poll_until`(async)或 `poll_until_sync`。 +- **禁止**在 service 主路径再发明平行的 `deadline + sleep` / 私有 `_wait_*` 轮询作为标准答案(HITL 专用等待除外,仍可用 kit 的 `wait_for_login` / `wait_for_captcha_pass`)。 +- 必须区分:**还在加载**(超时内继续等)vs **真的不是目标页/目标态**(有界等待失败后再判)。 +- 超时默认映射 `ERROR:UI_NOT_READY`(或业务等价就绪码);**不要**把「慢」直接判成 `PAGE_TYPE_MISMATCH`、配置门禁失败或 `ERROR:RATE_LIMITED`。 +- 超时秒数须允许环境变量 / `.env` 覆盖(慢机器、限频、轻量电源场景可调大)。 +- 可选浏览器启动超时:`launch_persistent_browser(..., timeout_ms=...)` 或 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`;超时码 `ERROR:BROWSER_START_TIMEOUT`。默认不开启,与历史行为兼容。 +- `health` 可带只读 `machine_power`(供电/限频提示);**不得**仅凭该信号 hard-fail 全技能。 + +**操作节奏:** + +- 两个正常业务动作之间默认随机等待 `1~3` 秒。 +- 输入采用逐字符随机延迟;鼠标使用自然轨迹;滚动采用分段操作。 +- 同一账号、Profile、桌面会话或手机设备默认串行。 +- 单批数量、单位时间操作量和运行时长必须有上限。 +- 只对明确可重试错误做有限重试;普通网络错误可递增退避,风险提示和权限错误不得自动重试。 + +**前后缓冲:** + +- 启动浏览器、桌面软件或手机会话后,先等待目标页面 / 窗口 / 控件真实就绪,再额外缓冲约 `1.5~2.5` 秒。 +- 页面导航后,关键控件就绪再缓冲约 `1~2` 秒。 +- 流程结束时,先确认业务结果、写日志并完成存证,再缓冲约 `2~3` 秒关闭会话。 +- 付款、审批、发布等重要动作确认结果并截图后,可缓冲约 `3~5` 秒。 + +缓冲用于稳定界面和改善用户体验,**不能作为成功判断**。RPA 主路径使用 `interruptible_sleep` 等可控制等待,确保暂停与停止能够生效。 + +**风险信号:** + +| 信号 | 标准处理 | +|------|----------| +| 验证码 / 人脸 / 指纹 / U盾 | 进入人工处理 | +| 频率限制 | 停止当前批次或长时间退避 | +| 账号风险 / 封禁 / 安全警告 | 立即停止,禁止换号继续 | +| 权限不足 | 明确失败,不尝试绕过 | +| 页面 / 控件结构变化 | 截图并停止,重新人工验证定位 | +| 提交结果不明确 | 查询业务状态,确认前禁止再次提交 | + +--- + +## 1. 浏览器(标准已成熟) + +**选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。 + +元素拾取、定位优先级、iframe/弹窗/Shadow DOM、动态列表和唯一性标准见 [`RPA_BROWSER.md`](RPA_BROWSER.md)。本节继续定义会话、启动、HITL 和工程边界。 + +### 1.0 生产路径(优先) + +| 项 | 标准 | +|----|------| +| 浏览器 | **优先系统 Chrome/Edge** + `launch_persistent_context`,`channel="chrome"` 或 Edge;不用内置 Chromium,**技能内不要 `playwright install`** | +| Profile / 账号 | **必须**经 account-manager **`ensure-web`**(或 `pick-web --ensure`)取得 `profile_dir`(见 §0.2);密码不进 `.env`;**是否站点登录**按平台在需求/教程约定;登记 ≠ 已登录 | +| CDP | **仅作诊断 / 桌面宿主类场景**;**不要**作为强风控站点的默认生产路径 | +| 行为 | 模拟真实用户:真实点击、键盘、鼠标、地址栏输入;**不要**拼接搜索结果 URL、DOM 注入、`el.value=`、JS 跳转 | +| 模式 | **有头** `OPENCLAW_BROWSER_HEADLESS=0`(生产强制,见 §0.2) | +| 浏览器兼容配置 | `OPENCLAW_PLAYWRIGHT_STEALTH` 仅用于经评估的兼容配置;不得用于伪造身份或绕过平台限制 | + +### 1.1 Playwright 启动标准 + +1. **有头**:`OPENCLAW_BROWSER_HEADLESS=0`(`.env.example` 默认;网页 RPA 生产路径禁止改成无头常态,见 §0.2)。 +2. **stealth 须受控**:`OPENCLAW_PLAYWRIGHT_STEALTH` 仅在目标平台授权、规则与项目评估允许时启用;不得把它理解为规避风控或伪造设备身份。 +3. **不要在技能里自行安装 playwright**;由宿主共享 runtime 提供。 +4. **不要默认传 `--no-sandbox`**(除非特定容器环境且已评估风险)。 +5. **不要默认传 `--disable-blink-features=AutomationControlled`**;platform-kit stealth 已覆盖,额外 flag 可能适得其反。 +6. **可以** `ignore_default_args=["--enable-automation"]`(platform-kit `launch_persistent_browser` 已处理)。 +7. **强风控平台**:优先真实点击、键盘、鼠标、地址栏、account-manager 持久 profile;**不要**直接拼接搜索结果 URL 或 DOM 注入。 +8. **先 Profile 再开浏览器**:`user_data_dir` 必须来自 ensure/pick 到的 `profile_dir`;未拿到则不得进入本节后续步骤。 + +浏览器兼容配置由共享 `jiangchang_skill_core.rpa` 提供(platform-kit **>= 1.2.0**)。业务技能不得自行扩展指纹伪造、代理轮换或规避安全检测的代码;遇到明确风控信号时转人工或停止。 + +**拟人操作**(必做): + +- 输入:逐字符 `keyboard.type(delay=90~240ms)`,**先真实点击聚焦再输入,绝不 `el.value=`**。 +- 鼠标:贝塞尔曲线轨迹 + 微抖动;进场随机晃动。 +- 导航:用真实点击触发,**不要 `window.location.href=` / JS 点击跳转**。 +- 翻页:真实点击翻页控件,注意排除禁用态。 +- 延迟:两个正常业务动作之间 `random_delay`(`.env` 中 `STEP_DELAY_MIN/MAX`,默认 1~3 秒);页面状态仍须显式等待。 + +### 1.2 页面启动标准 + +**`launch_persistent_context` 的 `args` 只能放 Chrome 启动参数**(例如 `--start-maximized`、`--disable-blink-features=AutomationControlled` 等)。 + +**绝对不要把 `https://...` 这类 URL 放进 `args`。** 否则 Playwright 会报错: + +``` +BrowserType.launch_persistent_context: Arguments can not specify page to be opened +``` + +**正确做法(生产路径):** + +1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, ...)` +2. `page = await context.new_page()` +3. `await page.goto(start_url, wait_until="domcontentloaded", timeout=60000)` + +对强风控站点,如果需要更拟人,可以启动后通过**地址栏输入 / 真实点击进入**目标页,但**仍然不能把 URL 塞进 launch args**。 + +完整参考实现见: + +- `examples/real_browser_rpa/README.md`(**开发真实浏览器 RPA 类 skill 时必须先读**) +- `examples/real_browser_rpa/scripts/service/browser_session.py` + +**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景。 + +### 1.3 HITL / 验证码 + +- 自动处理失败时**允许等待人工**:滑块、短信、人脸、U盾、动态口令等。 +- 等待人工验证时必须有:**字幕 step**(用户可见动作说明)、**结构化日志**、**超时**(`HUMAN_WAIT_TIMEOUT`)。 +- 检测到风控页(URL/DOM 特征)→ 抛 `ERROR:CAPTCHA_NEED_HUMAN`,轮询等待或超时,**不要强行绕过平台安全机制**。 +- **不要自动操作滑块**。 + +> 共享库:`jiangchang_skill_core.rpa.wait_for_captcha_pass`;**真实浏览器 RPA 成功案例**见 `examples/real_browser_rpa/`: +> +> - 先读 `examples/real_browser_rpa/README.md` +> - 再参考 `examples/real_browser_rpa/scripts/service/human_verification.py` +> - 再参考 `examples/real_browser_rpa/scripts/service/task_rpa.py` + +### 1.4 Playwright 与安装边界 + +- Playwright 是宿主共享 runtime 提供的通用 Python 包能力;**正式 skill 不负责安装**。 +- skill 侧**禁止**在 `requirements.txt`、安装脚本、运行脚本、根 `README.md` 用户说明中加入 `playwright install`、`pip install playwright` 或任何自动安装浏览器驱动的逻辑。 +- 真实浏览器 RPA 默认使用**系统 Chrome/Edge**,由宿主/runtime 管理。 +- **不要**执行 `playwright install chromium`、`playwright install chrome` 等浏览器驱动安装命令。 +- 仅当**本地独立开发环境排障**时,开发者可手工检查或补装 Python 包 `playwright`;该操作**不得**进入 skill 交付文件与自动流程。 + +页面启动仍遵循 1.2:`launch_persistent_context` 的 `args` 只能放 Chrome 启动参数,**不能把 URL 塞进 `args`**;打开页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入。 + +### 引用方式 + +共享实现位于宿主共享 runtime 安装的 `jiangchang-platform-kit`(`jiangchang_skill_core.rpa`)。复制后的业务技能直接 import,**技能仓库不得保留 rpa 公共代码副本**: + +```python +from jiangchang_skill_core.rpa import ( + launch_persistent_browser, + anti_detect, + wait_for_captcha_pass, + capture_failure, + errors, +) +from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRIPT +``` + +- `RpaVideoSession` 来自 platform-kit **>= 1.2.0**;ffmpeg、背景音乐、media-assets 由 platform-kit 统一解析;已提供前置/后置缓冲、字幕、TTS 旁白、背景音乐循环、结尾淡出。 +- `health` 对上述资源做只读诊断,不下载、不修复。 + +### 1.5 真实浏览器 RPA 示例(必读) + +开发**真实网站 + 浏览器操作 + 登录态/验证码/滚动采集**类 skill 时: + +1. **必须先阅读** `examples/real_browser_rpa/README.md` +2. 再参考以下代码: + - `examples/real_browser_rpa/scripts/service/browser_session.py` — persistent context 启动 + - `examples/real_browser_rpa/scripts/service/human_verification.py` — 人工验证等待 + - `examples/real_browser_rpa/scripts/service/task_rpa.py` — RPA 主流程 + - `examples/real_browser_rpa/scripts/service/account_client.py` — account-manager CLI 封装 + +仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/`(含 async `simulator_playwright.py`、薄 `simulator_rpa` adapter、`account_client.py` subprocess),**不能**替代真实浏览器 RPA 规范。 + +### 1.6 仿真浏览器 RPA 示例(必读) + +开发**自有仿真页面 / sandbox / 可控 DOM 的浏览器 RPA**(表单填写、批量提交、弹窗确认)时: + +1. **必须先阅读** `examples/simulator_browser_rpa/README.md` +2. 再参考: + - `examples/simulator_browser_rpa/scripts/service/browser_session.py` — async persistent context(**不含 goto**) + - `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py` — RPA 主流程(厚) + - `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock + dispatch) + - `examples/simulator_browser_rpa/scripts/service/account_client.py` — account-manager subprocess + - `examples/simulator_browser_rpa/scripts/service/task_service.py` — async 编排 + - `examples/simulator_browser_rpa/sandbox/demo_app.html` + +> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox / 行业仿真 → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。 + +### 1.7 行业仿真平台浏览器 RPA(jc2009 类) + +适用:**自有或共享 `sandbox.jc2009.com` 等行业仿真平台**,不是真实高风控第三方站。 + +工程范式与 §1.5 相同: + +| 项 | 标准 | +|----|------| +| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 | +| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`)+ `account_client.py` subprocess | +| 登录 | **双层**:门户 HITL(`#portal-user` / `#portal-pass` 类泛化 DOM)+ 业务系统登录(技能内自写) | +| 账号 | `url` 用行业根,不用 `/login`;`auth_strategy=per_session_manual`;`pick_web_account`(底层 ensure-web)+ `release_lease` | +| 禁止 | **不要** `import account-manager` 的 `rpa_helpers` 等内部模块 | +| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback;共享 sandbox **不要求**为技能加 testid | +| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 | + +权威 example:**修正后的** `examples/simulator_browser_rpa/`(不是任何生产技能仓库)。 + +--- + +## 2. 桌面软件(Windows 原生程序) + +**选型:Windows UI Automation(UIA)为主 + 图像识别兜底。** 当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。完整拾取、定位和降级标准见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md)。 + +桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。 + +| 技术 | 优先级 | 适用 / 说明 | +|------|--------|------| +| **uiautomation** | ✅ 已验证首选 | `add-friend-wecom` 真实成功案例使用;直接读取 UIA Window/Control 树 | +| **pywinauto(`backend="uia"`)** | ✅ 兼容 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python | +| **FlaUI**(经 pythonnet 调 .NET) | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 | +| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 | +| **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 | + +### 桌面端注意事项 + +- **窗口聚焦/置顶**:操作前确保目标窗口前置,避免误操作其它窗口。 +- **DPI/缩放**:图像识别方案必须固定显示缩放比例;UIA 方案不受影响(优先用 UIA 即是为此)。 +- **存证**:同样要失败截图(截目标窗口/全屏),存到 `rpa-artifacts`。 +- **人工兜底**:U盾插拔、动态口令、人脸 → 停下等人工,超时 `ERROR:XXX_NEED_HUMAN`。 +- **禁止侵入**:不注入或 Hook 第三方进程,不修改客户端文件,不反编译或调用未授权私有协议。 + +```bash +pip install uiautomation # 当前真实成功案例 +# 或:pip install pywinauto # UIA backend +# 图像兜底:pip install pyautogui opencv-python +``` + +> 状态:UIA 技术路线已经 `add-friend-wecom` 真实项目验证。脱敏、收紧后的参考 example 见 `examples/real_desktop_rpa/`;目标软件 selector 仍必须重新拾取。 + +--- + +## 3. 手机软件(USB 连接电脑) + +**选型:Android 用 uiautomator2(或 Appium);iOS 用 Appium + WebDriverAgent(需 Mac)。** 底层都是经 USB 的 ADB / WDA。 + +设备准备、Appium Inspector/UIAutoDev 拾取、Native/WebView、定位优先级和失败 XML 留证见 [`RPA_MOBILE.md`](RPA_MOBILE.md)。 + +| 平台 | 技术 | 优先级 | 说明 | +|------|------|--------|------| +| **Android** | **uiautomator2**(python 原生) | ✅ 首选 | ADB over USB,直接拿控件树点击/输入,比 Appium 轻快,纯 Python | +| Android | **Appium**(uiautomator2 driver) | 备选 | 需要跨平台统一接口、或团队已有 Appium 资产时用 | +| Android | **Airtest + Poco**(网易开源) | 兜底 | 图像+控件混合,自带 IDE 可录制;控件树拿不到时用 | +| **iOS** | **Appium + WebDriverAgent(XCUITest)** | 唯一可行 | **必须有一台 Mac 做中转**,Windows host 无法直接驱动 iOS | +| 投屏/人工介入 | **scrcpy** | 辅助 | USB 投屏到电脑,配合人工过验证码/人脸 | + +### 手机端注意事项 + +- **设备就绪检查**:`adb devices` 确认已授权连接;放进 `doctor` 自检。 +- **登录态**:靠 App 自身保持登录,必要时引导人工首登一次。 +- **人工兜底**:短信验证码、人脸、指纹 → scrcpy 投屏让人工完成,程序轮询等待。 +- **存证**:失败时 `adb screencap` / Appium 截图存 `rpa-artifacts`。 +- **设备边界**:使用正常授权设备与官方 App;不 Root / 越狱,不 Hook,不修改 APK / IPA,不伪造设备身份或绕过签名、绑定与安全校验。 + +```bash +# Android(首选) +pip install uiautomator2 +python -m uiautomator2 init # 初始化设备端 agent +# 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server) +``` + +> 状态:手机端标准已定义,但**尚无团队真实成功案例**。`examples/mobile_rpa/` 仅是 example 完成规格,不能作为已跑通代码照抄;收到成功案例后再升级状态。 + +--- + +## 4. 统一错误码(RPA 场景) + +skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层判断与重试: + +| 错误码 | 含义 | 上层处理建议 | +|--------|------|------| +| `ERROR:REQUIRE_LOGIN` | 未登录 / 登录态失效 | 触发登录流程(有头等待人工) | +| `ERROR:LOGIN_TIMEOUT` | 等待人工登录超时 | 提示用户重跑并及时操作 | +| `ERROR:CAPTCHA_NEED_HUMAN` | 命中滑块/验证码拦截 | 暂停等人工,或转人工队列 | +| `ERROR:LEASE_CONFLICT` | 已有账号但均被租约占用(ensure 不新建) | 稍后重试或释放租约;**不是**「无账号」 | +| `ERROR:AMBIGUOUS_ACCOUNT` | `--login-id` / `--label` 匹配到多条 | 改用数字 id 或更精确标识 | +| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 | +| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 | +| `ERROR:BROWSER_START_TIMEOUT` | 浏览器启动超时(可选 timeout) | 可重试;检查机器负载/供电;可调大 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS` | +| `ERROR:UI_NOT_READY` | 有界等待内页面/控件未就绪 | **默认可重试**;勿与「页类型错误/配置未开」混用;勿轻易标 session fatal | +| `ERROR:ENV_DEGRADED` | 环境降级提示类(限频/供电不足等,技能选用) | 可重试;提示接电源/退出省电/减小并发;**不是**配置错误 | +| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB | +| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 | + +业务空结果(如「本页无评论」)用业务码或成功空列表表达,**不要**复用上表账号/登录错误码。 + +**归因纪律(防误诊):** + +- 「未开 `ALLOW_REAL_RPA` / 档位门禁」必须用技能自己的配置类错误码,**禁止**与真实启动失败、启动超时共用一个 hint。 +- 页面慢导致的超时优先 `UI_NOT_READY` / 业务就绪码;仅当有明确频控 DOM/文案时才用 `RATE_LIMITED`。 +- 是否列入技能 `SESSION_FATAL` / `RETRYABLE` 由技能自定,但 **UI 未就绪 / 启动超时默认不应整批不可重试终止**(除非会话已明确不可恢复,如未登录、验证码人工超时)。 + +--- + +## 5. 存证与录屏规范 + +### 5.1 截图存证 + +- **路径**:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{slug}/rpa-artifacts/{batch_id}/{tag}_{timestamp}.png` +- **失败必截图**:受 `OPENCLAW_ARTIFACTS_ON_FAILURE`(默认开)控制。 +- **Playwright 不负责录屏**,仅浏览器自动化。 +- **常见 tag**:`before_submit` / `after_submit` / `captcha` / `login_fail` / `error`。 + +### 5.2 RPA 视频 step 标准 + +字幕是**用户可见动作说明**,不是技术日志。step 要贴近真实动作,不要只在大流程入口打点。 + +推荐关键动作示例(按业务裁剪): + +- 启动浏览器 +- 打开首页 +- 检查登录状态 +- 定位输入框 +- 输入关键词:xxx +- 点击搜索 +- 等待结果 +- 打开详情页 +- 提取信息 +- 写入结果 +- 任务完成 + +技术诊断、重复跳过、DB 写入可以显示但**通常不需要旁白**。 + +`title` / `closing_title` 必须由 skill 传入**中文业务文案**(如「开始执行示例任务」「示例任务执行完成」)。 + +### 5.3 录屏成片标准 + +- **skill-template 默认 `OPENCLAW_RECORD_VIDEO=0`**(开发调试更快,不启 ffmpeg)。 +- **`OPENCLAW_RECORD_VIDEO=1` 时启用录屏**:字幕、旁白、背景音、MP4 成片由 platform-kit 统一处理。 +- 使用 platform-kit 的 **`RpaVideoSession`**;**skill 不要自行合成视频**(不要自己调 ffmpeg 拼 MP4)。 +- **默认关闭 ≠ 可以删除录屏接入**:RPA / 长任务 **service 层**(`scripts/service/*.py`)**必须保留** `RpaVideoSession`、`video.add_step`、video artifact merge 进 `finish()`(见 `POLICY-RPA-003`)。 +- `OPENCLAW_RECORD_VIDEO=0` 时 session **无副作用**(不启 ffmpeg、不写字幕文件、不生成最终 MP4),但 **`RpaVideoSession.add_step` 仍可同步 Activity 进度**。 +- 生产留证 / 合规 / 客户现场复现时,由用户在 `.env` 或进程环境变量中开启 `OPENCLAW_RECORD_VIDEO=1`。 +- **ffmpeg 是唯一录屏器**(Windows:`gdigrab` + `desktop`)。 +- **最终视频**(`OPENCLAW_RECORD_VIDEO=1` 时):`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4` +- **中间产物**:`rpa-artifacts/{batch_id}/capture.mp4`、`subtitles/`、`logs/` 等。 +- 任务完成后 CLI / `finish()` 应包含 video 字段(开启录屏时有路径;关闭时仍保留 `video` 结构与诊断占位):`video_path`、`raw_video`、`video_log`、`video_warnings`、`music_path`、`voiceover_path`、`audio_warnings`(见 `scripts/service/task_run_support.py`)。 + +模板最小示范见 `scripts/service/task_service.py` 的 `_run_template_demo()`。 + +--- + +## 相关文档 + +- `RPA_ELEMENT_LOCATING.md` — 三端共用的拾取、element map、唯一性、降级和 AI 编程约束 +- `RPA_BROWSER.md` — 浏览器 Pick Locator、DOM 作用域和定位质量 +- `RPA_DESKTOP.md` — Windows UIA 拾取与已验证桌面路线 +- `RPA_MOBILE.md` — Android/iOS 拾取、context、设备与 XML 留证 +- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式 +- `CONFIG.md` — `.env` 配置规范与首次落盘机制 +- `RUNTIME.md` — 运行时目录与环境变量约定 diff --git a/development/RPA_BROWSER.md b/development/RPA_BROWSER.md new file mode 100644 index 0000000..15ede81 --- /dev/null +++ b/development/RPA_BROWSER.md @@ -0,0 +1,123 @@ +# 浏览器 RPA 元素拾取与定位标准 + +> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。本文只定义浏览器端的拾取、定位、验证和常见陷阱;账号、Profile、HITL 和录屏仍以 [`RPA.md`](RPA.md) 为准。 + +## 目录 + +1. 拾取工具 +2. 拾取步骤 +3. 定位优先级 +4. 特殊上下文 +5. 验证标准 +6. 禁止模式 +7. example 路由 + +## 1. 拾取工具 + +优先使用 Playwright Inspector / Codegen 的 **Pick Locator**,辅以 Chrome/Edge DevTools: + +- Pick Locator:点选页面元素,获取 role/label/text/testid 等候选定位器。 +- DevTools Elements:确认真实 DOM、属性、父容器、iframe 和 Shadow DOM。 +- Playwright trace/screenshot:复核动作发生时的页面状态。 + +检查器只负责给出候选。复制到生产代码前必须人工确认作用域、唯一性和稳定性。 + +## 2. 拾取步骤 + +1. 使用真实 `profile_dir` 和有头浏览器打开目标页面。 +2. 手工进入发生动作的准确页面、标签页和弹窗状态。 +3. 用 Pick Locator 点选元素,先记录建议 locator。 +4. 在 DevTools 中确认: + - accessible role/name + - label、placeholder、可见文本 + - 稳定 id/name + - `data-testid`(仅目标系统已提供或自有系统可维护时) + - 所在 iframe、dialog、表格行或卡片 +5. 先定位业务容器,再在容器内定位动作元素。 +6. 运行 `count()`;关键动作期望 `1`。 +7. 验证 `visible`、`enabled`、必要时 `editable/checked`。 +8. 执行动作并断言 URL、业务容器、成功提示、单号或状态变化。 +9. 刷新页面、关闭重开浏览器后复测。 + +## 3. 定位优先级 + +从高到低: + +1. `get_by_role(role, name=...)` +2. `get_by_label(...)` +3. 自有系统的稳定 `get_by_test_id(...)` +4. `get_by_placeholder(...)`、`get_by_alt_text(...)`、`get_by_title(...)` +5. 稳定业务文本,优先用于非交互元素或父容器 +6. 稳定 `id` / `name` / 短 CSS +7. 容器 + `filter(has_text=...)` / `has=...` +8. 短 XPath +9. 已验证的顺序定位 + +示例: + +```python +dialog = page.get_by_role("dialog", name="订单确认") +submit = dialog.get_by_role("button", name="确认提交") +if await submit.count() != 1: + raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS submit_button") +await submit.wait_for(state="visible") +await submit.click() +await page.get_by_text("提交成功", exact=True).wait_for(state="visible") +``` + +### 列表与重复按钮 + +不要直接选择全页第一个“添加”: + +```python +row = page.get_by_role("row").filter(has_text="客户A") +button = row.get_by_role("button", name="添加") +``` + +如果业务键不唯一,先停止并要求补充输入,不要用 `.first` 猜。 + +## 4. 特殊上下文 + +### iframe + +先确认元素属于哪个 iframe,再使用 `frame_locator`。不得因为主页面找不到就改用坐标。 + +### 新标签页和弹窗 + +点击前注册 popup/new page 等待;取得新 Page 后再定位。关闭弹窗不等于业务成功。 + +### Shadow DOM + +Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。 + +### 动态列表 + +等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。 + +### Canvas + +先确认是否存在可访问性节点或外层业务控件。完全无 DOM 时才进入局部图像方案,并记录分辨率、缩放和识别区域。 + +## 5. 验证标准 + +- locator 在真实目标页面匹配数量符合元素清单。 +- 不因语言、窗口宽度、列表顺序或一次刷新就指向不同业务对象。 +- 动作前检查可见、可用;动作后等待真实业务状态。 +- 登录态至少结合两个证据:例如稳定用户菜单 + 登录按钮不存在,不能只看单个哈希 class。 +- 真实和仿真站明确区分:仿真可以使用 `fill()` 加速测试;真实生产输入遵守项目拟人节奏。 + +## 6. 禁止模式 + +- 用全页所有 `input` 的尺寸和位置猜搜索框。 +- 用 `.first` 绕过严格模式。 +- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。 +- 只等 URL 含 `search` 就判定结果成功。 +- 页面结构变化后动态生成 selector 并继续提交。 +- 为方便测试给第三方网站注入 testid。 + +## 7. example 路由 + +- 真实网站、登录/HITL/滚动:[`../examples/real_browser_rpa/README.md`](../examples/real_browser_rpa/README.md) +- 自有 sandbox、表单和 adapter:[`../examples/simulator_browser_rpa/README.md`](../examples/simulator_browser_rpa/README.md) + +复制 example 前先完成目标站元素清单;example 中的任何 URL、selector 和业务字段都不得跨平台照抄。 diff --git a/development/RPA_DESKTOP.md b/development/RPA_DESKTOP.md new file mode 100644 index 0000000..c8d1431 --- /dev/null +++ b/development/RPA_DESKTOP.md @@ -0,0 +1,189 @@ +# Windows 桌面 RPA 元素拾取与定位标准 + +> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Windows 桌面首选 **UI Automation(UIA)**。当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。标准约束的是 UIA 方法,不强制混用两个库。 + +## 目录 + +1. 已验证基线 +2. 拾取工具和步骤 +3. 定位优先级 +4. 窗口与控件规则 +5. 输入、点击与等待 +6. UIA 不可用时的降级 +7. 诊断和留证 +8. example 路由 + +## 1. 已验证基线 + +真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`(含通讯录 → 新的客户 → 添加 → 搜索 → 发送全链路)。 + +该案例验证了: + +- 通过进程/窗口类名/标题找到企业微信顶层窗口。 +- 主窗激活后**最大化**,避免侧栏折叠导致控件不可见或坐标漂移。 +- 从 HWND 获取 UIA 根控件;在**子业务 pane**(如「企业微信-通讯录」)内缩小作用域。 +- 区分 UIA `Name` 与 `Value`:可见中文常在 Value / 子 Label,不在 Name。 +- 通用叶子名(`item` / `status_layout` / `wbtn_container`)必须绑业务父容器,并用文案校验。 +- 真实点击后校验后置状态(标题、专用容器、子窗口)。 +- 处理独立弹窗与嵌套弹窗。 +- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。 + +example 只提炼通用模式;企业微信名称、窗口类、控件名、列表顺序和业务结果不得复制到其他软件。 + +## 2. 拾取工具和步骤 + +可使用影刀元素拾取器、Microsoft Inspect/Accessibility Insights、FlaUInspect 或库自带树打印能力。 + +操作步骤: + +1. 启动目标软件,登录并手工进入准确页面;需要完整侧栏时先**最大化**。 +2. 记录目标进程名、PID、主窗口标题和 Window Class。 +3. 用检查器点选目标元素,记录: + - AutomationId + - **Name**(内部 id,可能是 `org_op_new` / `status_layout`) + - **Value**(可见中文文案常在这里,如「通讯录」「新的客户」) + - ControlType / ClassName / FrameworkId + - IsEnabled / IsOffscreen / BoundingRectangle + - 父容器链(含业务子窗/pane 标题) +4. 保存截图和原始 UIA 树/拾取器导出文件。 +5. 从长路径中找出稳定窗口、业务父容器和叶子属性。 +6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据,且优先用文案定位。 +7. 写入 element map(Name 与 Value 分开登记)。 +8. 运行 `probe-ui`(建议先激活并最大化),验证关键窗口和元素。 +9. 关闭重开软件、切换一次页面后再次验证。 + +### Name / Value(易错) + +- 影刀/检查器里的**选择器显示名**(如「新的客户」)≠ 控件 UIA `Name`。 +- 许多国产客户端:`Name` 是内部 id,**界面中文在 `Value` 或子控件 Value**。 +- 拾取时两者都记;定位时勿把业务中文当成 `Name=` 去搜。 + +### 从拾取结果提炼定位器 + +影刀等工具可能导出很长的 UIA 路径。不要整条照抄: + +```text +原始:主窗口 → 通讯录 pane → wlist_view → item[2] → status_layout + → … → main_chattext(Value=新的客户) +提炼:通讯录 pane → wlist_view → 行内 main_chattext.Value=新的客户 → 点击该行 status_layout +禁止:全树第一个 Name=status_layout(常为「群聊」等其它行) +``` + +长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。 + +## 3. 定位优先级 + +从高到低: + +1. `Process/PID + WindowClass + WindowTitle`(及稳定业务子 pane) +2. 目标窗口内 `AutomationId + ControlType` +3. 稳定内部 `Name + ControlType`(如 `org_op_new`、`searchedit`) +4. 业务父容器 + 稳定子元素属性 +5. **可见业务文案(优先读 Value / 子 Label)+ 相对关系** +6. `ClassName/FrameworkId` 辅助组合 +7. 有业务依据的 index(仅兜底;须写清父容器与序号含义,禁止「过滤后的列表 [2]」与「父节点 child index=2」混用) +8. UIA 元素实时 BoundingRectangle 派生坐标 +9. 局部截图模板/OCR +10. 写死绝对坐标:禁止作为生产方案 + +顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。 + +同名叶子(如多处 `wbtn_container`)必须限制在业务父容器内(如 `search_external`),并可用子文案(如 Value=`添加`)校验;禁止点搜索旁通用「+」。 + +## 4. 窗口与控件规则 + +### 窗口 + +- 校验窗口属于预期进程和当前用户会话。 +- 主窗口、**应用内业务子 pane**、搜索窗、确认窗分别建立作用域。 +- 进入依赖完整侧栏/工具栏的页面时:**激活后最大化**;`activate` **禁止**对已最大化窗口无条件调用 `SW_RESTORE`(会还原成普通窗,导致折叠与坐标漂移)。仅最小化时才 Restore。 +- 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。 +- 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。 +- 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。 + +### 控件 + +- 在最小业务父容器中搜索。 +- 默认要求唯一匹配。 +- 检查可见、启用、未离屏和边界矩形有效。 +- 虚拟列表只包含当前实例化元素;滚动后重新枚举。 +- 不长期保存 Control 对象。 + +## 5. 输入、点击与等待 + +### 点击 + +1. 激活目标窗口(主窗按需最大化;子对话框不要误 Maximize)。 +2. 重新定位元素。 +3. 检查边界矩形。 +4. 使用 UIA Click/Invoke 或正常鼠标点击。 +5. 等待后置窗口/控件/业务状态(如标题文案、专用容器、子窗口 Class)。 + +关键导航建议日志:`locate`(策略/文案)→ `click` → `verify`(后置)。这与 [`LOGGING.md`](LOGGING.md)「不必每个 Click 一行」不冲突:要求的是**关键跳转可观测**,不是像素级刷屏。 + +UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。 + +### 输入 + +- 先点击聚焦。 +- 清空并逐字输入,敏感值不写日志。 +- 输入后读取 Value/Text 或通过后续业务状态验证。 +- 剪贴板可能泄露敏感信息,非必要不使用。 + +### 等待 + +优先等待: + +- 目标窗口出现/消失。 +- 控件出现、启用或状态变化。 +- 列表产生业务结果。 +- 成功/失败提示出现。 + +固定 sleep 只作短缓冲,并使用可中断等待;不能作为成功条件。 + +## 6. UIA 不可用时的降级 + +先检查: + +1. 权限级别和 Windows 会话是否一致。 +2. 是否选错进程或子窗口;主窗是否未最大化导致侧栏未实例化。 +3. 控件是否离屏或尚未实例化。 +4. 应用是否为 Electron(可评估 Playwright)。 +5. 是否为自绘、游戏引擎、远程桌面或安全控件。 +6. 空树/树抖动时:最大化主窗;用 Inspect/影刀附着一次后再 probe(远程桌面工具可能干扰无障碍树)。 + +只有控件树确认不可用时才使用图像: + +- 固定或记录 DPI、缩放、分辨率和主题。 +- 限定目标窗口和搜索区域。 +- 记录模板来源、置信度阈值和多匹配处理。 +- 匹配不唯一时停止。 +- 点击后仍验证业务状态。 + +U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或坐标绕过。 + +## 7. 诊断和留证 + +桌面 `health` / `probe-ui` 至少输出: + +- UIA 依赖是否可用。 +- 目标进程和主窗口是否存在;是否已最大化(若业务需要)。 +- 关键窗口/控件是否各唯一匹配。 +- 当前窗口标题、Class、PID。 +- DPI/缩放诊断。 + +example 里的最小 `probe`(只探主窗+个别入口)**不等于**生产 probe 完整度;业务技能应对关键跳转节点分别探测或做深探。 + +手开 PowerShell 跑 `stats` / `health` / dump 时,必须显式对齐宿主的 `CLAW_USER_ID` / 数据根,否则会读到默认用户空库,误判熔断/冷却。 + +定位失败保存: + +- 目标窗口和全屏截图。 +- UIA 树。 +- 顶层窗口枚举。 +- element map 逻辑名、候选数量和属性。 +- 应用版本、Windows版本、DPI和会话类型。 + +## 8. example 路由 + +真实成功参考见 [`../examples/real_desktop_rpa/README.md`](../examples/real_desktop_rpa/README.md)。该 example 默认只演示 `probe`、`dry-run` 和 `assisted`;不可逆动作必须由业务技能显式实现确认策略。 diff --git a/development/RPA_ELEMENT_LOCATING.md b/development/RPA_ELEMENT_LOCATING.md new file mode 100644 index 0000000..8ecb1c3 --- /dev/null +++ b/development/RPA_ELEMENT_LOCATING.md @@ -0,0 +1,178 @@ +# RPA 元素定位与拾取总标准 + +> 本文定义浏览器、Windows 桌面和手机三端共用的“元素工程”标准。开始写任何界面操作代码前必须先完成元素拾取、元素清单和定位验证。分端实操分别见 [`RPA_BROWSER.md`](RPA_BROWSER.md)、[`RPA_DESKTOP.md`](RPA_DESKTOP.md)、[`RPA_MOBILE.md`](RPA_MOBILE.md)。 + +## 目录 + +1. 核心术语 +2. 强制工作流 +3. 元素清单 +4. 定位器质量规则 +5. 动作闭环 +6. 降级决策树 +7. 失败留证 +8. AI 编程约束 +9. 交付门禁 + +## 1. 核心术语 + +| 术语 | 定义 | +|---|---| +| **拾取** | 使用检查器在真实界面上点选元素,读取 DOM、UIA 或移动控件树属性 | +| **定位器** | 根据稳定属性在运行时重新找到元素的规则 | +| **作用域** | 页面、iframe、窗口、弹窗、父容器、列表行或 WebView 等查找边界 | +| **锚点** | 与目标元素存在稳定业务关系的父级、标题、标签或同一行元素 | +| **后置状态** | 动作完成后必须出现或消失的真实业务状态 | +| **降级** | 主定位器不可用时,改用已验证的备用定位器或图像方案 | +| **界面快照** | 失败现场的截图和 DOM/UIA/XML 控件树 | + +## 2. 强制工作流 + +按以下顺序开发,不得跳过拾取直接猜 selector: + +1. 在真实目标版本上手工走通业务流程,记录每一步的前置状态和成功表现。 +2. 使用对应端检查器拾取元素,保存截图和原始 DOM/UIA/XML。 +3. 识别正确作用域:页面/iframe、进程/窗口、Native/WebView、弹窗或列表行。 +4. 为每个关键元素填写元素清单。 +5. 按分端优先级生成候选定位器。 +6. 验证候选定位器在目标作用域中匹配数量符合预期,关键动作默认必须恰好为 `1`。 +7. 验证元素可见、启用、未离屏且边界矩形有效。 +8. 执行动作,并等待真实后置状态。 +9. 重启目标程序或会话后再次验证;动态列表还要更换数据验证。 +10. 记录已验证的软件版本、页面状态、系统缩放/设备信息和日期。 + +未完成第 2~6 步时,代码只能保留明确的 `TODO: NEED_REAL_UI_INSPECTION`,不得写“看起来可能正确”的 selector。 + +开发期推荐使用独立仓 [jiangchang-ui-recorder](https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder)(本机常见路径 `D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`,`python run.py`):以 **Raw Event Journal → Action → Element Catalog → 异步 Observation** 录制,优先把 `action_flow.json` 与 `element_catalog.json` 交给 AI 编程工具;原始事件可重新归并,tree/index 作为诊断证据。默认不再让完整页面 dump 阻塞动作采集。本模板仓内的 `tools/ui_inspector` 已移除,勿再从 skill-template 启动。 + +## 3. 元素清单 + +每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML: + +```yaml +logical_name: submit_button +purpose: 提交当前业务单据 +surface: browser # browser | windows | android | ios +screen: 订单确认 +scope: + kind: dialog + locator: role=dialog,name=订单确认 +primary_locator: + strategy: role + value: button,name=确认提交 +fallback_locators: [] +expected_count: 1 +ready_condition: + visible: true + enabled: true +postcondition: + description: 出现订单号且状态为已提交 +failure_evidence: + - screenshot + - ui_tree +verified_target_version: "待填写" +verified_at: "YYYY-MM-DD" +``` + +要求: + +- `logical_name` 使用业务语义,不使用 `button1`、`element2`。 +- `scope` 不得省略;全页面、全桌面或全设备盲搜不作为默认生产方案。 +- `fallback_locators` 只能填写人工验证过的定位器。 +- 不可逆动作必须同时登记确认前状态和提交后状态。 +- selector 变化时更新 `verified_target_version` 和验证日期。 + +## 4. 定位器质量规则 + +### 4.1 必须满足 + +- 关键动作定位器在目标作用域中唯一。 +- 定位器依赖业务语义、可访问性属性或稳定接口标识。 +- 每次页面/窗口/列表刷新后重新定位,不长期缓存元素句柄。 +- 同一元素的主定位器和 fallback 使用不同的稳定证据,不是同一脆弱路径的轻微改写。 +- 多结果是合法业务场景时,先定位业务容器/行,再在容器内定位按钮。 + +### 4.2 默认禁止 + +- 用 `.first`、`.nth(0)`、`hwnds[0]`、`instance=0` 消除本应解决的歧义。 +- 长绝对 XPath、深层 DOM/UIA 路径和大量 index。 +- 哈希 class、随机生成 id、易变化坐标作为唯一依据。 +- 扫描所有输入框后按宽度、位置“猜”目标。 +- 捕获所有异常后继续尝试随机 selector 或随机坐标。 +- UI结构变化后自动生成新 selector 并继续执行不可逆动作。 + +确有稳定业务顺序时可以使用 index,但必须在元素清单中写明锚点、顺序依据、匹配总数和版本验证结果。 + +## 5. 动作闭环 + +每一个动作实现为一个小状态机: + +```text +确认前置状态 +→ 获取正确作用域 +→ 重新定位元素 +→ 校验唯一/可见/可用 +→ 激活并执行真实输入 +→ 等待后置状态 +→ 记录成功证据 +``` + +- 缓冲等待只用于改善稳定性,不能替代后置状态。 +- 点击后状态不明确时先查询业务状态,禁止再次点击。 +- 付款、发送、删除、审批、发布等动作如果无法确认结果,返回 `ERROR:RESULT_UNCONFIRMED`。 +- 列表滚动、页面切换、窗口重建和移动端 Activity 切换后重新获取元素。 + +## 6. 降级决策树 + +找不到元素时依次检查: + +1. 目标应用、页面或设备是否正确启动并处于预期账号。 +2. 是否选错页面、iframe、进程、窗口、弹窗、Native/WebView 上下文。 +3. 元素是否尚未加载、被遮挡、禁用、离屏或在虚拟列表未实例化区域。 +4. 是否因管理员权限、远程会话、设备授权或驱动版本无法读取控件树。 +5. 主定位器属性是否变化;对比保存的界面快照。 +6. 是否可以用已验证的父容器、业务文案或相对锚点定位。 +7. 控件是否为 Canvas、自绘、远程桌面或未暴露可访问性树。 +8. 最后才评估局部图像/OCR/坐标方案。 + +任何一级确认“界面结构变化”时,截图、导出控件树并停止;不得在生产任务中让 AI 临时猜新定位器。 + +## 7. 失败留证 + +定位失败至少保存: + +- 当前屏幕/窗口截图。 +- 当前 DOM、UIA 或移动端 XML。 +- 页面 URL、进程/PID/窗口标题或设备 serial、包名、Activity、context。 +- 逻辑元素名、作用域、主定位器和已尝试 fallback。 +- 每个候选匹配数量、可见/启用/离屏状态和边界矩形。 +- 目标应用版本、浏览器版本或设备系统版本。 +- 失败阶段、错误码、时间和 batch_id。 + +敏感输入、Cookie、token、手机号和客户数据必须脱敏。 + +## 8. AI 编程约束 + +向 AI 编程工具提供本模板时,必须要求它: + +1. 先阅读本总标准和对应分端手册。 +2. 先输出业务步骤表和元素清单,再生成代码。 +3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。 +4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。 +5. 为每个动作实现前置状态、定位、动作和后置状态。 +6. 把诊断探针、界面树导出和失败截图作为实现的一部分。 +7. 不把 mock 通过、命令未报错或弹窗关闭当成真实业务成功。 +8. 不擅自切换到 Hook、注入、私有协议、验证码破解或其他禁止路线。 + +## 9. 交付门禁 + +- [ ] 关键元素均有元素清单和真实拾取证据 +- [ ] 每个不可逆动作默认唯一定位 +- [ ] 重启会话后定位仍然有效 +- [ ] 动态数据或列表顺序变化后不会误点 +- [ ] 每个动作有真实后置状态 +- [ ] UI结构变化会停止并留证 +- [ ] fallback 均已人工验证 +- [ ] 截图和控件树不泄露敏感数据 +- [ ] 真实主路径至少完整跑通一次 +- [ ] example 状态明确区分“已验证成功”和“规范骨架” diff --git a/development/RPA_MOBILE.md b/development/RPA_MOBILE.md new file mode 100644 index 0000000..4e2dd05 --- /dev/null +++ b/development/RPA_MOBILE.md @@ -0,0 +1,141 @@ +# 手机 RPA 元素拾取与定位标准 + +> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Android 首选 uiautomator2 或 Appium UiAutomator2;iOS 使用 Appium + XCUITest/WebDriverAgent。当前模板尚无团队真实成功案例,因此本文是强制设计标准,example 仅为规格骨架,不得宣称已生产验证。 + +## 目录 + +1. 状态与边界 +2. 设备准备 +3. 拾取工具和步骤 +4. Android 定位优先级 +5. iOS 定位优先级 +6. Native/WebView 与动态界面 +7. 手势、输入和等待 +8. 降级与留证 +9. example 路由 + +## 1. 状态与边界 + +- Android 标准可用于设计和实现,但交付前必须在授权真机和官方 App 上验证。 +- iOS 必须有 Mac、签名可用的 WebDriverAgent 和授权设备。 +- 不 Root、不越狱、不 Hook、不修改 APK/IPA、不伪造设备身份。 +- 短信、人脸、指纹、系统安全确认交给人工。 + +## 2. 设备准备 + +每次运行前检查: + +- 唯一设备 serial/UDID;多个设备时不得默认取第一个。 +- USB/网络连接和调试授权。 +- Android 包名/Activity,或 iOS bundle id。 +- 屏幕是否解锁、方向、分辨率和系统弹窗状态。 +- 自动化 driver/service 是否可用。 +- 设备租约/互斥;同一设备默认串行。 + +`doctor` 应输出上述信息,但不得输出设备隐私数据。 + +## 3. 拾取工具和步骤 + +Android 可使用 Appium Inspector、uiautomator2 的 UIAutoDev/层级导出;iOS 使用 Appium Inspector。 + +1. 在真机上手工进入准确页面。 +2. 刷新 Inspector screenshot 和 page source。 +3. 在截图或 XML 树中点选元素。 +4. 记录: + - Android:resource-id、content-desc、text、class、package、clickable、enabled、bounds + - iOS:name/accessibility id、label、value、type、enabled、visible +5. 确认当前 context 是 Native 还是正确 WebView。 +6. 记录稳定父容器、列表行和业务锚点。 +7. 在 Inspector 搜索定位器,确认匹配数量。 +8. 写入 element map,保存 screenshot + XML。 +9. 重启 App、重新进入页面并复测。 + +截图上看到的元素不一定存在于控件树;必须以 page source/可访问性树验证,不能直接凭坐标写代码。 + +## 4. Android 定位优先级 + +从高到低: + +1. 稳定且唯一的 `resourceId` +2. `description/content-desc`(accessibility) +3. 稳定 `text + className` +4. 业务父容器内的子元素组合 +5. UiSelector / 相对关系 +6. 短 XPath +7. 已验证的 instance/index +8. 图像/坐标 + +示例: + +```python +search = d(resourceId="com.example:id/search_input") +if search.count != 1: + raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS search_input") +search.click() +search.set_text(keyword) +if not d(resourceId="com.example:id/result_list").wait(timeout=10): + raise RuntimeError("ERROR:RESULT_TIMEOUT") +``` + +第三方 App 没有 resource-id 时,使用可访问性描述、稳定文案和父容器组合;不得修改第三方 APK 添加 id。 + +## 5. iOS 定位优先级 + +从高到低: + +1. accessibility id / name +2. `type + name/label/value` +3. iOS predicate string +4. iOS class chain +5. XPath +6. 图像/坐标 + +XPath 依赖完整 XML 且通常较慢,只作后备。多个同名按钮时先定位 navigation bar、cell、alert 或其他业务容器。 + +## 6. Native/WebView 与动态界面 + +### Native/WebView + +- 先枚举 contexts。 +- Native 元素使用移动端控件树。 +- WebView 元素切换到正确 web context 后使用 Web 定位器。 +- context 数量或名称变化时按包名、URL或页面特征验证,不默认取第一个 WebView。 + +### 动态列表 + +- 虚拟列表只暴露屏幕附近元素。 +- 每次滚动后重新定位。 +- 用业务键/行文案选择条目,不依赖当前 instance。 +- 到达末尾必须有明确状态或滚动前后内容对比。 + +### 系统弹窗 + +权限、通知、文件选择等可能属于系统包或 SpringBoard;为其建立独立作用域和显式处理策略,不把它们当业务 App 元素。 + +## 7. 手势、输入和等待 + +- 点击前确认元素可见、启用且 bounds 有效。 +- 输入后读取文本或等待结果状态。 +- 滑动以元素/屏幕相对区域描述,不跨设备写死绝对像素。 +- 页面切换等待 Activity/view/context 或业务元素变化。 +- 后台切前台、横竖屏变化后重新获取元素。 +- 人工验证期间可用 scrcpy/Appium Inspector 辅助观察,程序轮询真实状态并有超时。 + +## 8. 降级与留证 + +找不到元素先检查 context、Activity、系统弹窗、滚动位置和页面 source。确认控件树未暴露后才评估局部图像/OCR。 + +失败保存: + +- 设备 screenshot。 +- page source XML。 +- serial 脱敏值、包名、Activity、context 列表。 +- 屏幕分辨率、方向和系统版本。 +- element map 逻辑名、定位器和匹配数量。 +- App 版本、driver 版本和失败阶段。 + +图像方案必须限定设备矩阵和搜索区域;匹配不唯一时停止。 + +## 9. example 路由 + +手机 example 规格见 [`../examples/mobile_rpa/README.md`](../examples/mobile_rpa/README.md)。在团队提供真实成功案例前,该目录只能作为实施清单和验收契约,不能作为“已跑通代码”宣传或直接复制。 diff --git a/development/RUNTIME.md b/development/RUNTIME.md new file mode 100644 index 0000000..1ef4900 --- /dev/null +++ b/development/RUNTIME.md @@ -0,0 +1,145 @@ +# 运行时约定 + +## 共享 Python Runtime + +**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.11` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。 + +技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。 + +kit **源码仓**(可 clone 学习,勿 vendor)与其它公开公共仓登记见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。 + +### 何时上调 `platform_kit_min_version` + +| 情况 | 做法 | +|------|------| +| 技能依赖 kit 某次修复/新 API(如 `.env` 空值+行尾 `#` 解析、`poll_until`、启动超时) | 把 `metadata.openclaw.platform_kit_min_version` 调到**需要的最低版本**(模板默认 `"1.2.11"`) | +| PyPI/私有源刚发了更新,但本技能行为未依赖 | **不必**仅为「追新」抬版本 | +| 宿主升级时机 | 宿主在技能/宿主声明的最低版本**未满足**时按需升级共享 venv;**不会**仅因源上有更新就后台拉 latest | + +模板默认声明须与当前文档所依赖的最低 kit 能力对齐(见根 `SKILL.md`)。 + +| 场景 | 推荐做法 | +|------|----------| +| 日常运行(宿主 / Agent) | 使用 PATH 上的共享 venv `python`(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`) | +| 手工排查 / 测试机(环境未注入时) | `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/.../python` 执行 `scripts/main.py` | +| **禁止** | 在技能目录内 `uv run python …` — 会创建临时 venv,加载不到宿主共享 runtime | + +占位命令(路径因环境而异,勿写死本机目录): + +```text +推荐(已注入环境时): + python {baseDir}/scripts/main.py health + +Windows(未注入时): + {JIANGCHANG_DATA_ROOT}\python-runtime\.venv\Scripts\python.exe {baseDir}\scripts\main.py health + +Unix(未注入时): + {JIANGCHANG_DATA_ROOT}/python-runtime/.venv/bin/python3 {baseDir}/scripts/main.py health +``` + +共享解释器通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入(见 `jiangchang_skill_core.runtime_env`)。 + +## Runtime 诊断(platform-kit 1.2.11+;含既有 1.2.2+ 字段) + +`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段: + +- `skill_slug`、`python_executable`、`platform_kit_version`、`platform_kit_min_version` +- `jiangchang_skill_core_file` — 若从技能目录加载会输出 `runtime_issue[warning]` +- `JIANGCHANG_DATA_ROOT` / `resolved_data_root` +- `media_assets_root`、`ffmpeg_path`、`background_music_*` +- `record_video_enabled`、`runtime_issue[warning|error]` +- `machine_power` — 只读供电/限频探测(探不到则 `probed=false`);**不得**单独作为 hard-fail 依据 + +`health` 是**只读诊断**,**不会**触发 media-assets 下载或修复。并补充 `env_path` / `env_exists` / `example_path` 配置路径字段。 + +## 配置 bootstrap + +- 仓库内 `.env.example` 是配置模板(单一事实来源)。 +- 用户实际 `.env`:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`。 +- `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。 +- 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。 +- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.2.11`,**不得** vendored `scripts/jiangchang_skill_core/`。 + +## media-assets / ffmpeg / 背景音乐 + +背景音乐、ffmpeg 等共享资源由 platform-kit 统一解析,默认路径: + +```text +{JIANGCHANG_DATA_ROOT}/shared/media-assets +``` + +(由宿主注入 `JIANGCHANG_DATA_ROOT`。) + +RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均由 platform-kit 提供;技能只 import,不重复实现。 + +## 目录结构 + +新技能建议采用以下根目录结构: + +- `assets/` +- `development/` +- `references/` +- `scripts/` +- `tests/` +- `evals/` + +**标准 `scripts/` 分层(不含 `jiangchang_skill_core/` 副本):** + +- `scripts/main.py`:唯一 CLI 入口 +- `scripts/cli/`:参数解析与命令分发 +- `scripts/db/`:SQLite 或本地持久化层(标准时间字段见 `references/SCHEMA.md`) +- `scripts/service/`:业务用例与外部交互 +- `scripts/util/`:通用工具、常量、日志、路径薄封装 + +## 数据路径 + +推荐: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/ +``` + +数据库文件建议: + +```text +{...}/{skill_slug}.db +``` + +**业务文件(输入、输出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。用户可见目录为 `input/`(上行)与 `output/`(下行)。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。 + +- 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。 +- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。 + +## 明确禁止 + +- **不得** vendored `scripts/jiangchang_skill_core/` +- **不得**在技能内本地重复实现 `RuntimeDiagnostics` / `collect_runtime_diagnostics` +- **不得**写死开发机绝对路径作为运行时约定 + +## 测试时的运行时隔离 + +本模板的 `tests/_support.IsolatedDataRoot` 会在测试期间设置: + +- `JIANGCHANG_DATA_ROOT` → 临时 tempfile 目录 +- `JIANGCHANG_USER_ID` → `_test` + +退出时两个变量恢复,临时目录删除。所有 DB / 文件写入都被限制在临时目录内,不污染本机。 + +详见 `TESTING.md` 第 3 节。 + +## 编码与输出 + +- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**(不要只写「UTF-8」)。 +- Python 文件**绝对不能**带 UTF-8 BOM;否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`。 +- Windows 编辑器容易保存成 UTF-8 BOM;提交前确认文件开头无 `U+FEFF`。 +- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装 +- 机读输出优先单行 JSON +- 错误前缀建议统一 `ERROR:` + +## 发布打包约束(`scripts/**/*.py`) + +release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式: + +- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。 +- 接近上限时拆分到 `cli/`、`service/`、`db/`、`util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。 +- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。 diff --git a/development/SHARED_REPOS.md b/development/SHARED_REPOS.md new file mode 100644 index 0000000..bfbce29 --- /dev/null +++ b/development/SHARED_REPOS.md @@ -0,0 +1,131 @@ +# 公共仓库目录(可扩展) + +本页是技能开发用到的**公开公共仓唯一登记处**,面向技术人员与 AI 编程代理。 + +- 仓均在公司**自建 Gitea**:[https://git.jc2009.com/](https://git.jc2009.com/)(基于开源 Gitea 自建,**不是** Gitea 官网)。 +- 技术人员一般**可直接访问、随时 `git clone` 到本地**,用于研究、学习、对照与跟进更新。 +- **当前登记 3 项**;以后新增公开公共仓时,**只在本页按类型加行**,不必改散落长文。 + +深度规范仍在 [`RPA.md`](RPA.md)、[`RUNTIME.md`](RUNTIME.md)、[`ADAPTER.md`](ADAPTER.md)、[`DEVELOPMENT.md`](DEVELOPMENT.md);本页只做**指路 + 边界**。 + +--- + +## 1. 类型(可扩展) + +| 类型 id | 含义 | 典型消费方式 | +|---------|------|--------------| +| `skill-template` | 业务技能作者脚手架与规范源 | clone 学习;`scaffold` / 复制出新业务仓;**不要**在模板仓写业务 | +| `sibling-skill` | 与业务技能并列安装的公开基础设施技能 | clone 学契约;运行时经 **CLI / 封装客户端** 调用 | +| `python-sdk` | 共享 Python 库(源码仓 + 宿主共享 venv 安装) | clone 读实现 / 贡献;运行时 **`import`**,**禁止 vendor** 进技能仓 | +| `asset-bundle` | (预留)共享媒体/资源包等 | 按该仓说明拉取或由 kit/宿主解析 | +| `tooling` | (预留)发布脚本、脚手架、CI 辅助等 | clone 使用或引用其 workflow | + +新增类型时:先在本表加一行类型说明,再在 §2 登记表增加实例。 + +--- + +## 2. 当前登记表 + +| 短名 | 类型 | Gitea(唯一入口) | 一句话职责 | +|------|------|-------------------|------------| +| **skill-template** | `skill-template` | [https://git.jc2009.com/client-commons/skill-template](https://git.jc2009.com/client-commons/skill-template) | 新业务技能的开发模板、目录规范与发布约定 | +| **account-manager** | `sibling-skill` | [https://git.jc2009.com/client-commons/account-manager](https://git.jc2009.com/client-commons/account-manager) | 账号 / 凭据 / 浏览器 Profile / 租约的统一入口 | +| **jiangchang-platform-kit** | `python-sdk` | [https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit](https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit) | 共享 SDK(`jiangchang_skill_core` 等);技能发布可复用 CI 也在此仓 | + +本地常见路径(仅作对照,以你机器为准): + +- `D:\OpenClaw\client-commons\skill-template` +- `D:\OpenClaw\client-commons\account-manager` +- `D:\OpenClaw\client-jiangchang\jiangchang-platform-kit` + +### 2.1 skill-template + +| 项 | 说明 | +|----|------| +| **Clone 为了什么** | 读 `development/`、跟模板版本、用 `tools/scaffold_skill` 开新技能 | +| **怎么用** | 来源 B:scaffold;或学完后在业务仓开发。规范以本仓为准 | +| **红线** | 不要在模板仓写业务;业务仓 `origin` 不得指向本仓;不得保留 `.openclaw-skill-template` | +| **深入** | 本目录 [`README.md`](README.md)、[`DEVELOPMENT.md`](DEVELOPMENT.md) | + +```powershell +git clone https://git.jc2009.com/client-commons/skill-template.git +``` + +### 2.2 account-manager + +| 项 | 说明 | +|----|------| +| **Clone 为了什么** | 读 CLI / `references/INTEGRATION.md`、平台表、租约与错误码;排 `NO_ACCOUNT` / `LEASE_CONFLICT` 等 | +| **为什么必须用** | 支撑真实业务的一账号一 Profile、登录状态持久化、多账号矩阵和并发互斥;禁止浏览器“裸奔” | +| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir + lease_token`(`ensure-web` / `pick-web`) | +| **红线** | **禁止**业务技能 `import` 其内部 `rpa_helpers` / 散落存密码(见 `POLICY-RPA-001`);对方文档若写「同进程 rpa_helpers」,**以本模板约束为准** | +| **创建后查看** | 宿主数据管理;CLI `account list/get`、`lease list`;本地用户数据目录中的 `account-manager.db` 与 `profiles/` | +| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`、`SCHEMA.md`;本模板 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)、[`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) | + +```powershell +git clone https://git.jc2009.com/client-commons/account-manager.git +``` + +### 2.3 jiangchang-platform-kit + +| 项 | 说明 | +|----|------| +| **Clone 为了什么** | 读 `jiangchang_skill_core`(config / logging / activity·SRCP / rpa / sibling_bridge 等)、跟版本、贡献共享 API | +| **怎么用(业务技能)** | 运行时由**宿主共享 venv** 提供;技能内 `import jiangchang_skill_core...`。`SKILL.md` 的 `platform_kit_min_version` 是兼容声明 | +| **红线** | **不要**把 `jiangchang-platform-kit` / `playwright` 写入技能 `requirements.txt`;**不要**在技能仓 vendor `jiangchang_skill_core/`(`POLICY-RUNTIME-002`) | +| **深入** | 对方仓 `README.md`;本模板 [`RUNTIME.md`](RUNTIME.md)、[`LOGGING.md`](LOGGING.md) | + +```powershell +git clone https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit.git +``` + +私有 PyPI 索引(维护/排障用,日常技能开发通常不必手装): +`https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/` + +--- + +## 3. 三者关系(别当成三个无关收藏夹) + +```text +规范 / 脚手架 ──────────► skill-template(本仓) + │ +业务技能 scripts/ ──import──► 宿主共享 venv 中的 platform-kit + │ + └──CLI──► account-manager(兄弟技能,要先拿到 profile_dir 等) +``` + +- **写规范、开新仓** → 打开 **skill-template** +- **少写公共 Python、进度/RPA 原语** → 用 **kit**(运行靠宿主;源码可 clone) +- **账号与浏览器 Profile** → 用 **account-manager**(运行靠 CLI;源码可 clone) + +--- + +## 4. 何时该打开哪个仓 + +| 场景 | 优先打开 | +|------|----------| +| 不知道新技能目录/发布/developer_ids 怎么做 | **skill-template** `development/` | +| 定 `platform_key`、ensure-web、租约、凭证存储 | **account-manager** + 本模板 RPA/ADAPTER | +| 账号创建后不知道去哪看、登录态不保持、多账号互相污染 | 本模板 **ACCOUNT_MANAGER.md** + account-manager 数据管理 / CLI | +| 看 `emit` / `finish` / `@rpa_step` / `launch_persistent_browser` 实现 | **jiangchang-platform-kit** | +| 技能发布 CI / reusable workflow 从哪来 | **jiangchang-platform-kit** `.github/workflows/` | +| 「公共东西到底在哪」 | **本页登记表** | + +--- + +## 5. 以后如何新增一条公共仓(维护约定) + +1. 确认公开可访问,URL 使用 `https://git.jc2009.com/...`(勿写 Gitea 官网)。 +2. 选定或新增 §1 **类型**。 +3. 在 §2 **登记表**加一行;必要时增加 `§2.x` 短节(消费方式 + 红线 + clone 示例)。 +4. 若改变业务技能硬约束,同步 [`POLICY_MATRIX.md`](POLICY_MATRIX.md) / 相关深度文档。 +5. **不要**只在某个业务技能 README 里私藏链接而不登记本页。 + +--- + +## 6. 相关链接 + +- 开发入口:[`README.md`](README.md) +- 运行时:[`RUNTIME.md`](RUNTIME.md) +- 网页 RPA / 账号:[`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md) +- 脚手架:[`../tools/README.md`](../tools/README.md) diff --git a/development/SKILL_ACTION_RUNTIME.md b/development/SKILL_ACTION_RUNTIME.md new file mode 100644 index 0000000..95aa005 --- /dev/null +++ b/development/SKILL_ACTION_RUNTIME.md @@ -0,0 +1,213 @@ +# Skill Action Runtime(宿主任务中心契约) + +本文是匠厂宿主 **Skill Action / 任务中心** 的技能侧金标准。涉及浏览器 RPA、长耗时任务、数据管理按钮、定时任务或 Agent 直接调用时**必读**。 + +**产品入口(平级关系、何时挂哪个)**先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md);本文管 sync/async、任务中心与编排。 + +**边界:** + +| 文档 | 职责 | +|------|------| +| 本文 | sync/async、任务中心、Agent 禁令、单内核编排、队列 pick、同步 vs 导出 | +| [`../references/ACTIONS.md`](../references/ACTIONS.md) | `actions.json` 字段、placements、`bind.tables`、inputSchema、跨表 Lookup、字段条件显隐 `visibleWhen`、本地路径 `file`/`directory` + 可选 **`pathRoot`**、选择摘要「已选择」与映射 **`readOnly` / `revealInForm`**、**按钮中文名 `label` 参考** | +| [`LOGGING.md`](LOGGING.md) | `job_context` / `emit` / `finish` / Run Journal | +| [`DATA_PATHS.md`](DATA_PATHS.md) | 下载/导入等落盘路径 | + +宿主实现细节以匠厂 Host API 为准;技能侧只需保证 manifest 与 CLI 契约稳定。 + +--- + +## 1. 核心原则 + +1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。 +2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。 +3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **`visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** Policy L3 行态 `visibleWhen` 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。 +4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。 +5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。 +6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。 +7. **显式声明 `executionProfile`** — 心智默认 sync、例外才 async;每个 action **必须**写明 `sync` 或 `async`;宿主省略时按 sync,新技能仍禁止赌缺省;禁止第三种模糊模式(`auto` / `background` / `deferred` 等)。 + +--- + +## 2. `executionProfile`:sync vs async + +### 心智(先记住) + +1. **默认按 sync 想**:多数 Action(诊断、统计、入队、秒级查询)是短调用,当场返回,**不进**任务中心。 +2. **只有特殊情况标 async**:开浏览器 / RPA / 长下载 / 需要进度·暂停·停止时,才显式 `"executionProfile": "async"`,进入**任务中心**。 +3. **匠厂宿主**:`actions.json` 若**省略** `executionProfile`,按 **`sync`** 处理(不再把漏写当成进任务中心)。 +4. **新技能仍须显式写出** `sync` 或 `async`(模板 Schema / `POLICY-SKILL-ACTION-002` 硬门禁),禁止赌宿主缺省;显式写出也便于人和 AI 阅读。 + +| 值 | 宿主行为 | 超时 | 任务中心 | +|----|----------|------|----------| +| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 | +| **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 | + +直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。 + +### 选择建议(不是 placement 硬限制) + +| 场景 | 建议 | +|------|------| +| 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` | +| 单次可能接近或超过约 60s 的网络/下载/同步 | `async` | +| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` | +| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 | + +合法:`sync|async` × `toolbar|row|batch|cron|agent|skill-detail`。 + +历史说明:旧文档曾错误地把数据管理按钮入口与异步执行方式绑死。该约束**已废止**;模板测试与 Schema **不得**再建立「入口位置决定 sync/async」一类硬耦合。旧版宿主曾「省略 → async」;**当前宿主为「省略 → sync」**。新技能仍须显式声明,不得依赖省略行为。 + +--- + +## 3. 多入口如何感知 sync / async + +多个入口统一走 `POST /api/skill-actions/run`,可能带 `source.kind`: + +| 入口(匠厂侧栏 / 界面) | 常见 `source.kind` | 技能侧 | 开发者怎么测 | +|-------------------------|-------------------|--------|--------------| +| **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 | +| **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 | +| **数据管理 · 行级** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 操作列和/或**行右键**(及可选「⋯」)可见并触发(需主键);技能配什么就显示什么 | +| **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 | +| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 | +| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement | +| **技能详情** | `skill-detail` | `placements` 含 `skill-detail` | 市场详情安装/四 Tab;详情直调按钮随宿主上线后**同等自测** | + +技能 **不要** 在 Python 里按 `source` / placement 写业务分叉;宿主读 manifest 分流展示与等待策略。 +**已声明的入口都要自测**,不要只测对话。操作清单见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §15 第七步。 + +--- + +## 4. Action 分类与 placements(示例,不是限制) + +下表只表示常见组合示例。**placements 以 manifest 为准;sync/async 不影响展示入口。** + +| Action 类型 | 常见 `executionProfile` | 常见 placements | `confirmation` | +|-------------|-------------------------|-----------------|----------------| +| 运行检查 / 版本 / 配置路径 | `sync` | `skill-detail`, `agent` | 无 | +| 库 / 队列统计 | `sync` | `skill-detail`, `agent` | 无 | +| 外源 → 本地库同步 | `sync` 或 `async` | 可含 `toolbar`(须 `bind.tables`)、`cron`、`agent`、`skill-detail` | 可选 | +| 单行 / 批量数据管理动作 | `sync` 或 `async` | `row` / `batch`(建议 `bind.tables`;行内常用 `inputMapping`) | 可选 | +| 数据导入 / 失败重置 | `sync` 或 `async` | `skill-detail`, `agent` | 可选 | +| **单条**副作用 RPA | `async`(建议) | `agent`, `skill-detail` | **建议** | +| **批量顺序** RPA(pick) | `async`(建议) | `toolbar`, `cron`, `agent` | **建议** | + +禁忌: + +- 高副作用 action 不要默认只挂 `toolbar`(应有 confirmation)。 +- 含 `sensitive` 参数的 action 不要挂 `toolbar` / `cron`(除非有明确安全设计)。 +- 不要为了「按钮多」暴露所有 CLI 子命令。 +- 不要按入口复制多套采集 / 同步 / 处理实现。 + +--- + +## 5. 技能内编排范式(单内核 + 多入口) + +推荐分层: + +```text +scripts/cli/app.py # 参数解析 → 调用 task / service +scripts/service/task_service.py # job_context / emit / checkpoint / finish / 错误转换 +scripts/service/<domain>_service.py # 唯一业务内核 +scripts/db/*_repository.py # 只负责读写与事务 +assets/actions.json # 声明入口、参数、placement、executionProfile、bind +``` + +长任务 / RPA 常见拆分(勿把 `job_context` 塞进纯业务内核): + +```text +execute_single_<biz>(...) # 纯业务:鉴权 + 核心逻辑 + task_log;不含 job_context +cmd_run(...) # 单条:job_context + execute_single + finish +cmd_run_pick(N, ...) # 批量:job_context + for-loop execute_single + emit(progress) + finish +cmd_stats() # 只读 sync:JSON stdout,无 job_context +``` + +要点: + +1. **`job_context` 只包编排层**(`cmd_run` / `cmd_run_pick`),不包 `execute_single_*` / domain service。 +2. 批量 **顺序、不并发**;数量由参数 `pickCount` / `--pick N` 传入。 +3. pick 循环内:`checkpoint()` + `emit("正在处理 {i}/{total}", progress={...})`。 +4. 结束:全成功 `finish(success)`;部分失败 `finish(partial)`;全失败 `finish(failed)`。 +5. 同一浏览器 Profile / account lease:**禁止并发**。 +6. 兼容旧命令(例如旧 export 需先刷新)必须调用同一 sync / domain 内核,禁止重写采集逻辑。 + +详见 [`LOGGING.md`](LOGGING.md) §2.5 与 `scripts/service/task_service.py` 示范。可复用契约样例见 `tests/samples/test_action_core_contract.py.sample`、`tests/samples/test_sync_contract.py.sample`。 + +--- + +## 6. 队列 + pick(可选,Type B 批量) + +有「待处理列表」的技能建议: + +| 能力 | 约定 | +|------|------| +| 队列表 | `status`: `pending` → `processing` → `success` / `failed` | +| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/input/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) | +| 消费 | `run --pick N`,默认仅 `pending` | +| 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** | +| 批次 | 可选 `batch_id`,便于筛选与任务中心对照 | +| Manifest | `*-run-pick`:建议 `async`,`placements` 按需含 `toolbar` / `cron` / `agent`;有 toolbar 则 `bind.tables` 必填 | + +批量有两种合理形态(都是**顺序**): + +| 形态 | 任务中心条数 | 适用 | +|------|-------------|------| +| 一次 `run-pick` 处理 N 条 | **1** 条 Job | 同类 RPA、同 Profile、定时消费队列 | +| Agent/UI 触发 N 次单条 `run` | **N** 条 Job | 每条独立追踪 / 独立重试 | + +默认优先 **一次 pick、一条 Job**。 + +--- + +## 7. 业务同步与系统导出 + +| 能力 | 方向 | 负责方 | +|------|------|--------| +| 同步 / refresh / collect | 外部来源 → 技能本地 SQLite | 技能 domain service | +| 导出当前数据表 | 本地库 → CSV/Excel | **宿主**数据管理统一导出 | +| 特殊业务报告(文件) | 多表合并 / 特殊格式 → `output/` | 仅系统导出不足时提供 `export-*` | +| HTML 页面报告 | 写入 `{skill_data}/html/*.html` | 技能生成;宿主扫描预览;重新生成 Action id 固定为 **`render-html`** | + +同步 Action 默认写库、不写导出文件。HTML 报告契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」与 [`DATA_PATHS.md`](DATA_PATHS.md)。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。 + +--- + +## 8. Agent / SKILL.md 契约(复制后必改) + +业务技能 `SKILL.md` / `references/` 应明确(Agent 主读,勿只写在本文): + +1. 短查询:共享 Python(PATH 上的 `python`)跑 CLI;**禁止** `uv run`。 +2. 长任务 / RPA / async Action:必须通过宿主 **Skill Action**(`run_skill_action`);**禁止** `exec` + `process poll`。 +3. 单条 vs 批量 action id 与参数(如 `url` / `pickCount`)。 +4. `executionProfile: async` → 进度看**任务中心** / Skill Run Card;`sync` → 当场结果。与入口无关。 +5. 多入口复用同一业务内核。 + +模板源仓库的 `SKILL.md` / `references/ACTIONS.md` 含占位段落;scaffold 后按业务改写。 + +--- + +## 9. Policy + +自动检测索引见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md): + +- **POLICY-SKILL-ACTION-001**:Runtime 文档存在;schema 含 `executionProfile`;有 RPA 的业务技能须声明 async agent action。 +- **POLICY-SKILL-ACTION-002**:每个 Action 显式声明 `executionProfile`(sync/async)。 +- **POLICY-SKILL-ACTION-003**:toolbar Action 必须声明合法 `bind.tables`。 +- **POLICY-SKILL-ACTION-004**:placements 与 executionProfile 正交(文档/测试不得写死耦合)。 +- **POLICY-ARCH-CORE-001** / **POLICY-DATA-SYNC-001**:见矩阵;完整语义多用 sample / code review。 + +--- + +## 10. 与宿主 API(参考) + +| API | 用途 | +|-----|------| +| `GET /api/skill-actions/{skillSlug}` | 读 manifest | +| `POST /api/skill-actions/run` | 执行 action(sync inline / async job) | +| `GET /api/skill-actions/jobs` | 任务中心列表 | +| `GET /api/skill-actions/jobs/{jobId}` | 单 Job 详情 / 事件 | + +Agent 工具(插件):`list_skill_actions` → `get_skill_action` → `run_skill_action`;正常对话**不要**轮询 `get_skill_action_job`(交给 UI / 任务中心)。 + +表级 `bind.tables` 过滤依赖宿主支持;旧宿主忽略绑定时,toolbar Action 可能在技能全部数据表显示——新技能仍须显式绑定,待宿主升级后完整按表过滤生效。 diff --git a/development/TESTING.md b/development/TESTING.md new file mode 100644 index 0000000..5d9320c --- /dev/null +++ b/development/TESTING.md @@ -0,0 +1,282 @@ +# 测试开发指南 + +面向复制 `skill-template` 后的新业务 skill:**如何把自动化测试当作一等公民**,而不是等业务写完再补文档级别的空话。本文串起模板自带的 unittest 入口、`tests/` 目录分层与安全档位约定;**更细的开关取值、表格字段与环境变量组合仍以 [`tests/README.md`](../tests/README.md) 为权威来源**。建议你随手开一个编辑器分页:`TESTING.md`(本篇)、[`DEVELOPMENT.md`](DEVELOPMENT.md)(整体节奏)、[`tests/README.md`](../tests/README.md)(落地细则)。 + +默认心智模型可以用一句话概括:**根目录 `test_*.py` = CI / 本地每次提交都应能通过的无外联套件**;`*integration*`、`*.sample`、`desktop/` = 只在人被明确要求时才启用的高风险或重量级路径。 + +--- + +## 1. 测试体系总览 + +本模板把测试分成四层漏斗:**默认必跑(unittest + `run_tests.py`)**、从 `tests/samples/` **按需复制的 service / golden**、放在 `tests/integration/` **默认不落盘的仿真或真实联调范式(多数仍是 `.sample`)**、以及 **desktop E2E(pytest + 宿主 SDK)**。 + +[`tests/run_tests.py`](../tests/run_tests.py) 只做三件事:把 `scripts/` 与 `tests/` 放进 `sys.path`、做 Windows UTF-8 包装、收集 **`tests/` 根目录**下的 `test_*.py`。它不递归子目录——这正是刻意的安全边界:**不想让 AI 或拷贝粘贴 accidentally 把 integration 拉进默认套件**。 + +当你在设计一个新 skill 的测试策略时,请先问自己:**这段代码在没有外部凭证与浏览器的前提下是否有意义?** 若有,留在默认套件;若无,放进 integration / `.sample`,并要求明确的 `OPENCLAW_TEST_TARGET` 组合开关。 + +更深表格化的目录映射、`FakeAdapter` 与 profile 的耦合细节见 [`tests/README.md`](../tests/README.md) 开头章节『我该把测试写在哪里』。 + +--- + +## 2. 默认必跑测试要做什么 + +必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖: + +- CLI:导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`(runtime diagnostics)/ `version` / `init-db` 冒烟; +- 架构守护:无 `scripts/jiangchang_skill_core/`、`platform-kit>=1.2.0` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等); +- **真实 subprocess**:[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移; +- 运行时:`runtime_paths` 与 **`JIANGCHANG_*` 隔离**; +- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py); +- SQLite 骨架:`init_db` 幂等创建元数据表,默认不建业务表; +- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py`、`test_data_management_contract.py`); +- Skill Action manifest:`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`); +- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`); +- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。 +- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。 +- **开发规范守护(hard policy)**:[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。 +- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。 +- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线;业务技能若有自定义 `assets/html/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。 + +**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。 + +套件能力与表格参见 [`tests/README.md`](../tests/README.md) 「1.2 默认套件覆盖」。 + +--- + +## 3. 数据隔离:IsolatedDataRoot + +[`tests/_support.py`](../tests/_support.py) 提供的上下文管理器 `IsolatedDataRoot()`:进入一个专用临时目录,写入 `JIANGCHANG_DATA_ROOT`(tempfile)与 `JIANGCHANG_USER_ID` → `_test`。 + +结束时:**恢复原 environ**,删除目录。 + +这样可以断言:**SQLite DB / spill files / caches** 都在 sandbox;不会在开发者桌面遗留 `{REAL_ROOT}`。 + +示例: + +```python +from _support import IsolatedDataRoot + +def test_whatever(): + with IsolatedDataRoot(): + from db.connection import init_db + init_db() + # …断言读写均在隔离路径… +``` + +**不要把真实凭证路径硬编码进默认测试**:隔离不等于你有权触碰真实目录。 + +--- + +## 4. 测试目标档位(OPENCLAW_TEST_TARGET) + +模板采用统一闸门:**你想跑到哪一层外部世界,就用变量明说**。合法档位(非法值会让 helper 抛错)如下——**直接摘录自 [`tests/README.md`](../tests/README.md) §5.1**: + +| 取值 | 含义 | +|------|------| +| `unit` | **默认**:单元 / 内存 / mock,不跑仿真与真实外联 | +| `mock` | 与 `unit` 类似的安全档位(显式语义) | +| `simulator_api` | 仅允许 **仿真 HTTP** 类集成(如 localhost) | +| `simulator_rpa` | 仅允许 **仿真页面 / 录播 RPA** | +| `real_api` | 真实 API(显式设 `OPENCLAW_TEST_TARGET=real_api`) | +| `real_rpa` | 真实 RPA(显式设 `OPENCLAW_TEST_TARGET=real_rpa`) | + +未设置环境变量 ⇒ 等价 `unit`。 + +默认策略摘要:**不要在 unittest 必跑路径误设 `OPENCLAW_TEST_TARGET=real_*`**(CI 仍离线)。业务 `.env` 模板默认是 `real_rpa`,与「单测用 mock」不冲突;**mock 通 ≠ 交活**。 + +档位读取与业务代码一致:经 `jiangchang_skill_core.config.get("OPENCLAW_TEST_TARGET")`(见 `CONFIG.md`)。 + +--- + +## 5. FakeAdapter:怎么模拟外部系统 + +[`tests/adapter_test_utils.py`](../tests/adapter_test_utils.py) 暴露 `FakeAdapter`,典型四种 **mode**: + +| mode | 用途 | +|------|------| +| `success` | 构造干净的成功响应路径 | +| `timeout` | 模拟悬挂 / 慢链路 | +| `invalid_response` | 畸形负载 / schema drift | +| `unauthorized` | token / license / cookie 失效语义 | + +**何时用它**:service 层出现『调用第三方 HTTP / RPA stub』但又不能把真实系统纳入 CI。**契约测试**(复制 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))应优先组合 FakeAdapter,而不是直接把 CLI when-json 断言堆上天。 + +把它看成:**你把不确定性收敛到可控的测试矩阵里**,而不是在生产日志里才第一次看到错位字段。 + +--- + +## 6. 怎么从 .sample 启用一个测试 + +步骤模板: + +1. 找到范式文件(例如 [`tests/samples/test_service_contract.py.sample`](../tests/samples/test_service_contract.py.sample))。 +2. **复制**到 `tests/` 根:`tests/test_service_contract.py`(去掉 `.sample`)。 +3. 打开副本:**替换占位函数名 /技能特有枚举 / adapter profile**,删掉与本技能无关的示例断言。 +4. 本地执行:`python tests/run_tests.py -v [可选筛选关键词]`。 +5. **不要把 integration `.sample` 批量改名混进根目录**——除非你已经读过 [`tests/integration/README.md`](../tests/integration/README.md) 的风险清单。 + +Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../tests/samples/test_golden_cases.py.sample))。 + +--- + +## 7. 真实联调测试的安全约束 + +任何 touching **真实租户数据** 的路径: + +1. **禁止**硬编码 token / cookie / 内部域名落入仓库; +2. **禁止**默认套件隐式导入 integration; +3. **真实 RPA** 只能标记为手动触发(双人复核 / 本地 `.env` 不入库)。 + +范式阅读 [`tests/integration/README.md`](../tests/integration/README.md):那里有针对凭证来源、目录 artifact 忽略策略的补充。 + +记住:**测试代码也是一种部署面**,别把 staging 凭证写死进仓库。 + +--- + +## 7.1 simulator_rpa 联调检查表 + +浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认: + +- [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`) +- [ ] account-manager:`ensure-web`(或 `pick-web --ensure`)+ 账号有 `profile_dir`(无 profile 禁止开浏览器,见 `RPA.md` §0.2) +- [ ] `OPENCLAW_BROWSER_HEADLESS=0`(联调有头;勿默认无头) +- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`) +- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据) +- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过(mock 离线);example 内 `pytest` 不启真实浏览器 + +参考实现:`examples/simulator_browser_rpa/README.md`。 + +--- + +## 8. 新 skill 的最小测试清单 + +以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选): + +- [ ] `python tests/run_tests.py -v` 能通过。 +- [ ] `python scripts/main.py health` 能通过。 +- [ ] `python scripts/main.py version` 输出 JSON,且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。 +- [ ] `python scripts/main.py init-db` 幂等创建本地库并输出 JSON(`ok` / `skill` / `db_path`)。 +- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。 +- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API,不打开真实 RPA。 +- [ ] 至少有 1 个成功路径测试。 +- [ ] 至少有 1 个缺必填字段 / 非法输入测试。 +- [ ] 如果有 adapter,至少覆盖 timeout / unauthorized / invalid response(可用 fake 模拟)。 +- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture(脱敏)。 +- [ ] 若有 `assets/actions.json`,`tests/test_actions_manifest.py` 通过;不需要 Action 时可删除 manifest。 +- [ ] 若有本地 SQLite 业务表,`_jiangchang_*` 元数据与 `tests/test_data_management_contract.py` 规则一致。 +- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 + +--- + +## 9. AI 编程工具使用测试时的红线 + +改编自 [`tests/README.md`](../tests/README.md) 「AI 编程工具注意事项」,压缩成 skill 开发者视角: + +| 红线 | 解释 | +|------|------| +| 不改业务凑测试 | 除非需求变更已确认,否则**不要为了让 CI 变绿而砍掉业务分支** | +| **默认套件零外联** | 不把真实 HTTP / 浏览器写进 `tests/test_*.py` | +| `.sample` 尊重 | 集成范式改名前先读完 README;别让 `.sample` 悄悄变成根测试 | +| **零硬编码凭证** | token / cookie / 生产 URL → 用虚构域名或 vault ref | +| mock 优先 | 逻辑应在 service + FakeAdapter,而不是巨胖 CLI 断言 | +| 结构化错误 | 断言错误码字段,而不是 substring of stderr 漂移集合 | +| **integration = 显式档位** | 仅 `OPENCLAW_TEST_TARGET`(`real_*` 只放 integration / 手动触发) | + +--- + +## 9.1 开发规范守护测试 + +[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 将 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 中的 **hard policy** 落地为默认必跑 unittest: + +- 每条失败信息包含 `policy_id` 与来源文档路径,便于定位规范出处。 +- 只覆盖目录结构、runtime 依赖边界、配置读取、默认测试安全档位、RPA 禁令等**可静态扫描**规则;软规则列在矩阵「暂不自动检测」小节,**不**做成测试。 +- `POLICY-TESTING-001`:根目录 `tests/test_*.py` 非白名单文件不得出现 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`;**唯一白名单** `test_adapter_profile_policy.py`(档位 helper 自测)。`adapter_test_utils.py` 与 `tests/integration/*.sample` 不在默认套件内,不受此条扫描。 +- `POLICY-RPA-002`:只禁止 `subprocess` / `os.system` / `os.popen` **直接执行** ffmpeg,不误伤 `ffmpeg_path` 等诊断字段。 +- 与已有测试分工:`test_release_packaging_constraints.py` 继续守护 PyArmor 行数与 UTF-8 BOM;`test_no_rpa_helpers_import.py` 与 policy guard 的 `POLICY-RPA-001` 语义一致,二者并存防回归。 + +### 日志规范测试 + +[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 中的 `POLICY-LOGGING-*` 规则会检查 logging **hard policy**(见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md)): + +- CLI 是否调用 `setup_skill_logging`(`POLICY-LOGGING-001`) +- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`(`POLICY-LOGGING-002` / `003`) +- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`) +- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`) +- **域模块**(`scripts/service/**`,豁免薄工具)是否有文件日志调用(`POLICY-LOGGING-006`) +- RPA 技能是否具备事件词 `task_start` / `task_failed|rpa_failed` / `stage=` / 鉴权事件(`POLICY-LOGGING-007`) +- `print(...ERROR:)` 是否与同函数内 `log.warning|error|exception` 共现(`POLICY-LOGGING-008`) + +**局限**:hard 保证事件词与「失败先落盘」底线,**不**判断每个控件一步一日志。复制新 skill 后仍建议对照 [`LOGGING.md`](LOGGING.md) §3 事件词表,并按需启用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample)。 + +新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。 + +--- + +## 10. 测试和发布的关系 + +[`release.ps1`](../release.ps1) **默认**在 **auto commit 之前**、创建 tag 之前,对**当前工作区**执行 `python tests/run_tests.py -v`;测试失败会终止 release,不会自动提交、不会打 tag。仅紧急排障时可传 `-SkipTests`(会输出黄色警告,不应作为常规发布路径)。`-DryRun` 仍会实际运行默认测试。 + +即便手工发布,也应在运行 `release.ps1` 之前确认 `python tests/run_tests.py -v` 绿色。失败的默认套件意味着: + +- 打包路径可能根本不可运行; +- CI 加密前的静态假设可能在宿主崩溃; +- metadata slug 漂移将被市场拒绝。 + +把『本地 unittest 绿』视作 tag 的前置条件,而不是『有空再跑』。发布流水线成功后仍要做安装验证——那是另一个维度;**测试是第一个维度**。 + +--- + +## 11. pytest 收集卫生标准 + +模板根目录提供 `pytest.ini`(或等价配置),约束: + +- **只收集** `test_*.py` / `*_test.py` +- **不让** `.txt`、结果文件、日志文件被 pytest 误收集 +- `norecursedirs` 排除 `integration/`、`desktop/`、`samples/`、`fixtures/`、`artifacts/`、`diagnostics/` + +测试结果文件**不要**放在 `tests/` 根目录;应放 `tests/artifacts/` 或 `tests/diagnostics/` 并加入 `.gitignore`。 + +默认 `python tests/run_tests.py` 仍只发现 `tests/` 根目录一层 `test_*.py`(不递归子目录),与 pytest 策略一致。 + +--- + +## 12. RPA / video 测试标准 + +- 模板 `.env.example` 默认 **`OPENCLAW_RECORD_VIDEO=0`**;默认必跑测试**不应启动真实 ffmpeg**(patch session 或显式设 `OPENCLAW_RECORD_VIDEO=0`)。 +- **但测试必须守护录屏接入能力**(`POLICY-RPA-003` 扫描 `scripts/service/*.py`):`RpaVideoSession` 被构造、`video.add_step` 被调用、video artifact 结构 merge 进 `finish()`(`video` / `video_path` 等字段),与默认是否录屏无关。 +- 断言 `title` / `closing_title` 是**中文**业务文案。 +- 断言 step 文案贴近用户动作,不是技术日志(如「准备执行示例任务」而非「enter cmd_run」)。 +- 若需测试**真实录屏**(ffmpeg 成片),必须显式设 `OPENCLAW_RECORD_VIDEO=1`,并放在 `tests/integration/` 或手动触发场景,**不得**进入默认 unittest 套件。 + +参考 `tests/test_video_service.py`。 + +--- + +## 13. 宿主 E2E 标准 + +- 使用 `jiangchang_desktop_sdk.e2e_helpers`(见 `tests/desktop/` 下 `.sample`)。 +- **不伪造**用户数据目录;通过宿主 IPC 获取真实 `skill_data_dir`。 +- E2E **不自动生成**真实密钥或生产凭证。 + +--- + +## 14. 测试前检查清单(模板守护) + +复制新 skill 或修改模板后,确认: + +- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright` +- [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本 +- [ ] `platform_kit_min_version` **>= 1.2.11**(`SKILL.md` + `constants.py`) +- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行) +- [ ] `config-path` 可输出用户 `.env` 路径 JSON +- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py` +- [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件 +- [ ] `python tests/run_tests.py -v` 全部通过 +- [ ] `scripts/**/*.py` 单文件 < 1000 行(`test_release_packaging_constraints.py`) +- [ ] 文本文件为 UTF-8 without BOM,无 `U+FEFF`(同上) + +--- + +- [`tests/README.md`](../tests/README.md) — 表格、变量与目录细则 +- [`DEVELOPMENT.md`](DEVELOPMENT.md) §14.5 — 测试驱动的开发顺序 +- [`tests/integration/README.md`](../tests/integration/README.md) — 高风险用法 diff --git a/development/TUTORIAL_SURFACES.md b/development/TUTORIAL_SURFACES.md new file mode 100644 index 0000000..fa9e3c8 --- /dev/null +++ b/development/TUTORIAL_SURFACES.md @@ -0,0 +1,222 @@ +# 用户市场文档契约(说明 Tab / 教程 Tab) + +本文给**技能开发者**与 **AI 编程助手**读:规定根目录 `README.md`(技能说明)与 `TUTORIAL.md`(使用教程)**写什么、不写什么**,以及如何与宿主三入口、入口定位表、`actions.json` 对齐。 + +**单一真相链:** + +```text +REQUIREMENTS.md §0.1 入口定位表 + ↓ +assets/actions.json(placements / label / bind) + ↓ +TUTORIAL.md(用户可读、分入口操作) +README.md(能力与边界;不写步骤) +``` + +产品入口「挂什么」见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。本文只管**用户市场文档怎么投影**。 + +--- + +## 0. 与四 Tab 的关系 + +| 文件 | 市场 Tab | 本文角色 | +|------|----------|----------| +| `README.md` | **说明** | §1:是什么 / 给谁 / 边界 | +| `TUTORIAL.md` | **教程** | §2~§6:怎么用(分入口) | +| `DEMO.md` | 演示 | 观看顺序可对齐三入口,不另造标准 | +| `CHANGELOG.md` | 更新日志 | 与文档契约无关 | + +**铁律:** 说明就是说明,教程就是教程。**禁止**在 README 里写分步操作、Agent 话术块、排障「点哪里」;这些一律进 `TUTORIAL.md`。 + +### 0.1 标题层级(说明 / 教程共用) + +宿主详情页**已经**展示技能名;「说明 / 使用教程」Tab(及数据管理右侧教程抽屉)**已经**标明文档类型。因此: + +| 层级 | 用途 | +|------|------| +| `#` | **正文不要用**(会与页头/Tab 标题重复抢视觉) | +| `##` | 主分节(能帮你做什么、入口一:数据管理…) | +| `###` | 分节内步骤 | + +**禁止:** + +- `README.md` 文首 `# 【技能名】` / `# 审计疑点辅助` 这类与页头重复的 H1 +- `TUTORIAL.md` 文首 `# 【技能名】· 使用教程` 这类与 Tab/抽屉标题重复的 H1 + +去掉重复 H1 后,**不要**把原来的 `##` 全部升成 `#`。 + +--- + +## 1. README.md(说明 Tab) + +### 1.1 必须回答 + +| 问题 | 写法 | +|------|------| +| 这是什么 | 开篇一两句价值(**普通段落或引用**,不要再写技能名 H1) | +| 能做什么 | 3~6 条能力 bullet(**无步骤编号**) | +| 适合谁 / 场景 | 业务语言 | +| 使用前准备 | 安装、账号、权限等**前置条件**;不写「去哪张表点哪」 | +| 执行后得到什么 | 结果形态(表、报告、文件),**不教怎么生成** | +| 注意事项 | 边界、风险、人工复核 | +| 怎么使用(可选,短) | **一两句**:支持哪些入口 + **「步骤见使用教程 Tab」** | + +### 1.2 禁止写入 README + +- `1.` `2.` `3.` 操作步骤(如「选品任务 → 点某某按钮」) +- 「你可以这样告诉 Agent」整节或大块 `>` 示例话术 +- 「入口一 / 入口二」专章结构 +- 逐步排障(「先点 A 再点 B」) +- frontmatter `description` 不要写「以及如何开始使用」类教程承诺 + +### 1.3 FAQ 边界 + +- README FAQ:**概念类**(和真实系统区别、从哪开始学、是否支持定时)→ 可指向「使用教程」Tab +- **操作类**排障 → 只写在 `TUTORIAL.md`「常见卡住怎么办」 + +### 1.4 建议章节(标题可微调) + +```text +(开篇一两句价值;不要写 # 技能名) +## 这个技能能帮你做什么 +## 适合什么场景 +## 使用前需要准备什么 +## 怎么使用 ← 仅短摘要 + 指向教程 +## 执行后你会得到什么 +## 注意事项 +## 常见问题 ← 概念类 +``` + +--- + +## 2. TUTORIAL.md(教程 Tab)必选结构 + +复制业务技能后,**验收章节(标题可微调,语义不可缺)**: + +```text +(开篇:数据管理与对话平行;定时有/无一句。不要写 # 技能名 · 使用教程) + +## 本技能做什么 ← 业务数据流(非 CLI/SQL) +## 开始前 +## 入口一:数据管理 ← §0.1 有 toolbar/row/batch/replaces 则必填 +## 入口二:对话任务 ← §0.1 有 agent 则必填 +## 入口三:定时任务 ← 有 cron:怎么用;无 cron:不需要 + 理由 +## 主能力对照表 ← 双挂能力必填 +## 常见卡住怎么办 +``` + +### 2.1 与入口定位表的关系 + +| §0.1 / actions | TUTORIAL 要求 | +|----------------|---------------| +| 任一能力挂数据管理(toolbar/row/batch/replaces) | **必须**有「入口一:数据管理」专章 | +| 任一能力挂 `agent` | **必须**有「入口二:对话任务」专章(**禁止**用「或者你也可以说…」替代专章) | +| 任一能力挂 `cron` | **必须**有「入口三」且写清如何建定时、选哪个 Action | +| **无** `cron` | **必须**写「本技能不需要定时」+ 一句理由(与 §0.1 一致) | +| 某能力只挂一端 | 对照表注明「仅数据管理」或「仅对话」 | + +### 2.2 平行关系(数据管理 ∥ 对话) + +- 两者是**平行主入口**,不是「对话主、点表辅」或反过来 +- 禁止全文只有「第一步:打开对话说清目标」且无数据管理专章 +- 允许用户只用其中一种;教程须写清「两种效果一致,选你习惯的」 + +--- + +## 3. 入口一:数据管理(怎么写) + +适合写: + +1. 左侧有哪些业务表、各干什么(业务语言) +2. 首次准备(载入/导入/新建)——**按钮必须出现在用户能看到的地方**(见 §5) +3. 主流程:进哪张表 → 是否要选中行 → 点哪个 `label` → 去哪看结果 +4. 行级 / 批量操作(若有 `row` / `batch`) + +按钮中文名 = `assets/actions.json` 的 `label`,不要另造别名。 + +--- + +## 4. 入口二:对话任务(怎么写) + +适合写: + +1. 什么时候用对话(与数据管理互补) +2. 示例话术表(与主能力一一对应) +3. Agent 可能追问什么、用户怎么答 + +**不要**把对话写成唯一路径;也不要把话术塞进 README。 + +--- + +## 5. 准确性铁律(用户看得见才写) + +教程步骤必须对应 **宿主真实 UI**,不是只对应 manifest 声明。 + +| 规则 | 说明 | +|------|------| +| **placements 对齐** | 教程写的入口 ∈ `actions.json` 已挂 placement | +| **skill-detail 不可作主路径** | 声明了 `skill-detail` **不等于**详情页一定渲染出按钮;当前宿主常只显示四 Tab。**禁止**把「技能详情 → 点某某」写成用户主路径 | +| **准备/载入/导入类** | 优先挂 **数据管理 `toolbar`** + `bind.tables`(空表也可点的能力尤其如此);可保留 `agent`;`skill-detail` 仅作可选兼容 | +| **写入后刷新** | 建议 `steps: [{ "when": "afterSuccess", "do": "refreshCatalog" }]`,避免用户点完表不刷新 | +| **对照表** | 「想做什么 \| 数据管理 \| 对话」覆盖双挂主能力;`label` 与界面一致 | + +字段细节见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「用户可见性与教程准确性」。 + +--- + +## 6. 入口三:定时任务(可选) + +### 6.1 何时挂 `cron`(教程教「怎么用定时」) + +- 参数相对固定、按节奏重复(同步、巡检、消费队列) +- 无人值守仍安全、可幂等或可失败重试 +- 与数据管理是「白天点选 + 夜里自动啃」组合 + +教程写:侧栏定时任务 → 新建 → 选本技能 → 选 Action → 填参 → 保存/立即运行。 + +### 6.2 何时不挂(教程写「不需要」) + +- 一次性 / 课堂演示,对象当场指定 +- 强不可逆、须当场确认 +- 每次对象不同、须人指定链接或范围 +- 主路径依赖验证码 / 人工介入 + +决策三问(与 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) §2.3 一致): + +1. 明天人不盯着、同样参数再跑一遍,业务上合理吗? +2. 失败后自动重跑可接受吗? +3. 有没有须人当场确认或动态指定的输入? + +①是 且 ②可接受 且 ③否 → 可挂 cron;否则不挂,§0.1 写理由,教程声明 N/A。 + +--- + +## 7. 复制后自检(发布前) + +- [ ] README 无分步操作、无 Agent 话术块 +- [ ] TUTORIAL 有三入口结构(定时有或 N/A) +- [ ] 对照表与 `actions.json` `label` 一致 +- [ ] 准备类按钮在数据管理表顶可见(若技能有数据管理主路径) +- [ ] 教程未把 skill-detail 当唯一载入/主流程入口 +- [ ] §0.1「挂」的入口在 TUTORIAL 有专章;「不挂」有理由且教程一致 + +交活清单见 [`CHECKLIST.md`](CHECKLIST.md) A2-7~A2-11。 + +--- + +## 8. 参考样例 + +已验收参考(业务技能,非模板正文):`client-gdcm/review-sku-selection-default` +(说明 Tab 瘦身 + 教程三入口 + 「载入课堂样例」挂 `selection_cases` toolbar。) + +复制模板后**不要**照搬选品表名与样例编码;只复用**章节结构与职责划分**。 + +--- + +## 9. 相关文档 + +- 宿主入口何时挂:[`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) +- 入口定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md) §0.1 +- Action placements / 可见性:[`../references/ACTIONS.md`](../references/ACTIONS.md) +- 市场四 Tab 同步:[`DEVELOPMENT.md`](DEVELOPMENT.md) §6 附近「用户市场四 Tab」 +- 交活验收:[`CHECKLIST.md`](CHECKLIST.md) §2 diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..fadf59e --- /dev/null +++ b/evals/README.md @@ -0,0 +1,5 @@ +# evals + +这里放人工评估脚本、示例输入输出、回归验收清单等材料。 + +模板仓库保持最小占位,不再放旧式杂项代码片段。 diff --git a/evals/golden-cases.md b/evals/golden-cases.md new file mode 100644 index 0000000..b259881 --- /dev/null +++ b/evals/golden-cases.md @@ -0,0 +1,39 @@ +# 人工验收评估场景(金标准用例) + +> 配套自动化测试:`tests/test_fund_rules.py`。以下场景用于宿主安装后的人工验收(对话 / 数据管理 / 定时三入口)。 + +## 场景 A:执行正常(绿色) + +**输入**:立项 50 万、到账 40 万、已报销 35 万(支出率 87.5%)、执行期覆盖当前日期约 70%。 + +**预期**:状态 🟢 正常;偏差在 ±20% 内;简报柱状图两条长度比例正确。 + +## 场景 B:严重滞后 + 暂付款超期(红色) + +**输入**:支出率 20%、序时进度 70%、暂付款 1.5 万元发生于 4 个月前。 + +**预期**:状态 🔴 严重滞后;播报含 D1 诊断与 N1 冲销提醒(账龄天数准确);建议含预算调剂沟通与冲销。 + +## 场景 C:异常偏快 + 临近结题(橙色) + +**输入**:支出率 95%、序时进度 70%、距执行期结束 60 天。 + +**预期**:状态 🟠 支出异常偏快;播报含 D2(突击花钱风险)与 N2(结题临近提醒,剩余天数准确)。 + +## 场景 D:数据不齐(灰色兜底) + +**输入**:缺已到账金额 / 到账为 0 / 缺执行期。 + +**预期**:分别归为"待补录""未到账""待补录",不硬算;对话引导补录;C3 提示缺失字段清单。 + +## 场景 E:阈值调整生效 + +**输入**:把 lag_threshold 从 0.10 调到 0.15,播报一个偏差 −12% 的项目。 + +**预期**:调整前状态"正常"(−12% 未超过 −10%…… 实际超了;此处应构造偏差 −12% 在 0.10 口径下已滞后、0.15 口径下仍滞后的对照),或使用偏差 −12% vs −8% 两组数据验证状态随阈值变化。 + +## 场景 F:月度定时播报(宿主) + +**输入**:宿主定时任务创建"技能直调 → 月度经费播报",手动"立即运行"。 + +**预期**:任务中心出现任务、有逐项目进度、完成后生成月度汇总简报 `brief-*.html` 并可侧栏预览;台账无在研项目时任务失败原因可读。 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5f9cdff --- /dev/null +++ b/examples/README.md @@ -0,0 +1,88 @@ +# Skill Template Examples + +`examples/` 是面向**技术人员与 AI 编程工具**的**核心实现案例**目录:提供可运行、可测试、可复制的参考代码,用于指导新技能的 `scripts/` 应如何编写。 + +**`examples/` 不是完整 skill 仓库。** 不要整包照搬 `examples/<mode>/` 到新技能根目录。应按各 example README 的 **copy map**,**选择性复制**到新技能的 `scripts/`(及按需的 `tests/`)。 + +复制任何案例前先读 [`../development/AI_RPA.md`](../development/AI_RPA.md):案例只示范授权范围内的正常界面自动化,禁止把它扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避平台限制的实现。 + +根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。 + +## 外部交互示例矩阵 + +adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。 + +| 目录 | 类型 | 状态 | 适用场景 | +|---|---|---|---| +| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 | +| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 | +| `real_desktop_rpa` | 真实 Windows 桌面 RPA | **已有成功来源并已提炼** | UIA 拾取、窗口/控件、probe、dry-run/assisted | +| `mobile_rpa` | Android/iOS RPA | **仅规格,待成功案例** | 设备、控件树、Native/WebView、example 完成契约 | +| `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流(尚未沉淀可复制实现) | +| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) | + +## 两类浏览器 RPA 的区别 + +| | real_browser_rpa | simulator_browser_rpa(修正后) | +|---|---|---| +| 目标 | 真实第三方网页 | 自有仿真页面 / sandbox / jc2009 类 | +| 风控 | 高(验证码、登录态、反爬) | 低(DOM 可控) | +| 账号 | account-manager + profile | account-manager + profile(`simulator_rpa` 档) | +| 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 | + +### 工程范式对比 + +| 工程范式 | real_browser_rpa | simulator_browser_rpa(修正后) | +|---|---|---| +| async Playwright | ✅ | ✅ | +| 薄 adapter + `*_playwright.py` | `task_rpa.py` | `simulator_playwright.py` | +| account-manager subprocess | ✅ | ✅(`simulator_rpa` 档位) | + +两者工程范式一致,差异在目标系统(真实站 vs 仿真站/本地 demo)。 + +## 如何选择示例 + +| 你的场景 | 参考目录 | +|---|---| +| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** | +| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** | +| Windows 原生软件 + UIA | **real_desktop_rpa** | +| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) | +| 真实系统 API | **real_api**(当前占位,不可作为实现参考) | +| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) | + +## 轻量结构约定 + +每个 `examples/<mode>/` 推荐结构: + +```text +examples/<mode>/ + README.md + scripts/ + service/ + util/ + tests/ +``` + +- `simulator_browser_rpa` 可额外包含 `sandbox/` +- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture +- 仅未来确实需要样例数据时可加 `fixtures/` +- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件 + +## `scripts/` 与 `examples/` 边界 + +| | `scripts/` | `examples/` | +|---|---|---| +| 定位 | 真实技能业务逻辑的标准骨架 | 核心实现案例与复制参考 | +| 内容 | 干净的技能骨架,无教学/demo 代码 | 可运行参考实现,非完整 skill | +| 旧示例 | **不得**出现 `example_*` 目录或文件 | 四类一级目录固定保留 | + +复制规则: + +- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/` +- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`(adapter 分层权威参考:`scripts/service/adapter/`) +- Windows 桌面 RPA → 参考 `examples/real_desktop_rpa/`(真实成功来源:`add-friend-wecom`) +- 手机 RPA → 先按 `examples/mobile_rpa/` 定义完成标准;真实成功案例补齐前不得照骨架宣称跑通 +- `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考 + +> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。 diff --git a/examples/mobile_rpa/README.md b/examples/mobile_rpa/README.md new file mode 100644 index 0000000..985574b --- /dev/null +++ b/examples/mobile_rpa/README.md @@ -0,0 +1,94 @@ +# Mobile RPA Example Specification + +本目录定义 Android/iOS example 必须具备的结构和验收契约。**当前没有团队真实成功案例,因此本目录不是可复制实现,也不得宣称已跑通。** + +先读: + +1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md) +2. [`../../development/RPA_MOBILE.md`](../../development/RPA_MOBILE.md) + +## 为什么现在不放伪代码实现 + +移动端 selector、driver、Activity/context、权限弹窗和设备兼容性必须在真实授权设备上验证。没有成功案例时放一套看似完整的代码,会诱导开发者和 AI 猜 `resource-id`、默认取第一个设备或用坐标硬点,违背模板目标。 + +## 未来成功 example 的固定结构 + +```text +mobile_rpa/ + README.md + android/ + README.md + assets/element-map.yaml + scripts/service/device_session.py + scripts/service/mobile_locator.py + scripts/service/example_flow.py + tests/test_locator_contract.py + tests/integration/test_real_device.py.sample + ios/ + README.md + assets/element-map.yaml + scripts/service/device_session.py + scripts/service/mobile_locator.py + scripts/service/example_flow.py + tests/test_locator_contract.py + tests/integration/test_real_device.py.sample +``` + +只有对应端出现真实成功案例后才创建代码文件。 + +## Android example 完成标准 + +- 唯一设备 serial 和设备互斥 +- doctor:ADB授权、包名、Activity、屏幕状态、driver +- Appium Inspector/UIAutoDev 拾取步骤和原始 XML +- element map:resource-id/content-desc/text/class/父容器 +- Native/WebView context 选择 +- 点击、输入、滑动和真实后置状态 +- 权限弹窗、系统弹窗和人工验证 +- screenshot + page source 失败留证 +- 虚拟列表滚动后重新定位 +- 至少一台授权真机完整跑通 + +## iOS example 完成标准 + +- Mac、WebDriverAgent签名和设备 UDID 准备 +- doctor:WDA、bundle id、系统版本、屏幕状态 +- Appium Inspector 拾取和原始 page source +- accessibility id/name、predicate/class chain 定位 +- Native/WebView context 选择 +- 系统 alert 和人工验证 +- screenshot + page source 失败留证 +- 至少一台授权真机完整跑通 + +## element map 最小字段 + +```yaml +logical_name: login_button +surface: android +screen: 登录页 +scope: + package: com.example.app + activity: .LoginActivity +primary_locator: + strategy: resource_id + value: com.example.app:id/login +expected_count: 1 +ready_condition: + visible: true + enabled: true +postcondition: + description: 首页业务标记出现 +verified_device: 待填写 +verified_app_version: 待填写 +``` + +## 状态升级规则 + +满足以下全部条件后,才可把本目录状态改为“已有成功案例”: + +1. 提供用户确认的成功项目来源。 +2. 在真实授权设备上跑通完整业务闭环。 +3. 保存脱敏 element map 和失败证据样例。 +4. 有默认离线单测和显式真实集成测试。 +5. 经过设备重连、App重启和页面变化验证。 +6. 用户人工验收通过。 diff --git a/examples/real_api/README.md b/examples/real_api/README.md new file mode 100644 index 0000000..7a82850 --- /dev/null +++ b/examples/real_api/README.md @@ -0,0 +1,35 @@ +# Real API Example + +这个目录用于沉淀「真实系统 API」类 skill 的可复制成功案例。 + +## 适用场景(规划) + +- 目标系统提供真实 API +- 需要 token / secret / credential +- 需要请求重试、权限校验、限流处理、schema 校验和幂等写库 + +## 当前状态:规划占位 + +- **本目录当前为规划占位**,仅保留最小结构(`README.md`、`scripts/`、`tests/` 占位)。 +- **尚未沉淀可复制实现**;目录内无参考代码。 +- **AI 编程工具不得**基于空目录自行脑补 API 对接实现。 +- 后续补齐可复制案例后,再按 README copy map 选择性复制到新技能 `scripts/`。 + +## 轻量结构 + +```text +real_api/ + README.md + scripts/ + service/ + util/ + tests/ +``` + +不要在本目录添加 `SKILL.md`、市场 README、`references/`、`development/`、`.github/` 等完整 skill 仓库层文件。 + +## 复制边界(待案例补齐后生效) + +- 参考代码将从 `examples/real_api/scripts/` 选择性复制到新技能 `scripts/` +- 根 `README.md`、`SKILL.md`、`references/` 仍按模板主目录规则编写 +- 当前阶段:**不可作为实现参考** diff --git a/examples/real_api/scripts/service/.gitkeep b/examples/real_api/scripts/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/real_api/scripts/util/.gitkeep b/examples/real_api/scripts/util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/real_api/tests/.gitkeep b/examples/real_api/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/real_browser_rpa/README.md b/examples/real_browser_rpa/README.md new file mode 100644 index 0000000..bc01fa3 --- /dev/null +++ b/examples/real_browser_rpa/README.md @@ -0,0 +1,204 @@ +# Real Browser RPA Example + +这是一个真实网站浏览器 RPA skill 的可复制参考实现,适用于需要登录、人工验证、搜索、滚动加载、采集和入库的场景。 + +**说明:** + +- 本示例是通用模板,不绑定具体平台。 +- 示例抽象自已跑通的真实浏览器 RPA skill(抖音视频链接采集),但代码已改成中性示例命名。 +- 新 skill 开发时可以复制本目录,再替换目标站点、选择器、解析逻辑和业务字段。 +- 只用于授权范围内的正常界面操作;禁止破解、逆向、私有协议、验证码绕过、自动换号或其他规避平台限制的扩展。 + +## 适用场景 + +- 真实网站操作(非 sandbox / mock) +- 需要登录态与 persistent Chrome/Edge profile +- 需要通过 account-manager 管理账号和 `profile_dir` +- 需要处理人工登录、滑块验证码、短信验证码 +- 需要关键词搜索、滚动加载、采集可见条目 +- 需要结构化日志、用户友好的停止原因、失败留痕(截图/视频) + +## 目录结构 + +``` +real_browser_rpa/ +├── README.md +├── scripts/ +│ ├── service/ +│ │ ├── account_client.py # subprocess 调用 account-manager +│ │ ├── browser_session.py # 系统 Chrome + persistent context +│ │ ├── human_verification.py # 滑块/短信/保存登录弹窗检测(不依赖 task_rpa 错误类型) +│ │ ├── task_rpa.py # RPA 主流程 +│ │ └── task_service.py # 轻量编排(示例无 DB) +│ └── util/ +│ ├── constants.py # 平台常量、selector 占位 +│ └── logging.py # 结构化日志与脱敏 +├── assets/ +│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写 +└── tests/ + ├── test_human_verification.py # 验证码误判防护 + └── test_stop_reason.py # 停止原因文案 +``` + +## 核心流程 + +1. **ensure account** — 通过 `account_client.pick_web_account()`(底层 `ensure-web`)获取 `profile_dir` 与租约;无账号则自动登记,全忙则 `LEASE_CONFLICT` 不重复建号。登记 ≠ 站点已登录。 +2. **启动浏览器** — `launch_persistent_context` + `new_page()` + `page.goto(start_url)` +3. **人工验证(启动后)** — 检测滑块/短信,等待用户完成 +4. **等待登录** — 检测登录按钮消失 / 登录态 marker 出现 +5. **保存登录弹窗** — 等待“是否保存登录信息”倒计时消失 +6. **人工验证(登录后)** — 再次检测 +7. **搜索** — 定位搜索框 → 输入关键词 → 点击搜索 +8. **等待结果容器** — `#search-result-container`(示例 selector) +9. **滚动采集循环** — 采集可见条目 → 判断 stop_reason → 滚动加载 +10. **返回结果** — `ScrapeRunResult` + `format_stop_reason_for_user()` +11. **释放租约** — `finally` 中 `release_lease()` + +## 开发新 skill 时如何复制 + +1. **先阅读**本 README 及下方「Copy map」「复制边界」章节 +2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map +3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`) +4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量 +5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**) +6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同) +7. 在 `task_service.py` 中接入真实 DB、RpaVideoSession +8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README + +## Copy map(复制到新技能 `scripts/`) + +| 来源(examples) | 目标(新技能) | 说明 | +|---|---|---| +| `examples/real_browser_rpa/scripts/service/browser_session.py` | `scripts/service/browser_session.py` | persistent context 启动范式 | +| `examples/real_browser_rpa/scripts/service/account_client.py` | `scripts/service/account_client.py` | account-manager CLI 集中封装 | +| `examples/real_browser_rpa/scripts/service/human_verification.py` | `scripts/service/human_verification.py` | 人工验证等待 | +| `examples/real_browser_rpa/scripts/service/task_rpa.py` | `scripts/service/<业务名>_rpa.py` 或 `scripts/service/task_rpa.py` | RPA 主流程,按业务改名 | +| `examples/real_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 参考并合并编排层 | +| `examples/real_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) | +| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 | +| `examples/real_browser_rpa/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 | +| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 | +| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README | + +**复制时必须替换:** selector、URL、平台名称、账号平台字段、slug、logger 名等业务标识。**不要**照搬示例字段到其他平台。 + +**浏览器与启动约束:** + +- **先 Profile 再开浏览器**:必须 `pick_web_account`(底层 ensure-web)拿到 `profile_dir`;拿不到则失败退出,禁止落到系统默认用户目录(见模板 `development/RPA.md` §0.2) +- **RPA 等待**:主路径用 `interruptible_sleep`,禁止裸 `asyncio.sleep`(`POLICY-CONTROL-003`) +- **有头**:生产 / 联调 `OPENCLAW_BROWSER_HEADLESS=0`;勿把无头当交付常态 +- **登录按平台**:是否要求站点登录由本技能约定;「可不登录」≠「可不接 account-manager」 +- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供 +- `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`** +- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入 + +## 复制边界:哪些能复制,哪些必须替换 + +### 可以直接参考/复制的 + +- `browser_session.py` 的启动结构:**persistent context → new_page → goto** +- `human_verification.py` 的人工验证等待模式(容器级检测 + 等待人工,不自动破解) +- `task_rpa.py` 的流程分层、结构化日志点、`stop_reason` 设计 +- `account_client.py` 的 account-manager CLI **集中封装**方式(subprocess 仅限此文件) +- `tests/test_human_verification.py` 的误判防护测试思路 + +### 必须替换的 + +- `SKILL_SLUG` +- `TARGET_PLATFORM` +- `LEASE_HOLDER` +- `DEFAULT_START_URL` +- `SEARCH_INPUT_SELECTORS` / `SEARCH_BUTTON_SELECTOR` +- `RESULT_CONTAINER_SELECTOR` / `RESULT_ITEM_SELECTOR` +- `SCROLL_CONTAINER_SELECTORS` +- `RESULT_END_TEXT` +- `LOGGED_IN_SELECTORS` / `LOGGED_OUT_SELECTOR`(见下方说明) +- `_parse_item_locator()` 业务字段解析 +- 用户可见中文文案 +- 数据库写入逻辑 +- 录屏/截图业务标题 + +### 禁止照抄的 + +- 不要照抄目标站点 selector(示例 selector 不等于真实 DOM) +- 不要照抄平台名、slug、logger 名 +- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖 +- 不要把示例里的 result selector 当成真实站点 selector +- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器 +- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性 +- 不要把示例代码原样发布为业务 skill +- 不要把真实账号、手机号、cookie、token 写入代码或 README + +### 登录 selector 说明 + +`task_rpa.py` 中的 `LOGGED_IN_SELECTORS` 与 `LOGGED_OUT_SELECTOR` **只是示例**,偏中文「登录」按钮和常见站点结构。复制到新 skill 后: + +- **必须**用目标站点 F12 / DOM 实测后替换 +- **不允许**凭经验猜 selector +- **不允许**因为示例在本机曾跑通就直接照搬到其他平台 + +### account-manager 路径解析说明 + +`account_client.py` 末尾的 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 是**开发环境兜底路径**,仅用于模板/本机调试。复制到真实 skill 后: + +- **优先**通过 `ACCOUNT_MANAGER_ROOT`、Platform Kit `get_sibling_skills_root` 或宿主运行环境解析 account-manager +- **不要**把个人机器路径作为生产依赖 + +## 必须保留的安全原则 + +- **不 import account-manager 内部模块** — 只通过 CLI/subprocess 调用 +- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成 +- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理 +- **日志脱敏** — 不输出完整手机号/账号 +- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放 +- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位) + +## 常见坑 + +| 坑 | 正确做法 | +|---|---| +| 直接 `import account_manager.service...` | 通过 `subprocess` 调用 `account-manager/scripts/main.py` | +| 把 URL 放进 `launch_persistent_context(args=[url])` | `args` 只放 Chrome 参数;URL 用 `page.goto(start_url)` | +| 启动后直接操作 DOM | 先 `context.new_page()` 再 `page.goto()` | +| 用整页 `body.inner_text()` 判断验证码 | 限定在 `[role='dialog']`、`.vc-captcha-verify` 等容器 | +| 搜索结果含“验证码教程”文字就误判 | 单元测试覆盖:无验证 DOM 时不应 `present=True` | +| 无结构化日志 | 关键节点打 `rpa_start` / `browser_ready` / `collect_round` 等 | +| 把 CLI 参数暴露给普通用户 | 用户说明放根 `README.md`;运行契约与触发规则放 `SKILL.md`(LLM/平台入口) | + +## 需要替换的内容清单 + +| 文件 | 替换项 | +|---|---| +| `util/constants.py` | `SKILL_SLUG`、`TARGET_PLATFORM`、`DEFAULT_START_URL`、所有 selector | +| `browser_session.py` | `get_start_url()` 配置来源、stealth 脚本(可选) | +| `human_verification.py` | `SLIDER_SELECTORS`、`SMS_SELECTORS`、`DIALOG_TEXT_CONTAINERS` | +| `task_rpa.py` | `LOGGED_IN_SELECTORS`、`LOGGED_OUT_SELECTOR`、`_parse_item_locator()` | +| `account_client.py` | `PLACEHOLDER_PLATFORM`、`LEASE_HOLDER`、账号 setup 文案 | +| `task_service.py` | 接入 DB、entitlement、视频留痕 | + +## 依赖说明 + +本目录是 **示例**,不影响 skill-template 根目录运行。真实 skill 需要: + +- Python 3.10+ +- Python 包 `playwright`:属于**宿主共享 runtime** 的通用能力;技能侧**不要**在 `requirements.txt` 中重复声明,也**不要**在代码或脚本中执行 `pip install playwright` +- 真实 skill **默认使用系统 Chrome/Edge**,**不使用** Playwright 下载的内置 Chromium +- 技能侧**不要**执行 `playwright install`、`playwright install chromium` 或类似命令 +- 若本地独立开发环境缺少 Playwright Python 包,只能作为开发机临时环境补装;**生产/宿主运行**由共享 runtime 解决 +- `jiangchang_skill_core`(如 `find_chrome_executable` 等,由 platform-kit 提供) +- 兄弟 skill `account-manager` + +## 运行测试 + +```bash +cd examples/real_browser_rpa +python -m pytest tests/ -v +``` + +测试为纯函数/Mock DOM,**不需要启动真实浏览器**。 + +## 当前状态 + +- 示例代码已就绪,可直接作为新 skill 复制起点。 +- 未绑定具体平台业务字段;selector 为占位示例。 +- 未接入真实数据库与 CLI 入口。 diff --git a/examples/real_browser_rpa/assets/element-map.sample.yaml b/examples/real_browser_rpa/assets/element-map.sample.yaml new file mode 100644 index 0000000..a7bc28f --- /dev/null +++ b/examples/real_browser_rpa/assets/element-map.sample.yaml @@ -0,0 +1,45 @@ +surface: browser +status: placeholder_requires_real_site_inspection +elements: + - logical_name: search_input + purpose: 输入搜索关键词 + scope: + kind: main + business_container: 待真实拾取 + primary_locator: + strategy: role_or_label + value: 待真实拾取 + fallback_locators: [] + expected_count: 1 + ready_condition: + visible: true + enabled: true + postcondition: + description: 搜索结果容器出现 + verified_target_version: 待填写 + + - logical_name: search_button + purpose: 提交搜索 + scope: + logical_parent: search_form + primary_locator: + strategy: role + value: button,name=搜索 + fallback_locators: [] + expected_count: 1 + postcondition: + description: 结果页或业务错误状态出现 + verified_target_version: 待填写 + + - logical_name: result_container + purpose: 承载搜索结果 + scope: + kind: main + primary_locator: + strategy: stable_business_container + value: 待真实拾取 + fallback_locators: [] + expected_count: 1 + postcondition: + description: 列表项、空结果或结束标记可区分 + verified_target_version: 待填写 diff --git a/examples/real_browser_rpa/scripts/service/account_client.py b/examples/real_browser_rpa/scripts/service/account_client.py new file mode 100644 index 0000000..b068608 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/account_client.py @@ -0,0 +1,355 @@ +"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 + +业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional, Tuple + +from jiangchang_skill_core import config +from jiangchang_skill_core.runtime_env import get_sibling_skills_root + +from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM +from util.logging import mask_text + +logger = logging.getLogger(__name__) + +PLACEHOLDER_PLATFORM = TARGET_PLATFORM + +ACCOUNT_SETUP_MESSAGE = ( + f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" + f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" +) + +LEASE_BUSY_MESSAGE = ( + "该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。" +) + +DEFAULT_AUTH_STRATEGY = "qr_code_manual" +DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认" +DEFAULT_START_URL = "https://www.example.com/" + + +class AccountManagerError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def mask_login_id(login_id: str) -> str: + return mask_text(login_id) + + +def format_account_label(account: dict) -> str: + """人读账号标识:优先 login_id / 备注,最后才用数字 id。""" + for key in ("login_id", "account_label", "label"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + for key in ("id", "account_id"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + +def _is_placeholder_config(raw: str) -> bool: + s = (raw or "").strip() + if not s: + return True + if s.startswith("#"): + return True + low = s.lower() + if low in ("default-account", "none", "null", "n/a", "-"): + return True + return False + + +def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]: + """从配置解析 (account_id, login_id)。 + + - ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id + - ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id) + - 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web + """ + account_id: Optional[str] = None + login_id: Optional[str] = None + + aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip() + if not _is_placeholder_config(aid_raw): + if aid_raw.isdigit(): + account_id = aid_raw + else: + login_id = aid_raw + logger.info("default_account_id_treated_as_login_id value=%s", aid_raw) + + lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip() + if not _is_placeholder_config(lid_raw): + login_id = lid_raw + + if account_id: + return account_id, None + return None, login_id + + +def _resolve_account_manager_main() -> str: + """解析 account-manager CLI 入口路径。 + + 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。 + 末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。 + """ + env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() + if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): + return os.path.join(os.path.abspath(env_root), "scripts", "main.py") + + scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_root = get_sibling_skills_root(scripts_dir) + candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py") + if os.path.isfile(candidate): + return candidate + + # 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录 + dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" + if os.path.isfile(dev): + return dev + + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + "未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。", + ) + + +def _parse_last_json(stdout: str) -> Any: + lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()] + for raw in reversed(lines): + if raw.startswith("ERROR:"): + continue + try: + return json.loads(raw) + except json.JSONDecodeError: + continue + raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。") + + +def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]: + main_py = _resolve_account_manager_main() + env = os.environ.copy() + return subprocess.run( + [sys.executable, main_py, *argv_suffix], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + + +def _normalize_error_code(raw: str) -> str: + code = (raw or "").strip() + if code.startswith("ERROR:"): + code = code[6:] + return code + + +def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + if payload.get("success") is False: + err = payload.get("error") if isinstance(payload.get("error"), dict) else {} + code = _normalize_error_code(str(err.get("code") or "")) + message = str(err.get("message") or "") + if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: + raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE) + if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code: + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。", + ) + if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: + raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") + raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") + + if not payload.get("profile_dir"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。") + if not payload.get("lease_token"): + raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。") + return payload + + +def _ensure_web_with_lease( + platform: str, + *, + login_id: Optional[str] = None, +) -> Dict[str, Any]: + start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL + lid = (login_id or "").strip() or None + label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL + argv = [ + "account", + "ensure-web", + "--platform", + platform, + "--url", + start_url, + "--auth-strategy", + DEFAULT_AUTH_STRATEGY, + "--label", + label, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if lid: + argv.extend(["--login-id", lid]) + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "ENSURE_WEB_FAILED", + (proc.stderr or "").strip() or "ensure-web 子进程失败", + ) + payload = _parse_last_json(out) + if isinstance(payload, dict) and payload.get("success") is False: + return _validate_pick_payload(payload) + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + return _validate_pick_payload(payload) + + +def _pick_existing_with_lease( + platform: str, + *, + login_id: Optional[str] = None, + label: Optional[str] = None, +) -> Dict[str, Any]: + argv = [ + "account", + "pick-web", + "--platform", + platform, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if login_id: + argv.extend(["--login-id", login_id]) + elif label: + argv.extend(["--label", label]) + else: + raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。") + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "PICK_WEB_FAILED", + (proc.stderr or "").strip() or "pick-web 子进程失败", + ) + payload = _parse_last_json(out) + return _validate_pick_payload(payload) + + +def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]: + proc = _run_argv(["account", "get", str(account_id)]) + out = proc.stdout or "" + if proc.returncode != 0 or out.startswith("ERROR:"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + raw = _parse_last_json(out) + if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict): + data = raw["data"] + elif isinstance(raw, dict): + data = raw + else: + raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") + + if data.get("success") is False: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + platform_key = str(data.get("platform_key") or "").lower() + if platform_key != platform.lower(): + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + f"账号 {account_id} 不是 {platform} 平台账号。", + ) + profile_dir = str(data.get("profile_dir") or "").strip() + if not profile_dir: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") + if str(data.get("status") or "active").lower() != "active": + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。") + + try: + selected = _pick_existing_with_lease( + platform, + login_id=(str(data.get("login_id") or "").strip() or None), + label=(str(data.get("account_label") or "").strip() or None), + ) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"): + raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc + raise + if str(selected.get("id")) != str(account_id): + release_lease(str(selected.get("lease_token") or "")) + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。", + ) + return selected + + +def pick_web_account( + platform: str, + account_id: Optional[str] = None, + login_id: Optional[str] = None, +) -> dict: + """获取网页账号(profile_dir + lease_token)。 + + 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 + """ + platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM + + aid_raw = (account_id or "").strip() + if aid_raw and not _is_placeholder_config(aid_raw): + if not aid_raw.isdigit(): + if not (login_id or "").strip(): + login_id = aid_raw + aid_raw = "" + else: + return _pick_by_id_with_lease(platform_key, int(aid_raw)) + + lid = (login_id or "").strip() or None + if lid and _is_placeholder_config(lid): + lid = None + + try: + return _ensure_web_with_lease(platform_key, login_id=lid) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: + raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc + raise + + +def release_lease(lease_token: Optional[str]) -> None: + token = (lease_token or "").strip() + if not token: + return + try: + proc = _run_argv(["lease", "release", token]) + if proc.returncode != 0: + logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip()) + except Exception: + logger.warning("lease_release_exception", exc_info=True) diff --git a/examples/real_browser_rpa/scripts/service/browser_session.py b/examples/real_browser_rpa/scripts/service/browser_session.py new file mode 100644 index 0000000..4b89336 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/browser_session.py @@ -0,0 +1,135 @@ +"""浏览器会话启动(系统 Chrome/Edge + Playwright persistent context)。""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Optional, Tuple + +from util.constants import DEFAULT_START_URL + +logger = logging.getLogger(__name__) + +DEFAULT_PAGE_TIMEOUT_MS = 15_000 +GOTO_TIMEOUT_MS = 60_000 + +STEALTH_INIT_SCRIPT = """ +(() => { + try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {} +})(); +""" + + +def get_start_url() -> str: + return (os.getenv("TARGET_START_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL + + +def _headless_from_env() -> bool: + v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() + return v in ("1", "true", "yes", "on") + + +def _find_chrome_executable() -> str | None: + try: + from jiangchang_skill_core.runtime_env import find_chrome_executable + + chrome = find_chrome_executable() + if chrome and os.path.isfile(chrome): + return chrome + except ImportError: + pass + + candidates = [ + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + ] + for path in candidates: + if path and os.path.isfile(path): + return path + return None + + +async def start_browser_session( + profile_dir: str, + *, + headless: Optional[bool] = None, +) -> Tuple[Any, Any, Any]: + """ + 启动持久化 Chrome profile,new_page + goto 入口页,返回 (playwright, context, page)。 + + 真实 skill 需要安装 playwright 与 jiangchang_skill_core。 + """ + from playwright.async_api import async_playwright + + hl = headless if headless is not None else _headless_from_env() + start_url = get_start_url() + chrome = _find_chrome_executable() + if not chrome: + raise RuntimeError( + "ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。" + ) + + launch_args = ["--start-maximized", "--disable-blink-features=AutomationControlled"] + logger.info( + "browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s", + chrome, + profile_dir, + start_url, + launch_args, + hl, + ) + print(f"[浏览器] 系统 Chrome: {chrome}") + print(f"[浏览器] profile_dir: [{profile_dir}]") + print(f"[浏览器] startup_url={start_url}") + print(f"[浏览器] args={launch_args}") + + pw = await async_playwright().start() + context = None + try: + context = await pw.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=hl, + executable_path=chrome, + locale="zh-CN", + no_viewport=True, + args=launch_args, + ignore_default_args=["--enable-automation"], + ) + await context.add_init_script(STEALTH_INIT_SCRIPT) + + page = await context.new_page() + page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS) + + try: + await page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS) + except Exception as exc: + raise RuntimeError( + f"浏览器已启动,但未能打开目标首页 {start_url}。" + "请检查网络、Chrome profile、代理或页面访问状态。" + f"({exc})" + ) from exc + + return pw, context, page + except Exception: + if context is not None: + try: + await context.close() + except Exception: + pass + await pw.stop() + raise + + +async def close_browser_context(pw, context) -> None: + try: + if context is not None: + await context.close() + except Exception: + pass + try: + if pw is not None: + await pw.stop() + except Exception: + pass diff --git a/examples/real_browser_rpa/scripts/service/human_verification.py b/examples/real_browser_rpa/scripts/service/human_verification.py new file mode 100644 index 0000000..e6ed82e --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/human_verification.py @@ -0,0 +1,450 @@ +"""人工验证等待:保存登录倒计时、滑块、短信验证码等(不自动破解)。""" + +from __future__ import annotations + +import asyncio +import logging +import random +import re +import time +from dataclasses import dataclass +from typing import Optional + +from util.constants import LOG_LOGGER_NAME + +logger = logging.getLogger(LOG_LOGGER_NAME) + +TEXT_EXCERPT_MAX = 120 + +SAVE_LOGIN_STRONG_MARKERS = ( + "是否保存登录信息", + "下次登录更便捷", + "保存登录信息", +) + +SAVE_LOGIN_CONTAINER_SELECTORS = ( + "[role=\"dialog\"]", + ".semi-modal", + ".semi-modal-content", +) + +SLIDER_SELECTORS = [ + ".vc-captcha-verify.slide", + ".captcha_verify_bar--title", + ".captcha-slider-tips", + "#captcha_verify_image", + ".captcha-slider-btn", +] + +SMS_SELECTORS = [ + ".second-verify-panel", + ".uc-ui-verify_sms-input", + ".uc-ui-verify-new_header-title", + "input[placeholder='手机号']", + "input[placeholder='请输入验证码']", +] + +DIALOG_TEXT_CONTAINERS = [ + "[role='dialog']", + ".semi-modal", + ".semi-modal-content", + ".vc-captcha-verify", + ".second-verify-panel", +] + +RISK_MARKERS = ( + "操作频繁", + "访问过于频繁", + "风险验证", + "安全验证", +) + + +@dataclass +class HumanVerificationState: + present: bool + kind: str + message: str + selector: str = "" + text_excerpt: str = "" + + +@dataclass +class HumanVerificationWaitResult: + timed_out: bool + kind: str = "" + code: str = "" + message: str = "" + stage: str = "" + + +def _text_excerpt(text: str) -> str: + cleaned = re.sub(r"\s+", " ", (text or "").strip()) + if len(cleaned) <= TEXT_EXCERPT_MAX: + return cleaned + return cleaned[:TEXT_EXCERPT_MAX] + "..." + + +def _contains_any(text: str, markers: tuple[str, ...]) -> bool: + return any(m in text for m in markers) + + +async def _selector_visible(page, selector: str) -> bool: + try: + loc = page.locator(selector).first + return await loc.is_visible(timeout=500) + except Exception: + return False + + +async def _selector_inner_text(page, selector: str) -> str: + try: + return await page.locator(selector).first.inner_text(timeout=1000) + except Exception: + return "" + + +async def _visible_text_from_first_visible(page, selectors: tuple[str, ...] | list[str]) -> tuple[str, str]: + for sel in selectors: + try: + loc = page.locator(sel).first + if await loc.is_visible(timeout=500): + text = await loc.inner_text(timeout=1000) + return sel, text or "" + except Exception: + continue + return "", "" + + +def _save_login_popup_from_text(text: str) -> bool: + if not text: + return False + if _contains_any(text, SAVE_LOGIN_STRONG_MARKERS): + return True + if "保存" in text and "取消" in text and ("登录" in text or "登录信息" in text): + return True + return False + + +async def detect_save_login_popup(page) -> bool: + sel, text = await _visible_text_from_first_visible(page, SAVE_LOGIN_CONTAINER_SELECTORS) + if sel and _save_login_popup_from_text(text): + return True + return False + + +async def wait_save_login_countdown_if_present(page, timeout_sec: int = 8) -> bool: + """若出现“保存登录信息”倒计时弹窗则等待其消失;未出现则立即返回 False。""" + if not await detect_save_login_popup(page): + return False + + logger.info("save_login_popup_detected") + print("[登录] 检测到“保存登录信息”倒计时弹窗,等待其自动关闭...") + start = time.monotonic() + deadline = start + timeout_sec + while time.monotonic() < deadline: + if not await detect_save_login_popup(page): + elapsed = round(time.monotonic() - start, 2) + logger.info("save_login_popup_gone elapsed_sec=%s", elapsed) + return True + await asyncio.sleep(0.5) + + elapsed = round(time.monotonic() - start, 2) + logger.warning( + "save_login_popup_still_visible elapsed_sec=%s timeout_sec=%s", + elapsed, + timeout_sec, + ) + print( + f"[登录] 警告:保存登录信息弹窗超过 {timeout_sec} 秒仍未消失," + "将继续后续流程(您可能已手工点击保存/取消)。" + ) + return True + + +async def detect_slider_verification(page) -> Optional[HumanVerificationState]: + sel_slide = ".vc-captcha-verify.slide" + if await _selector_visible(page, sel_slide): + text = await _selector_inner_text(page, sel_slide) + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel_slide, + text_excerpt=_text_excerpt(text), + ) + + sel_title = ".captcha_verify_bar--title" + if await _selector_visible(page, sel_title): + title_text = await _selector_inner_text(page, sel_title) + if "请完成下列验证后继续" in title_text: + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel_title, + text_excerpt=_text_excerpt(title_text), + ) + + sel_tips = ".captcha-slider-tips" + if await _selector_visible(page, sel_tips): + tips_text = await _selector_inner_text(page, sel_tips) + if "拖动完成上方拼图" in tips_text: + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel_tips, + text_excerpt=_text_excerpt(tips_text), + ) + + if await _selector_visible(page, "#captcha_verify_image"): + for btn_sel in (".captcha-slider-btn", ".captcha_verify_slide--button", ".captcha-slider"): + if await _selector_visible(page, btn_sel): + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=f"#captcha_verify_image+{btn_sel}", + text_excerpt="", + ) + + return None + + +async def detect_sms_verification(page) -> Optional[HumanVerificationState]: + sel_panel = ".second-verify-panel" + if await _selector_visible(page, sel_panel): + text = await _selector_inner_text(page, sel_panel) + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel_panel, + text_excerpt=_text_excerpt(text), + ) + + sel_sms = ".uc-ui-verify_sms-input" + if await _selector_visible(page, sel_sms): + text = await _selector_inner_text(page, sel_sms) + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel_sms, + text_excerpt=_text_excerpt(text), + ) + + sel_hdr = ".uc-ui-verify-new_header-title" + if await _selector_visible(page, sel_hdr): + hdr_text = await _selector_inner_text(page, sel_hdr) + if "短信验证" in hdr_text: + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel_hdr, + text_excerpt=_text_excerpt(hdr_text), + ) + + phone_visible = await _selector_visible( + page, + ".second-verify-panel input[placeholder='手机号'], .uc-ui-verify_sms-input input[placeholder='手机号']", + ) + code_visible = await _selector_visible( + page, + ".second-verify-panel input[placeholder='请输入验证码'], .uc-ui-verify_sms-input input[placeholder='请输入验证码']", + ) + if phone_visible and code_visible: + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector="sms-phone-and-code-inputs", + text_excerpt="手机号+请输入验证码", + ) + + return None + + +async def detect_human_verification_from_containers(page) -> Optional[HumanVerificationState]: + sel, text = await _visible_text_from_first_visible(page, DIALOG_TEXT_CONTAINERS) + if not sel or not text: + return None + + if _contains_any(text, RISK_MARKERS): + return HumanVerificationState( + present=True, + kind="risk", + message="安全/频控验证", + selector=sel, + text_excerpt=_text_excerpt(text), + ) + + if sel in (".vc-captcha-verify", ".vc-captcha-verify-visibility"): + if "请完成下列验证后继续" in text or "拖动完成上方拼图" in text: + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=sel, + text_excerpt=_text_excerpt(text), + ) + + if sel in (".second-verify-panel", ".uc-ui-verify_sms-input"): + if "短信验证" in text or "获取验证码" in text: + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=sel, + text_excerpt=_text_excerpt(text), + ) + + return None + + +async def detect_human_verification(page) -> HumanVerificationState: + """主检测入口:仅在验证码/弹窗容器级检测,不扫整页 body。""" + state = await detect_slider_verification(page) + if state is not None: + return state + state = await detect_sms_verification(page) + if state is not None: + return state + state = await detect_human_verification_from_containers(page) + if state is not None: + return state + return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="") + + +def detect_human_verification_from_text(text: str) -> HumanVerificationState: + """ + 根据 HTML/容器文本判断验证弹窗(单元测试辅助,不能作为真实页面主检测入口)。 + 只检查验证容器 DOM 标记,不根据正文关键词误判。 + """ + html_lower = (text or "").lower() + has_slider_dom = ( + "vc-captcha-verify" in html_lower + and "slide" in html_lower + and ( + "captcha-slider-tips" in html_lower + or "captcha_verify_bar--title" in html_lower + or "captcha_verify_image" in html_lower + ) + ) + if has_slider_dom: + selector = ".vc-captcha-verify.slide" + if "captcha-slider-tips" in html_lower: + selector = ".captcha-slider-tips" + return HumanVerificationState( + present=True, + kind="slider", + message="滑块验证码", + selector=selector, + text_excerpt="", + ) + + has_sms_dom = "second-verify-panel" in html_lower or "uc-ui-verify_sms-input" in html_lower + if has_sms_dom: + selector = ".second-verify-panel" if "second-verify-panel" in html_lower else ".uc-ui-verify_sms-input" + return HumanVerificationState( + present=True, + kind="sms", + message="短信验证码", + selector=selector, + text_excerpt="", + ) + + return HumanVerificationState(present=False, kind="", message="", selector="", text_excerpt="") + + +def _wait_log_message(state: HumanVerificationState, wait_sec: int) -> str: + if state.kind == "slider": + return f"[人工验证] 检测到滑块验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..." + if state.kind == "sms": + return f"[人工验证] 检测到短信验证码,请在浏览器中手工完成,最多等待 {wait_sec} 秒..." + return f"[人工验证] 检测到安全验证,请在浏览器中手工处理,最多等待 {wait_sec} 秒..." + + +def _timeout_error_code(kind: str) -> str: + if kind == "slider": + return "SLIDER_VERIFICATION_TIMEOUT" + if kind == "sms": + return "SMS_VERIFICATION_TIMEOUT" + return "HUMAN_VERIFICATION_TIMEOUT" + + +def _timeout_error_message(kind: str, wait_sec: int) -> str: + if kind == "slider": + return ( + f"滑块验证码未在 {wait_sec} 秒内完成,任务已停止。" + "请重新运行任务并及时在浏览器中完成验证。" + ) + if kind == "sms": + return ( + f"短信验证码未在 {wait_sec} 秒内完成,任务已停止。" + "请重新运行任务并及时完成短信验证。" + ) + return ( + f"安全验证未在 {wait_sec} 秒内完成,任务已停止。" + "请重新运行任务并及时在浏览器中完成验证。" + ) + + +async def wait_human_verification_if_present( + page, + *, + wait_sec: int = 180, + stage: str = "unknown", +) -> Optional[HumanVerificationWaitResult]: + """ + 若存在人工验证则等待用户完成;无验证或已完成返回 None;超时返回 HumanVerificationWaitResult。 + 不自动拖动滑块、不自动填写短信验证码。 + """ + state = await detect_human_verification(page) + if not state.present: + return None + + logger.warning( + "human_verification_detected stage=%s kind=%s selector=%s text=%s", + stage, + state.kind, + state.selector, + state.text_excerpt, + ) + print(_wait_log_message(state, wait_sec)) + + last_state = state + start = time.monotonic() + deadline = start + wait_sec + + while time.monotonic() < deadline: + state = await detect_human_verification(page) + if not state.present: + elapsed = round(time.monotonic() - start, 2) + logger.info( + "human_verification_passed stage=%s kind=%s elapsed_sec=%s", + stage, + last_state.kind, + elapsed, + ) + await asyncio.sleep(random.uniform(1.0, 2.0)) + return None + last_state = state + await asyncio.sleep(2.0) + + logger.warning( + "human_verification_timeout stage=%s kind=%s wait_sec=%s selector=%s", + stage, + last_state.kind, + wait_sec, + last_state.selector, + ) + return HumanVerificationWaitResult( + timed_out=True, + kind=last_state.kind, + code=_timeout_error_code(last_state.kind), + message=_timeout_error_message(last_state.kind, wait_sec), + stage=stage, + ) diff --git a/examples/real_browser_rpa/scripts/service/task_rpa.py b/examples/real_browser_rpa/scripts/service/task_rpa.py new file mode 100644 index 0000000..d7248c6 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/task_rpa.py @@ -0,0 +1,637 @@ +"""真实网站关键词搜索 RPA 示例(async Playwright)。""" + +from __future__ import annotations + +import logging +import os +import random +import re +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Tuple + +from jiangchang_skill_core import config +from jiangchang_skill_core.activity import interruptible_sleep +from jiangchang_skill_core.rpa import poll_until + +from service.browser_session import close_browser_context, get_start_url, start_browser_session +from service.human_verification import ( + HumanVerificationWaitResult, + wait_human_verification_if_present, + wait_save_login_countdown_if_present, +) +from util.constants import ( + DEFAULT_MAX_ITEMS, + DEFAULT_MAX_SCROLLS, + DEFAULT_NO_NEW_ROUNDS_LIMIT, + HUMAN_WAIT_TIMEOUT, + LOG_LOGGER_NAME, + RESULT_CONTAINER_SELECTOR, + RESULT_END_TEXT, + RESULT_ITEM_SELECTOR, + SCROLL_CONTAINER_SELECTORS, + SEARCH_BUTTON_SELECTOR, + SEARCH_INPUT_SELECTORS, +) +from util.logging import mask_text, safe_log_value + +logger = logging.getLogger(LOG_LOGGER_NAME) + + +@dataclass +class ScrapeError: + code: str + message: str + browser_started: bool = False + stage: Optional[str] = None + + +@dataclass +class ScrapeRunResult: + success: bool + items: List[Dict[str, Any]] = field(default_factory=list) + stop_reason: Optional[str] = None + error: Optional[ScrapeError] = None + + +StepCallback = Callable[[str], None] + +# 示例登录态 selector — 复制到新 skill 后必须用目标站点 F12/DOM 实测替换,禁止凭经验猜测或跨平台照抄 +LOGGED_IN_SELECTORS = ( + "a[href*='/user/self']", + "[data-user-avatar]", + ".user-avatar", +) +LOGGED_OUT_SELECTOR = "button:has-text('登录')" + + +def format_stop_reason_for_user(stop_reason: str | None, collected_count: int) -> str: + """将内部 stop_reason 枚举翻译为用户可理解的中文说明。""" + if stop_reason == "max_items": + return ( + f"已达到本次默认采集规模({collected_count} 条)。" + "如需更多,可以继续说「继续采集更多」或「尽量多采集」。" + ) + if stop_reason == "end_marker": + return "页面提示没有更多结果,本次采集已完成。" + if stop_reason == "no_new_rounds": + return "连续多次加载没有发现新结果,本次采集已完成。" + if stop_reason == "max_scrolls": + return ( + "已达到本次页面加载保护范围,本次采集已完成。" + "如需更深度采集,可以说明「尽量多采集」。" + ) + if stop_reason == "human_verification_timeout": + return "人工验证未在规定时间内完成,任务已停止。" + return "本次采集已完成。" + + +def _mask_account_ref(account: Dict[str, Any]) -> str: + for key in ("login_id", "phone", "mobile"): + raw = str(account.get(key) or "").strip() + if raw: + return mask_text(raw) + aid = account.get("id") or account.get("account_id") + if aid is not None: + return f"id:{aid}" + return "unknown" + + +def _step_cb(cb: Optional[StepCallback], text: str) -> None: + if cb: + cb(text) + + +async def _random_delay() -> None: + lo = max(0.0, config.get_float("STEP_DELAY_MIN", 1.0)) + hi = max(lo, config.get_float("STEP_DELAY_MAX", 3.0)) + await interruptible_sleep(random.uniform(lo, hi)) + + +async def _scroll_wait() -> None: + await interruptible_sleep(random.uniform(1.2, 3.5)) + + +def _headless() -> bool: + v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() + return v in ("1", "true", "yes", "on") + + +def _no_new_limit() -> int: + try: + return int(os.getenv("RPA_NO_NEW_ROUNDS_LIMIT") or str(DEFAULT_NO_NEW_ROUNDS_LIMIT)) + except ValueError: + return DEFAULT_NO_NEW_ROUNDS_LIMIT + + +async def _visible(locator) -> bool: + try: + return await locator.count() > 0 and await locator.is_visible(timeout=500) + except Exception: + return False + + +async def _is_logged_out(page) -> bool: + try: + btn = page.locator(LOGGED_OUT_SELECTOR).first + return await _visible(btn) + except Exception: + return False + + +async def _is_logged_in(page) -> bool: + if await _is_logged_out(page): + return False + for sel in LOGGED_IN_SELECTORS: + try: + loc = page.locator(sel).first + if await _visible(loc): + return True + except Exception: + continue + return False + + +async def _open_login_panel_if_needed(page) -> None: + if not await _is_logged_out(page): + return + try: + btn = page.locator(LOGGED_OUT_SELECTOR).first + if await _visible(btn): + await btn.click() + await interruptible_sleep(random.uniform(1.0, 3.0)) + except Exception: + pass + + +async def _ensure_logged_in(page, *, wait_sec: int) -> None: + if await _is_logged_in(page): + return + + await _open_login_panel_if_needed(page) + if await _is_logged_in(page): + return + + print(f"[登录] 请在浏览器中完成登录,最多等待 {wait_sec} 秒...") + deadline = time.monotonic() + wait_sec + while time.monotonic() < deadline: + if await _is_logged_in(page): + print("[登录] 检测到登录成功") + return + await interruptible_sleep(2.0) + + raise RuntimeError( + f"ERROR:LOGIN_TIMEOUT 浏览器已打开,但未完成登录。" + f"请重新运行任务并在浏览器打开后及时完成登录。" + ) + + +async def _find_search_input(page) -> Tuple[Optional[Any], str]: + for sel in SEARCH_INPUT_SELECTORS: + try: + loc = page.locator(sel) + count = await loc.count() + if count == 1 and await _visible(loc): + return loc, sel + if count > 1: + logger.warning("search_input_ambiguous selector=%s count=%s", sel, count) + except Exception: + continue + + try: + ph = page.get_by_placeholder(re.compile("搜索")) + count = await ph.count() + if count == 1 and await ph.is_visible(): + return ph, "placeholder=搜索" + if count > 1: + logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count) + except Exception: + pass + return None, "" + + +async def _find_search_button(page): + try: + btn = page.get_by_role("button", name="搜索") + count = await btn.count() + if count == 1 and await btn.is_visible(): + return btn + if count > 1: + logger.warning("search_button_ambiguous selector=role_button_search count=%s", count) + except Exception: + pass + try: + btn = page.locator(SEARCH_BUTTON_SELECTOR) + count = await btn.count() + if count == 1 and await btn.is_visible(): + return btn + if count > 1: + logger.warning( + "search_button_ambiguous selector=%s count=%s", + SEARCH_BUTTON_SELECTOR, + count, + ) + except Exception: + pass + return None + + +async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool: + """有界等待搜索结果容器出现(kit poll_until;区分加载中与超时未就绪)。""" + + async def _ready() -> bool: + try: + url = page.url or "" + if "/search" in url or "search" in url.lower(): + if await page.locator(RESULT_CONTAINER_SELECTOR).count() > 0: + return True + except Exception: + return False + return False + + result = await poll_until(_ready, timeout_sec=timeout_sec, interval_sec=0.8) + return result.ok + + +async def _has_end_marker(page) -> bool: + try: + container = page.locator(RESULT_CONTAINER_SELECTOR).first + if await container.count() > 0: + text = await container.inner_text(timeout=2000) + if RESULT_END_TEXT in text or "暂时没有更多了" in text: + return True + except Exception: + pass + try: + if await page.locator(f"text={RESULT_END_TEXT}").count() > 0: + return True + except Exception: + pass + return False + + +async def _mouse_wheel_scroll(page) -> Tuple[str, int]: + wheel_delta = random.randint(650, 1200) + for sel in SCROLL_CONTAINER_SELECTORS: + loc = page.locator(sel) + try: + if await loc.count() > 0 and await loc.first.is_visible(): + await loc.first.hover() + await _random_delay() + await page.mouse.wheel(0, wheel_delta) + return sel, wheel_delta + except Exception: + continue + await page.mouse.wheel(0, wheel_delta) + return "page", wheel_delta + + +async def _parse_item_locator(item) -> Dict[str, Any]: + item_id = await item.get_attribute("data-item-id") or await item.get_attribute("id") or "" + title = "" + try: + title = (await item.inner_text(timeout=1000)).strip()[:200] + except Exception: + pass + return { + "item_id": item_id or title[:32] or str(id(item)), + "title": title, + } + + +async def _collect_visible_items(page, seen_ids: set[str]) -> List[Dict[str, Any]]: + new_items: List[Dict[str, Any]] = [] + try: + items = await page.locator(RESULT_ITEM_SELECTOR).all() + except Exception: + items = [] + for item in items: + try: + parsed = await _parse_item_locator(item) + item_id = parsed.get("item_id") + if not item_id or item_id in seen_ids: + continue + seen_ids.add(str(item_id)) + new_items.append(parsed) + except Exception: + continue + return new_items + + +async def _count_visible_items(page) -> int: + try: + return await page.locator(RESULT_ITEM_SELECTOR).count() + except Exception: + return 0 + + +def _fail_result(error: ScrapeError, *, collected: int = 0) -> ScrapeRunResult: + logger.warning( + "rpa_failed code=%s stage=%s message=%s collected=%s", + error.code, + error.stage, + error.message, + collected, + ) + return ScrapeRunResult(success=False, error=error) + + +def _fail_on_verification_timeout( + verification_timeout: HumanVerificationWaitResult, + *, + collected: int = 0, +) -> ScrapeRunResult: + return _fail_result( + ScrapeError( + code=verification_timeout.code, + message=verification_timeout.message, + browser_started=True, + stage=verification_timeout.stage or "unknown", + ), + collected=collected, + ) + + +async def run_keyword_search_async( + account: Dict[str, Any], + keyword: str, + *, + max_items: int = DEFAULT_MAX_ITEMS, + max_scrolls: int = DEFAULT_MAX_SCROLLS, + data_dir: str = "", + batch_id: str = "", + step_callback: Optional[StepCallback] = None, +) -> ScrapeRunResult: + profile_dir = account.get("profile_dir") or "" + account_ref = _mask_account_ref(account) + wait_sec = int(os.getenv("HUMAN_WAIT_TIMEOUT") or str(HUMAN_WAIT_TIMEOUT)) + no_new_limit = _no_new_limit() + + logger.info( + "rpa_start keyword=%s max_items=%s max_scrolls=%s wait_sec=%s batch_id=%s account_ref=%s", + safe_log_value(keyword), + max_items, + max_scrolls, + wait_sec, + batch_id, + account_ref, + ) + + if not profile_dir: + err = ScrapeError( + "ACCOUNT_NOT_FOUND", + "账号缺少 profile_dir。", + browser_started=False, + stage="account_prepare", + ) + return _fail_result(err) + + pw = None + context = None + page = None + browser_started = False + collected_count = 0 + + try: + _step_cb(step_callback, "启动关键词搜索采集任务") + try: + pw, context, page = await start_browser_session(profile_dir, headless=_headless()) + except RuntimeError as exc: + msg = str(exc) + err = ScrapeError( + "MISSING_BROWSER" if "MISSING_BROWSER" in msg else "UNKNOWN_ERROR", + msg.replace("ERROR:MISSING_BROWSER ", "").replace("ERROR:", ""), + browser_started="未能打开目标首页" in msg, + stage="browser_start" if "未能打开目标首页" in msg else "account_prepare", + ) + return _fail_result(err) + browser_started = True + + logger.info( + "browser_ready start_url=%s current_url=%s profile_dir_exists=%s", + get_start_url(), + page.url, + os.path.isdir(profile_dir), + ) + + _step_cb(step_callback, "打开目标站点首页") + await _random_delay() + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="browser_start" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + _step_cb(step_callback, "检查账号登录状态") + logger.info("login_check_start current_url=%s", page.url) + try: + await _ensure_logged_in(page, wait_sec=wait_sec) + except RuntimeError as exc: + msg = str(exc) + code = "LOGIN_TIMEOUT" if "LOGIN_TIMEOUT" in msg else "REQUIRE_LOGIN" + err = ScrapeError( + code, + msg.replace("ERROR:LOGIN_TIMEOUT ", "").replace("ERROR:", ""), + browser_started=True, + stage="login_check", + ) + return _fail_result(err) + logger.info("login_check_done current_url=%s", page.url) + + await wait_save_login_countdown_if_present(page) + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="login_check" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + _step_cb(step_callback, "定位搜索框") + search_input, input_selector = await _find_search_input(page) + if search_input is None: + err = ScrapeError( + "SEARCH_INPUT_NOT_FOUND", + "未找到搜索输入框。", + browser_started=True, + stage="search", + ) + return _fail_result(err) + logger.info("search_input_found selector=%s", input_selector) + + _step_cb(step_callback, f"输入关键词:{keyword}") + await search_input.click() + await _random_delay() + await page.keyboard.press("Control+A") + await page.keyboard.type(keyword, delay=random.randint(90, 240)) + await _random_delay() + + _step_cb(step_callback, "点击搜索") + search_btn = await _find_search_button(page) + search_method = "button" + if search_btn is not None: + await search_btn.click() + else: + search_method = "enter" + await page.keyboard.press("Enter") + logger.info( + "search_submitted keyword=%s method=%s current_url=%s", + safe_log_value(keyword), + search_method, + page.url, + ) + await _random_delay() + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="search_verification" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + _step_cb(step_callback, "等待搜索结果") + if not await _wait_search_results(page): + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="search_results" + ) + if verification_timeout: + return _fail_on_verification_timeout(verification_timeout) + + if not await _wait_search_results(page, timeout_sec=10.0): + err = ScrapeError( + "RESULT_CONTAINER_NOT_FOUND", + "搜索结果未出现,且未检测到需要人工处理的验证码,请检查关键词或页面状态。", + browser_started=True, + stage="search", + ) + return _fail_result(err) + + container_count = await page.locator(RESULT_CONTAINER_SELECTOR).count() + logger.info( + "search_results_ready current_url=%s container_count=%s", + page.url, + container_count, + ) + + try: + await page.locator(RESULT_CONTAINER_SELECTOR).first.wait_for(state="visible", timeout=15_000) + except Exception: + err = ScrapeError( + "RESULT_CONTAINER_NOT_FOUND", + "搜索结果容器未出现。", + browser_started=True, + stage="search", + ) + return _fail_result(err) + + seen_ids: set[str] = set() + collected: List[Dict[str, Any]] = [] + no_new_rounds = 0 + stop_reason = "max_scrolls" + rounds_executed = 0 + + for scroll_round in range(max_scrolls + 1): + rounds_executed = scroll_round + _step_cb(step_callback, "读取当前结果列表") + visible_count = await _count_visible_items(page) + batch = await _collect_visible_items(page, seen_ids) + new_count = len(batch) + if batch: + collected.extend(batch) + no_new_rounds = 0 + else: + no_new_rounds += 1 + + collected_count = len(collected) + end_marker = await _has_end_marker(page) + logger.info( + "collect_round round=%s max_scrolls=%s visible_count=%s new_count=%s " + "collected=%s no_new_rounds=%s no_new_limit=%s end_marker=%s current_url=%s", + scroll_round, + max_scrolls, + visible_count, + new_count, + collected_count, + no_new_rounds, + no_new_limit, + end_marker, + page.url, + ) + + if len(collected) >= max_items: + collected = collected[:max_items] + stop_reason = "max_items" + break + + if end_marker: + stop_reason = "end_marker" + _step_cb(step_callback, "发现结果到底或达到采集数量") + break + + if no_new_rounds >= no_new_limit: + stop_reason = "no_new_rounds" + logger.info( + "collect_no_new_stop round=%s no_new_rounds=%s no_new_limit=%s collected=%s", + scroll_round, + no_new_rounds, + no_new_limit, + collected_count, + ) + break + + if scroll_round >= max_scrolls: + break + + _step_cb(step_callback, "滚动加载更多结果") + scroll_target, wheel_delta = await _mouse_wheel_scroll(page) + logger.info( + "scroll round=%s target=%s wheel_delta=%s", + scroll_round, + scroll_target, + wheel_delta, + ) + await _scroll_wait() + + verification_timeout = await wait_human_verification_if_present( + page, wait_sec=wait_sec, stage="collect" + ) + if verification_timeout: + return _fail_on_verification_timeout( + verification_timeout, + collected=collected_count, + ) + + logger.info( + "collect_stop reason=%s collected=%s rounds=%s no_new_rounds=%s", + stop_reason, + collected_count, + rounds_executed, + no_new_rounds, + ) + + if not collected: + err = ScrapeError( + "NO_RESULTS", + "未采集到任何结果。", + browser_started=True, + stage="collect", + ) + return _fail_result(err, collected=0) + + _step_cb(step_callback, "采集完成") + logger.info( + "rpa_success collected=%s stop_reason=%s", + len(collected[:max_items]), + stop_reason, + ) + return ScrapeRunResult(success=True, items=collected[:max_items], stop_reason=stop_reason) + + except Exception as exc: + err = ScrapeError( + "UNKNOWN_ERROR", + str(exc), + browser_started=browser_started, + stage="rpa", + ) + return _fail_result(err, collected=collected_count) + finally: + await close_browser_context(pw, context) diff --git a/examples/real_browser_rpa/scripts/service/task_service.py b/examples/real_browser_rpa/scripts/service/task_service.py new file mode 100644 index 0000000..e49aff8 --- /dev/null +++ b/examples/real_browser_rpa/scripts/service/task_service.py @@ -0,0 +1,146 @@ +"""轻量任务编排示例(无真实数据库依赖)。""" + +from __future__ import annotations + +import asyncio +import uuid +from typing import Any, Optional + +from service.account_client import ( + AccountManagerError, + pick_web_account, + release_lease, + resolve_account_selectors_from_config, +) +from service.task_rpa import ( + ScrapeRunResult, + format_stop_reason_for_user, + run_keyword_search_async, +) +from util.constants import ( + DEFAULT_MAX_ITEMS, + DEFAULT_MAX_SCROLLS, + SKILL_SLUG, + TARGET_PLATFORM, +) + + +def _error_payload( + code: str, + message: str, + *, + browser_started: Optional[bool] = None, + stage: Optional[str] = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "success": False, + "error": {"code": code, "message": message}, + } + if browser_started is not None: + payload["browser_started"] = browser_started + if stage: + payload["stage"] = stage + return payload + + +def _success_payload( + *, + keyword: str, + collected_count: int, + items: list[dict[str, Any]], + stop_reason: Optional[str], +) -> dict[str, Any]: + return { + "success": True, + "skill": SKILL_SLUG, + "keyword": keyword, + "collected_count": collected_count, + "items": items, + "stop_reason": stop_reason, + "stop_reason_message": format_stop_reason_for_user(stop_reason, collected_count), + } + + +async def run_keyword_search_task( + keyword: str, + *, + max_items: int = DEFAULT_MAX_ITEMS, + max_scrolls: int = DEFAULT_MAX_SCROLLS, + account_id: Optional[str] = None, + platform: str = TARGET_PLATFORM, +) -> dict[str, Any]: + """ + 编排一次关键词搜索采集。 + + 真实 skill 中还应: + - 写 SQLite 入库(items 表) + - 使用 RpaVideoSession 录制步骤留痕 + - 失败时 capture_failure 截图 + """ + batch_id = uuid.uuid4().hex[:12] + data_dir = "" # 示例占位;真实 skill 使用 get_skill_data_dir() + lease_token: Optional[str] = None + account: Optional[dict[str, Any]] = None + + try: + cfg_account_id, cfg_login_id = resolve_account_selectors_from_config() + account = pick_web_account( + platform, + account_id=account_id or cfg_account_id, + login_id=cfg_login_id, + ) + lease_token = account.get("lease_token") + + scrape_result: ScrapeRunResult = await run_keyword_search_async( + account, + keyword, + max_items=max_items, + max_scrolls=max_scrolls, + data_dir=data_dir, + batch_id=batch_id, + ) + + if not scrape_result.success: + err = scrape_result.error + return _error_payload( + err.code if err else "UNKNOWN_ERROR", + err.message if err else "未知错误", + browser_started=err.browser_started if err else True, + stage=err.stage if err else "rpa", + ) + + collected_count = len(scrape_result.items) + return _success_payload( + keyword=keyword, + collected_count=collected_count, + items=scrape_result.items[:10], + stop_reason=scrape_result.stop_reason, + ) + except AccountManagerError as exc: + return _error_payload( + exc.code, + exc.message, + browser_started=False, + stage="account_prepare", + ) + finally: + release_lease(lease_token) + + +def run_keyword_search_task_sync( + keyword: str, + *, + max_items: int = DEFAULT_MAX_ITEMS, + max_scrolls: int = DEFAULT_MAX_SCROLLS, + account_id: Optional[str] = None, + platform: str = TARGET_PLATFORM, +) -> dict[str, Any]: + return asyncio.run( + run_keyword_search_task( + keyword, + max_items=max_items, + max_scrolls=max_scrolls, + account_id=account_id, + platform=platform, + ) + ) diff --git a/examples/real_browser_rpa/scripts/util/constants.py b/examples/real_browser_rpa/scripts/util/constants.py new file mode 100644 index 0000000..f90f552 --- /dev/null +++ b/examples/real_browser_rpa/scripts/util/constants.py @@ -0,0 +1,31 @@ +"""示例常量(复制到新 skill 时请替换为目标站点与业务字段)。""" + +SKILL_SLUG = "example-real-browser-rpa" +LOG_LOGGER_NAME = "openclaw.skill.example_real_browser_rpa" + +TARGET_PLATFORM = "target_platform" +LEASE_HOLDER = "example-real-browser-rpa" +LEASE_TTL_SEC = "1800" + +DEFAULT_START_URL = "https://example.com" +DEFAULT_MAX_ITEMS = 100 +DEFAULT_MAX_SCROLLS = 40 +DEFAULT_NO_NEW_ROUNDS_LIMIT = 5 +HUMAN_WAIT_TIMEOUT = 180 + +RESULT_END_TEXT = "没有更多结果了" + +# 示例 selector — 复制到新 skill 时按目标站点 DOM 替换 +SEARCH_INPUT_SELECTORS = ( + 'input[type="search"]', + 'input[name="q"]', + 'input[placeholder*="搜索"]', +) +SEARCH_BUTTON_SELECTOR = 'button:has-text("搜索")' +RESULT_CONTAINER_SELECTOR = "#search-result-container" +RESULT_ITEM_SELECTOR = "[data-result-item]" +SCROLL_CONTAINER_SELECTORS = ( + "#search-result-container", + ".search-result-scroll", + ".route-scroll-container", +) diff --git a/examples/real_browser_rpa/scripts/util/logging.py b/examples/real_browser_rpa/scripts/util/logging.py new file mode 100644 index 0000000..d0a014c --- /dev/null +++ b/examples/real_browser_rpa/scripts/util/logging.py @@ -0,0 +1,34 @@ +"""示例结构化日志工具。""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from util.constants import LOG_LOGGER_NAME + + +def get_logger(name: str | None = None) -> logging.Logger: + return logging.getLogger(name or LOG_LOGGER_NAME) + + +def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str: + s = (value or "").strip() + if len(s) >= 11 and s.isdigit(): + return s[:3] + "****" + s[-4:] + if len(s) > keep_start + keep_end: + return s[:keep_start] + "****" + s[-keep_end:] + return "****" + + +def safe_log_value(value: Any) -> str: + if value is None: + return "" + text = str(value) + if re.fullmatch(r"\d{7,}", text): + return mask_text(text) + if "@" in text and len(text) > 6: + local, _, domain = text.partition("@") + return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}" + return text diff --git a/examples/real_browser_rpa/tests/conftest.py b/examples/real_browser_rpa/tests/conftest.py new file mode 100644 index 0000000..5b922c9 --- /dev/null +++ b/examples/real_browser_rpa/tests/conftest.py @@ -0,0 +1,10 @@ +"""pytest 路径配置:将 examples/real_browser_rpa/scripts 加入 import 路径。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) diff --git a/examples/real_browser_rpa/tests/test_account_client.py b/examples/real_browser_rpa/tests/test_account_client.py new file mode 100644 index 0000000..70c70a8 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_account_client.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import subprocess +import unittest +from unittest.mock import patch + +from service.account_client import AccountManagerError, pick_web_account + + +def _completed(payload: dict) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["python", "account-manager"], + returncode=0, + stdout=json.dumps(payload, ensure_ascii=False), + stderr="", + ) + + +class TestExplicitAccountLease(unittest.TestCase): + @patch("service.account_client._run_argv") + def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + leased = {**account, "lease_token": "lease-42"} + run_argv.side_effect = [_completed(account), _completed(leased)] + + result = pick_web_account("target-platform", account_id="42") + + self.assertEqual(result["id"], 42) + self.assertEqual(result["lease_token"], "lease-42") + self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"]) + pick_args = run_argv.call_args_list[1].args[0] + self.assertIn("pick-web", pick_args) + self.assertIn("--lease", pick_args) + self.assertIn("user@example.com", pick_args) + + @patch("service.account_client._run_argv") + def test_missing_lease_token_is_rejected(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + run_argv.side_effect = [_completed(account), _completed(account)] + + with self.assertRaises(AccountManagerError) as raised: + pick_web_account("target-platform", account_id="42") + + self.assertEqual(raised.exception.code, "LEASE_REQUIRED") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/real_browser_rpa/tests/test_human_verification.py b/examples/real_browser_rpa/tests/test_human_verification.py new file mode 100644 index 0000000..5b27f82 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_human_verification.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""人工验证检测单元测试(DOM 精准检测,不依赖真实浏览器)。""" +from __future__ import annotations + +import asyncio +import time +import unittest + +from service.human_verification import ( + HumanVerificationWaitResult, + detect_human_verification, + detect_human_verification_from_text, + wait_human_verification_if_present, + wait_save_login_countdown_if_present, +) + + +SLIDER_HTML = """ +<div class="vc-captcha-verify slide"> + <div class="captcha_verify_bar--title">请完成下列验证后继续</div> + <div class="captcha-slider-tips">按住左边按钮拖动完成上方拼图</div> +</div> +""" + +SMS_HTML = """ +<div class="second-verify-panel"> + <div class="uc-ui-verify_sms-input"> + <div class="uc-ui-verify-new_header-title">短信验证</div> + <input placeholder="手机号" /> + <input placeholder="请输入验证码" /> + <button>获取验证码</button> + </div> +</div> +""" + +SEARCH_RESULTS_HTML = """ +<div id="search-result-container"> + <div data-result-item="1">RPA 滑块验证码识别教程</div> + <div data-result-item="2">短信验证码自动化</div> +</div> +""" + + +class MockLocator: + def __init__(self, selector: str, visible: set[str], texts: dict[str, str]) -> None: + self._selector = selector + self._visible = visible + self._texts = texts + + @property + def first(self) -> MockLocator: + return self + + async def is_visible(self, timeout: int = 500) -> bool: + return self._selector in self._visible + + async def inner_text(self, timeout: int = 1000) -> str: + return self._texts.get(self._selector, "") + + async def count(self) -> int: + return 1 if self._selector in self._visible else 0 + + +class MockPage: + def __init__(self, visible: set[str], texts: dict[str, str] | None = None) -> None: + self._visible = visible + self._texts = texts or {} + + def locator(self, selector: str) -> MockLocator: + return MockLocator(selector, self._visible, self._texts) + + +class TestHumanVerificationFromText(unittest.TestCase): + def test_slider_dom_detect(self) -> None: + state = detect_human_verification_from_text(SLIDER_HTML) + self.assertTrue(state.present) + self.assertEqual(state.kind, "slider") + self.assertTrue(state.selector) + + def test_sms_dom_detect(self) -> None: + state = detect_human_verification_from_text(SMS_HTML) + self.assertTrue(state.present) + self.assertEqual(state.kind, "sms") + self.assertTrue(state.selector) + + def test_search_results_not_misclassified(self) -> None: + state = detect_human_verification_from_text(SEARCH_RESULTS_HTML) + self.assertFalse(state.present) + + def test_body_keywords_without_dom_not_misclassified(self) -> None: + text = "RPA 滑块验证码识别教程\n短信验证码自动化" + state = detect_human_verification_from_text(text) + self.assertFalse(state.present) + + +class TestHumanVerificationDomDetect(unittest.TestCase): + def test_slider_dom_visible(self) -> None: + page = MockPage( + {".vc-captcha-verify.slide"}, + {".vc-captcha-verify.slide": "请完成下列验证后继续"}, + ) + state = asyncio.run(detect_human_verification(page)) + self.assertTrue(state.present) + self.assertEqual(state.kind, "slider") + self.assertEqual(state.selector, ".vc-captcha-verify.slide") + + def test_sms_dom_visible(self) -> None: + page = MockPage( + {".second-verify-panel"}, + {".second-verify-panel": "短信验证 获取验证码"}, + ) + state = asyncio.run(detect_human_verification(page)) + self.assertTrue(state.present) + self.assertEqual(state.kind, "sms") + + def test_no_verification_when_empty(self) -> None: + page = MockPage(set()) + state = asyncio.run(detect_human_verification(page)) + self.assertFalse(state.present) + + +class TestSaveLoginCountdown(unittest.TestCase): + def test_wait_not_present_no_delay(self) -> None: + page = MockPage(set()) + start = time.monotonic() + result = asyncio.run(wait_save_login_countdown_if_present(page, timeout_sec=8)) + elapsed = time.monotonic() - start + self.assertFalse(result) + self.assertLess(elapsed, 1.0) + + +class TestHumanVerificationWait(unittest.TestCase): + def test_wait_timeout_slider_dom(self) -> None: + page = MockPage( + {".vc-captcha-verify.slide"}, + {".vc-captcha-verify.slide": "请完成下列验证后继续"}, + ) + result = asyncio.run( + wait_human_verification_if_present(page, wait_sec=1, stage="search_verification") + ) + self.assertIsInstance(result, HumanVerificationWaitResult) + self.assertTrue(result.timed_out) + self.assertEqual(result.code, "SLIDER_VERIFICATION_TIMEOUT") + self.assertIn("滑块验证码未在 1 秒内完成", result.message) + self.assertEqual(result.stage, "search_verification") + + def test_wait_timeout_sms_dom(self) -> None: + page = MockPage( + {".second-verify-panel"}, + {".second-verify-panel": "短信验证 获取验证码"}, + ) + result = asyncio.run( + wait_human_verification_if_present(page, wait_sec=1, stage="login_check") + ) + self.assertIsInstance(result, HumanVerificationWaitResult) + self.assertTrue(result.timed_out) + self.assertEqual(result.code, "SMS_VERIFICATION_TIMEOUT") + self.assertEqual(result.stage, "login_check") + + def test_collect_no_verification_immediate_none(self) -> None: + page = MockPage(set()) + start = time.monotonic() + err = asyncio.run(wait_human_verification_if_present(page, wait_sec=180, stage="collect")) + elapsed = time.monotonic() - start + self.assertIsNone(err) + self.assertLess(elapsed, 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/real_browser_rpa/tests/test_stop_reason.py b/examples/real_browser_rpa/tests/test_stop_reason.py new file mode 100644 index 0000000..70110e5 --- /dev/null +++ b/examples/real_browser_rpa/tests/test_stop_reason.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""stop_reason 用户友好文案单元测试。""" +from __future__ import annotations + +import unittest + +from service.task_rpa import format_stop_reason_for_user + + +class TestFormatStopReasonForUser(unittest.TestCase): + def test_max_items(self) -> None: + msg = format_stop_reason_for_user("max_items", 100) + self.assertIn("默认采集规模", msg) + self.assertIn("100", msg) + + def test_end_marker(self) -> None: + msg = format_stop_reason_for_user("end_marker", 42) + self.assertIn("没有更多结果", msg) + + def test_no_new_rounds(self) -> None: + msg = format_stop_reason_for_user("no_new_rounds", 30) + self.assertIn("连续多次加载没有发现新结果", msg) + + def test_max_scrolls(self) -> None: + msg = format_stop_reason_for_user("max_scrolls", 80) + self.assertIn("页面加载保护范围", msg) + + def test_none_or_unknown(self) -> None: + self.assertEqual(format_stop_reason_for_user(None, 10), "本次采集已完成。") + self.assertEqual(format_stop_reason_for_user("unknown", 10), "本次采集已完成。") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/real_desktop_rpa/README.md b/examples/real_desktop_rpa/README.md new file mode 100644 index 0000000..82da4e0 --- /dev/null +++ b/examples/real_desktop_rpa/README.md @@ -0,0 +1,152 @@ +# Real Windows Desktop RPA Example + +这是从已跑通业务技能 `D:\OpenClaw\client-marketing\add-friend-wecom` 提炼的 Windows UIA 参考案例。它用于教技术人员和 AI 编程工具如何从真实拾取结果得到稳定定位器,并完成窗口、控件、动作、等待和诊断闭环。 + +## 状态 + +- **技术路线已真实验证**:Windows UIA + Python `uiautomation` +- **来源业务已真实跑通**:企业微信添加客户(通讯录 → 新的客户 → 添加 → 搜索 → 发送) +- **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请 +- **不是完整 skill 仓库**:按 Copy map 选择性复制 + +## 先读 + +1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md) +2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子) +3. 本 README 的“原始拾取到生产定位器” + +## 成功案例验证了什么 + +```text +枚举目标顶层窗口 +→ 激活并最大化主窗(勿无条件 SW_RESTORE) +→ 从 HWND 获取 UIA root / 业务子 pane +→ 在父容器中定位(Name 与 Value 分开看) +→ 真实点击并校验后置状态 +→ 等待子窗口或业务结果 +→ 处理独立/嵌套弹窗 +→ probe-ui 诊断 +``` + +来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。 + +本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。 + +## 目录结构 + +```text +real_desktop_rpa/ + README.md + assets/ + element-map.sample.yaml + selector-capture.sample.xml + scripts/ + service/ + desktop_session.py + uia_locator.py + wecom_reference_flow.py + util/ + selectors.py + tests/ + test_selector_contract.py +``` + +## 原始拾取到生产定位器 + +### 搜索框(添加客户窗) + +```text +原始:Window(SearchExternalsWnd) → 多层 Layout/index → search_layout → searchedit +提炼:窗口 → search_layout → Name=searchedit +``` + +### 新的客户(易错) + +```text +原始:通讯录 pane → wlist_view → item[2] → status_layout → main_chattext +提炼:wlist_view 内找 子控件 main_chattext.Value=新的客户 的那一行,再点该行 status_layout +禁止:全树第一个 Name=status_layout(常为「群聊」) +禁止:把选择器标题「新的客户」当成 UIA Name 去搜 +``` + +### 添加按钮 + +```text +提炼:search_external → wbtn_container(子文案 Value=添加) +禁止:搜索旁 plus_button 下的同名 wbtn_container +``` + +正确提炼方式: + +1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。 +2. 保留最小业务父容器。 +3. 叶子用稳定 Name **和/或** Value / 子控件 Value。 +4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。 +5. 验证父容器内候选数量为 `1`。 +6. 重启应用后再次验证。 + +不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。 + +## Element map + +本目录的 [`assets/element-map.sample.yaml`](assets/element-map.sample.yaml) 只展示来源案例的元素登记方法。复制后必须重新拾取目标软件并全部替换。 + +关键规则: + +- 窗口不唯一时失败,不取 `hwnds[0]`。 +- 控件不唯一时失败,不取第一个匹配。 +- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。 +- 行顺序不是身份;用业务文案(Value)选择结果行。 +- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。 +- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。 + +## 三种实施模式 + +| 模式 | 行为 | 用途 | +|---|---|---| +| `probe` | 只检查依赖、窗口和关键控件 | 安装、自检、版本变更排障 | +| `dry-run` | 导航并填写,提交前停止 | 开发和验收默认 | +| `assisted` | 填写后等待用户人工确认 | 不可逆动作或现场联调 | + +业务技能若增加 `auto`,必须实现明确确认策略、频率限制、业务后置状态和 `RESULT_UNCONFIRMED`。 + +## Copy map + +| 来源 | 目标 | 说明 | +|---|---|---| +| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活/最大化 | +| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位(含 Value)、点击、输入 | +| `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 | +| `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector | +| `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 | +| `tests/test_selector_contract.py` | `tests/test_selector_contract.py` | 定位器契约测试思路 | + +## 必须补齐的业务能力 + +- 目标应用安装/版本 doctor +- 真实业务元素清单 +- 失败截图、UIA树和顶层窗口快照 +- Activity/RpaVideoSession 步骤 +- 不可逆动作确认 +- 业务成功/失败/结果不明确状态 +- 频率限制、幂等和断点续跑 +- 真实集成测试(默认不进 CI) + +## 禁止照抄 + +- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd` +- `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext` +- 企业微信列表第 0/1/2 行含义或固定 index +- 来源案例的本机路径、账号和业务数据 +- 任何未经目标软件真实拾取验证的 fallback + +## 验收 + +- `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点) +- 每个关键元素在目标作用域内唯一 +- 关闭重开软件后仍能定位 +- 多窗口时不会误选 +- UI变化时停止并保存截图/UIA树 +- dry-run 不执行不可逆提交 +- assisted 清楚提示用户确认 +- 提交后仅在业务后置状态成立时报告成功 diff --git a/examples/real_desktop_rpa/assets/element-map.sample.yaml b/examples/real_desktop_rpa/assets/element-map.sample.yaml new file mode 100644 index 0000000..ecebceb --- /dev/null +++ b/examples/real_desktop_rpa/assets/element-map.sample.yaml @@ -0,0 +1,117 @@ +source_case: add-friend-wecom +status: verified_source_redacted_example +surface: windows +driver: uiautomation +notes: + - 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label + - 禁止全树第一个 status_layout / wbtn_container + - 主窗流程先 maximize;activate 勿无条件 SW_RESTORE +elements: + - logical_name: main_window + purpose: 企业微信主窗口 + scope: + process_name: WXWork + primary_locator: + window_class: WeWorkWindow + title: 企业微信 + expected_count: 1 + postcondition: UIA root 可读取;业务需要时已最大化 + replace_in_new_skill: true + + - logical_name: contacts_entry + purpose: 打开通讯录 + scope: + logical_parent: main_window + primary_locator: + name: org_op_new + control_type: TreeItem + value_hint: 通讯录 + expected_count: 1 + postcondition: 出现企业微信-通讯录业务 pane + replace_in_new_skill: true + + - logical_name: contacts_pane + purpose: 通讯录业务子层作用域 + scope: + logical_parent: main_window + primary_locator: + name_contains: 通讯录 + expected_count: 1 + postcondition: 可枚举 wlist_view + replace_in_new_skill: true + + - logical_name: new_customer_row + purpose: 侧栏进入「新的客户」 + scope: + logical_parent: contacts_pane + ancestor_name: wlist_view + primary_locator: + row_leaf_name: status_layout + child_name: main_chattext + child_value: 新的客户 + expected_count: 1 + postcondition: panel_title_label Value=新的客户 或出现 search_external + failure_note: 禁止点全树第一个 status_layout(常为群聊) + replace_in_new_skill: true + + - logical_name: panel_title + purpose: 校验已进入新的客户页 + scope: + logical_parent: contacts_pane + primary_locator: + name: panel_title_label + value: 新的客户 + expected_count: 1 + replace_in_new_skill: true + + - logical_name: add_customer_button + purpose: 打开添加客户窗口 + scope: + logical_parent: contacts_pane + ancestor_name: search_external + primary_locator: + name: wbtn_container + child_name: ww_btn_text + child_value: 添加 + expected_count: 1 + postcondition: 出现 SearchExternalsWnd + failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container + replace_in_new_skill: true + + - logical_name: add_customer_window + purpose: 添加客户窗口 + scope: + process_name: WXWork + primary_locator: + window_class: SearchExternalsWnd + title: 添加客户 + expected_count: 1 + postcondition: search_input 可定位 + replace_in_new_skill: true + + - logical_name: search_input + purpose: 输入待搜索账号 + scope: + logical_parent: add_customer_window + ancestor_name: search_layout + primary_locator: + name: searchedit + expected_count: 1 + ready_condition: + visible: true + enabled: true + postcondition: 结果列表或用户不存在弹窗出现 + replace_in_new_skill: true + + - logical_name: verify_message_input + purpose: 填写添加验证语 + scope: + window_class: InputReasonWnd + title_any: + - 输入认证信息 + - 发送添加邀请 + primary_locator: + name: name_edit + expected_count: 1 + postcondition: 输入值可读取或发送按钮可用 + replace_in_new_skill: true diff --git a/examples/real_desktop_rpa/assets/selector-capture.sample.xml b/examples/real_desktop_rpa/assets/selector-capture.sample.xml new file mode 100644 index 0000000..2414611 --- /dev/null +++ b/examples/real_desktop_rpa/assets/selector-capture.sample.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 脱敏教学摘录:展示“拾取器长路径”而非可直接复制的生产 selector。 + 生产代码应保留稳定窗口、业务父容器和叶子属性,删除无业务含义的深层 index。 +--> +<selector name="search_input" source="inspector-export"> + <window process="WXWork" class="SearchExternalsWnd" title="添加客户" /> + <uia name="content_container" index="1" /> + <uia name="search_layout" index="0" /> + <uia name="inner_search_layout" index="1" /> + <uia name="searchedit" index="1" /> +</selector> + +<!-- + 新的客户:长路径示意 → 生产应改为 + wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。 + 禁止全树第一个 status_layout。 +--> +<selector name="new_customer_row" source="inspector-export"> + <window process="WXWork" class="WeWorkWindow" title="企业微信" /> + <pane name="企业微信-通讯录" /> + <uia name="wlist_view" /> + <uia name="item" index="2" /> + <uia name="status_layout" /> + <uia name="main_chattext" value="新的客户" /> +</selector> diff --git a/examples/real_desktop_rpa/scripts/service/desktop_session.py b/examples/real_desktop_rpa/scripts/service/desktop_session.py new file mode 100644 index 0000000..1a44a4e --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/desktop_session.py @@ -0,0 +1,129 @@ +"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。""" + +from __future__ import annotations + +import ctypes +import ntpath +import time +from ctypes import wintypes +from dataclasses import dataclass +from typing import Iterable + +from util.selectors import WindowSpec + + +class DesktopRpaError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + super().__init__(f"ERROR:{code} {message}") + self.code = code + self.message = message + + +@dataclass(frozen=True) +class WindowCandidate: + hwnd: int + pid: int + process_name: str + class_name: str + title: str + visible: bool + + +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 +SW_RESTORE = 9 +SW_MAXIMIZE = 3 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + +def _process_name(pid: int) -> str: + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return "" + try: + size = wintypes.DWORD(32768) + buffer = ctypes.create_unicode_buffer(size.value) + if not kernel32.QueryFullProcessImageNameW( + handle, + 0, + buffer, + ctypes.byref(size), + ): + return "" + return ntpath.splitext(ntpath.basename(buffer.value))[0] + finally: + kernel32.CloseHandle(handle) + + +def enumerate_top_windows() -> list[WindowCandidate]: + found: list[WindowCandidate] = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def callback(hwnd, _lparam): + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + cls = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, cls, 256) + length = user32.GetWindowTextLengthW(hwnd) + title = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, title, length + 1) + found.append( + WindowCandidate( + hwnd=int(hwnd), + pid=int(pid.value), + process_name=_process_name(int(pid.value)), + class_name=cls.value, + title=title.value, + visible=bool(user32.IsWindowVisible(hwnd)), + ) + ) + return True + + user32.EnumWindows(callback, 0) + return found + + +def resolve_unique_window( + spec: WindowSpec, + *, + candidates: Iterable[WindowCandidate] | None = None, +) -> WindowCandidate: + pool = list(candidates) if candidates is not None else enumerate_top_windows() + matched = [ + item + for item in pool + if item.visible + and item.process_name.casefold() == spec.process_name.casefold() + and item.class_name == spec.class_name + and (spec.title is None or item.title == spec.title) + ] + if not matched: + raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到") + if len(matched) != 1: + raise DesktopRpaError( + "ELEMENT_AMBIGUOUS", + f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域", + ) + return matched[0] + + +def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None: + """激活窗口。 + + maximize=True:主窗进入依赖完整侧栏的流程时使用。 + maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。 + """ + hwnd = window.hwnd + iconic = bool(user32.IsIconic(hwnd)) + zoomed = bool(user32.IsZoomed(hwnd)) + if maximize: + if iconic: + user32.ShowWindow(hwnd, SW_RESTORE) + if not zoomed: + user32.ShowWindow(hwnd, SW_MAXIMIZE) + else: + if iconic: + user32.ShowWindow(hwnd, SW_RESTORE) + if not user32.SetForegroundWindow(hwnd): + raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}") + time.sleep(0.35 if maximize else 0.2) diff --git a/examples/real_desktop_rpa/scripts/service/uia_locator.py b/examples/real_desktop_rpa/scripts/service/uia_locator.py new file mode 100644 index 0000000..e039dd2 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/uia_locator.py @@ -0,0 +1,127 @@ +"""作用域内 UIA 唯一定位、真实点击和逐字输入。""" + +from __future__ import annotations + +import random +from typing import Any, Iterator + +from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window +from util.selectors import ElementSpec + + +def require_uiautomation(): + try: + import uiautomation as auto + except ImportError as exc: + raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc + return auto + + +def root_from_window(window: WindowCandidate): + auto = require_uiautomation() + return auto.ControlFromHandle(window.hwnd) + + +def control_value(control: Any) -> str: + try: + auto = require_uiautomation() + raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty) + return str(raw or "").strip() + except Exception: + return "" + + +def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]: + if depth > max_depth: + return + yield root + if depth == max_depth: + return + try: + children = root.GetChildren() + except Exception: + children = [] + for child in children: + yield from _walk(child, depth=depth + 1, max_depth=max_depth) + + +def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool: + for node in _walk(control, max_depth=max_depth): + if (getattr(node, "Name", None) or "") != child_name: + continue + if control_value(node) == expected_value: + return True + return False + + +def _matches(control: Any, spec: ElementSpec) -> bool: + if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name: + return False + if spec.value is not None and not spec.child_value_name: + if control_value(control) != spec.value: + return False + if spec.automation_id is not None and ( + getattr(control, "AutomationId", None) or "" + ) != spec.automation_id: + return False + if spec.control_type is not None and ( + (getattr(control, "ControlTypeName", None) or "").removesuffix("Control") + != spec.control_type.removesuffix("Control") + ): + return False + if spec.class_name is not None and ( + getattr(control, "ClassName", None) or "" + ) != spec.class_name: + return False + if spec.child_value_name and spec.value: + if not _child_has_value(control, spec.child_value_name, spec.value): + return False + return True + + +def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25): + search_root = root + if spec.ancestor_name: + ancestor_spec = ElementSpec( + logical_name=f"{spec.logical_name}.ancestor", + name=spec.ancestor_name, + ) + search_root = locate_unique(root, ancestor_spec, max_depth=max_depth) + + matched = [ + control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec) + ] + if not matched: + raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到") + if len(matched) != spec.expected_count: + raise DesktopRpaError( + "ELEMENT_AMBIGUOUS", + f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个", + ) + return matched[0] + + +def click_control( + control: Any, + *, + window: WindowCandidate, + maximize: bool = False, +) -> None: + activate_window(window, maximize=maximize) + rect = control.BoundingRectangle + if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000: + raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效") + control.Click( + x=random.randint(-2, 2), + y=random.randint(-2, 2), + simulateMove=True, + ) + + +def type_text(control: Any, text: str, *, window: WindowCandidate) -> None: + auto = require_uiautomation() + click_control(control, window=window, maximize=False) + auto.SendKeys("{Ctrl}a", waitTime=0.05) + auto.SendKeys("{Delete}", waitTime=0.05) + for char in text: + auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12)) diff --git a/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py b/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py new file mode 100644 index 0000000..804a474 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/service/wecom_reference_flow.py @@ -0,0 +1,58 @@ +"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from service.desktop_session import activate_window, resolve_unique_window +from service.uia_locator import locate_unique, root_from_window, type_text +from util.selectors import ( + ADD_CUSTOMER_WINDOW, + CONTACTS_ENTRY, + MAIN_WINDOW, + NEW_CUSTOMER_ROW, + SEARCH_INPUT, +) + + +def probe() -> dict[str, Any]: + """最小 probe:主窗 + 通讯录入口。 + + 生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。 + 调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。 + """ + main = resolve_unique_window(MAIN_WINDOW) + activate_window(main, maximize=True) + root = root_from_window(main) + contacts = locate_unique(root, CONTACTS_ENTRY) + payload: dict[str, Any] = { + "success": True, + "minimized_probe": True, + "main_window": asdict(main), + "contacts_entry": { + "name": getattr(contacts, "Name", ""), + "control_type": getattr(contacts, "ControlTypeName", ""), + }, + } + # 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败) + try: + row = locate_unique(root, NEW_CUSTOMER_ROW) + payload["new_customer_row"] = {"name": getattr(row, "Name", "")} + except Exception: + payload["new_customer_row"] = None + return payload + + +def fill_search_dry_run(target: str) -> dict[str, Any]: + """要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。""" + search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW) + activate_window(search_window, maximize=False) + root = root_from_window(search_window) + search_input = locate_unique(root, SEARCH_INPUT) + type_text(search_input, target, window=search_window) + return { + "success": True, + "mode": "dry-run", + "message": "已填写搜索条件,未发送邀请", + } diff --git a/examples/real_desktop_rpa/scripts/util/selectors.py b/examples/real_desktop_rpa/scripts/util/selectors.py new file mode 100644 index 0000000..7fd2994 --- /dev/null +++ b/examples/real_desktop_rpa/scripts/util/selectors.py @@ -0,0 +1,63 @@ +"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class WindowSpec: + logical_name: str + process_name: str + class_name: str + title: Optional[str] = None + + +@dataclass(frozen=True) +class ElementSpec: + logical_name: str + name: Optional[str] = None + """UIA Name(内部 id,未必是界面中文)。""" + value: Optional[str] = None + """UIA Value(可见中文文案常在此)。""" + automation_id: Optional[str] = None + control_type: Optional[str] = None + class_name: Optional[str] = None + ancestor_name: Optional[str] = None + """先定位到该 Name 的祖先,再在其下搜索。""" + child_value_name: Optional[str] = None + """在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。""" + expected_count: int = 1 + + +MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信") +ADD_CUSTOMER_WINDOW = WindowSpec( + "add_customer_window", + "WXWork", + "SearchExternalsWnd", + "添加客户", +) +CONTACTS_ENTRY = ElementSpec( + "contacts_entry", + name="org_op_new", + control_type="TreeItem", +) +# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout) +NEW_CUSTOMER_ROW = ElementSpec( + "new_customer_row", + name="status_layout", + ancestor_name="wlist_view", + child_value_name="main_chattext", + value="新的客户", +) +ADD_BUTTON = ElementSpec( + "add_customer_button", + name="wbtn_container", + ancestor_name="search_external", +) +SEARCH_INPUT = ElementSpec( + "search_input", + name="searchedit", + ancestor_name="search_layout", +) diff --git a/examples/real_desktop_rpa/tests/test_selector_contract.py b/examples/real_desktop_rpa/tests/test_selector_contract.py new file mode 100644 index 0000000..ff6de92 --- /dev/null +++ b/examples/real_desktop_rpa/tests/test_selector_contract.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from service.desktop_session import ( # noqa: E402 + DesktopRpaError, + WindowCandidate, + activate_window, + resolve_unique_window, +) +from service import uia_locator # noqa: E402 +from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402 + + +class SelectorContractTest(unittest.TestCase): + def test_unique_window_is_required(self) -> None: + candidate = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True) + self.assertEqual( + resolve_unique_window(MAIN_WINDOW, candidates=[candidate]), + candidate, + ) + + def test_duplicate_windows_fail_instead_of_taking_first(self) -> None: + candidates = [ + WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True), + WindowCandidate(2, 11, "WXWork", "WeWorkWindow", "企业微信", True), + ] + with self.assertRaises(DesktopRpaError) as ctx: + resolve_unique_window(MAIN_WINDOW, candidates=candidates) + self.assertEqual(ctx.exception.code, "ELEMENT_AMBIGUOUS") + + def test_hidden_window_is_not_a_candidate(self) -> None: + hidden = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", False) + with self.assertRaises(DesktopRpaError) as ctx: + resolve_unique_window(MAIN_WINDOW, candidates=[hidden]) + self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND") + + def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None: + window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True) + with patch("service.desktop_session.user32") as user32: + user32.IsIconic.return_value = False + user32.IsZoomed.return_value = True + user32.SetForegroundWindow.return_value = True + activate_window(window, maximize=False) + user32.ShowWindow.assert_not_called() + user32.SetForegroundWindow.assert_called_once_with(42) + + def test_activate_maximize_when_not_zoomed(self) -> None: + window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True) + with patch("service.desktop_session.user32") as user32: + user32.IsIconic.return_value = False + user32.IsZoomed.return_value = False + user32.SetForegroundWindow.return_value = True + activate_window(window, maximize=True) + user32.ShowWindow.assert_called() + user32.SetForegroundWindow.assert_called_once_with(42) + + def test_locate_unique_matches_child_value(self) -> None: + row = MagicMock() + row.Name = "status_layout" + row.AutomationId = "" + row.ControlTypeName = "TreeControl" + row.ClassName = "" + label = MagicMock() + label.Name = "main_chattext" + label.GetChildren.return_value = [] + row.GetChildren.return_value = [label] + + root = MagicMock() + root.Name = "wlist_view" + root.AutomationId = "" + root.ControlTypeName = "TreeControl" + root.ClassName = "" + root.GetChildren.return_value = [row] + + with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""): + spec = ElementSpec( + "new_customer_row", + name="status_layout", + ancestor_name="wlist_view", + child_value_name="main_chattext", + value="新的客户", + ) + # ancestor is root itself when searching from root with name wlist_view + found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view")) + self.assertIs(found, root) + found_row = uia_locator.locate_unique(root, spec) + self.assertIs(found_row, row) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_api/README.md b/examples/simulator_api/README.md new file mode 100644 index 0000000..bf8fd6a --- /dev/null +++ b/examples/simulator_api/README.md @@ -0,0 +1,35 @@ +# Simulator API Example + +这个目录用于沉淀「仿真环境 / API」类 skill 的可复制成功案例。 + +## 适用场景(规划) + +- 使用 mock / sandbox / simulator API +- 不需要启动浏览器 +- 适合演示输入校验、API 调用、结果入库、日志、错误契约和测试 + +## 当前状态:规划占位 + +- **本目录当前为规划占位**,仅保留最小结构(`README.md`、`scripts/`、`tests/` 占位)。 +- **尚未沉淀可复制实现**;目录内无参考代码。 +- **AI 编程工具不得**基于空目录自行脑补 mock API 或仿真接口实现。 +- 后续补齐可复制案例后,再按 README copy map 选择性复制到新技能 `scripts/`。 + +## 轻量结构 + +```text +simulator_api/ + README.md + scripts/ + service/ + util/ + tests/ +``` + +不要在本目录添加 `SKILL.md`、市场 README、`references/`、`development/`、`.github/` 等完整 skill 仓库层文件。 + +## 复制边界(待案例补齐后生效) + +- 参考代码将从 `examples/simulator_api/scripts/` 选择性复制到新技能 `scripts/` +- 根 `README.md`、`SKILL.md`、`references/` 仍按模板主目录规则编写 +- 当前阶段:**不可作为实现参考** diff --git a/examples/simulator_api/scripts/service/.gitkeep b/examples/simulator_api/scripts/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_api/scripts/util/.gitkeep b/examples/simulator_api/scripts/util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_api/tests/.gitkeep b/examples/simulator_api/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/README.md b/examples/simulator_browser_rpa/README.md new file mode 100644 index 0000000..22951ed --- /dev/null +++ b/examples/simulator_browser_rpa/README.md @@ -0,0 +1,163 @@ +# Simulator Browser RPA Example + +这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。 + +本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制。 + +本示例只教学授权范围内的正常界面自动化;禁止把 sandbox 做法扩展为破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或规避真实平台限制的实现。 + +## 适合什么场景 + +- 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html` +- 需要 **浏览器 RPA** 做端到端演示或回归 +- 表单较复杂:门户 HITL、多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析 +- 需要 **adapter 分层**:`mock`(CI)与 `simulator_rpa`(半集成)可切换 + +## 不适合什么场景 + +- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md) +- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA +- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill +- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态 + +## 目录结构 + +``` +simulator_browser_rpa/ +├── README.md +├── sandbox/ +│ └── demo_app.html # 本地仿真页面(门户门闩 + 批量提交教学 DOM) +├── scripts/ +│ ├── service/ +│ │ ├── browser_session.py # async 启动 persistent context(不含 goto) +│ │ ├── account_client.py # account-manager subprocess(对齐 real_browser_rpa) +│ │ ├── simulator_playwright.py # RPA 主流程(厚) +│ │ ├── task_service.py # async 编排层 +│ │ └── adapter/ +│ │ ├── base.py # BatchItem / BatchSubmitResult 契约 +│ │ ├── mock.py # mock/unit 档 +│ │ ├── simulator_rpa.py # 薄 adapter:pick / lease / 委托 RPA +│ │ └── __init__.py # select_adapter +│ └── util/ +│ ├── constants.py +│ └── logging.py +└── tests/ + ├── test_adapter_dispatch.py + ├── test_no_rpa_helpers_import.py + ├── test_simulator_rpa_unit.py + └── test_stop_reason.py +``` + +## 文件职责 + +| 文件 | 职责 | +|---|---| +| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** | +| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) | +| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 | +| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` | +| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI | +| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` | +| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 | + +## 网页 RPA 硬规则(与模板 `development/RPA.md` §0.2 一致) + +- **先 Profile 再开浏览器**:`simulator_rpa` 档必须 `pick_web_account`(ensure-web)拿到 `profile_dir`;拿不到则失败,禁止系统默认用户目录 +- **有头**:联调 / 交付默认 `OPENCLAW_BROWSER_HEADLESS=0` +- **登录按平台约定**:本示例需要仿真登录;其他技能可为 optional / not_needed,但**仍须**走 account-manager + +## 核心流程 + +1. `await run_batch_submit(target, items)` 校验参数 +2. `select_adapter()` 按 `OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA +3. `simulator_rpa` 档:`SimulatorBrowserRpaAdapter.submit_batch` 内 `pick_web_account(sim_batch)` → `submit_batch_rpa` → `release_lease` +4. RPA:门户门闩(若可见则 HITL 轮询)→ 登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id` +5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult` + +## 两档仿真目标 + +| 档位 | 目标 | 说明 | +|---|---|---| +| 本地教学 | `sandbox/demo_app.html`(`file://`) | 结构教学 + mock 单测默认 | +| 线上联调 | `https://sandbox.jc2009.com/...` | 设 `SIMULATOR_BASE_URL` + account-manager `sim_batch` 账号 | + +联调时账号 `url` 用行业根路径,**不要**写 `/login` 子路径;`auth_strategy=per_session_manual`。 + +## account-manager 注册示例(泛化) + +```bash +python .../account-manager/scripts/main.py account add-web \ + --platform sim_batch \ + --login-id demo001 \ + --url file:///.../demo_app.html \ + --environment simulator \ + --auth-strategy per_session_manual +``` + +线上 sandbox 时将 `--url` 换为 `https://sandbox.jc2009.com/...` 占位路径(按团队部署填写)。 + +**PIN/密码演示值**:RPA 从 account 的 `login_id` 取账号;`password` / `token_pin` 允许从 account 字段或环境变量 `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` 回退;验证码固定演示值 `0000`(仅 sandbox 用)。 + +## Copy map(复制到新技能 `scripts/`) + +| 来源(examples) | 目标(新技能) | 说明 | +|---|---|---| +| `browser_session.py` | `scripts/service/browser_session.py` | async;URL 仅 `page.goto`,不进 launch args | +| `account_client.py` | `scripts/service/account_client.py` | subprocess,对齐 `real_browser_rpa` | +| `simulator_playwright.py` | `scripts/service/<platform>_playwright.py` | RPA 主流程(厚) | +| `adapter/` | `scripts/service/<domain>_adapter/` | 薄 `simulator_rpa` + mock | +| `task_service.py` | `scripts/service/task_service.py` | async 编排 | +| `util/constants.py` | `scripts/util/constants.py` | 须替换 `TARGET_PLATFORM` 等业务常量 | +| `tests/` | `tests/` | 可按需复制 dispatch / unit / 禁止 import 守护 | +| `sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill | + +## 明确禁止 + +- **不要** `import account-manager` 内部模块(`rpa_helpers`、`inject_account_manager_scripts_path`、`get_account_credential`) +- **不要** sync Playwright 用于完整 RPA 主路径 +- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess +- **不要在技能侧**执行 `playwright install` / `pip install playwright` +- **不要把 URL 放进** `launch_persistent_context(args=[...])` +- **不要** vendor `scripts/jiangchang_skill_core/` +- **不要**照抄 slug、logger 名、演示账号到生产 skill + +> 旧版 `pick_simulator_account()` / `SimulatorAccount` / `set_credentials()` 已废弃;账号统一由 account-manager 下发。 + +## 联调检查表 + +- [ ] 数据目录 `.env` 中 `OPENCLAW_TEST_TARGET=simulator_rpa`(非 mock) +- [ ] account-manager:`platform ensure` + 账号 `active` + 有 `profile_dir` +- [ ] 目标 sandbox UI 已部署(跨团队) +- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(可用 `--user-data-dir` 手工打开清站点数据) +- [ ] 默认 `tests/run_tests.py` / 本 example `pytest` 仍全部通过(mock 离线) + +## 依赖说明 + +- Python 3.10+ +- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要 `pip install playwright` +- 默认使用**系统 Chrome/Edge**;技能侧不要 `playwright install` +- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选) + +## 环境变量(示例) + +| 变量 | 说明 | +|---|---| +| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock;`simulator_rpa` → 仿真 RPA | +| `SIMULATOR_BASE_URL` | 仿真页 URL;默认本地 `demo_app.html` 的 file URI | +| `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` | 演示密码/PIN 回退(账号字段优先) | +| `PORTAL_LOGIN_WAIT_SEC` | 门户 HITL 超时(默认 120) | +| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头(CI 可用) | + +## 运行测试 + +```bash +cd examples/simulator_browser_rpa +python -m pytest tests/ -v +``` + +测试为 mock/纯函数,**不启动真实浏览器**。 + +## 当前状态 + +- async Playwright + 薄 adapter + `simulator_playwright.py` 工程范式已对齐 `real_browser_rpa`。 +- 未接入完整 CLI、数据库与 entitlement。 diff --git a/examples/simulator_browser_rpa/sandbox/demo_app.html b/examples/simulator_browser_rpa/sandbox/demo_app.html new file mode 100644 index 0000000..52ee286 --- /dev/null +++ b/examples/simulator_browser_rpa/sandbox/demo_app.html @@ -0,0 +1,155 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> + <meta charset="UTF-8" /> + <title>仿真批量提交系统(示例 Sandbox) + + + +

仿真业务系统 · 批量提交(示例)

+ +
+

平台门户登录(示例)

+
+ + + +
+
+ + + + + + + + + + + + + + diff --git a/examples/simulator_browser_rpa/scripts/service/.gitkeep b/examples/simulator_browser_rpa/scripts/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/scripts/service/account_client.py b/examples/simulator_browser_rpa/scripts/service/account_client.py new file mode 100644 index 0000000..b068608 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/account_client.py @@ -0,0 +1,355 @@ +"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 + +业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional, Tuple + +from jiangchang_skill_core import config +from jiangchang_skill_core.runtime_env import get_sibling_skills_root + +from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM +from util.logging import mask_text + +logger = logging.getLogger(__name__) + +PLACEHOLDER_PLATFORM = TARGET_PLATFORM + +ACCOUNT_SETUP_MESSAGE = ( + f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" + f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" +) + +LEASE_BUSY_MESSAGE = ( + "该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。" +) + +DEFAULT_AUTH_STRATEGY = "qr_code_manual" +DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认" +DEFAULT_START_URL = "https://www.example.com/" + + +class AccountManagerError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def mask_login_id(login_id: str) -> str: + return mask_text(login_id) + + +def format_account_label(account: dict) -> str: + """人读账号标识:优先 login_id / 备注,最后才用数字 id。""" + for key in ("login_id", "account_label", "label"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + for key in ("id", "account_id"): + v = account.get(key) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + +def _is_placeholder_config(raw: str) -> bool: + s = (raw or "").strip() + if not s: + return True + if s.startswith("#"): + return True + low = s.lower() + if low in ("default-account", "none", "null", "n/a", "-"): + return True + return False + + +def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]: + """从配置解析 (account_id, login_id)。 + + - ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id + - ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id) + - 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web + """ + account_id: Optional[str] = None + login_id: Optional[str] = None + + aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip() + if not _is_placeholder_config(aid_raw): + if aid_raw.isdigit(): + account_id = aid_raw + else: + login_id = aid_raw + logger.info("default_account_id_treated_as_login_id value=%s", aid_raw) + + lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip() + if not _is_placeholder_config(lid_raw): + login_id = lid_raw + + if account_id: + return account_id, None + return None, login_id + + +def _resolve_account_manager_main() -> str: + """解析 account-manager CLI 入口路径。 + + 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。 + 末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。 + """ + env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() + if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): + return os.path.join(os.path.abspath(env_root), "scripts", "main.py") + + scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_root = get_sibling_skills_root(scripts_dir) + candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py") + if os.path.isfile(candidate): + return candidate + + # 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录 + dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" + if os.path.isfile(dev): + return dev + + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + "未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。", + ) + + +def _parse_last_json(stdout: str) -> Any: + lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()] + for raw in reversed(lines): + if raw.startswith("ERROR:"): + continue + try: + return json.loads(raw) + except json.JSONDecodeError: + continue + raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。") + + +def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]: + main_py = _resolve_account_manager_main() + env = os.environ.copy() + return subprocess.run( + [sys.executable, main_py, *argv_suffix], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + + +def _normalize_error_code(raw: str) -> str: + code = (raw or "").strip() + if code.startswith("ERROR:"): + code = code[6:] + return code + + +def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + if payload.get("success") is False: + err = payload.get("error") if isinstance(payload.get("error"), dict) else {} + code = _normalize_error_code(str(err.get("code") or "")) + message = str(err.get("message") or "") + if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: + raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE) + if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code: + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。", + ) + if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: + raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") + raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") + + if not payload.get("profile_dir"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。") + if not payload.get("lease_token"): + raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。") + return payload + + +def _ensure_web_with_lease( + platform: str, + *, + login_id: Optional[str] = None, +) -> Dict[str, Any]: + start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL + lid = (login_id or "").strip() or None + label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL + argv = [ + "account", + "ensure-web", + "--platform", + platform, + "--url", + start_url, + "--auth-strategy", + DEFAULT_AUTH_STRATEGY, + "--label", + label, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if lid: + argv.extend(["--login-id", lid]) + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "ENSURE_WEB_FAILED", + (proc.stderr or "").strip() or "ensure-web 子进程失败", + ) + payload = _parse_last_json(out) + if isinstance(payload, dict) and payload.get("success") is False: + return _validate_pick_payload(payload) + if not isinstance(payload, dict): + raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") + return _validate_pick_payload(payload) + + +def _pick_existing_with_lease( + platform: str, + *, + login_id: Optional[str] = None, + label: Optional[str] = None, +) -> Dict[str, Any]: + argv = [ + "account", + "pick-web", + "--platform", + platform, + "--lease", + "--holder", + LEASE_HOLDER, + "--ttl-sec", + LEASE_TTL_SEC, + "--purpose", + "rpa", + ] + if login_id: + argv.extend(["--login-id", login_id]) + elif label: + argv.extend(["--label", label]) + else: + raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。") + + proc = _run_argv(argv) + out = proc.stdout or "" + if proc.returncode != 0 and not out.strip(): + raise AccountManagerError( + "PICK_WEB_FAILED", + (proc.stderr or "").strip() or "pick-web 子进程失败", + ) + payload = _parse_last_json(out) + return _validate_pick_payload(payload) + + +def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]: + proc = _run_argv(["account", "get", str(account_id)]) + out = proc.stdout or "" + if proc.returncode != 0 or out.startswith("ERROR:"): + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + raw = _parse_last_json(out) + if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict): + data = raw["data"] + elif isinstance(raw, dict): + data = raw + else: + raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") + + if data.get("success") is False: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") + platform_key = str(data.get("platform_key") or "").lower() + if platform_key != platform.lower(): + raise AccountManagerError( + "ACCOUNT_NOT_FOUND", + f"账号 {account_id} 不是 {platform} 平台账号。", + ) + profile_dir = str(data.get("profile_dir") or "").strip() + if not profile_dir: + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") + if str(data.get("status") or "active").lower() != "active": + raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。") + + try: + selected = _pick_existing_with_lease( + platform, + login_id=(str(data.get("login_id") or "").strip() or None), + label=(str(data.get("account_label") or "").strip() or None), + ) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"): + raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc + raise + if str(selected.get("id")) != str(account_id): + release_lease(str(selected.get("lease_token") or "")) + raise AccountManagerError( + "AMBIGUOUS_ACCOUNT", + f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。", + ) + return selected + + +def pick_web_account( + platform: str, + account_id: Optional[str] = None, + login_id: Optional[str] = None, +) -> dict: + """获取网页账号(profile_dir + lease_token)。 + + 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 + """ + platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM + + aid_raw = (account_id or "").strip() + if aid_raw and not _is_placeholder_config(aid_raw): + if not aid_raw.isdigit(): + if not (login_id or "").strip(): + login_id = aid_raw + aid_raw = "" + else: + return _pick_by_id_with_lease(platform_key, int(aid_raw)) + + lid = (login_id or "").strip() or None + if lid and _is_placeholder_config(lid): + lid = None + + try: + return _ensure_web_with_lease(platform_key, login_id=lid) + except AccountManagerError as exc: + if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: + raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc + raise + + +def release_lease(lease_token: Optional[str]) -> None: + token = (lease_token or "").strip() + if not token: + return + try: + proc = _run_argv(["lease", "release", token]) + if proc.returncode != 0: + logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip()) + except Exception: + logger.warning("lease_release_exception", exc_info=True) diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/__init__.py b/examples/simulator_browser_rpa/scripts/service/adapter/__init__.py new file mode 100644 index 0000000..4884e90 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/__init__.py @@ -0,0 +1,41 @@ +"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。""" + +from __future__ import annotations + +import logging +from typing import Optional + +from jiangchang_skill_core import config + +from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult +from service.adapter.mock import MockBatchAdapter +from service.adapter.simulator_rpa import SimulatorBrowserRpaAdapter + +logger = logging.getLogger(__name__) + +__all__ = [ + "select_adapter", + "BatchAdapterBase", + "BatchItem", + "BatchSubmitResult", + "MockBatchAdapter", + "SimulatorBrowserRpaAdapter", +] + + +def select_adapter(artifacts_dir: Optional[str] = None) -> BatchAdapterBase: + target = (config.get("OPENCLAW_TEST_TARGET") or "").strip().lower() + + if target in ("mock", "unit"): + logger.info("target '%s': MockBatchAdapter", target) + return MockBatchAdapter() + + if target == "real_rpa": + logger.warning("target real_rpa 未实现,回退 MockBatchAdapter") + return MockBatchAdapter() + + if target and target != "simulator_rpa": + logger.warning("未知 target '%s',回退 simulator_rpa", target) + + logger.info("target '%s': SimulatorBrowserRpaAdapter", target or "(unset=default)") + return SimulatorBrowserRpaAdapter(artifacts_dir=artifacts_dir) diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/base.py b/examples/simulator_browser_rpa/scripts/service/adapter/base.py new file mode 100644 index 0000000..3022840 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/base.py @@ -0,0 +1,32 @@ +"""批量提交 adapter 基类与数据契约。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class BatchItem: + row_index: int + name: str + account: str + amount: float + note: str = "" + + +@dataclass +class BatchSubmitResult: + ok: bool + batch_id: Optional[str] + submitted_count: int + submitted_amount: float + error_msg: Optional[str] + artifacts: Dict[str, Any] = field(default_factory=dict) + + +class BatchAdapterBase: + name: str = "base" + + async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: + raise NotImplementedError diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/mock.py b/examples/simulator_browser_rpa/scripts/service/adapter/mock.py new file mode 100644 index 0000000..c613f77 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/mock.py @@ -0,0 +1,36 @@ +"""mock / unit 档位:不启动浏览器。""" + +from __future__ import annotations + +import time +from typing import List + +from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult + + +class MockBatchAdapter(BatchAdapterBase): + name = "mock" + + async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: + del target + if not items: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="items 为空", + artifacts={"adapter": self.name}, + ) + + time.sleep(0.05) + total = sum(it.amount for it in items) + fake_batch_id = f"MOCK-{int(time.time())}" + return BatchSubmitResult( + ok=True, + batch_id=fake_batch_id, + submitted_count=len(items), + submitted_amount=total, + error_msg=None, + artifacts={"adapter": self.name}, + ) diff --git a/examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py b/examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py new file mode 100644 index 0000000..04f6cd1 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/adapter/simulator_rpa.py @@ -0,0 +1,109 @@ +"""仿真浏览器 RPA 薄 adapter:pick 账号、lease、委托 RPA、release lease。""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +from jiangchang_skill_core import config + +from service.account_client import AccountManagerError, pick_web_account, release_lease +from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult +from service.browser_session import find_chrome_executable +from service.simulator_playwright import submit_batch_rpa +from util.constants import TARGET_PLATFORM, resolve_simulator_base_url + +logger = logging.getLogger(__name__) + + +def _resolve_entry_url(account: dict) -> str: + url = str(account.get("url") or "").strip() + if url: + return url.rstrip("/") + return resolve_simulator_base_url() + + +class SimulatorBrowserRpaAdapter(BatchAdapterBase): + name = "simulator_rpa" + + def __init__( + self, + artifacts_dir: Optional[str] = None, + headless: Optional[bool] = None, + ) -> None: + self.artifacts_dir = artifacts_dir + if headless is None: + self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) + else: + self.headless = headless + + async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: + try: + from playwright.async_api import async_playwright # noqa: F401 + except ImportError: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=( + "playwright Python 包不可用。" + "生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。" + ), + artifacts={"adapter": self.name}, + ) + + if not items: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="items 为空", + artifacts={"adapter": self.name}, + ) + + if not find_chrome_executable(): + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。", + artifacts={"adapter": self.name}, + ) + + lease_token: Optional[str] = None + try: + account = pick_web_account(TARGET_PLATFORM) + lease_token = account.get("lease_token") + base_url = _resolve_entry_url(account) + return await submit_batch_rpa( + account, + target=target, + items=items, + base_url=base_url, + artifacts_dir=self.artifacts_dir, + headless=self.headless, + ) + except AccountManagerError as exc: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=f"ERROR:{exc.code} {exc.message}", + artifacts={"adapter": self.name}, + ) + except Exception as exc: + logger.exception("adapter_unexpected: %s", exc) + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=f"未预期异常:{type(exc).__name__}: {exc}", + artifacts={"adapter": self.name}, + ) + finally: + release_lease(lease_token) diff --git a/examples/simulator_browser_rpa/scripts/service/browser_session.py b/examples/simulator_browser_rpa/scripts/service/browser_session.py new file mode 100644 index 0000000..87bfa4c --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/browser_session.py @@ -0,0 +1,126 @@ +"""浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context)。""" + +from __future__ import annotations + +import logging +import os +from typing import Any, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +DEFAULT_PAGE_TIMEOUT_MS = 15_000 + +CHROME_LAUNCH_ARGS: List[str] = [ + "--start-maximized", + "--disable-blink-features=AutomationControlled", +] + +STEALTH_INIT_SCRIPT = """ +(() => { + try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {} +})(); +""" + + +def chrome_launch_args() -> List[str]: + return list(CHROME_LAUNCH_ARGS) + + +def _headless_from_env() -> bool: + v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower() + return v in ("1", "true", "yes", "on") + + +def find_chrome_executable() -> str | None: + try: + from jiangchang_skill_core.runtime_env import find_chrome_executable as _find + + chrome = _find() + if chrome and os.path.isfile(chrome): + return chrome + except ImportError: + pass + + candidates = [ + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + ] + for path in candidates: + if path and os.path.isfile(path): + return path + return None + + +def _clear_node_options() -> None: + os.environ.pop("NODE_OPTIONS", None) + + +async def start_browser_session( + profile_dir: str, + *, + headless: Optional[bool] = None, +) -> Tuple[Any, Any, Any]: + """ + 启动持久化 Chrome profile,返回 (playwright, context, page)。 + + URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto。 + """ + from playwright.async_api import async_playwright + + _clear_node_options() + hl = headless if headless is not None else _headless_from_env() + chrome = find_chrome_executable() + if not chrome: + raise RuntimeError( + "ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请先安装系统浏览器后重试。" + ) + + args = chrome_launch_args() + logger.info( + "browser_launch chrome=%s profile_dir=%s args=%s headless=%s", + chrome, + profile_dir, + args, + hl, + ) + + pw = await async_playwright().start() + context = None + try: + context = await pw.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=hl, + executable_path=chrome, + locale="zh-CN", + no_viewport=True, + args=args, + ignore_default_args=["--enable-automation"], + ) + await context.add_init_script(STEALTH_INIT_SCRIPT) + + page = context.pages[0] if context.pages else await context.new_page() + page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS) + return pw, context, page + except Exception: + if context is not None: + try: + await context.close() + except Exception: + pass + await pw.stop() + raise + + +async def close_browser_session(playwright: Any, context: Any) -> None: + try: + if context is not None: + await context.close() + except Exception: + pass + try: + if playwright is not None: + await playwright.stop() + except Exception: + pass diff --git a/examples/simulator_browser_rpa/scripts/service/simulator_playwright.py b/examples/simulator_browser_rpa/scripts/service/simulator_playwright.py new file mode 100644 index 0000000..4ba8fd3 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/simulator_playwright.py @@ -0,0 +1,400 @@ +"""仿真批量提交 RPA 主流程(async Playwright)。""" + +from __future__ import annotations + +import asyncio +import logging +import os +import random +import re +import time +from dataclasses import dataclass +from typing import Any, List, Optional + +from jiangchang_skill_core import config + +from service.adapter.base import BatchItem, BatchSubmitResult +from service.browser_session import ( + close_browser_session, + find_chrome_executable, + start_browser_session, +) +from util.constants import ( + DEFAULT_DEMO_CAPTCHA, + DEFAULT_DEMO_LOGIN_ID, + DEFAULT_DEMO_PASSWORD, + DEFAULT_DEMO_TOKEN_PIN, + DEFAULT_TIMEOUT_MS, + ENV_SIMULATOR_PASSWORD, + ENV_SIMULATOR_TOKEN_PIN, + LOGIN_NAVIGATE_TIMEOUT_MS, + SUBMIT_NAVIGATE_TIMEOUT_MS, + portal_login_wait_sec, +) + +logger = logging.getLogger(__name__) + +GOTO_TIMEOUT_MS = 60_000 + +_PAUSE_MIN_MS = 500 +_PAUSE_MAX_MS = 2000 + + +class RpaError(Exception): + def __init__(self, message: str, screenshot_path: Optional[str] = None): + super().__init__(message) + self.screenshot_path = screenshot_path + + +@dataclass +class _Credentials: + login_id: str + password: str + token_pin: str + + +def _resolve_credentials(account: dict[str, Any]) -> _Credentials: + login_id = str( + account.get("login_id") or account.get("username") or DEFAULT_DEMO_LOGIN_ID + ).strip() + password = str( + account.get("password") + or os.getenv(ENV_SIMULATOR_PASSWORD) + or config.get(ENV_SIMULATOR_PASSWORD) + or DEFAULT_DEMO_PASSWORD + ).strip() + token_pin = str( + account.get("token_pin") + or account.get("pin") + or os.getenv(ENV_SIMULATOR_TOKEN_PIN) + or config.get(ENV_SIMULATOR_TOKEN_PIN) + or DEFAULT_DEMO_TOKEN_PIN + ).strip() + return _Credentials(login_id=login_id, password=password, token_pin=token_pin) + + +def _headless() -> bool: + return config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) + + +async def _pause(min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None: + await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0) + + +async def _visible(locator) -> bool: + try: + return await locator.count() > 0 and await locator.is_visible(timeout=500) + except Exception: + return False + + +async def _wait_portal_gate(page, wait_sec: int) -> None: + portal_user = page.locator("#portal-user") + try: + if not await _visible(portal_user): + return + except Exception: + return + + print(f"[门户] 检测到平台门户登录门闩,请手工完成门户登录,最多等待 {wait_sec} 秒...") + deadline = time.monotonic() + wait_sec + while time.monotonic() < deadline: + try: + portal_section = page.locator("#portal-section") + login_step1 = page.locator('form[name="simLoginStep1"]') + if not await _visible(portal_section) or await _visible(login_step1): + print("[门户] 门户门闩已通过,进入业务系统登录") + return + except Exception: + pass + await asyncio.sleep(2.0) + + raise RpaError("ERROR:LOGIN_TIMEOUT 门户登录超时,请重新运行并及时完成门户登录。") + + +async def _login(page, creds: _Credentials, artifacts_dir: str | None) -> None: + try: + await page.wait_for_selector( + 'form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS + ) + await _pause() + await page.fill('form[name="simLoginStep1"] input[name="loginId"]', creds.login_id) + await _pause() + await page.click('form[name="simLoginStep1"] button[type="submit"]') + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "login_step1_failed") + raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc + + try: + await page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS) + await _pause() + await page.fill('form[name="simLoginStep2"] input[name="password"]', creds.password) + await page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA) + await _pause() + await page.click('form[name="simLoginStep2"] button[type="submit"]') + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "login_step2_failed") + raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc + + try: + await page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "login_navigate_failed") + raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc + + +async def _goto_batch_page(page, artifacts_dir: str | None) -> None: + try: + await page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS) + await _pause() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "goto_batch_failed") + raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc + + +async def _select_target_account(page, target: str) -> None: + sel = page.locator('select[name="fromAccountNo"]') + await sel.wait_for(timeout=DEFAULT_TIMEOUT_MS) + n = await sel.locator("option").count() + matched_value: Optional[str] = None + first_value: Optional[str] = None + for i in range(n): + opt = sel.locator("option").nth(i) + val = (await opt.get_attribute("value")) or "" + if not val.strip(): + continue + label_txt = await opt.inner_text() + if first_value is None: + first_value = val + if target.strip() and target.strip() in label_txt: + matched_value = val + break + pick = matched_value or first_value + if not pick: + raise RpaError("来源账户下拉无可选项") + if not matched_value and target.strip(): + logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick) + await sel.select_option(value=pick) + await _pause() + + +async def _fill_batch_form( + page, target: str, items: List[BatchItem], artifacts_dir: str | None +) -> None: + try: + await page.get_by_role("button", name=re.compile(r"页面填写")).click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "tab_switch_failed") + raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc + + try: + await _select_target_account(page, target) + except RpaError: + raise + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "select_account_failed") + raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc + + try: + existing = await page.locator("tr[data-row-index]").count() + need_more = max(0, len(items) - existing) + add_btn = page.get_by_role("button", name=re.compile(r"新增一行")) + for _ in range(need_more): + await _pause(400, 900) + await add_btn.click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "add_rows_failed") + raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc + + for it in items: + try: + row = page.locator(f'tr[data-row-index="{it.row_index}"]') + await row.wait_for(timeout=DEFAULT_TIMEOUT_MS) + await _pause(300, 800) + await row.locator('input[name="name"]').fill(it.name) + await row.locator('input[name="account"]').fill(it.account) + await row.locator('input[name="note"]').fill(it.note or "") + await row.locator('input[name="amount"]').fill(f"{it.amount}") + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, f"fill_row_{it.row_index}_failed") + raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc + + try: + await page.fill('input[name="purpose"]', "仿真批量提交示例") + except Exception as exc: + logger.warning("填写用途失败(非致命):%s", exc) + + +async def _submit_and_get_batch_id(page, token_pin: str, artifacts_dir: str | None) -> str: + try: + await _pause(800, 1500) + await page.locator('form[name="simBatchForm"] button[type="submit"]').click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "click_submit_failed") + raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc + + dlg_sel = 'div[role="dialog"][aria-modal="true"]' + try: + await page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "pin_dialog_not_open") + raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc + + try: + pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]') + await _pause(500, 1200) + await pin_input.fill(token_pin) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "fill_pin_failed") + raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc + + try: + await page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click() + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "click_confirm_failed") + raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc + + try: + await page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS) + except Exception as exc: + sp = await _safe_screenshot(page, artifacts_dir, "success_area_not_visible") + raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc + + batch_id = await page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or "" + if not batch_id: + batch_id = (await page.locator('[data-testid="batch-id"]').inner_text()).strip() + url = page.url + if not batch_id and "#/batch/" in url: + batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/") + if not batch_id: + sp = await _safe_screenshot(page, artifacts_dir, "batch_id_empty") + raise RpaError(f"无法解析 batch_id,url={url}", screenshot_path=sp) + return batch_id + + +async def _safe_screenshot(page, artifacts_dir: str | None, tag: str) -> Optional[str]: + if not artifacts_dir: + return None + try: + os.makedirs(artifacts_dir, exist_ok=True) + path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png") + await page.screenshot(path=path, full_page=True) + logger.info("screenshot_saved path=%s", path) + return path + except Exception as exc: + logger.warning("screenshot_failed tag=%s err=%s", tag, exc) + return None + + +async def submit_batch_rpa( + account: dict[str, Any], + *, + target: str, + items: list[BatchItem], + base_url: str, + artifacts_dir: str | None, + video_session: Any = None, + headless: bool | None = None, +) -> BatchSubmitResult: + del video_session # 示例占位;真实 skill 可接入 RpaVideoSession + + profile_dir = str(account.get("profile_dir") or "").strip() + if not profile_dir: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="账号缺少 profile_dir", + artifacts={"adapter": "simulator_rpa"}, + ) + + if not items: + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="items 为空", + artifacts={"adapter": "simulator_rpa"}, + ) + + if not find_chrome_executable(): + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。", + artifacts={"adapter": "simulator_rpa"}, + ) + + creds = _resolve_credentials(account) + portal_wait = portal_login_wait_sec() + hl = headless if headless is not None else _headless() + + pw = None + context = None + page = None + try: + pw, context, page = await start_browser_session(profile_dir, headless=hl) + logger.info("rpa_goto url=%s", base_url) + await page.goto(base_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS) + + await _wait_portal_gate(page, portal_wait) + logger.info("rpa_login_start") + await _login(page, creds, artifacts_dir) + logger.info("rpa_login_done") + + await _goto_batch_page(page, artifacts_dir) + logger.info("rpa_batch_page_ready") + + await _fill_batch_form(page, target, items, artifacts_dir) + logger.info("rpa_batch_form_filled rows=%s", len(items)) + + batch_id = await _submit_and_get_batch_id(page, creds.token_pin, artifacts_dir) + logger.info("rpa_submit_success batch_id=%s", batch_id) + + total = sum(it.amount for it in items) + return BatchSubmitResult( + ok=True, + batch_id=batch_id, + submitted_count=len(items), + submitted_amount=total, + error_msg=None, + artifacts={"adapter": "simulator_rpa"}, + ) + except RpaError as exc: + logger.error("rpa_failed: %s", exc) + arts = {"adapter": "simulator_rpa"} + if exc.screenshot_path: + arts["screenshot"] = exc.screenshot_path + elif page is not None: + sp = await _safe_screenshot(page, artifacts_dir, "rpa_error") + if sp: + arts["screenshot"] = sp + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=str(exc), + artifacts=arts, + ) + except Exception as exc: + logger.exception("rpa_unexpected: %s", exc) + arts = {"adapter": "simulator_rpa"} + if page is not None: + sp = await _safe_screenshot(page, artifacts_dir, "unexpected_error") + if sp: + arts["screenshot"] = sp + return BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg=f"未预期异常:{type(exc).__name__}: {exc}", + artifacts=arts, + ) + finally: + await close_browser_session(pw, context) diff --git a/examples/simulator_browser_rpa/scripts/service/task_service.py b/examples/simulator_browser_rpa/scripts/service/task_service.py new file mode 100644 index 0000000..e40ebdd --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/service/task_service.py @@ -0,0 +1,100 @@ +"""轻量任务编排:校验输入 → 选 adapter → 提交批次。""" + +from __future__ import annotations + +import os +import tempfile +import uuid +from typing import Any, Dict, List, Optional + +from service.adapter import BatchItem, select_adapter +from util.logging import get_logger + +logger = get_logger(__name__) + + +def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optional[str]]: + if not items: + return [], "items 不能为空" + parsed: List[BatchItem] = [] + for idx, raw in enumerate(items, start=1): + name = str(raw.get("name") or "").strip() + account = str(raw.get("account") or "").strip() + note = str(raw.get("note") or "").strip() + try: + amount = float(raw.get("amount")) + except (TypeError, ValueError): + return [], f"第 {idx} 行 amount 无效" + if amount <= 0: + return [], f"第 {idx} 行 amount 必须大于 0" + if not name or not account: + return [], f"第 {idx} 行 name/account 不能为空" + parsed.append( + BatchItem( + row_index=int(raw.get("row_index") or idx), + name=name, + account=account, + amount=amount, + note=note, + ) + ) + return parsed, None + + +async def run_batch_submit( + target: str, + items: List[Dict[str, Any]], + *, + force: bool = False, + artifacts_dir: Optional[str] = None, +) -> Dict[str, Any]: + """ + 最小编排示例。 + + 真实 skill 中还应:写业务表、entitlement、RpaVideoSession 等。 + 账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。 + """ + del force # 示例占位;真实 skill 可用于跳过确认 + + target_key = (target or "").strip() + if not target_key: + return {"success": False, "error": {"code": "INVALID_TARGET", "message": "target 不能为空"}} + + batch_items, err = _validate_items(items) + if err: + return {"success": False, "error": {"code": "INVALID_ITEMS", "message": err}} + + art_dir = artifacts_dir or os.path.join( + tempfile.gettempdir(), + "openclaw-simulator-rpa-artifacts", + uuid.uuid4().hex[:8], + ) + os.makedirs(art_dir, exist_ok=True) + logger.info( + "batch_submit_start target=%s item_count=%s artifacts_dir=%s", + target_key, + len(batch_items), + art_dir, + ) + + adapter = select_adapter(artifacts_dir=art_dir) + result = await adapter.submit_batch(target_key, batch_items) + summary: Dict[str, Any] = { + "success": result.ok, + "target": target_key, + "adapter": result.artifacts.get("adapter") or adapter.name, + "submitted_count": result.submitted_count, + "submitted_amount": result.submitted_amount, + "batch_id": result.batch_id, + "artifacts": result.artifacts, + "artifacts_dir": art_dir, + } + if not result.ok: + summary["error"] = {"code": "BATCH_SUBMIT_FAILED", "message": result.error_msg or "提交失败"} + logger.info( + "batch_submit_done success=%s batch_id=%s adapter=%s", + result.ok, + result.batch_id, + adapter.name, + ) + return summary diff --git a/examples/simulator_browser_rpa/scripts/util/.gitkeep b/examples/simulator_browser_rpa/scripts/util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/scripts/util/constants.py b/examples/simulator_browser_rpa/scripts/util/constants.py new file mode 100644 index 0000000..9da48e0 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/util/constants.py @@ -0,0 +1,52 @@ +"""仿真浏览器 RPA 示例常量。""" + +from __future__ import annotations + +import os +from pathlib import Path + +from jiangchang_skill_core import config + +SKILL_SLUG = "example-simulator-browser-rpa" +LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa" + +TARGET_PLATFORM = "sim_batch" +LEASE_HOLDER = "example-simulator-browser-rpa" +LEASE_TTL_SEC = "1800" + +DEFAULT_TIMEOUT_MS = 15_000 +LOGIN_NAVIGATE_TIMEOUT_MS = 30_000 +SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000 +DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120 + +DEFAULT_DEMO_LOGIN_ID = "demo001" +DEFAULT_DEMO_PASSWORD = "demo-password" +DEFAULT_DEMO_CAPTCHA = "0000" +DEFAULT_DEMO_TOKEN_PIN = "123456" + +ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL" +ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD" +ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN" +ENV_PORTAL_LOGIN_WAIT_SEC = "PORTAL_LOGIN_WAIT_SEC" + +_EXAMPLE_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html" + + +def portal_login_wait_sec() -> int: + raw = ( + os.getenv(ENV_PORTAL_LOGIN_WAIT_SEC) + or config.get(ENV_PORTAL_LOGIN_WAIT_SEC) + or str(DEFAULT_PORTAL_LOGIN_WAIT_SEC) + ) + try: + return max(1, int(str(raw).strip())) + except (TypeError, ValueError): + return DEFAULT_PORTAL_LOGIN_WAIT_SEC + + +def resolve_simulator_base_url() -> str: + env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip() + if env: + return env.rstrip("/") + return DEFAULT_DEMO_APP_PATH.as_uri() diff --git a/examples/simulator_browser_rpa/scripts/util/logging.py b/examples/simulator_browser_rpa/scripts/util/logging.py new file mode 100644 index 0000000..569bcc9 --- /dev/null +++ b/examples/simulator_browser_rpa/scripts/util/logging.py @@ -0,0 +1,38 @@ +"""示例日志工具。""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from util.constants import LOG_LOGGER_NAME + + +def get_logger(name: str | None = None) -> logging.Logger: + return logging.getLogger(name or LOG_LOGGER_NAME) + + +def configure_logging(level: int = logging.INFO) -> None: + logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s") + + +def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str: + s = (value or "").strip() + if len(s) >= 11 and s.isdigit(): + return s[:3] + "****" + s[-4:] + if len(s) > keep_start + keep_end: + return s[:keep_start] + "****" + s[-keep_end:] + return "****" + + +def safe_log_value(value: Any) -> str: + if value is None: + return "" + text = str(value) + if re.fullmatch(r"\d{7,}", text): + return mask_text(text) + if "@" in text and len(text) > 6: + local, _, domain = text.partition("@") + return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}" + return text diff --git a/examples/simulator_browser_rpa/tests/.gitkeep b/examples/simulator_browser_rpa/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/simulator_browser_rpa/tests/conftest.py b/examples/simulator_browser_rpa/tests/conftest.py new file mode 100644 index 0000000..0ba6eb8 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/conftest.py @@ -0,0 +1,10 @@ +"""pytest 路径:将 examples/simulator_browser_rpa/scripts 加入 import 路径。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) diff --git a/examples/simulator_browser_rpa/tests/test_account_client.py b/examples/simulator_browser_rpa/tests/test_account_client.py new file mode 100644 index 0000000..70c70a8 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_account_client.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import subprocess +import unittest +from unittest.mock import patch + +from service.account_client import AccountManagerError, pick_web_account + + +def _completed(payload: dict) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["python", "account-manager"], + returncode=0, + stdout=json.dumps(payload, ensure_ascii=False), + stderr="", + ) + + +class TestExplicitAccountLease(unittest.TestCase): + @patch("service.account_client._run_argv") + def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + leased = {**account, "lease_token": "lease-42"} + run_argv.side_effect = [_completed(account), _completed(leased)] + + result = pick_web_account("target-platform", account_id="42") + + self.assertEqual(result["id"], 42) + self.assertEqual(result["lease_token"], "lease-42") + self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"]) + pick_args = run_argv.call_args_list[1].args[0] + self.assertIn("pick-web", pick_args) + self.assertIn("--lease", pick_args) + self.assertIn("user@example.com", pick_args) + + @patch("service.account_client._run_argv") + def test_missing_lease_token_is_rejected(self, run_argv) -> None: + account = { + "id": 42, + "platform_key": "target-platform", + "account_label": "主账号", + "login_id": "user@example.com", + "profile_dir": "D:/data/profile-42", + "status": "active", + } + run_argv.side_effect = [_completed(account), _completed(account)] + + with self.assertRaises(AccountManagerError) as raised: + pick_web_account("target-platform", account_id="42") + + self.assertEqual(raised.exception.code, "LEASE_REQUIRED") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_adapter_dispatch.py b/examples/simulator_browser_rpa/tests/test_adapter_dispatch.py new file mode 100644 index 0000000..2ca882c --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_adapter_dispatch.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""adapter 工厂 dispatch 单元测试。""" +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from service.adapter import ( + BatchItem, + MockBatchAdapter, + SimulatorBrowserRpaAdapter, + select_adapter, +) + + +class TestAdapterDispatch(unittest.TestCase): + def test_default_returns_simulator_rpa(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENCLAW_TEST_TARGET", None) + adapter = select_adapter() + self.assertIsInstance(adapter, SimulatorBrowserRpaAdapter) + + def test_unit_returns_mock(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "unit"}): + self.assertIsInstance(select_adapter(), MockBatchAdapter) + + def test_mock_returns_mock(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "mock"}): + self.assertIsInstance(select_adapter(), MockBatchAdapter) + + def test_simulator_rpa_returns_simulator(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_rpa"}): + self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter) + + def test_real_rpa_falls_back_to_mock(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "real_rpa"}): + self.assertIsInstance(select_adapter(), MockBatchAdapter) + + def test_unknown_target_falls_back_simulator_rpa(self) -> None: + with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_api"}): + self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter) + + +class TestMockAdapter(unittest.IsolatedAsyncioTestCase): + async def test_mock_success(self) -> None: + items = [ + BatchItem(row_index=1, name="Alice", account="1001", amount=10.5), + BatchItem(row_index=2, name="Bob", account="1002", amount=20.0), + ] + result = await MockBatchAdapter().submit_batch("acct-demo-001", items) + self.assertTrue(result.ok) + self.assertEqual(result.submitted_count, 2) + self.assertEqual(result.submitted_amount, 30.5) + self.assertTrue((result.batch_id or "").startswith("MOCK-")) + + async def test_mock_empty_items(self) -> None: + result = await MockBatchAdapter().submit_batch("acct-demo-001", []) + self.assertFalse(result.ok) + self.assertEqual(result.error_msg, "items 为空") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py b/examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py new file mode 100644 index 0000000..5ca2e16 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_no_rpa_helpers_import.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""禁止 import account-manager 内部 RPA 辅助模块。""" +from __future__ import annotations + +import os +import re +import unittest +from pathlib import Path + +FORBIDDEN_PATTERNS = ( + re.compile(r"\brpa_helpers\b"), + re.compile(r"\binject_account_manager_scripts_path\b"), + re.compile(r"\bget_account_credential\b"), +) + +SCRIPTS_ROOT = Path(__file__).resolve().parents[1] / "scripts" + + +class TestNoRpaHelpersImport(unittest.TestCase): + def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None: + violations: list[str] = [] + for root, _dirs, files in os.walk(SCRIPTS_ROOT): + for name in files: + if not name.endswith(".py"): + continue + path = Path(root) / name + text = path.read_text(encoding="utf-8") + for pattern in FORBIDDEN_PATTERNS: + if pattern.search(text): + violations.append(f"{path.relative_to(SCRIPTS_ROOT)}: {pattern.pattern}") + self.assertEqual( + violations, + [], + msg="Forbidden account-manager internal imports found:\n" + "\n".join(violations), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py b/examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py new file mode 100644 index 0000000..a23a8a6 --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_simulator_rpa_unit.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。""" +from __future__ import annotations + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from service.adapter import BatchItem, SimulatorBrowserRpaAdapter +from service.browser_session import CHROME_LAUNCH_ARGS +from service.simulator_playwright import RpaError + + +class TestSimulatorRpaUnit(unittest.IsolatedAsyncioTestCase): + async def test_submit_batch_empty_items(self) -> None: + adapter = SimulatorBrowserRpaAdapter() + result = await adapter.submit_batch("acct-demo-001", []) + self.assertFalse(result.ok) + self.assertIn("items", result.error_msg or "") + + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None) + async def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None: + adapter = SimulatorBrowserRpaAdapter() + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + self.assertFalse(result.ok) + msg = result.error_msg or "" + self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg) + + def test_chrome_launch_args_must_not_contain_url(self) -> None: + for arg in CHROME_LAUNCH_ARGS: + lowered = arg.lower() + self.assertFalse(lowered.startswith("http://")) + self.assertFalse(lowered.startswith("https://")) + self.assertFalse(lowered.startswith("file://")) + + @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock) + @patch("service.adapter.simulator_rpa.pick_web_account") + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") + async def test_adapter_delegates_to_playwright_with_account_url( + self, + _chrome: MagicMock, + mock_pick: MagicMock, + mock_rpa: AsyncMock, + ) -> None: + from service.adapter.base import BatchSubmitResult + + mock_pick.return_value = { + "login_id": "demo001", + "profile_dir": "/tmp/profile", + "url": "file:///demo/demo_app.html", + "lease_token": "tok-1", + } + mock_rpa.return_value = BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="login fail", + artifacts={"adapter": "simulator_rpa"}, + ) + + adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art") + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + + self.assertFalse(result.ok) + mock_rpa.assert_awaited_once() + call_kwargs = mock_rpa.call_args.kwargs + self.assertEqual(call_kwargs["base_url"], "file:///demo/demo_app.html") + for arg in CHROME_LAUNCH_ARGS: + self.assertNotIn(arg, call_kwargs["base_url"]) + + @patch("service.simulator_playwright.start_browser_session", new_callable=AsyncMock) + async def test_playwright_opens_url_via_goto_not_launch_args(self, mock_start: AsyncMock) -> None: + from service.simulator_playwright import submit_batch_rpa + + page = AsyncMock() + page.goto = AsyncMock() + mock_start.return_value = (MagicMock(), MagicMock(), page) + + account = {"profile_dir": "/tmp/p", "login_id": "demo001"} + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + + with patch( + "service.simulator_playwright._wait_portal_gate", + new_callable=AsyncMock, + return_value=None, + ): + with patch( + "service.simulator_playwright._login", + new_callable=AsyncMock, + side_effect=RpaError("login fail"), + ): + with patch( + "service.simulator_playwright.find_chrome_executable", + return_value="C:/Chrome/chrome.exe", + ): + with patch( + "service.simulator_playwright.close_browser_session", + new_callable=AsyncMock, + ): + result = await submit_batch_rpa( + account, + target="acct-demo-001", + items=items, + base_url="file:///demo/demo_app.html", + artifacts_dir=None, + ) + + self.assertFalse(result.ok) + page.goto.assert_awaited_once() + goto_url = page.goto.call_args[0][0] + self.assertEqual(goto_url, "file:///demo/demo_app.html") + for arg in CHROME_LAUNCH_ARGS: + self.assertNotIn(arg, goto_url) + + async def test_safe_screenshot_without_artifacts_dir(self) -> None: + from service.simulator_playwright import _safe_screenshot + + page = AsyncMock() + self.assertIsNone(await _safe_screenshot(page, None, "tag")) + + +class TestSyncHelpers(unittest.TestCase): + def test_asyncio_run_smoke(self) -> None: + async def _noop() -> bool: + return True + + self.assertTrue(asyncio.run(_noop())) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simulator_browser_rpa/tests/test_stop_reason.py b/examples/simulator_browser_rpa/tests/test_stop_reason.py new file mode 100644 index 0000000..65ec6af --- /dev/null +++ b/examples/simulator_browser_rpa/tests/test_stop_reason.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""RPA 失败结果与 artifacts 单元测试。""" +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from service.adapter import BatchItem, SimulatorBrowserRpaAdapter +from service.adapter.base import BatchSubmitResult + + +class TestBatchSubmitFailureResult(unittest.IsolatedAsyncioTestCase): + @patch("service.adapter.simulator_rpa.release_lease") + @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock) + @patch("service.adapter.simulator_rpa.pick_web_account") + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") + async def test_rpa_error_converts_to_failed_result( + self, + _chrome: MagicMock, + mock_pick: MagicMock, + mock_rpa: AsyncMock, + _release: MagicMock, + ) -> None: + mock_pick.return_value = { + "profile_dir": "/tmp/profile", + "lease_token": "tok", + } + mock_rpa.return_value = BatchSubmitResult( + ok=False, + batch_id=None, + submitted_count=0, + submitted_amount=0.0, + error_msg="登录第一步失败:timeout", + artifacts={"adapter": "simulator_rpa", "screenshot": "/tmp/art/x.png"}, + ) + + adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art") + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + + self.assertFalse(result.ok) + self.assertIn("登录第一步失败", result.error_msg or "") + self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa") + self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png") + + @patch("service.adapter.simulator_rpa.release_lease") + @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock) + @patch("service.adapter.simulator_rpa.pick_web_account") + @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") + async def test_unexpected_error_includes_adapter_name( + self, + _chrome: MagicMock, + mock_pick: MagicMock, + mock_rpa: AsyncMock, + _release: MagicMock, + ) -> None: + mock_pick.return_value = { + "profile_dir": "/tmp/profile", + "lease_token": "tok", + } + mock_rpa.side_effect = RuntimeError("boom") + + adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None) + items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] + result = await adapter.submit_batch("acct-demo-001", items) + + self.assertFalse(result.ok) + self.assertIn("未预期异常", result.error_msg or "") + self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa") + self.assertNotIn("screenshot", result.artifacts) + + +if __name__ == "__main__": + unittest.main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d4413ff --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +norecursedirs = .git __pycache__ .pytest_cache integration desktop samples fixtures artifacts diagnostics +filterwarnings = + ignore::DeprecationWarning diff --git a/references/ACTIONS.md b/references/ACTIONS.md new file mode 100644 index 0000000..642230c --- /dev/null +++ b/references/ACTIONS.md @@ -0,0 +1,1158 @@ +# Skill Action Manifest(匠厂宿主) + +本文件说明 `assets/actions.json` 与 `scripts/main.py` CLI 的对应关系,供 **Agent 编排**与宿主 **Skill Action Runtime** 使用。这是 Agent 侧关于 Action / 任务中心的**主契约**;实现细节与开发规范见 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 + +**先读产品入口:** 对话 / 数据管理 / 定时任务在宿主上**平级**,何时挂哪个入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md)(本文只讲字段与执行模型)。 + +**边界:** 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);CLI 参数见 [`CLI.md`](CLI.md)。 + +## Agent 何时用 Action(与 CLI 的分工) + +| 场景 | 做法 | +|------|------| +| 短查询 / 只读 / 秒级 | bash + 共享 Python CLI(见 [`CLI.md`](CLI.md));**不必**强行走 Action | +| 数据管理按钮 / 定时任务 / 技能详情按钮 | **必须**在 `actions.json` 声明对应 Action | +| 长耗时 / RPA / 需要进度与取消 | **必须** `run_skill_action`;对应 Action 一般为 `executionProfile: "async"` | +| 已声明的 `sync` Action | `run_skill_action` 当场返回结果,**不进**任务中心 | +| 已声明的 `async` Action | `run_skill_action` 立即返回 `jobId`,**进入**任务中心 | + +**进不进任务中心只取决于该 Action 的 `executionProfile`,与从数据管理、定时任务还是 Agent 触发无关。** + +禁止对长任务 `exec`/`bash` + `process poll` 干等 CLI。禁止 `uv run python` 跑技能脚本。 + +## async 副作用 Action 最小组合(复制验收 checklist) + +对每个「会开浏览器 / 长耗时 / 需进度或取消」的 Action,声明时建议同时满足: + +1. `executionProfile: "async"`(进入任务中心) +2. 若要让 Agent 触发:`placements` **含** `agent` +3. 有可感知副作用时:提供 `confirmation.message` +4. 需要用户/Agent 入参时:简单 `inputSchema` + entrypoint 中的 `{{fieldName}}` +5. `SKILL.md`「用户意图路由表」与 `CLI.md`「Action 强制黑名单」**出现同一 action id / 命令** +6. 若处理的是**业务队列表中的一行**:遵守 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)——默认只捞**可操作**角色;开始抢占为**进行中**;结束回写成功/失败(失败带原因);确认文案写清仅可操作态可处理 + +默认模板 `assets/actions.json` 仅含诊断类 sync Action,**不要**为通过本 checklist 而塞假业务 Action;业务技能按需添加。完整字段见下文 Phase 1。 + +## 可选:域前缀 CLI(嵌套子命令) + +小技能可继续扁平:`entrypoint.command = "run"`。 +命令变多时,可采用**域前缀**:`command` 为域(如 ``),业务动词与参数放在 `args` 数组(如 `["", "--flag", "{{field}}"]`)。与扁平入口等价,须仍指向同一 domain service。 + +## 模板严格规范 vs 宿主向后兼容 + +| 角色 | 定位 | +|------|------| +| **skill-template** | 新技能推荐的**严格规范**:必填字段、明确类型、`additionalProperties: false` | +| **宿主运行时** | **向后兼容**的历史 manifest 解析器:可能接受部分缺省字段(如 `description`、`placements`、`entrypoint.args`、缺失的 `bind.tables`) | + +**新技能不应依赖宿主缺省行为。** 复制模板后按 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) 与本文完整填写 manifest。 + +JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这与宿主共享类型中的可选字段并不矛盾——宿主为兼容旧技能可能更宽松,模板为防漂移要求更严。 + +## Action 是否必需 + +Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**决定(见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md)),不是「可有可无的附属能力」: + +- 定位需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中时,**必须**提供 `assets/actions.json` 并声明对应入口。 +- 定位确认无任何宿主结构化入口时,**可以不提供** `actions.json`。 +- **含浏览器 RPA / 长耗时任务**的技能:**必须**提供至少一条 `executionProfile: "async"` 且 `placements` 含 `agent` 的 action(见 `POLICY-SKILL-ACTION-001`)。 + +## 文件位置 + +```text +{skill_root}/assets/actions.json +``` + +宿主扫描路径示例: + +```text +~/.openclaw/skills/your-skill-slug/assets/actions.json +``` + +## 顶层结构 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `schemaVersion` | 是 | 当前固定为 `1` | +| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 | +| `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) | + +模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。 + +## 六职责正交(必读) + +| 字段 / 层 | 只决定什么 | 不决定什么 | +|-----------|------------|------------| +| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 | +| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** | +| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | +| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 | +| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** | +| **`hoverHint`** | **可选**:数据管理**工具栏悬停**文案 | 不决定短按钮名;不决定点下去弹不弹窗;**不回退** `description` / `confirmation.message`;省略 = 不显示悬停。当前宿主实现为顶栏技能按钮 | +| **`inputSchema.visibleWhen`** | **可选**:Action **参数表单**字段条件显隐(先选模式再出对应参数) | **不是** Data Policy L3 行态 `visibleWhen`;不决定 placements;旧宿主忽略则字段平铺全显 | +| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 | +| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | +| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 | +| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | +| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | + +一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。需要按用户/角色藏按钮或滤行时,**不要**按角色复制 Action,应启用顶层 `policy` 并由技能算出 Snapshot(见 [`POLICY.md`](POLICY.md))。 + +## Phase 1 当前稳定支持(新技能应只使用这些) + +| 层级 | 稳定字段 / 能力 | +|------|----------------| +| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) | +| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `hoverHint`、`inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) | +| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) | +| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 | +| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` \| `"import"` — 接管默认新增/编辑/删除/导入(须 `bind.tables`) | +| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`、`columnMapping`、**`labelColumn`**(行内/批量预填与选择摘要标签,见下节) | +| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 | +| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 | +| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly`、可选 **`revealInForm`**;可选 `display_type` / `format`(含 `file` / `directory` 等);可选 **`pathRoot`**(file/directory 起始目录,相对技能数据根);可选 **`visibleWhen`**(字段条件显隐,见专节) | +| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 | +| **hoverHint** | 可选字符串。数据管理**工具栏悬停**文案;与 `description` / `confirmation.message` **独立**。省略则不显示悬停,不回退。短名已够懂时不要写。需宿主已支持该字段;当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出 | +| **visibleWhen** | 可选。写在 **property** 上(非 action 顶层):依赖同表单其它字段值决定是否展示;隐藏字段不提交、不校验 `required`。**勿与** [`POLICY.md`](POLICY.md) L3 行态 `visibleWhen` 混淆;也**勿**用它藏「已选择」已表达的映射主键(见「选择摘要」) | +| **revealInForm** | 可选。写在 **property** 上:有「已选择」时仍强制画出该 `readOnly` 字段;省略 = 映射只读字段默认不占参数区(仍提交) | +| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) | +| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) | +| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) | +| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) | +| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) | + +## `bind.tables`(表级绑定) + +```json +{ + "bind": { + "tables": ["business_table"] + } +} +``` + +规则: + +- `bind` 为 object,`additionalProperties: false`;当前允许 `tables`(必填键当存在 bind 时)与可选 `inputMapping`、`columnMapping`、**`labelColumn`**。 +- `tables` 非空数组、元素唯一、英文 **snake_case**、不得为空字符串。 +- **`placements` 含 `toolbar` 时必须显式声明非空 `bind.tables`**(模板 Schema `if/then` + 自动测试强制)。 +- `row` / `batch` **建议**同样声明 `bind.tables`,避免在无关表上露出按钮;未声明时宿主可能按兼容逻辑在更多表上展示。 +- 表名须真实存在于技能 SQLite 与 `_jiangchang_tables`。 +- 希望出现在多张表时,显式列出所有表;**不要**依赖「无 bind 则全表展示」——那是宿主对**旧技能**的兼容,不是新技能标准。 +- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段。映射字段建议标 `readOnly: true`:无「已选择」时只读展示;**有「已选择」时默认不占参数区**(仍提交,见下节)。不要用 `visibleWhen` 去「藏」映射主键。 +- **`columnMapping`(可选)**:把 inputSchema 属性名映射到物理列名,供表单复用列展示名 / lookup。 +- **`labelColumn`(可选)**:物理列名,供数据管理 Action 弹层「已选择」摘要展示 `#id + 标签`。**仅影响展示,不改变 CLI/提交 payload,也不决定业务是否跳过某些状态。** 省略时宿主用常见列名启发(如 `title` / `name` / `nickname` 等);再没有则只显示编号。列名不常见、或启发会选错列时再显式声明。 +- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。 + +### 数据管理:选择摘要(宿主默认能力) + +当用户在数据管理**勾选行**(或行内打开带行上下文的动作)后打开 `confirm` / `form` 弹层时,宿主会展示通用 **「已选择 · N 条」** 摘要(`#主键` + 可读标签;超过约 3 条可展开查看全部)。这是**宿主 UX**,对所有技能默认生效: + +- **技能零改动即可受益**;不要在 `confirmation.message` 里手写「已选 id 列表」,也不要为了面板去拼业务状态跳过文案。 +- 摘要与执行解耦:勾选了谁由 UI 说明;**实际处理哪些行、是否跳过某些状态,仍由技能 CLI / domain service 决定**。 +- 需要稳定标签列时,在 `bind` 上声明可选 `labelColumn`(见上)。示例:[`assets/examples/selection-label-column.action.example.json`](../assets/examples/selection-label-column.action.example.json)。 + +**与 `confirm` / `form` 的信息架构对齐(映射只读字段):** + +| 交互 | 参数区 | 映射主键(`inputMapping` + `readOnly`) | +|------|--------|----------------------------------------| +| `confirm` | 整区不画 | 不画;预设值仍提交 | +| `form`(有「已选择」) | 只画用户要填/要改的字段 | **默认不画**;预设值仍提交 | +| `form`(无「已选择」) | 正常画字段 | `readOnly` **只读展示**(如工具栏靠 lookup 手选作业) | + +- 需要用户在有「已选择」时仍看见某只读字段:该 property 加 **`revealInForm: true`**(逃生阀;不改变提交)。 +- **禁止**用 `visibleWhen` 藏映射主键:`visibleWhen` 隐藏的字段**不进提交**、不校验 `required`,会弄断 `--id` / `{{job_id}}`。 +- 非映射的只读说明字段(未出现在 `inputMapping`)在有摘要时**仍会显示**;仅映射上下文字段被默认折叠。 + +## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入) + +有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。 + +### 怎么选 + +| 场景 | 建议 | +|------|------| +| 纯表字段、无副作用 | 不配 `replaces`,宿主直写即可 | +| 单条新增/编辑/删除有副作用 | `replaces: create` / `update` / `delete` | +| 批量导入也有副作用(推荐) | 另配 `replaces: import`(与 create 可同表并存) | +| 只有 create、暂无 import | 宿主「导入」面板确认后会**逐行调 create**(兼容);正式产品应补 `import` | + +### 导入分流(必读) + +点工具栏「导入」时: + +1. **UI 一律**打开宿主「导入数据」面板(下载模板 → 选文件 → 映射 → 出错规则 → 预览)。 +2. **写库**按表能力分流: + - 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入) + - 仅有 `replaces: create` → 宿主按行调用 create(兼容) + - 都没有 → 宿主直写 SQLite + +**不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。 + +可抄文件见 [`assets/examples/`](../assets/examples/):`replaces-*.action.example.json`。 + +### 示例:create + +```json +{ + "id": "item-create", + "label": "新增记录", + "description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。", + "replaces": "create", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["items"] }, + "hostEffects": { "onSuccess": ["reloadTable"] }, + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["create", "--name", "{{name}}", "--note", "{{note}}"] + } +} +``` + +### 示例:update + +```json +{ + "id": "item-update", + "label": "编辑记录", + "description": "编辑业务记录并重算派生字段。", + "replaces": "update", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["items"], + "inputMapping": { + "id": "$row.$pk", + "name": "$row.name", + "note": "$row.note" + } + }, + "hostEffects": { "onSuccess": ["reloadTable"] }, + "inputSchema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true }, + "name": { "type": "string", "title": "名称" }, + "note": { "type": "string", "title": "备注" } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"] + } +} +``` + +### 示例:delete + +```json +{ + "id": "item-delete", + "label": "删除记录", + "description": "删除业务记录并清理关联副作用。", + "replaces": "delete", + "placements": [], + "executionProfile": "sync", + "interaction": "confirm", + "confirmation": { "message": "确定删除该记录?关联数据将一并清理。" }, + "bind": { + "tables": ["items"], + "inputMapping": { "id": "$row.$pk" } + }, + "hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] }, + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "number", "title": "编号", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["delete", "--id", "{{id}}"] + } +} +``` + +### 示例:import + +```json +{ + "id": "item-import", + "label": "导入记录", + "description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。", + "replaces": "import", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["items"] }, + "hostEffects": { "onSuccess": ["reloadTable"] }, + "inputSchema": { + "type": "object", + "required": ["filePath"], + "properties": { + "filePath": { + "type": "string", + "title": "导入文件", + "description": "由宿主导入面板传入的 Excel/CSV 路径。", + "format": "file" + }, + "onError": { + "type": "string", + "title": "导入规则(出错时)", + "enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"], + "default": "跳过该行,继续导入" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "item", + "args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"] + } +} +``` + +规则: + +- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。 +- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)/ `import`(导入写库)。 +- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。 +- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增/导入」。 +- 点默认「新增/编辑/删除」时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。 +- 点默认「导入」时**固定**打开宿主导入面板;确认后若有 `replaces:import` 则调用该 Action(`interaction` 不决定是否再弹技能表单)。 +- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。 +- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头。 +- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。 +- **列元数据复用(lookup)**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉: + - 优先让属性名 = 物理列名(如 `platform_key`); + - 或 `bind.columnMapping`:`{ "platform": "platform_key" }`; + - 或字段上写 `"column": "platform_key"`。 + - 当前表没有对应列时,用字段内联 `lookup`(见下文「跨表引用:Lookup」)。 + - **不要**在 actions.json 里写死会过期的全量平台 `enum`,也**不要**让用户手填内部编号。 + +## `interaction`(点击交互方式) + +与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。 + +| 值 | 数据管理体验 | 典型场景 | +|----|--------------|----------| +| `direct` | **一点即跑**,无弹窗 | 打开目录、打开浏览器、无参只读、行内主键已映射 | +| `confirm` | 轻量确认弹窗(无参数区;文案优先 `confirmation.message`;有勾选时宿主展示「已选择」摘要;映射主键仍提交) | 处理待发送、删除前确认、有副作用但无可编辑入参 | +| `form` | 完整参数表单(有勾选时同样展示「已选择」摘要;**映射 `readOnly` 默认不占参数区**,见「选择摘要」) | 导入、选文件、查询条件、需用户改写的字段 | +| `auto` / 省略 | 宿主推断:有**可编辑**参数 → `form`;仅有 `confirmation` → `confirm`;否则 → `direct` | 旧技能兼容;新技能建议显式写出 | + +```json +{ + "id": "reveal-profile", + "label": "打开目录", + "description": "打开该账号的浏览器 Profile 目录。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": ["accounts"], + "inputMapping": { "accountId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["accountId"], + "properties": { + "accountId": { "type": "number", "title": "账号 ID", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "account", + "args": ["reveal-profile", "{{accountId}}"] + } +} +``` + +```json +{ + "id": "open-browser", + "label": "打开浏览器", + "description": "用该账号持久化 Profile 打开系统浏览器,仅供肉眼核对。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": ["accounts"], + "inputMapping": { "accountId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["accountId"], + "properties": { + "accountId": { "type": "number", "title": "账号 ID", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "account", + "args": ["open", "{{accountId}}"] + } +} +``` + +可选 `interactionByPlacement`:同一 Action 在不同入口用不同交互(例如 `row: "direct"`、`toolbar: "form"`)。值只能是 `direct`/`confirm`/`form`,不能写 `auto`。 + +**边界:** + +- **cron 配置**:仍展示可编辑参数;`confirmation.message` 可作为配置页风险提示(由宿主决定)。 +- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。 +- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。 + +## Action 按钮中文名(`label`,参考) + +`label` 是数据管理 **toolbar / row / batch** 以及技能详情直调上用户看见的短按钮名。本节是**参考契约,不是白名单**:行业专有动作(私信、审单、报关登记等)按业务起名。同类通用动作尽量用同一动词,避免每个技能一套叫法。 + +**写法:** 短中文,动词或「动词 + 对象」(约 2~8 字)。同一能力用**一个** Action 换 `placements`,不要为入口复制同义按钮。 + +以后增补:在下表加行,并在模板 `CHANGELOG.md` 记一笔。废弃说法挪到「不要叫」,不要直接删行。 + +### 宿主已有,技能不要再做一套同义按钮 + +数据管理宿主已提供:**查看 / 编辑 / 删除 / 新增 / 导入 / 导出 / 刷新**。 + +技能要用 CLI 接管增删改导入时用 `replaces`(见上文),**不要**再把同义按钮挂到 `placements: ["row"]` 或 `toolbar`(例如「编辑平台」「删除 Key」与底下宿主「编辑」「删除」叠两套)。用户仍点宿主那几个字。 + +### 建议动词(有则用之) + +| 类型 | 建议 `label` | 不要叫 | 说明 | +|------|----------------|--------|------| +| 基础设施 | 运行检查、查看版本、查看配置位置、初始化数据 | 健康检查、版本信息、配置路径、初始化数据库 | 多挂 `skill-detail` | +| 打开 | 打开浏览器、打开目录、打开文件 | 打开文件位置、在资源管理器中打开、用浏览器打开 | 见下节静默成功 | +| 改库 | 新增…、编辑…、删除…、启用/停用、设置默认…、加入待…、重置为待… | 表记录用「添加」;无必要拆成「启用」「停用」两个按钮 | 对象名可带,如「新增账号」 | +| 外源入库 | 同步数据、全量同步、采集…、扫描… | 把宿主「刷新」叫成同步;把导出 CSV 叫成同步 | 默认用「同步数据」;全量才写「全量同步」 | +| 队列与探测 | 处理待…、测试 API、测试网页、抽检本条、抽检所选、批量抽检 | 行 / 勾选 / 工具栏三个入口都叫「抽检」 | 行=本条,勾选=所选,不管勾选的批量=批量 | +| 生成发布 | 生成 HTML、生成…、发布到… | 用「导出」代替宿主表导出 | 固定 id `render-html` 的 label 用「生成 HTML」 | + +### 打开类:一点即开,成功不提示 + +打开目录 / 打开浏览器 / 打开文件:`interaction: "direct"`,`executionProfile` 通常 `"sync"`。 + +- **成功**:不要输出用户可见成功文案。若打印 JSON,令 `"message": ""`(宿主据此不弹「执行成功」)。操作系统已经弹出目录或浏览器,就是反馈。 +- **失败**:仍 `ERROR::`(可加 `HINT:`)。 +- **不要**用宿主「查看」表示打开目录或浏览器——「查看」是只读详情。 + +## `hoverHint`(工具栏悬停,可选) + +与 `label` / `description` / `confirmation.message` **独立**。只决定数据管理**顶栏技能按钮**鼠标悬停时显示什么。 + +- **省略 / 空白** ⇒ 不显示悬停,**不回退**灰色说明或确认黄条。 +- 短按钮名已经够懂时**不要写**,避免噪音。适合:业务黑话、会覆盖本机/远程、不点进去看不出后果。 +- 悬停文案不要写成「确认开始?」;那是点下去之后黄条的口吻。 +- 当前宿主实现为**工具栏技能按钮**;宿主内置删除/刷新/新增/导入/导出不走本字段。 +- 需宿主已支持 `hoverHint`;更旧宿主会忽略未知字段(模板 Schema 仍会校验新技能)。 + +```json +{ + "id": "kb-sync-feishu", + "label": "拉取到本机", + "description": "把飞书知识库覆盖到本机工作区。未发布的本地修改会丢失。", + "hoverHint": "将用飞书覆盖本机知识库,未发布的本地修改会丢失。", + "placements": ["toolbar"], + "executionProfile": "async", + "interaction": "confirm", + "confirmation": { "message": "将用飞书覆盖本机知识库,未发布的本地修改会丢失。确认开始?" }, + "bind": { "tables": ["kb_sources"] } +} +``` + +## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5) + +与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。 + +- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。 +- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。 +- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。 +- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。 + +### `hostEffects`(UI 副作用简写) + +```json +{ + "hostEffects": { + "onSuccess": ["reloadTable"], + "onFailure": ["clearSelection"] + } +} +``` + +| 字段 | 触发时机 | +|------|----------| +| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) | +| `onFailure` | **sync** 失败;或 **async** **启动被拒** | + +闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。 + +**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。 + +### `steps`(有序编排,推荐) + +```json +{ + "steps": [ + { "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" }, + { "when": "afterSuccess", "do": "reloadTable" }, + { "when": "afterFailure", "do": "clearSelection" } + ] +} +``` + +| `when` | 含义 | +|--------|------| +| `before` | 调用 CLI **之前** | +| `afterSuccess` | 同 `hostEffects.onSuccess` | +| `afterFailure` | 同 `hostEffects.onFailure` | + +| `do` | 说明 | +|------|------| +| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects | +| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 | +| `deleteRows` | 删指定表行;须 `table` | + +规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。 + +典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。 + +```json +{ + "id": "register-one", + "label": "登记本行", + "description": "更新当前行业务字段。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["business_records"], + "inputMapping": { "recordId": "$row.$pk" } + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], + "entrypoint": { + "type": "cli", + "command": "register", + "args": ["--id", "{{recordId}}"] + } +} +``` + +默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例: + +```json +{ + "id": "sync-records", + "label": "同步记录", + "description": "从外部来源同步到技能本地数据库,不生成导出文件。", + "placements": ["toolbar", "cron", "agent", "skill-detail"], + "executionProfile": "async", + "bind": { "tables": ["business_records"] }, + "entrypoint": { "type": "cli", "command": "sync-records", "args": [] } +} +``` + +行内示例: + +```json +{ + "id": "retry-one", + "label": "重试本行", + "description": "对当前行执行一次业务重试。", + "placements": ["row"], + "executionProfile": "async", + "bind": { + "tables": ["business_records"], + "inputMapping": { "recordId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["recordId"], + "properties": { + "recordId": { "type": "string", "title": "记录 ID", "readOnly": true } + } + }, + "entrypoint": { + "type": "cli", + "command": "retry", + "args": ["--id", "{{recordId}}"] + } +} +``` + +## 预留能力(当前不可当作已实现能力) + +| 类别 | 预留项 | 说明 | +|------|--------|------| +| action 字段 | `concurrency`、`locks` | 并发与锁控制,未稳定开放 | +| inputSchema | `array`、nested `object`、`oneOf` 等 | 复杂表单,不属于当前推荐范围 | + +`bind`、`row`、`batch` **不是**保留能力;已按匠厂宿主正式支持写入本规范。 +## 执行模型 + +宿主将 action 转为 argv,再用**共享 Python**(用户数据目录下 `python-runtime/.venv`)执行: + +```text +python {skill_root}/scripts/main.py +``` + +- `entrypoint.type` 当前只支持 `cli` +- `entrypoint.command` → CLI 子命令(须真实存在) +- `entrypoint.args` → 子命令参数(**必须为 JSON 数组**) +- `{{fieldName}}` → 来自 `inputSchema` 用户输入的简单模板替换 + +完整子命令定义见 [`scripts/cli/app.py`](../scripts/cli/app.py)。 + +### `executionProfile`(仅 sync / async) + +**心智**:多数 Action 按 **sync**(短调用、不进任务中心);只有长耗时 / RPA / 需进度控制时才标 **async**。匠厂宿主若**省略**该字段,按 **sync** 处理;模板与新技能仍要求**每个 Action 显式写出** `sync` 或 `async`,禁止发明 `auto` / `background` / `deferred`,也禁止赌省略行为。 + +| 值 | 宿主行为 | 任务中心 | +|----|----------|----------| +| `sync`(或省略时的宿主缺省) | 调用方等待结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 不进 | +| `async`(须显式声明) | 宿主创建后台 Job,立即返回 `jobId`;用 `emit` / `checkpoint` / `finish` 报告进度与终态 | **进入** | + +合法组合(全部允许):`sync|async` × `toolbar|row|batch|cron|agent|skill-detail`。 + +经验建议(**不是** placement 硬限制):耗时不可预测、需要暂停/停止/进度展示的任务,用 `async`;其余默认写 `sync`。 + +直接执行短 CLI 时,终端自然等待进程结束。宿主通过 Action 调用同一 CLI 时,再按 `executionProfile` 决定同步等待还是建 Job。技能**不得**自行实现第二套任务中心或后台队列。 + +### CLI 输出契约 + +技能 CLI 必须: + +- 使用稳定的退出码 +- 成功时输出可解析的结构化结果 +- **例外(打开类)**:打开目录 / 打开浏览器 / 打开文件成功时不要输出用户可见成功文案;若打印 JSON,令 `"message": ""`。失败仍走下一款错误约定 +- 失败时输出稳定错误信息 +- 对可预期错误提供稳定错误码 +- 对用户可处理的问题提供 `HINT` +- 不依赖 Agent 连续对话才能完成 +- 不自行实现宿主任务中心 +- 不在 CLI 中硬编码宿主安装目录 +- 不直接依赖某个具体 UI 页面 + +建议错误输出格式: + +```text +ERROR:: +HINT: +``` + +## placements(入口位置) + +| 值 | 含义 | +|----|------| +| `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) | +| `row` | 数据管理**行级入口**(建议 `bind.tables` + `inputMapping`;表需有主键)。技能声明后,由宿主呈现为操作列链接、**行右键菜单**和/或行内「⋯」等壳;**不**新增 `row-context-menu` 等 placement | +| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) | +| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) | +| `agent` | Agent 可直接调用(`run_skill_action`) | +| `cron` | 定时任务「技能直调」 | + +### 用户可见性与教程准确性 + +`placements` 决定 Action **可以**出现在哪;教程只能写用户在宿主里**实际能点到**的路径。完整用户文档契约见 [`../development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md)。 + +| 规则 | 说明 | +|------|------| +| **skill-detail ≠ 一定可见** | 声明 `skill-detail` 不保证详情页渲染出按钮;当前宿主详情常只有说明/教程/演示/更新日志四 Tab。**禁止**把「技能详情 → 点某某」写成用户主路径或教程唯一入口 | +| **准备 / 载入 / 导入 / 重置** | 若技能以数据管理为主路径,此类能力优先 `toolbar` + 非空 `bind.tables`(空表也可点的准备动作尤其如此);可同时挂 `agent`;`skill-detail` 仅作可选兼容 | +| **写入后刷新** | 会写入业务表的 Action 建议 `steps: [{ "when": "afterSuccess", "do": "refreshCatalog" }]`,避免用户点完表不更新 | +| **教程按钮名** | 必须等于本文件 `label`;对照表与数据管理顶栏/行内文案一致 | +| **README vs 教程** | 说明 Tab 不写分步;操作步骤只写 `TUTORIAL.md` | + +### 行级入口 `row`(宿主呈现契约) + +- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。 +- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。 +- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。 +- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。 +- **禁止**:为右键单独发明 placement、菜单注册表或第二套 Action 列表。 + +### 数据管理技能按钮顺序(宿主正式契约) + +宿主**没有**单独的 `sortOrder` / `uiOrder` 字段。同一 `placement`(`toolbar` / `row` / `batch`)下,技能按钮的展示顺序 = `actions` 数组中**匹配该项 placement(且通过 `bind.tables` 过滤)的相对先后**。 + +规则: + +1. 想调整顺序 → **调整 `actions` 数组先后**(不必改 `id` / `label`)。 +2. 无关 placement 的 Action(如仅 `skill-detail` 的 `health`)夹在中间**不影响** toolbar/row 的相对顺序;宿主按匹配子集保序渲染。 +3. 同一 Action 可同时挂多个 placement;它在各入口的位置,分别是该入口匹配子集里的相对位置。 +4. **宿主内置按钮不参与本契约、位置固定**: + - 表顶栏右侧:删除(有勾选时)/ 刷新 / 新增 / 导入 / 导出 —— 技能 toolbar/batch 按钮在左侧,不能与右侧内置按钮混排。 + - 行级:技能 `row` 项按本顺序出现在操作列 / 行右键(及可选「⋯」)中;宿主「查看」→(非只读)「编辑」→「删除」为内置项,位置由宿主固定(操作列常在技能项之后;右键中可与技能项分组)。 +5. **表顶栏全部直接展示,无「更多操作」溢出菜单**:匹配到的 toolbar/batch 技能按钮一律渲染(顺序仍按本契约)。**行级**不以「顶栏溢出菜单」模型截断;行右键是 `row` 的正式呈现壳之一,不是 toolbar 式「更多操作」截断。 +6. **表顶栏宽窄布局(宿主行为,技能无需配置)**: + - 够宽:左右各横排一行(左技能按钮 + 右宿主工具)。 + - 不够宽:保持左右分栏,栏内按钮上下竖排(左栏技能、右栏刷新/导出等)。 + +禁止: + +- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。 +- 臆造 `tooltip` / `title` 等字段当悬停(用可选 `hoverHint`;不要把 `confirmation.message` 复用成悬停)。 +- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。 +- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。 +- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。 +- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。 + +### Action 类型 × placements(常见示例,不是限制) + +下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。 + +| Action 类型 | 常见 executionProfile | 常见 placements | confirmation | +|-------------|----------------------|-----------------|--------------| +| health / version / config-path | sync | skill-detail, agent | 无 | +| 统计 / 只读查询 | sync | skill-detail, agent | 无 | +| 数据同步(外源 → 本地库) | sync 或 async | toolbar + cron + agent + skill-detail(按需) | 可选 | +| 单行重试 / 行内处理 | sync 或 async | row(可加 agent) | 可选 | +| 批量处理勾选行 | sync 或 async | batch(可加 toolbar/cron) | 可选 | +| 导入 / 重置 | sync 或 async | **优先** toolbar(须 `bind.tables`)+ agent;skill-detail 仅可选 | 可选 | +| 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** | +| 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** | +## 同步数据 vs 导出当前表 vs 特殊业务报告 vs HTML 页面 + +不要混用「导出」一词: + +| 能力 | 语义 | 谁负责 | +|------|------|--------| +| **同步数据**(sync / refresh / collect) | 外部来源 → 技能本地数据库 | 技能 Action / domain service | +| **导出当前数据表** | 技能本地数据库 → CSV/Excel 等 | 数据管理页**宿主统一导出** | +| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的 **xlsx/csv/json 等文件** | 仅当系统导出无法满足时,才提供独立 `export-*` Action;文件落 `output/` | +| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 技能写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) | + +同步型 Action 默认:读外部数据、写技能自己的库、**不**生成 CSV/Excel/JSON 文件、不重复实现宿主表导出。兼容旧 export 命令若需要刷新数据,必须调用同一个 sync 业务内核。通用同步语义见 [`SCHEMA.md`](SCHEMA.md) 与 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 + +## HTML 页面报告(数据管理预览) + +> 一句话:**技能负责生成并覆盖 `html/*.html`;宿主负责发现、预览、用 `render-html` 触发重新生成。** + +### 何时用 + +- 需要**可浏览的整页报告**(KPI + 表格 + 自带样式),而不是再导出一张表 +- 与 `export-*` / 宿主表导出**并列**:HTML 走 `html/`;文件型特殊报告仍走 `output/` + `export-*` + +### 目录与发现(宿主契约) + +| 项 | 约定 | +|----|------| +| 路径 | `{skill_data}/html/*.html`,**只扫一层**(子目录忽略) | +| helper | `util.runtime_paths.get_html_dir()`;写入用 `service.html_page_service.write_html_page` | +| 侧栏名 | 取文件内 ``(缺省则用文件名) | +| 预览 | 宿主 iframe 预览;右键:重新生成 / 浏览器打开 / 刷新 | +| 模板资源 | 建议放 `assets/html/*.html`(安装包内),渲染后**写出**到数据目录 `html/` | + +### Action:`render-html`(固定 id) + +宿主「重新生成」**硬编码**查找 Action id `render-html`(见 `service.html_page_service.HOST_HTML_RENDER_ACTION_ID`)。技能未提供该 id → 提示「该技能未提供生成 HTML」。 + +推荐形态: + +| 字段 | 建议 | +|------|------| +| `id` | **必须** `render-html` | +| `executionProfile` | `sync` | +| `interaction` | `direct` | +| `placements` | `toolbar` + `skill-detail`(配合表顶栏与 HTML 右键);可选 `agent` | +| `bind.tables` | 数据**来源表**(含 `toolbar` 时必填);宿主从 HTML 页重新生成时会用 bind 补齐跑表上下文 | +| `steps` | `afterSuccess` → **`refreshCatalog`**(重扫 `htmlPages`;不要只 `reloadTable`) | +| `entrypoint` | CLI 子命令(如 `render-html`)调用同一写入内核 | + +完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)(已含数据管理 iframe 预览的布局基线;复制后可按需扩展 `{{BODY}}` 内结构)。 + +### 宿主预览布局 + +数据管理在 **iframe** 内预览 `html/*.html`;宿主 iframe 已占满右侧预览区,**技能页不应再人为收窄正文**(尤其数据管理窗口通常较窄)。 + +| 级别 | 约定 | +|------|------| +| **必须** | 页面横向**铺满** iframe 可用宽度:`main { width: 100%; min-width: 0; }` | +| **必须** | **禁止** `main`(或等价正文容器)使用 `max-width: Npx` 且**无** `margin: 0 auto`(宽屏会左对齐留白;窄宿主窗浪费横向空间) | +| **必须** | `html` / `body` 设 `overflow-x: hidden`;滚动以**纵向**为主,避免横向滚动条 | +| **必须** | 写入 `{{BODY}}` 的业务数据经 `html.escape`(或等价)转义,防 XSS | +| **应当** | `header` 与 `main` 水平内边距一致(如共用 CSS 变量或 `clamp()`) | +| **应当** | 多列表格:`width: 100%` + 单元格 `word-break` / `overflow-wrap`,长文本在格内换行 | +| **应当** | KPI / 卡片网格用 `repeat(auto-fit, minmax(…, 1fr))` 等流体列,勿死写固定列数 | +| **禁止** | 在 `<td>` 上用 `max-width` 限制「说明列」等业务列(易导致列宽异常或横向溢出) | +| **禁止** | 依赖外链 CSS/JS 或 `networkidle` 式加载(iframe `sandbox` 仅 `allow-scripts`;页面应自包含) | + +长文阅读型报告若确需版心,可对整个 `main` 使用 `max-width` **且** `margin: 0 auto`,并令 `header` 与 `main` 同宽;**数据看板类**默认不设 `max-width`。 + +### 实现要点 + +1. 用 `load_html_template` + `render_html_placeholders` + `write_html_page`(原子 `*.tmp` + `os.replace`)覆盖同名文件;**不要**每次另存带时间戳的副本(除非产品明确要求历史版本)。 +2. 页头时间建议用**写出时刻**(「报告生成于」),不要误用业务表里过期的 `generated_at` 冒充「刚重新生成」。 +3. 可选:数据重算成功后调用静默写入(吞掉错误),保持侧栏报告与库一致。 +4. 权限:按业务决定谁能生成(管理员 / 角色);在 CLI/`cmd_render_html` 内强制校验。 +5. CHANGELOG / 用户说明:模板或样式升级后,提醒「请在数据管理对报告执行一次重新生成」。 + +### 验收(复制后自测) + +- [ ] `html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常 +- [ ] 预览区**横向铺满**、**无横向滚动条**;拖窄侧栏后内容自适应(见上节「宿主预览布局」) +- [ ] 表顶栏(若声明 toolbar)或报告右键「重新生成」能跑通;成功后预览刷新 +- [ ] 未登录/无权限用户被拒绝,且有可读错误 +- [ ] 成功路径带 `refreshCatalog`(或等价让侧栏/预览看到更新) + +## inputSchema 与参数表单 + +### 跨表引用:Lookup(宿主已支持;勿让用户填编号) + +当 Action 参数要引用**同库另一张业务表**的一行(话术模板、平台、账号等)时,必须用 Lookup 下拉,**禁止**做成「请填写编号」的数字框(用户记不住内部 id,体验差)。 + +| 场景 | 怎么配 | 说明 | +|------|--------|------| +| 当前表已有同含义列,且列上已是 CRUD lookup | `column` / `bind.columnMapping`,或属性名 = 列名 | toolbar/batch 弹窗与 create/update 抽屉都会复用该列已解析的选项 | +| 当前表没有该列(如在「潜客」表上选「话术模板」) | `display_type: "lookup"` + 字段内联 `lookup` 对象 | 打开表单时宿主按当前技能库解析选项 | + +内联 `lookup` 与列元数据 `options_json` **同构**(见 [`SCHEMA.md`](SCHEMA.md)「跨表引用 lookup」): + +```json +{ + "templateId": { + "type": "number", + "title": "话术模板", + "description": "仅在正文留空时生效;选 -- 表示自动挑选。", + "display_type": "lookup", + "lookup": { + "ref_table": "message_templates", + "value_column": "id", + "label_column": "name", + "extra_column": "channel", + "filter_column": "is_active", + "filter_value": 1 + } + }, + "engineIds": { + "type": "string", + "title": "监测引擎", + "description": "可多选;提交为逗号分隔编号。", + "display_type": "lookup", + "lookup": { + "ref_table": "engines", + "value_column": "id", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1, + "multiple": true + } + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `ref_table` | 是 | 同库被引用表 | +| `value_column` | 是 | 提交给 CLI 的值(业务 id / 编号) | +| `label_column` | 是 | 主文案列 | +| `extra_column` | 否 | 第三展示列(任意列名,不限「备注」) | +| `filter_column` / `filter_value` | 否 | 等值过滤 | +| `multiple` | 否 | `true` = 多选;省略/`false` = 单选(默认) | + +**下拉/选项展示(宿主统一,技能只配列):** 单选为 `{value} · {label}`;有非空 `extra_column` 时为 `{value} · {label}({extra})`。例:`2 · 通用私信-留资`、`2 · 通用私信-留资(dm)`。 + +**多选存值与模板:** Action / CRUD 存 **`TEXT` 逗号分隔 id**(如 `1,2,5`);`{{engineIds}}` 原样替换进 CLI。多选字段在 `inputSchema` 里用 `type: "string"`(逗号串),不要用尚未稳定的 JSON array 当业务控件。 + +**多选控件(宿主策略):** 选项少(建议 ≤6)用勾选列表;选项多用字段内 Chip + 可搜索下拉(不是再开业务弹窗)。 + +**可选「自动」(单选常见):** 字段不要放进 `required`;不要用 `minimum: 1` 卡死留空。宿主会省略空的可选数字参数,避免「提交的数据无效」。 + +**反模式(勿做):** + +- 标题写成「某某编号」,说明里教用户去左侧表抄 id +- 用静态 `enum` 罗列会增长/变更的业务行(会过期) +- 在技能里自己拼假 label 列代替宿主统一格式(难维护) +- 另造 `lookup_multi` / 第二套 display_type(应只加 `multiple: true`) + +形状参考:[`assets/examples/lookup-action.example.json`](../assets/examples/lookup-action.example.json)。 + +### 表单字段类型对照(须与 CRUD 对称) + +Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件语义**。CRUD 侧配置见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」;本表只写 Action 怎么配。 + +| 控件语义 | Action `inputSchema` 怎么写 | CRUD 怎么写(对照) | +|----------|----------------------------|---------------------| +| 单行文本 | `type: string` | `display_type: text` 或缺省 | +| 多行文本 | `display_type` 或 `format`: `textarea` | `display_type: textarea` | +| JSON 长文本 | `type: object`/`array`(以 JSON 字符串编辑)或 `textarea` | `display_type: json` / `textarea` | +| 数字 | `type: number` | `display_type: number` 或 INT/REAL/NUMERIC | +| 开关 | `type: boolean` | `display_type: boolean` | +| 枚举下拉 | `enum: [...]` | `options_json` 数组 | +| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(可选 `multiple`),或绑列 | 同左;多选存 `TEXT` 逗号分隔 id | +| 密码 | `sensitive: true` | 列 `sensitive` | +| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` | +| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** | +| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` | +| Email(薄层) | `display_type` 或 `format`: `email` | `display_type: email` | +| **本地文件路径** | `display_type`/`format`: **`file`**;可选 **`pathRoot`** | CRUD 一般不用;见下节 | +| **本地目录路径** | `display_type`/`format`: **`directory`**;可选 **`pathRoot`** | CRUD 一般不用;见下节 | +| 说明灰字 | `description` | `_jiangchang_columns.description` | +| 表格横幅 / 列头「?」 | (无;Action 不用) | `_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help` | +| 默认值 | `default` | SQLite 列 `DEFAULT` | +| 只读 | `readOnly: true` | 列 `editable: 0` / 宿主只读规则 | +| **条件显隐** | **`visibleWhen`**(见下节) | **本版 CRUD 无对称**;仅 Action / 定时任务参数表单 | +| **列筛选「值」** | **无对称**(Action 无表格筛选) | 见 [`SCHEMA.md`](SCHEMA.md)「数据管理:列筛选与全局搜索」:枚举 / boolean / lookup 在筛选栏用**下拉 Select**(非开关、非分段单选);存库仍用 value,展示与搜索认 label | + +**本版不做(勿宣传):** 浏览器内文件上传控件、复杂 `oneOf` / nested object 专用表单;`visibleWhen` 多条件 AND/OR 树、跨 Action 依赖、CRUD 新增/编辑对称显隐;Action 参数表单的列筛选对称。 +(**Lookup 多选**已支持:同一 `lookup` + `multiple: true`,见上文;不是任意标签数组。) +(**本地路径选择**已支持:`file` / `directory` + 可选 `pathRoot`,见下节;提交值仍是本机绝对路径字符串,不是 multipart 上传。) + +日期时间约定: + +- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致) +- `date` 只表示日历日,值为 `YYYY-MM-DD` +- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单 + +### 本地路径选择与 `pathRoot` + +用于数据管理 / 定时任务 **Action 参数表单**里选本机文件或目录(原生对话框)。与宿主工具栏内置「导入」面板(默认打开本技能 `input/`,见 [`../development/DATA_PATHS.md`](../development/DATA_PATHS.md))是**两条 UI 路径**,不要混为一谈。 + +| 字段 | 含义 | +|------|------| +| `display_type` / `format`: `file` \| `directory` | 渲染「选文件 / 选文件夹」控件;提交值为本机**绝对路径**字符串 | +| **`pathRoot`(可选)** | 对话框**起始目录**,相对**当前技能数据根** `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` | + +宿主行为(匠厂已支持): + +| `pathRoot` | 打开对话框时 | +|------------|--------------| +| 省略 / 空 | `{skill_data}/input` | +| `input/库存数据` 等 | `{skill_data}/` + 该相对路径 | +| `output` / `output/…` | 落在技能 `output` 下 | +| 裸 `库存数据` | 视为 `input/库存数据` | +| 绝对路径、盘符、`..` | 非法 → 回退 `{skill_data}/input` | + +硬规则: + +1. **禁止**写用户 ID、盘符、绝对路径;用户与数据根由宿主按当前登录解析。 +2. **`description` 只给人看**,宿主**不会**解析文案来决定起始目录;要默认进子目录必须写 `pathRoot`。 +3. Agent / CLI **忽略** `pathRoot`(仍直接传路径字符串)。 +4. 输入框**已有路径**时,宿主优先用已有值作为起始(便于再次浏览)。 + +```json +{ + "id": "refresh-inventory", + "label": "刷新库存数据", + "description": "从输入目录选择 JSON 快照并更新库存。", + "placements": ["toolbar", "agent"], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["sku_inventory"] }, + "inputSchema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "title": "库存数据文件", + "description": "请选择 input/库存数据/ 下的 JSON。", + "format": "file", + "display_type": "file", + "pathRoot": "input/库存数据" + } + } + }, + "entrypoint": { + "type": "cli", + "command": "refresh-inventory", + "args": ["--file", "{{file}}"] + } +} +``` + +形状参考:[`assets/examples/path-root-file-action.example.json`](../assets/examples/path-root-file-action.example.json)。 + +### 当前宿主稳定支持(摘要) + +- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用) +- `enum` / `default` / `required` / `sensitive` / `readOnly` / 可选 `revealInForm` +- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`** +- 可选 **`pathRoot`**(仅 `file` / `directory`):相对技能数据根的起始目录;省略默认 `{skill_data}/input` +- 字段内联 `lookup`(与 CRUD `options_json` lookup 对象同构;可选 `extra_column`、`multiple`) +- 属性级 `description`(表单标签下方灰字说明) +- 简单模板替换(`{{fieldName}}`) +- 可选空数字字段提交时由宿主省略,避免 `""` 触发 number 校验失败 +- **字段条件显隐 `visibleWhen`**(可选):依赖同表单其它字段值决定是否展示;隐藏字段不进提交、不校验 `required` +- **选择摘要下的映射只读折叠**:`form` +「已选择」时,`inputMapping` 映射且 `readOnly` 的字段默认不渲染(仍提交);`revealInForm: true` 可强制显示;**勿**用 `visibleWhen` 代替 + +### 字段条件显隐(`visibleWhen`) + +用于「先选模式,再出对应参数」类表单。写在 `inputSchema.properties.<field>` 上(**不是** action 顶层字段)。 + +> **消歧义:** 本文的 `visibleWhen` = **Action 参数表单控件显隐**。 +> [`POLICY.md`](POLICY.md) L3 预留的 `visibleWhen` = **行态/权限策略**(未冻结),**不是**同一能力。勿混用、勿互相替代。 + +```json +"keyword": { + "type": "string", + "title": "关键词", + "visibleWhen": { "field": "collectMode", "equals": "按关键词采集" } +}, +"authorUrl": { + "type": "string", + "title": "作者主页链接", + "visibleWhen": { "collectMode": "按作者主页采集" } +} +``` + +支持形态: + +| 写法 | 含义 | +|------|------| +| `{ "field": "x", "equals": "a" }` | `x === a` 时显示 | +| `{ "field": "x", "in": ["a", "b"] }` | `x` 属于集合时显示 | +| `{ "x": "a" }` / `{ "x": ["a","b"] }` | 单键简写,同上 | + +宿主行为(需已支持该字段的匠厂宿主;更旧版本会忽略未知字段 → 参数平铺全显): + +- 数据管理 / 定时任务参数表单按当前值求值并隐藏 +- 若依赖字段自身因 `visibleWhen` 隐藏,则下游字段一并隐藏(避免切模式后残留) +- 提交与服务端 `validateActionInput` 会剥离隐藏字段;隐藏字段不校验 `required` +- 无 `visibleWhen` 的字段保持始终可见(兼容旧技能) + +形状参考:[`assets/examples/visible-when-action.example.json`](../assets/examples/visible-when-action.example.json)。 + +互斥模式**优先**用 `visibleWhen`,不要只靠 `description` 灰字写「仅某某时填写」。 + +**与数据管理「新增 / 编辑」表单的分工(勿混用配置入口):** + +| 表单 | 短标题 | 说明灰字 | 默认值 | 控件类型 | +|------|--------|----------|--------|----------| +| Skill Action 弹窗 | `inputSchema.title` | `inputSchema.description` | `inputSchema.default` | `type` + `display_type`/`format` | +| 数据管理新增/编辑 | `_jiangchang_columns.display_name` | `_jiangchang_columns.description` | 物理列 SQLite `DEFAULT` | `display_type` + 物理类型推断 | + +数据管理**表格**上方说明与列头「?」用 `_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help`(空则不渲染),**不要**用 `description`。见 [`SCHEMA.md`](SCHEMA.md)「表格说明 `grid_help`」。 + +CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」「新增/编辑表单:字段说明与默认值」。同一业务含义两边文案与控件应对齐,但分别配置。 + +示例: + +```json +{ + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "title": "查询条件", + "description": "支持关键词或完整业务编号" + }, + "limit": { + "type": "number", + "title": "返回数量", + "description": "最多返回多少条;填 0 表示不限制(若业务支持)", + "default": 50 + }, + "dueDate": { + "type": "string", + "title": "截止日期", + "display_type": "date", + "description": "日历日,格式 YYYY-MM-DD" + }, + "publishAt": { + "type": "number", + "title": "发布时间", + "display_type": "datetime_unix_seconds", + "description": "提交值为 Unix 秒" + }, + "homepage": { + "type": "string", + "title": "主页链接", + "display_type": "url" + } + } + } +} +``` + +批量 pick 参数约定(有队列表时): + +```json +{ + "pickCount": { + "type": "number", + "title": "处理数量", + "description": "从队列选取「可操作」状态记录的条数(具体状态名由本技能定义,见 WORK_ITEM_STATUS)" + } +} +``` + +默认**不要**把失败/成功/跳过混进该 pick;重做靠人工改回可操作态。详见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。 + +若还需要默认篇数,可在同一 `properties` 里为 `pickCount` 增加 `"default": 1`、`"minimum": 1`。 + +约定: + +- `boolean` 在宿主中渲染为开关 +- `enum` 在宿主中渲染为选择框 +- `type: number` 渲染为数字输入 +- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交 +- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关) +- `required` 字段在提交前校验 +- `sensitive` 字段不得出现在低信任入口 +- `readOnly: true`:数据管理表单中标记为只读;**有「已选择」且该字段来自 `inputMapping`(或无 mapping 时的约定 id 字段)时,宿主默认不渲染该控件,预设值仍随提交传入**(与 `confirm` 无参数区对齐信息架构)。无「已选择」时仍只读展示。需要强制露出时加 `revealInForm: true`。**不要**用 `visibleWhen` 藏映射主键(会不提交)。 +- `placements` 含 `batch` 的动作在数据管理表顶常驻,无勾选时禁用(与表删除一致) +- 需要确认的副作用操作使用 `confirmation.message` +- 短名看不懂或会覆盖数据时,用独立的 `hoverHint`;不要把确认黄条复用成悬停 +- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明 +- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null` +- **不要**在当前模板中宣传文件上传、任意 JSON 多选数组、`oneOf`、复杂 nested object 等尚未稳定支持的能力;Lookup 多选请用 `lookup.multiple` +## 风险与入口配置 + +- 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent` +- 低风险、常用动作才考虑放 `toolbar`(须 `bind.tables`;executionProfile 按耗时自选) +- 会修改本地数据、访问外部系统、执行长时间任务的动作必须谨慎配置 +- 有副作用的动作应添加 `confirmation.message` +- 高风险动作不应默认出现在 `toolbar` +- 含 `sensitive` 参数的 action 不应放入 `toolbar` 或 `cron`,除非有明确安全设计 +- 不要为了「按钮多」而暴露所有 CLI 子命令 +- 一个 CLI 命令可以拆成多个语义清晰的 action,但每个 action 必须有明确语义,并复用同一业务内核 +- **Agent 必须用 `run_skill_action`,禁止 `exec` 直跑长任务 CLI**(见 `SKILL_ACTION_RUNTIME.md`) +- **禁止**按 `source=toolbar/cron/agent/skill-detail` 在业务代码中分叉业务行为 + +## 模板示例 action 一览 + +| id | CLI | executionProfile | placements | 说明 | +|----|-----|------------------|------------|------| +| `health` | `health` | sync | skill-detail, agent | 运行环境检查 | +| `version` | `version` | sync | skill-detail | 版本 JSON | +| `config-path` | `config-path` | sync | skill-detail | 配置路径 JSON | + +业务技能复制后,按需增加例如: + +| 模式 | id 示例 | executionProfile | placements | bind | +|------|---------|------------------|------------|------| +| 数据同步 | `sync-records` | async 或 sync | toolbar, cron, agent, skill-detail | 必填 tables | +| 单条 RPA | `your-run` | async(建议) | agent, skill-detail | 按需 | +| 队列 pick | `your-run-pick` | async(建议) | toolbar, cron, agent | 有 toolbar 则必填 | +| 统计 | `your-stats` | sync | skill-detail, agent | 无 | + +不需要宿主入口时可删除整个 `actions.json`(无 RPA 长任务时)。 + +## 与宿主 API(参考) + +| 宿主 API | 用途 | +|----------|------| +| `GET /api/skill-actions/{skillSlug}` | 读取 manifest | +| `POST /api/skill-actions/run` | `{ skillSlug, actionId, input?, source? }` | +| `GET /api/skill-actions/jobs` | 任务中心 | + +具体路径与字段以宿主实现为准;技能侧只需保证 manifest 与 CLI 契约稳定。 diff --git a/references/CELL_PRESENTATION.md b/references/CELL_PRESENTATION.md new file mode 100644 index 0000000..a5c9fca --- /dev/null +++ b/references/CELL_PRESENTATION.md @@ -0,0 +1,210 @@ +# 数据管理单元格呈现(Cell Presentation)契约(v1.0) + +> **状态:正式契约 v1.0(`schemaVersion` 1)。** +> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。 +> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。 +> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。 + +相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。 + +--- + +## 0. 一句话 + +技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。 +用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。 +**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。 + +--- + +## 0.1 能力档位 + +| 档位 | 技能行为 | 宿主行为 | +|------|----------|----------| +| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 | +| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 | + +多数技能保持 **P0**。需要视觉强调时再启用。 + +--- + +## 1. 职责边界 + +| 角色 | 负责 | 不负责 | +|------|------|--------| +| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 | +| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 | + +**派生状态(重要):** 呈现是计算结果,不是粘性开关。 +用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。 + +--- + +## 2. 文档形状(冻) + +技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。 + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "table": "business_records", + "rowKey": "id", + "generatedAt": "2026-08-10T06:00:00Z", + "cells": [ + { + "rowId": "42", + "column": "qty", + "tone": "danger", + "message": "库存低于安全线", + "alert": true + }, + { + "rowId": "42", + "column": "status", + "tone": "warning", + "message": "待审核" + } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `schemaVersion` | 是 | 固定 `1` | +| `skill` | 是 | 与 slug 一致 | +| `table` | 是 | 当前文档作用的业务表 | +| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) | +| `generatedAt` | 否 | 排障用 | +| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** | + +### 2.1 `cells[]` 项 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `rowId` | 是 | `rowKey` 列值的字符串形式 | +| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 | +| `tone` | 是 | 见 §3 | +| `message` | 否 | 短说明(≤500);悬停 / 无障碍 | +| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 | + +同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。 +`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。 + +--- + +## 3. `tone` 闭集(冻) + +| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) | +|------|-------------------|-------------------------------| +| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) | +| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) | +| `info` | 提示性标记 | 轻强调(如浅蓝) | +| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 | +| `muted` | 弱化 / 次要 | 灰底或灰字 | + +**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。 +以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。 + +「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。 + +--- + +## 4. 推荐持久化(给宿主实现) + +v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列): + +```sql +CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + table_name TEXT NOT NULL, + row_key TEXT NOT NULL, + row_id TEXT NOT NULL, + column_name TEXT NOT NULL, + tone TEXT NOT NULL, + message TEXT, + alert INTEGER NOT NULL DEFAULT 0, + generated_at TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE (table_name, row_id, column_name) +); +``` + +约定: + +- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。 +- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。 +- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。 + +未创建该旁路表 = P0,宿主忽略。 + +--- + +## 5. 在 `actions.json` 中启用(可选,推荐) + +与 `policy` 类似,顶层可选: + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "cellPresentation": { + "enabled": true + }, + "actions": [] +} +``` + +| 字段 | 说明 | +|------|------| +| `cellPresentation` | 省略 = P0 | +| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 | + +v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。 + +--- + +## 6. 何时重算(技能必须遵守) + +至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现: + +1. 全量/增量把外部数据写入本地投影之后(如同步成功) +2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等) +3. (可选)用户显式「刷新呈现」类 Action + +进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。 +提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。 + +--- + +## 7. 与其它契约的关系 + +| 契约 | 关系 | +|------|------| +| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 | +| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 | +| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 | +| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 | + +--- + +## 8. 反模式 + +- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案 +- 技能下发 CSS / 内联颜色 +- 只在首次超时时打标、填完不重算导致红字残留 +- 宿主写死某个技能 slug 的特殊染色逻辑 +- 把 Cell Presentation 做成仅服务 SLA 的专用协议 + +--- + +## 9. 示例场景(说明用途不限) + +| 场景 | column | tone | message 例 | +|------|--------|------|------------| +| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 | +| 库存低于安全线 | `qty` | `warning` | 低于安全库存 | +| 审核驳回 | `status` | `danger` | 已驳回,请修改 | +| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 | + +完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。 diff --git a/references/CLI.md b/references/CLI.md new file mode 100644 index 0000000..af63ef6 --- /dev/null +++ b/references/CLI.md @@ -0,0 +1,88 @@ +# 通用业务技能模板 CLI 标准 + +将 `{baseDir}` 替换为技能根目录(含 `SKILL.md`、`scripts/` 的目录)。 + +## 解释器(必读) + +必须使用宿主**共享 Python runtime**(`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,已在 PATH / `VIRTUAL_ENV`)。一条命令直接跑,不要先探系统 Python。 + +```bash +python {baseDir}/scripts/main.py <command> … +``` + +| 场景 | 写法 | +|------|------| +| **推荐(Agent)** | `python {baseDir}/scripts/main.py <command> …` | +| 手工排查(环境未注入时) | `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/Scripts/python.exe`(Windows)或 `…/.venv/bin/python3`(Unix) | + +**禁止**对技能脚本使用 `uv run python …`(会创建临时环境,加载不到 `jiangchang-platform-kit` / `jiangchang_skill_core`)。 +**禁止** Agent 自行 `pip install` / `uv pip install` 往临时环境塞依赖。 + +## Agent 分流(业务技能复制后必改) + +用本表列出**本技能**哪些子命令可走短 CLI,哪些必须走 Skill Action。默认:短查询/只读写本地可用短 CLI;长耗时 / RPA / 需进度取消进**黑名单**。有 async/RPA 的技能:**黑名单不得为空**,且黑名单命令须映射到 `actions.json` 中的 async Action。 + +| 类型 | 本技能命令(自填) | Agent | +|------|--------------------|-------| +| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` | +| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` | + +细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。 + +## 最小命令 + +```bash +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py config-path +python {baseDir}/scripts/main.py version +python {baseDir}/scripts/main.py init-db +``` + +## 标准行为 + +- **任意 CLI 启动前**执行 `bootstrap_skill_config()`(`main.py` 与 `cli.app.main()` 均触发)。 +- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。 +- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。 +- **`version`**:输出 JSON(`version`、`skill`)。 +- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。 +- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。 +- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。 +- **排查顺序**(由快到慢): + 1. 任务中心 / Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl` + 2. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id`、`task_failed` 等) + 3. 业务表(宿主数据管理;有才建) + 4. RPA artifacts / `video_log` / 失败截图(开发规范见 `development/RPA.md`);未开启录屏时可能只有截图或日志 +- **录屏默认关闭**:模板 `.env.example` 为 `OPENCLAW_RECORD_VIDEO=0`;若未开启录屏,任务**可能不会**打印录屏路径。 +- 需要录屏时在用户 `.env` 或进程环境变量中设置 **`OPENCLAW_RECORD_VIDEO=1`**;开启后任务完成时 CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`),且 `finish()` 含 video 字段。 + +## config-path(配置路径) + +```json +{ + "skill": "your-skill-slug", + "env_path": "{DATA_ROOT}/{USER_ID}/your-skill-slug/.env", + "example_path": "{skill_root}/.env.example" +} +``` + +## health 标准输出(runtime diagnostics) + +`health` 委托 `jiangchang_skill_core.collect_runtime_diagnostics`,典型字段: + +- `python_executable` — 当前 Python 解释器路径(应落在共享 python-runtime) +- `platform_kit_version` / `platform_kit_min_version` / `platform_kit_version_ok` +- `jiangchang_skill_core_file` — 公共库加载来源(若来自技能目录副本会报 warning) +- `media_assets_root`、`ffmpeg_path`、`ffmpeg_available`、`background_music_mp3_count` +- `runtime_issue[warning|error]` — 非致命/致命问题列表 +- `env_path` / `env_exists` / `example_path` — 用户 `.env` 与仓库模板路径 + +## 通用业务命令骨架 + +```bash +python {baseDir}/scripts/main.py run +python {baseDir}/scripts/main.py run -t <target> -i <input_id> +``` + +| 命令 | 用途 | Agent 注意 | +|------|------|------------| +| `run` | 执行一次任务(常为长任务 / RPA) | **走 `run_skill_action`**,不要 bash poll | diff --git a/references/POLICY.md b/references/POLICY.md new file mode 100644 index 0000000..d0afea3 --- /dev/null +++ b/references/POLICY.md @@ -0,0 +1,383 @@ +# Skill Data Policy 契约(v1.0) + +> **状态:正式契约 v1.0(`schemaVersion` 1)。** +> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。 +> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(行态 `visibleWhen` 等)。 +> **注意:** L3 预留的行态 `visibleWhen` **不是** Action 参数表单的 `inputSchema.properties.*.visibleWhen`(表单控件显隐,见 [`ACTIONS.md`](ACTIONS.md)「字段条件显隐」)。二者同名不同义,勿混用。 +> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。 + +相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。 + +--- + +## 0. 能力档位(必须按此理解) + +| 档位 | 技能配置 | 宿主行为 | +|------|----------|----------| +| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 | +| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 | +| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 | +| **L3** | (预留,本契约不冻结行为) | 如**行态** `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等(≠ Action 表单 `inputSchema.visibleWhen`) | + +**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。 + +--- + +## 0.1 已冻结最佳实践(v1.0) + +| 议题 | 决定 | 技能开发者怎么做 | +|------|------|------------------| +| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` | +| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 | +| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 | +| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 | + +--- + +## 1. 启用字段(冻在 `assets/actions.json` 顶层) + +`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。 + +### 1.1 形状(L0/L1 必冻) + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "policy": { + "enabled": true, + "entrypoint": { + "type": "cli", + "command": "policy", + "args": ["--table", "{{table}}"] + }, + "appliesTo": ["data-management"] + }, + "actions": [] +} +``` + +| 字段 | 必填 | 冻结说明 | +|------|------|----------| +| `policy` | 否 | 缺省 = L0 | +| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 | +| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` | +| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` | +| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI | +| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 | + +### 1.2 占位符白名单(冻) + +| 占位符 | 含义 | 例 | +|--------|------|----| +| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` | +| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 | +| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 | + +未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。 + +### 1.3 显式不放进启用块的内容(防推翻) + +以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot): + +- 角色列表、人员表、飞书 app id、外部 IAM URL +- 具体 allowlist / scope(那是 Snapshot 的事) +- 按角色复制的 Action 定义 + +### 1.4 Action 级可选提示(不冻为运行时权威) + +允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**: + +```json +"authz": { "capability": "optional.skill.defined.string" } +``` + +运行时权威仅为 Snapshot §2 的 `actions`。 + +--- + +## 2. Snapshot 最小字段集(`policy` CLI 标准输出) + +- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。 +- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。 +- 非 0 或非合法 JSON → §6 fail-closed。 + +### 2.1 根对象(L0 不出现;L1 必冻) + +| 字段 | 必填 | 类型 | 说明 | +|------|------|------|------| +| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** | +| `skill` | 是 | `string` | 必须等于 manifest `skill` | +| `actor` | 是 | `object` | 见 §2.2 | +| `actions` | 是 | `object` | 见 §2.3 | +| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) | +| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) | +| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 | + +根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。 + +### 2.2 `actor`(L1 必冻) + +| 字段 | 必填 | 说明 | +|------|------|------| +| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 | +| `displayName` | 否 | 展示用 | +| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` | +| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 | + +### 2.3 `actions`(L1 必冻)— Skill Action 授权 + +**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。 + +```json +"actions": { + "mode": "allowlist", + "ids": ["whoami", "create-record", "register-submit"] +} +``` + +| 字段 | 必填 | 冻结取值 | +|------|------|----------| +| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` | +| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** | +| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action | + +规则: + +1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。 +2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。 +3. `ids` 中的未知 id:**忽略**(不报错)。 +4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。 +5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。 + +`replaces` 接管的默认新增/编辑/删除/导入:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮(导入按钮是否可用还受 `hostCapabilities` 含 `import` 影响)。`mode=open` 时不按 id 限制 replaces。 + +### 2.4 `hostCapabilities`(L1 可选冻) + +控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。 + +本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。 + +| 写法 | 含义 | 何时用 | +|------|------|--------| +| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 | +| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 | +| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 | + +未知字符串:忽略。**禁止**把「省略」解释成全禁。 + +--- + +## 3. L2:`data` 与 scope 语法白名单 + +### 3.1 `data` 形状 + +```json +"data": { + "tables": { + "customs_timelines": { + "accessible": true, + "columns": { + "visible": ["id", "contract_no", "status"], + "writable": ["status"] + }, + "rows": { + "mode": "scope", + "scope": { + "all": [ + { "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" } + ] + } + } + } + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 | +| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 | +| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 | +| `rows` | 否 | 见下 | + +`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。 + +### 3.2 `rows.mode`(冻) + +| 值 | 本期 | 含义 | +|----|------|------| +| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 | +| `open` | 允许 | 显式不限制行 | +| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` | + +缺省:无 `rows` = `open`。 + +### 3.3 `scope` 布尔结构(冻) + +```text +Scope = + | { "all": Predicate[] } // AND,长度 ≥ 1 + | { "any": Predicate[] } // OR,长度 ≥ 1 + | Predicate // 单个谓词的语法糖 +``` + +**禁止**:嵌套超过一层。 + +### 3.4 `Predicate` 白名单(冻) + +**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null` + +**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。 + +**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。 + +**`valueFrom` v1 仅:** `actor.userId`。 +`actor.roles` / `actor.attributes.*` **非法** → fail-closed。 + +`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。 + +### 3.5 与宿主原有过滤的关系 + +最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。 + +--- + +## 4. 宿主消费算法 + +```text +IF manifest.policy?.enabled !== true: + → L0;END + +调用 entrypoint(替换占位符),解析 Snapshot +IF 失败 / 非法 / skill 不匹配 / mode 非法: + → fail-closed(§6);END + +候选 = filter(placements, bind.tables, …) +IF mode == allowlist: 候选 ∩ ids +IF mode == open: 候选不变 + +渲染 skill 按钮 ← 候选 +渲染宿主内置 ← hostCapabilities(省略则不限制) +应用 data.tables[T](若有) +``` + +拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。 + +--- + +## 5. 身份与环境 + +| 项 | 约定 | +|----|------| +| 用户主键 | `JIANGCHANG_USER_ID` | +| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** | + +--- + +## 6. Fail-closed(启用后必冻) + +当且仅当 `policy.enabled === true`: + +| 情况 | 宿主行为 | +|------|----------| +| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 | +| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 | +| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope | +| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` | + +--- + +## 7. 安全边界 + +1. Snapshot **只**服务宿主 UI 与查询约束。 +2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。 +3. 禁止「只藏按钮」宣称为完整权限方案。 +4. Cron / Agent **不**自动套用本 Snapshot。 + +--- + +## 8. 最小示例 + +模板仓库另有可复制示例: + +- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json) +- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json) + +### 8.1 L0 + +不写 `policy` 字段。 + +### 8.2 L1 allowlist(不碰宿主内置) + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] }, + "actions": { + "mode": "allowlist", + "ids": ["whoami", "create-record", "register-submit"] + } +} +``` + +### 8.3 L1 + 限制导出 + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { "userId": "u1", "roles": ["clerk"] }, + "actions": { "mode": "allowlist", "ids": ["whoami"] }, + "hostCapabilities": ["refresh", "view"] +} +``` + +### 8.4 L1 open + L2 scope + +```json +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actor": { "userId": "u1" }, + "actions": { "mode": "open" }, + "data": { + "tables": { + "orders": { + "rows": { + "mode": "scope", + "scope": { + "all": [ + { "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" } + ] + } + } + } + } + } +} +``` + +--- + +## 9. 刻意延后 + +| 项 | 备注 | +|----|------| +| `actions.mode: denylist` | 另开 `schemaVersion` | +| `valueFrom: actor.roles` | **明确不做** | +| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做(此处指**行态**策略字段;Action 表单 `inputSchema.visibleWhen` 见 ACTIONS.md,不属本表) | +| `rows.mode: delegate` 宿主实现 | 字段名已占坑 | +| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 | + +--- + +## 10. 冻结检查清单 + +- [x] L0 缺省无歧义 +- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo` +- [x] `actions.mode` 仅 `allowlist` / `open` +- [x] `hostCapabilities` 省略 = 不限制 +- [x] scope 小白名单;`valueFrom` 仅 `actor.userId` +- [x] fail-closed;写路径技能自检 diff --git a/references/README.md b/references/README.md new file mode 100644 index 0000000..8619000 --- /dev/null +++ b/references/README.md @@ -0,0 +1,27 @@ +# Agent 参考索引 + +本目录只是 Agent **运行、编排、调用** skill 时可渐进加载的资料索引,**不是**用户市场说明。 + +**边界规则:** + +- 本文**不得**包含 YAML frontmatter,**不得**包含 `description` 字段。 +- 技能市场四 Tab 材料**必须**放在根目录:[`README.md`](../README.md)、[`TUTORIAL.md`](../TUTORIAL.md)、[`DEMO.md`](../DEMO.md)、[`CHANGELOG.md`](../CHANGELOG.md)。 +- **Agent 主读本目录**(`CLI.md` / `ACTIONS.md` / `SCHEMA.md`)与根 [`SKILL.md`](../SKILL.md)。 +- [`../development/`](../development/) 是开发者 / 编码助手深度规范,**不是**对话 Agent 的默认读盘路径。 + +| 文档 | 用途 | +|------|------| +| 根目录 [`README.md`](../README.md) | 市场说明 Tab(`readme_md`) | +| 根目录 [`TUTORIAL.md`](../TUTORIAL.md) | 市场教程 Tab(`tutorial_md`) | +| 根目录 [`DEMO.md`](../DEMO.md) | 市场演示 Tab(`demo_video_url` + `demo_md`) | +| 根目录 [`CHANGELOG.md`](../CHANGELOG.md) | 市场更新日志 Tab(版本小节 → `changelog`) | +| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 | +| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 | +| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) | +| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 | +| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) | +| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) | +| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) | +| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) | + +按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md new file mode 100644 index 0000000..186eb32 --- /dev/null +++ b/references/SCHEMA.md @@ -0,0 +1,841 @@ +# 数据存储与数据管理展示规范 + +本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。 + +**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。 + +实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。 + +## 职责边界 + +### 技能负责 + +- 定义真实 SQLite 表结构 +- 使用英文 `snake_case` 表名和字段名 +- 使用 `_jiangchang_tables` 提供表级展示元数据 +- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明) +- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里) +- 定义哪些字段可见、可搜索、可创建、可编辑 +- 定义字段展示类型和枚举选项 +- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)) +- 保证元数据与真实数据库结构一致 +- 保证元数据初始化幂等 + +### 宿主负责 + +- 读取 `_jiangchang_*` 元数据 +- 读取 SQLite `PRAGMA table_info` +- **数据管理侧栏/表列表**:当库中存在 `_jiangchang_tables` 时,**仅展示**在该表注册且 `visible=1` 的业务表;孤儿物理表(有表、无注册元数据)**不得**出现在侧栏,直接访问 schema API 应返回「表不存在」。极老库无 `_jiangchang_tables` 时,宿主可保留物理表枚举 fallback(兼容 `_schema_meta` 时代) +- 根据 schema 渲染数据管理页面 +- 根据 `editable` 推导字段是否可创建或编辑 +- 在**新增 / 编辑**表单中,若列有 `_jiangchang_columns.description`,在标签下方展示该说明(灰字;空则不显示) +- 在数据管理**表格**中,若表有 `_jiangchang_tables.grid_help` 则在表上方展示可折叠说明;若列有 `_jiangchang_columns.grid_help` 则在列头显示「?」;空/未写则不渲染。**不**把 `description` 当作表格说明 +- 打开**新增**表单时,对可创建字段用 SQLite 列 `DEFAULT`(`PRAGMA table_info.dflt_value`)预填;无 DEFAULT 则留空 +- 执行通用查询、新增、编辑、删除、导入、导出 +- 对字段名、表名、类型和权限进行最终校验 +- 不猜测技能业务含义 +- 不根据具体技能名称写特殊逻辑 +- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果 +- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则 + +## 初始化入口 + +幂等建库/迁移:`python scripts/main.py init-db`。业务仓储也可懒触发 `init_db()`,与 CLI 结果一致。 + +SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`;SQL 文件里的 `-- 注释` 也不会成为可查询结构。 +因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info` 的 `cid`)。 + +> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。 + +## 技能名称(SKILL.md) + +```yaml +--- +name: 中文业务名称 +description: 中文技能说明 +metadata: + openclaw: + slug: english-kebab-slug +--- +``` + +- `name`:用户可见的中文业务名称。 +- `description`:能力说明,不是技能名称。 +- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md)。 +- 复制模板后不得将 `your-skill-slug`、`My Skill` 等占位内容作为正式技能发布名称。 + +## 数据库路径与命名 + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db +``` + +- 数据库文件:英文 slug,例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。 +- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items`、`created_at`。 +- **禁止**用中文作为 SQLite 真实表名或字段名。 + +## 外部来源只读与本地数据可写 + +某些技能会从外部系统读取数据并写入技能自己的本地数据库。需要区分: + +```text +外部来源数据库: + 可能只读,不应被修改 + +技能本地数据库: + 可以根据业务语义允许新增、编辑或删除 +``` + +不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。 + +若创建/更新/删除/导入带有领域副作用(自动建目录、加密、级联清理、归一化字段),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete|import` 接管默认按钮。其中 **import** 时用户仍使用宿主「导入数据」面板,确认后由技能写库。详见 [`ACTIONS.md`](ACTIONS.md) 与 `assets/examples/replaces-*.action.example.json`。 + +## 表与表之间:禁止 SQLite 外键 + +业务表之间的「属于哪条父记录 / 引用哪条来源」**只存业务 id**(整数或文本标量),**禁止**在 DDL 中写 SQLite `FOREIGN KEY` / `REFERENCES`,也**不要**指望 `PRAGMA foreign_keys = ON` 替你校验或级联。 + +| 要做的事 | 正确做法 | 禁止 | +|----------|----------|------| +| 子表指向父表 | 子表列如 `case_id INTEGER NOT NULL`;写入前在 repository/service 查父表是否存在 | `FOREIGN KEY (case_id) REFERENCES parent(id)` | +| 父表记「最新一版」 | 父表列如 `latest_draft_id INTEGER NOT NULL DEFAULT 0`;由 service 更新指针 | 同上 | +| 删除 / 覆盖 / 级联清理 | repository/service 内显式 `DELETE` / `UPDATE`;需要时用 Action `steps` 的 `deleteByFk` | 依赖 DB 级联或宿主 CRUD 被 FK 拦住 | +| 数据管理里显示名称 | `_jiangchang_columns.display_type = lookup` + `options_json` 对象(见下文「跨表引用 lookup」) | 把 lookup 当成 DDL 外键 | + +**为什么:** 宿主数据管理对业务表执行通用增删改,会直接写 SQLite。一旦 DDL 带外键,删除、迁移 rename 父表、或父表 id 变更时极易出现「本地数据操作失败」且难排查。关联语义属于技能业务,必须在代码里写清。 + +**迁移注意:** 避免「`ALTER TABLE parent RENAME TO parent_old` → 建新 parent → 删 old」这类会隐性改写子表 FK 指向的套路;优先 `ADD COLUMN` + 拷列,或重建子表且**不带** FK。 + +`display_metadata_validator` 与 `validate_template_database` 会扫描业务表 DDL;含 `FOREIGN KEY` / `REFERENCES` 时报错(见 `development/CHECKLIST.md` A6-6)。 + +## 标准数据同步语义(外源 → 本地库) + +适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。 + +| 要求 | 说明 | +|------|------| +| 外部来源默认只读 | 技能不得修改外源 | +| 本地 SQLite 可事务写入 | 写入走 repository + 事务 | +| 幂等 | 同一业务键重复同步结果稳定 | +| 稳定唯一业务键 | 明确 upsert / 匹配字段 | +| 全量或增量 | 在实现与文档中显式声明 | +| 完整快照才提交 | 完整性校验通过后再 commit;调用方**必须显式提供**完整性状态(如 keyword-only 必填的 `snapshot_complete`),**禁止**默认视为完整,**禁止**靠数据条数猜测 | +| 空结果 / 读取失败保护 | 读取失败、分页不完整或不可信空结果(`snapshot_complete=False`)**不得**清库或误删;仅当完整快照**显式确认**为空(`snapshot_complete=True` 且结果为空)时,才可按全量失效策略处理旧数据 | +| 全量失效策略 | 物理删除、`removed`/`is_active` 标记、或保留历史——必须三选一并写清 | +| 部分失败 | 不得伪装成整体成功 | +| 半成品 | 事务失败不得留下可见不一致状态 | + +建议统一返回统计: + +```json +{ + "total": 100, + "inserted": 10, + "updated": 20, + "unchanged": 65, + "removed": 5 +} +``` + +### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面 + +| 能力 | 语义 | 负责方 | +|------|------|--------| +| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service | +| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** | +| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` | +| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) | + +同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。 + +## 元数据表(宿主兼容) + +宿主读取字段(与匠厂 `metadata-resolver` 一致): + +### `_jiangchang_tables` 表级标准 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 真实英文表名 | +| `display_name` | 是 | 用户可见的中文业务名称 | +| `description` | 否 | 表说明(表单/备注用途);**不**用于表格上方横幅 | +| `grid_help` | 否 | 数据管理表格上方说明;空/未写则不出横幅。与 `description` 独立,禁止互拷(见下节) | +| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 | +| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 | +| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 | +| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 | +| `default_visible_columns_json` | 否 | 数据管理**默认展示列**(见下节);空则全显 | + +#### 表级默认排序 `default_sort_json`(宿主支持) + +技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退: + +`updated_at DESC` → `created_at DESC` → 主键 DESC。 + +JSON 数组(可多字段,按顺序): + +```json +[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}] +``` + +规则: + +- `column` 必须是该表真实列名;敏感列会被宿主忽略 +- `direction` 仅 `asc` / `desc`(缺省按 `desc`) +- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认 +- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 + +示例(消息时间线): + +```python +upsert_table_metadata( + cursor, + table_name="messages", + display_name="消息", + readonly=0, + default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]', +) +``` + +#### 表级默认展示列 `default_visible_columns_json`(宿主支持) + +技能可声明该表在数据管理表格中的**默认展示列**(软默认,不是硬隐藏): + +- **已配置**:首次打开(用户尚未改过「列」)只显示名单中的列;其余列仍在「列」面板中,用户可勾回 +- **未配置 / 空 / 无效**:显示全部 schema 可见列(与今日行为一致) +- **用户改过「列」或表头「隐藏此列」后**:以本机偏好为准;「恢复默认列布局」后重新回到技能默认(若有) + +JSON 数组(物理列名;也可写 `{"column":"col"}` 对象,宿主同样接受): + +```json +["stat_type","period","stage","ops_count","timeout_ratio"] +``` + +规则: + +- 列名必须是该表真实列,且 `_jiangchang_columns.visible=1`(硬隐藏列本来就不进表格) +- 与 `visible=0` **不同**:`visible=0` = 列从数据管理 schema /「列」面板移除;本字段只影响默认勾选状态 +- 名单为空或全部无效时,宿主 fail-open:全显 +- 通过 `upsert_table_metadata(..., default_visible_columns_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 + +示例(时效统计:默认不展示编号与时间戳列): + +```python +upsert_table_metadata( + cursor, + table_name="timeliness_stats", + display_name="时效统计", + readonly=1, + default_visible_columns_json=( + '["stat_type","period","stage","ops_count",' + '"timeout_open","timeout_done","timeout_total","timeout_ratio"]' + ), +) +``` + +#### 表格说明 `grid_help`(宿主支持) + +技能可给数据管理**表格**写说明,与新增/编辑表单的 `description` **完全独立**: + +| 写在哪 | 宿主做什么 | 空/未写 | +|--------|------------|---------| +| `_jiangchang_tables.grid_help` | 表格上方可折叠横幅(首行摘要;有后续行才出展开) | 不出横幅 | +| `_jiangchang_columns.grid_help` | 列头「?」,点击查看该列说明 | 不出「?」 | + +规则: + +- **opt-in**:大多数表/列不需要写;不写则行为与今日一致 +- **禁止**把 `description` 拷进 `grid_help`,也禁止把表格规则写进 `description`(`description` 只给表单灰字) +- 多行文本时:第一行是摘要,其余行点「展开」后可见 +- 通过 `upsert_table_metadata(..., grid_help=...)` / `upsert_column_metadata(..., grid_help=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 +- 需已支持该字段的匠厂宿主;更旧版本会忽略未知列,不写则无影响 + +示例(仅在确有表格规则时写;模板默认业务表不要预填): + +```python +upsert_table_metadata( + cursor, + table_name="timeliness_records", + display_name="时效登记", + readonly=1, + grid_help="超时按环节规则判定。\n交单:预计到港日前 3 天须交单", +) +upsert_column_metadata( + cursor, + table_name="timeliness_records", + column_name="submit_date", + display_name="交单日期", + grid_help="预计到港日前 3 天须交单", + editable=0, +) +``` + +禁止: + +- 使用中文真实表名 +- 使用 `_schema_meta` 作为新的正式契约 +- 只写英文物理名而不写中文 `display_name` +- 让元数据引用不存在的表 +- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来) + +### `_jiangchang_columns` 字段级标准 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `table_name` | 是 | 所属物理表 | +| `column_name` | 是 | 真实英文字段名 | +| `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 | +| `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` | +| `grid_help` | 否 | 数据管理表头「?」;空/未写则不出。与表单 `description` 独立,禁止互拷 | +| `display_order` | 否 | 兼容字段,默认保持 `NULL` | +| `visible` | 是 | 是否进入数据管理 schema /「列」面板(`0` = **硬隐藏**,用户无法勾回;默认不展示但可勾回请用表级 `default_visible_columns_json`) | +| `searchable` | 是 | 是否参与**列筛选**与**顶栏全局搜索**(`0` = 两者均不参与);详见下节「数据管理:列筛选与全局搜索」 | +| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 | +| `display_type` | 否 | 字段渲染类型;稳定取值见下节「表单与展示:稳定字段类型」 | +| `options_json` | 否 | 枚举选项 JSON | + +这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。 + +#### 表单与展示:稳定字段类型(宿主支持,须与 Action 对称) + +数据管理「新增 / 编辑」表单与 Skill Action 参数表单应使用**同一套控件语义**(配置入口不同,见 [`ACTIONS.md`](ACTIONS.md))。新技能只使用下表;宿主须两边都实现,避免「Action 有、CRUD 没有」。 + +**本版稳定支持(须齐):** + +| `display_type`(CRUD) | 物理类型建议 | 列表展示 | 新增/编辑控件 | 存库约定 | +|------------------------|--------------|----------|---------------|----------| +| `text`(或缺省) | `TEXT` / 一般标量 | 原文 | 单行文本 | 原文 | +| `textarea` | `TEXT` | 原文(可截断) | 多行文本 | 原文 | +| `json` | `TEXT` | 原文 | 多行文本(JSON 字符串) | 原文;提交时宿主可做基础 JSON 合法性校验 | +| `number` | `INTEGER` / `REAL` / `NUMERIC` | 原文 | **数字输入**(`type=number`) | 数字;无 `display_type` 时,物理类型为 INT/REAL/NUMERIC 也应走数字控件 | +| `boolean` | `INTEGER` 0/1 或 BOOL | 开关语义展示 | 开关 | `0` / `1` | +| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` | +| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) | +| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) | +| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` | +| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 | +| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 | +| `email` | `TEXT` | 原文 | 单行输入 `type=email`(薄层;校验从宽) | 原文 | + +说明灰字与创建默认值见下一小节;与 Action 的对照写在 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。 + +**明确不在本版稳定范围(勿在新技能宣传为已支持):** + +- 文件 / 图片上传 +- 复杂 `oneOf` / nested object 表单(任意 JSON 数组当业务多选控件) +- 把业务可编辑时间存成毫秒、ISO 文本或 SQLite `datetime` 文本(审计字段规范见「标准时间字段」) + +> **Lookup 多选**已纳入本版契约(见下文 `multiple`),与「任意多选/标签数组」不是同一能力。 + +`datetime_unix_milliseconds`:仅兼容**列表展示**旧数据;**新技能不要用**;可编辑时间一律 `datetime_unix_seconds`。 + +#### 新增/编辑表单:字段说明与默认值(宿主支持) + +数据管理「新增记录 / 编辑记录」与 Skill Action 弹窗是**两套表单**;CRUD 侧约定如下(交互对齐 Action 的 `inputSchema.description` / `default`,但**配置入口不同**)。 + +| 能力 | 技能写什么 | 宿主做什么 | +|------|------------|------------| +| 字段说明 | `_jiangchang_columns.description`(可选) | 新增/编辑表单中,在标签下方以灰字展示;空则不显示 | +| 创建默认值 | 物理列 SQLite `DEFAULT`(如 `limit_n INTEGER NOT NULL DEFAULT 0`) | 打开「新增」时,对可创建字段预填该默认值;无 DEFAULT 则留空 | +| 编辑已有行 | — | **不**用 DEFAULT 覆盖已有行数据 | +| 表格列头标题 | `display_name` | 短名;不要把长说明塞进列头文字 | +| 表格列头「?」 | `_jiangchang_columns.grid_help`(可选) | 有内容才显示「?」;**不**回退 `description` | +| 表格上方说明 | `_jiangchang_tables.grid_help`(可选) | 有内容才显示横幅;**不**回退表级 `description` | + +规则与禁止: + +- **推荐**对易填错、语义特殊的可编辑字段写 `description`(示例链接、`0` 表示不限制等);非强制每个字段都有 +- **禁止**把说明硬塞进 `display_name`(如 `评论上限(0不限)`);表头会变脏,也无法与 Action 说明对齐 +- 业务语义(如「0 = 不限制」)写在 `description` 里;宿主只展示,**不**解读业务含义 +- 第一期**不**新增元数据「表单默认值」列;CRUD 默认值 = DDL `DEFAULT`,与 Action 的 `inputSchema.default` 分开配置 +- 同一业务字段若同时出现在 Action 与新增表单,**文案应对齐**(短标题 + 说明),但分别写在 `actions.json` 与 `_jiangchang_columns` / DDL +- 数字类可编辑列推荐 `display_type: "number"`(或依赖 INT/REAL/NUMERIC 推断)并在 DDL 写清 `DEFAULT`(如条数上限默认 `0`) + +示例(可编辑列种子 + DDL 默认): + +```sql +-- 物理列:创建默认 0 +comment_limit INTEGER NOT NULL DEFAULT 0 +``` + +```python +seed_column_metadata_batch( + cursor, + table_name="videos", + columns=( + { + "column_name": "video_url", + "display_name": "视频链接", + "editable": 1, + "display_type": "url", + "description": "填平台视频页链接;需包含可识别的视频路径片段。", + }, + { + "column_name": "comment_limit", + "display_name": "采集评论条数", + "editable": 1, + "display_type": "number", + "description": "最多采多少条。填 0 = 不限制;填正整数则按该数量封顶。", + }, + ), + valid_column_names=physical, +) +``` + +Action 参数表单的 `title` / `description` / `default` / `display_type` 见 [`ACTIONS.md`](ACTIONS.md);勿与本节 CRUD 配置入口混用。 + +#### 数据管理:列筛选与全局搜索(宿主支持) + +数据管理表格提供两类与 `searchable` 相关的检索能力,技能通过 `_jiangchang_columns.searchable` 声明是否参与: + +| 能力 | 用户入口 | `searchable = 1` 时 | `searchable = 0` 时 | +|------|----------|---------------------|---------------------| +| **列筛选** | 表格「筛选」→ 字段 / 操作符 / 值 | 出现在「字段」下拉,可按类型选值与操作符 | 不出现在列筛选字段列表 | +| **全局搜索** | 顶栏搜索框 | 参与 OR 模糊匹配(宿主对参与列数有上限,如最多 8 列) | 不参与全局搜索 | + +**存库 vs 展示(核心契约)** + +- **列表**:有 `options_json` 时展示 **label**(如「启用」「停用」) +- **筛选 / 查询 / 全局搜索**:比较与匹配 **value**(如 `0`/`1`、`active`、`toutiaohao`) +- 宿主负责 label ↔ value 映射;**禁止**为迁就 UI 把存库改成中文枚举,也**禁止**在 `description` 里写「筛选请填 0/1」 + +**筛选栏控件 ≠ 新增/编辑表单(勿混用)** + +列筛选「值」与表单使用同一套**类型语义**,但控件形态不同:筛选栏须与「字段」「操作符」同一行工具栏对齐(如统一 `h-9` 下拉),**枚举 / boolean / lookup 在筛选栏一律用下拉 Select**,不用新增/编辑表单里的开关或横向分段单选。表单侧仍按上表「新增/编辑控件」配置。 + +| 列类型 | 筛选「值」控件 | 常用操作符(宿主按类型裁剪) | 技能须配置 | +|--------|----------------|------------------------------|------------| +| 文本 / `url` / `email` | 文本框 | 等于 / 包含 / 开头 / 结尾 / 为空… | 通常 `searchable: 1` | +| 数字 | 数字框 | 等于 / 大于 / 小于… | `display_type: number` 或 INT/REAL | +| 枚举(`options_json` **数组**) | 下拉(展示 label) | 等于 / 不等于 / 为空 | **`options_json` 必填** + `searchable: 1` | +| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label | +| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 | +| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id | +| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` | +| 敏感列 | **不可筛** | — | 宿主强制排除 | + +**全局搜索扩展(用户可搜中文)** + +宿主在 `searchable = 1` 的列上除原文 `LIKE` 外,还应: + +- 有 `options_json` 枚举:**label 或 value** 均可命中 +- `boolean` 无 options:认「是/否」等常见别名 +- `lookup` 多选:按逗号分隔 id 的 **token 边界** 匹配;选项 label 亦可参与 +- `datetime_unix_seconds`:搜 `YYYY-MM-DD` 形态可匹配当天 Unix 秒区间 + +**技能配置建议** + +推荐: + +- 用户常筛的状态列(启用/停用、待生成/已生成、`status` 等)→ `searchable: 1` + `options_json`(value 与 DDL 存库一致) +- `boolean` 业务列:`display_type: boolean` **且** `options_json` 写中文 label(示例见下) + +禁止: + +- 仅为筛选把存库 value 改成中文 +- 在文档或 `description` 要求用户记内部 magic number / 英文 code + +示例(启用开关 + 中文筛选): + +```python +{ + "column_name": "enabled", + "display_name": "是否启用", + "visible": 1, + "searchable": 1, + "editable": 1, + "display_type": "boolean", + "options_json": json.dumps( + [{"value": 0, "label": "停用"}, {"value": 1, "label": "启用"}], + ensure_ascii=False, + ), +} +``` + +Action 参数表单**无**列筛选对称能力;筛选语义以本节为准,表单对照见 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。 + +## editable 与 createable 的关系 + +技能模板只维护 `editable` 这一份字段权限声明: + +```text +skill metadata: + editable + +host runtime: + createable / editable +``` + +宿主根据以下因素计算字段是否能够出现在新增或编辑表单中: + +- 表级 `readonly` +- 字段 `editable` +- 主键 +- generated 字段 +- sensitive 字段 +- BLOB 类型 +- not-null 约束 +- default value + +通用规则: + +```text +新增表单: + editable = 1 + 且不是主键 + 且不是 generated + 且不是敏感字段 + 且不是 BLOB + +编辑表单: + editable = 1 + 且不是主键 + 且不是 generated + 且不是敏感字段 + 且不是 BLOB +``` + +若一张表: + +```text +readonly = 0 +但所有字段 editable = 0 +``` + +则: + +- 这张表技术上可写,但不适合手工新增或编辑 +- 宿主不应打开空白新增表单 +- 技能应通过自身业务流程产生数据 +- 这不是宿主猜测业务,而是技能通过元数据声明的结果 + +## 系统字段标准 + +通常保持 `editable = 0` 的字段类型包括: + +- 自增主键 +- generated 字段 +- `created_at` / `updated_at` +- 同步时间戳 +- 内容哈希 +- 计算结果 +- 内部状态 +- 删除标记 +- 审计字段 +- 敏感凭据或密钥字段 + +不要把具体业务字段名写死;按字段类型和职责设置 `editable`。 + +## 表结构标准 + +- 业务表和字段使用英文 `snake_case` +- 可写表优先使用单字段主键 +- 自增主键优先放在 `CREATE TABLE` 第一列 +- `created_at` 和 `updated_at` 放在业务字段末尾 +- 字段展示顺序以 `CREATE TABLE` 和 `PRAGMA table_info(cid)` 为准 +- 不依赖 `display_order` 重新排列字段 +- 不要让宿主通过英文字段名推断展示顺序 +- 复杂字段需要通过 `display_type` 或 `visible` 明确处理 + +推荐顺序(可按业务裁剪,但已有字段应遵守相对位置): + +```text +1. 主键 id +2. 关联标识与核心业务字段 +3. 普通业务字段 +4. 状态、结果、错误等辅助字段 +5. created_at(如有) +6. updated_at(如有) +``` + +## 标准时间字段(新技能统一规范) + +新技能业务表若需要时间审计字段,**统一**采用 Unix **秒级**时间戳,不要使用毫秒时间戳、ISO 字符串或 SQLite 文本 datetime。 + +| 项目 | 规范 | +|------|------| +| 物理字段 | `created_at INTEGER`、`updated_at INTEGER` | +| 存储格式 | Unix 秒级整数 | +| 展示元数据 `display_type` | `datetime_unix_seconds` | +| 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** | +| 用户编辑 | **审计字段** `editable = 0`(系统维护,列表只展示格式化结果,**不**进可编辑表单) | +| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` | +| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` | + +### 业务可编辑时间 vs 审计时间 + +| 场景 | `editable` | 列表 | 新增/编辑表单 | +|------|------------|------|----------------| +| `created_at` / `updated_at` 等审计列 | `0` | 格式化为 `YYYY-MM-DD HH:mm:ss` | 不出现(或只读,由宿主既有规则) | +| 业务时间列(预约、截止、活动开始等) | `1` | 同上格式化 | **日期时间控件**;提交写入 Unix **秒**整数 | + +宿主对 `display_type = datetime_unix_seconds`: + +- **列表**:格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不**改库内原始整数 +- **可编辑表单**:提供日期时间选择;保存时写回 Unix **秒**(不要写 ISO 字符串、不要写毫秒) + +### 自动生成与维护 + +- **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。 +- **updated_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。 +- **updated_at 更新**:业务表通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新(`init_updated_at_trigger`)。 + +实现参考: + +- DDL 与 trigger:`scripts/db/connection.py`、`scripts/db/timestamp_columns.py` +- 中文元数据种子:`scripts/db/display_metadata.py` +- 自检:`scripts/db/display_metadata_validator.py` + +### 系统自动写入表的只读约定 + +技能内部**系统自动写入**、不供用户在宿主数据管理中手工维护的表,应声明: + +| 层级 | 值 | 说明 | +|------|-----|------| +| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 | +| 相关业务字段 `editable` | `0` | 元数据权限只约束宿主 UI,不限制技能代码写库 | + +模板**默认不建**此类审计表。过程看 `jiangchang.log`,进度/终态看任务中心,业务结果看业务表。 + +### 业务行生命周期(处理队列) + +若技能对某一业务行做「生成 / 触达 / 私信 / 发布」等有副作用处理,状态字段须遵守 **角色模型**(可操作 / 进行中 / 成功·失败·跳过类终态),**不要**把某一技能的状态名当成全平台唯一枚举。默认只处理可操作态;重做靠人工改回可操作。完整规则见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。 + +建议: + +| 项 | 说明 | +|----|------| +| 状态列 | `options_json` 列出本技能全部业务状态(含进行中与各类终态) | +| 失败原因 | 失败类终态应有用户可读的原因字段(勿写密钥) | +| 可编辑 | 终态/进行中建议可改回可操作,或提供等价 Action | +| `grid_help` | 可一句话说明「仅某某状态可处理」 | + +表级 `readonly = 1` 时,宿主数据管理只读展示;技能 repository 仍可写入。 + +### 通用示例:`demo_items`(测试 fixture 参考) + +以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表: + +```sql +CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + generated_value TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +``` + +对应元数据要点: + +| 物理字段 | editable | 说明 | +|----------|----------|------| +| id | 0 | 自增主键 | +| title | 1 | 业务字段 | +| description | 1 | 业务字段 | +| status | 1 | 枚举,`options_json` 演示 | +| generated_value | 0 | 系统/计算字段 | +| created_at | 0 | 系统时间 | +| updated_at | 0 | 系统时间 | + +## 枚举字段 options_json + +`options_json` 支持两种形态: + +### 1)静态枚举(数组) + +每项包含 `value` 与 `label`: + +```json +[ + {"value": "draft", "label": "草稿"}, + {"value": "published", "label": "已发布"}, + {"value": "archived", "label": "已归档"} +] +``` + +宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。 + +### 2)跨表引用 lookup(对象,用于关联 id 字段) + +当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组)。这是**展示与表单选择**契约,**不是** SQLite 外键。 + +**单选(默认,与既有行为一致):** + +```json +{ + "ref_table": "platforms", + "value_column": "platform_key", + "label_column": "display_name", + "extra_column": "category", + "filter_column": "enabled", + "filter_value": 1 +} +``` + +**多选(平行扩展,仅多一个开关):** + +```json +{ + "ref_table": "engines", + "value_column": "id", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1, + "multiple": true +} +``` + +| 字段 | 必填 | 说明 | +|---|---|---| +| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) | +| `value_column` | 是 | 写入当前字段的值列(业务 id / 编号) | +| `label_column` | 是 | 选项主文案列(名称) | +| `extra_column` | 否 | 第三展示列(不必叫「备注」);有值时拼进括号 | +| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) | +| `multiple` | 否 | `true` = 多选 Lookup;省略或 `false` = **单选**(默认) | + +宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 选项列表。 + +**存库约定:** + +| 模式 | 物理列建议 | 存什么 | +|------|------------|--------| +| 单选 | 整数或文本标量 | 一个业务 id(如 `2`、`douyin`) | +| 多选 | **`TEXT`** | 逗号分隔 id,如 `1,2,5`(与 `$selection.ids` 风格一致);**不要**建联接子表当 UI 多选 | + +**统一展示文案(宿主固定一种,技能只配列):** + +- 单选默认:`{value} · {label}`,例如 `2 · 通用私信-留资`、`douyin · 抖音` +- 配置了 `extra_column` 且该行有非空值:`{value} · {label}({extra})` +- 未配置 `extra_column`、或额外列为空:不加 `()` +- `label` 为空或与 `value` 相同时:只显示 `value`(可再带额外括号) +- **多选列表格**:按 id 拆开后逐个映射为 label,再用顿号/逗号拼接(如 `豆包、元宝`);过长可截断并提示「+N」 + +**控件呈现(宿主策略,非第二套契约):** + +- 单选:下拉 Select(现状) +- 多选且选项较少(建议 ≤6):勾选列表 +- 多选且选项较多:字段内 Chip + 可搜索下拉(**不是**再开一层业务弹窗) + +Action 弹窗内联 Lookup 与上表 **同构**(字段名一致,含 `multiple`),见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。 + +示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表;私信 Action 选话术 → `message_templates`;抽检 Action 多选引擎 → `engines` + `multiple: true`。 + +## 幂等初始化标准 + +- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化 +- 多次执行初始化不能重复记录 +- 重新初始化可以更新展示名、描述、`grid_help`、`visible`、`searchable`、`editable`、`display_type`、`options_json` +- 元数据不能残留旧字段(删除或重命名字段时必须同步清理) +- 元数据不能引用不存在的表或字段 +- 新增字段或表时必须同步增加元数据 +- 不要直接修改用户真实数据库作为迁移方式 + +实现 helper: + +- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入 +- `seed_column_metadata_batch` — 批量 upsert;**可选**传入 `valid_column_names` 时在 upsert 后自动 prune +- `prune_stale_column_metadata` — 清理已删除物理字段的元数据 + +### 新增业务表时如何初始化 + +1. 在 `connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。 +2. 用 `PRAGMA table_info` 或 `list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。 +3. 调用 `upsert_table_metadata` 写入表级元数据。 +4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`: + +```python +from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata + +physical = list_physical_column_names(cursor, "demo_items") +upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0) +seed_column_metadata_batch( + cursor, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical, +) +``` + +传入 `valid_column_names` 时:先 upsert,再删除不在物理表中的陈旧 `_jiangchang_columns` 行。 + +### 字段删除或重命名时如何清理 + +1. 在 DDL / 迁移中完成物理字段删除或重命名。 +2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。 +3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`。 + +**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。 + +不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。 + +## 表增删改与退役(技能 schema 迁移) + +技能拥有 SQLite 物理 schema;宿主只渲染**已注册**的元数据。增删改表时技能须在 `init_db()` 中做幂等迁移,并与 `_jiangchang_*` 同步。 + +| 操作 | 技能必须做 | +|------|------------| +| **新增**用户可见表 | `CREATE TABLE` + `upsert_table_metadata` + 列元数据种子 | +| **修改**表/列 | 物理迁移(`ALTER` / 重建)+ 更新或 `prune_stale_column_metadata` | +| **临时隐藏**(表仍保留) | 元数据 `visible=0`(表与元数据行都保留) | +| **永久废弃**用户可见表 | `drop_deprecated_user_table(...)`(见下) | + +### 永久废弃用户可见表 + +使用 `scripts/db/display_metadata.py` 的 **`drop_deprecated_user_table`**(幂等): + +1. `DROP TRIGGER`(若有 `{table}_set_updated_at` 等) +2. `DROP TABLE IF EXISTS` +3. `DELETE` 对应 `_jiangchang_columns` / `_jiangchang_tables` 行 + +```python +from db.display_metadata import drop_deprecated_user_table + +def init_db() -> None: + ... + drop_deprecated_user_table(cur, "task_logs") + init_display_metadata(cur) +``` + +**反模式(禁止):** 只删 `_jiangchang_*` 元数据、保留物理表。宿主虽可能不展示孤儿表,但库内残留、自检失败,且违背「元数据与物理结构一致」。 + +**新技能禁止**创建或写入 `task_logs` 一类通用任务审计表。过程 → `jiangchang.log` / 运行日志;进度/终态 → 任务中心;业务结果 → 业务表(见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)、[`../development/LOGGING.md`](../development/LOGGING.md))。 + +### 旧库迁移测试(废弃表时必做) + +复制 `tests/samples/test_table_deprecation_migration.py.sample`,构造「旧库含废弃表 + 元数据 + 历史行」→ `init_db()` 后断言: + +- `sqlite_master` 无该表 +- `_jiangchang_*` 无残留行 +- `iter_user_tables` 不含该表 + +卸载技能**不删**用户数据目录;重装会复用旧库,迁移必须在 `init_db()` 中完成(见 [`../development/DATA_PATHS.md`](../development/DATA_PATHS.md))。 + +## 中文命名质量 + +- 使用普通用户能理解的业务用语:`error_msg` → **错误信息**,`created_at` → **创建时间**。 +- 禁止:`Task Logs`、`input编号`、`errormsg`、直接把物理名当 `display_name`。 +- 不宜向用户暴露的实现细节(如 `payload_json`、`selector`)应设 `visible = 0` 或给出可理解中文名。 + +## 元数据同步规则 + +1. 新增用户可见业务表 → 同步 `_jiangchang_tables`(`visible=1` 方可在数据管理展示)。 +2. 新增用户可见字段 → 同步 `_jiangchang_columns`。 +3. 删除/重命名表或字段 → 物理迁移 + 同步清理元数据;**废弃表**用 `drop_deprecated_user_table`,禁止只删元数据留物理表。 +4. 初始化脚本必须幂等。 +5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`。 +6. `init_db()` 后:`iter_user_tables` 中每个物理业务表都应有对应 `_jiangchang_tables.display_name`,或已被 `drop_deprecated_user_table` 物理删除。 + +## 开发自检 + +- `init_db()` 后运行 `tests/test_display_metadata.py` 与 `tests/test_data_management_contract.py`。 +- 若提供宿主 Action 入口,运行 `tests/test_actions_manifest.py`。 +- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。 + +## 模板原则 + +- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。 +- 元数据 DDL 与种子 helper 以 `scripts/db/display_metadata.py` 为单一权威来源。 +- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。 diff --git a/references/WORK_ITEM_STATUS.md b/references/WORK_ITEM_STATUS.md new file mode 100644 index 0000000..4943162 --- /dev/null +++ b/references/WORK_ITEM_STATUS.md @@ -0,0 +1,157 @@ +# 业务行生命周期契约(Work Item Lifecycle) + +> **状态:正式契约 v1.0。** +> **目标:** 冻结「对某一行做有副作用处理」时的**状态角色与操作权**规则,使队列型技能(生成、触达、私信、发布等)行为一致、可验收。 +> **非目标:** 规定某一技能的具体状态码或状态个数;强制所有表都有 status;替代任务中心 / 运行日志;规定 Data Policy 授权。 +> **适用范围:** 有「处理业务行」类 Action 的技能。纯配置表、目录表、只读展示表标 N/A。 + +相关正交轴:[`ACTIONS.md`](ACTIONS.md)(入口与 sync/async)、[`SCHEMA.md`](SCHEMA.md)(表与展示)、[`POLICY.md`](POLICY.md)(谁能点)、任务中心(宿主 Job)。 + +--- + +## 0. 为什么需要本契约 + +若「处理这一行」的按钮对**任意状态**都可点,会出现: + +- 已成功的行被重复执行(重复发信、重复生成、覆盖产物) +- 失败行被默认批量静默重试,运营无法区分「未做」与「做过但失败」 +- 任务中心显示成功,业务表仍停在初始态(结果未回写) + +本契约要求:**默认可自动操作的只有「可操作」角色;其它角色须人工改回可操作后再处理。** + +--- + +## 1. 状态角色(通用,不绑死枚举) + +技能自己定义业务状态字符串与中文标签;契约只认**角色**。一个角色可对应**一个或多个**业务状态。 + +| 角色 | 含义 | 默认可被处理 Action 捞起? | +|------|------|---------------------------| +| **operable(可操作)** | 尚未成功处理、允许自动或行操作启动处理 | **是(默认唯一)** | +| **in_progress(进行中)** | 已抢占,正在处理 | **否**(防双跑) | +| **terminal_success(成功类终态)** | 已按业务定义完成 | **否** | +| **terminal_failure(失败类终态)** | 已尝试且失败;通常带失败原因 | **否** | +| **terminal_skipped(跳过类终态)** | 人工放弃或不处理 | **否** | + +### 1.1 举例(仅对照,不是规范枚举) + +| 角色 | 技能 A(内容队列)可能叫 | 技能 B(线索触达)可能叫 | +|------|--------------------------|--------------------------| +| operable | 待生成、新建 | 新建、私信未发 | +| in_progress | 生成中 | 触达中 | +| terminal_success | 已生成 | 已私信 | +| terminal_failure | 失败 | 触达失败 | +| terminal_skipped | 已跳过 | 已忽略 | + +技能可以有更多业务状态(审核中、待确认、部分成功等):须在需求里写明各自映射到哪一角色,或声明「不属于处理队列、本契约 N/A」。 + +### 1.2 技能必须声明映射 + +复制模板后,在 `development/REQUIREMENTS.md`(或等价需求)中写清: + +```text +处理队列表:<table> +状态字段:<column> +可操作:<status1>, <status2>, … +进行中:<status…> +成功类终态:… +失败类终态:…(失败原因字段:…) +跳过类终态:… +``` + +未声明映射的「处理队列」技能,交活 Checklist 不得标 PASS。 + +--- + +## 2. 硬规则(技能实现必须遵守) + +1. **默认只认 operable** + 行操作 / 批量 / 定时 / Agent 触发的「处理」类 Action,pick 与 claim **默认**只包含可操作状态。 + 不得把成功、失败、跳过、进行中混进默认捞取集合(除非另做**显式命名**的「重试失败」类 Action,且确认文案写清范围——仍推荐不如人工改回可操作简单)。 + +2. **开始即原子抢占 → in_progress** + 在真正产生副作用前,用「仅当当前为 operable 时 UPDATE 为进行中」的方式抢占。抢占失败 → 跳过或返回明确错误(如「当前不是可操作状态」),**禁止**继续副作用。 + +3. **结束必须回写业务表** + - 成功 → 对应成功类终态,并写入产物路径/摘要等业务字段(若有) + - 失败 → 对应失败类终态,并写入**失败原因**(用户可读;勿塞密钥) + - 禁止「任务中心成功、业务行仍停在可操作/进行中」而不回写 + +4. **重做靠人工改回 operable** + 成功 / 失败 / 跳过 / 卡住的进行中,默认不能再点处理。运营在数据管理把状态改回可操作后,方可再次处理。 + +5. **任务中心 ≠ 业务状态** + Job「进行中 / 成功 / 失败」是宿主任务态。业务「进行中」必须写在业务表。进度 `emit` 不替代状态回写。 + +6. **过程与结果分工**(延续既有约定) + 过程 → 运行日志 / 任务中心 Activity;业务结果 → 业务表。**不要**靠新建 `task_logs` 一类审计表充当状态机。 + +--- + +## 3. Action 与文案约定 + +- 处理类 Action 的 `description` / `confirmation.message` 应写明:**仅处理「可操作」状态**(用本技能用户能懂的词,如「仅待生成」「仅新建」)。 +- 对非可操作行触发时:返回明确失败码与中文说明,提示「请先改回 xxx」。 +- 长任务(`async`):除任务中心进度外,业务行应尽快进入进行中,避免界面长时间仍显示可操作导致连点。 + +正交提醒: + +| 轴 | 管什么 | 不管什么 | +|----|--------|----------| +| 本契约 | 什么**状态**能处理 | 谁有权点按钮 | +| Data Policy | 谁有权 | 状态机 | +| executionProfile | sync/async 通道 | 业务终态 | + +--- + +## 4. 数据管理展示建议 + +- 状态列:`options_json` 枚举完整业务状态(含进行中与各类终态)。 +- 失败原因列:失败类终态可见;成功后宜清空或保留末次原因(技能自定,须一致)。 +- 终态与进行中的状态字段建议 **可编辑**(或提供「改回可操作」Action),以便人工重做;**不要**只能靠删行重建。 +- `grid_help` 可一句话写清:「仅某某状态可处理;其它状态请改回后再操作。」 + +详见 [`SCHEMA.md`](SCHEMA.md)。 + +--- + +## 5. 可观测性(与 ACTIONS / 日志约定对齐) + +推荐阶段(名称可自定,须能对应到开始 / 关键节点 / 结束): + +1. 已抢占(业务 → 进行中) +2. 处理中关键步骤(可 `emit`) +3. 回写成功或失败 + +故意失败一条时:业务表应为失败类终态 + 原因;任务中心为失败;日志含 `task_failed` / `error_code=`。 + +--- + +## 6. 非目标与常见误区 + +| 误区 | 正确做法 | +|------|----------| +| 把「新建/成功/失败/跳过」写成全平台唯一枚举 | 只用角色;各技能自定状态名 | +| 默认批量包含失败「方便自动重试」 | 默认不含;重试改回可操作或单独显式 Action | +| 只有任务成功、不改业务状态 | 必须回写业务表 | +| 用进行中替代任务中心 | 两者都要:业务防双跑,任务轨看进度 | +| 所有表都套本契约 | 无「处理行」副作用的表标 N/A | + +--- + +## 7. 验收摘要(完整条目见 CHECKLIST) + +有处理队列时至少验证: + +1. 成功类终态再点处理 → 拒绝或跳过,不产生重复副作用 +2. 可操作行处理中 → 先见进行中,结束后见成功或失败+原因 +3. 失败行改回可操作后 → 可以再次处理 +4. 任务中心有过程(长任务)且业务表状态与结果一致 + +--- + +## 8. 版本 + +| 版本 | 说明 | +|------|------| +| v1.0 | 首版:角色模型 + 硬规则 + 需求映射 + 与 Action/SCHEMA/任务中心边界 | diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..db77b94 --- /dev/null +++ b/release.ps1 @@ -0,0 +1,424 @@ +<# +.SYNOPSIS + Local git release for skill-template repo. + +.DESCRIPTION + Handles git commit / push / semver tag / push tag only. + Encryption, ZIP packaging, and marketplace sync are done by CI + (.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main). + + POLICY-RELEASE-001: before tagging, requires SKILL.md version and + scripts/util/constants.py SKILL_VERSION to equal the next tag (without prefix). + release.ps1 does not auto-edit those fields. + + Requires: git, PowerShell 5+ + + After fetch, runs default tests against the current working tree (before auto-commit): + python tests/run_tests.py -v + Use -SkipTests only for emergency diagnostics. -DryRun still runs tests. +#> + +[CmdletBinding()] +param( + [string]$Prefix = "v", + [string]$Message, + [switch]$AutoCommit, + [switch]$RequireClean, + [string]$CommitMessage, + [switch]$DryRun, + [switch]$SkipTests +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Default tag message is 「正式发布」 from UTF-8 bytes (not a Chinese literal in +# this file). Windows PowerShell 5.1 parses UTF-8 scripts without BOM as the +# system ANSI code page, which would turn a source literal into 「姝e紡鍙戝竷」. +if (-not $PSBoundParameters.ContainsKey("Message") -or [string]::IsNullOrWhiteSpace($Message)) { + $Message = [System.Text.Encoding]::UTF8.GetString([byte[]](0xE6, 0xAD, 0xA3, 0xE5, 0xBC, 0x8F, 0xE5, 0x8F, 0x91, 0xE5, 0xB8, 0x83)) +} + +function Remove-GitNoise { + param([string[]]$Lines) + return @($Lines | Where-Object { + $_ -and ($_ -notmatch '^warning:\s+unable to access ') + }) +} + +function Convert-GitOutputLines { + param($Output) + return @( + @($Output) | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } + else { "$_" } + } + ) +} + +# Call git.exe directly. Never wrap with `cmd /c`: on Chinese Windows, cmd +# re-encodes the command line through the OEM code page. +# Also never pass Chinese via `git -m`: Windows PowerShell 5.1 encodes native +# argv using the console code page, so 「正式发布」 becomes 「姝e紡鍙戝竷」 +# in the annotated tag. Commit/tag messages go through a UTF-8 file + `-F`. +function Invoke-GitRaw { + param([Parameter(Mandatory = $true)][string[]]$GitArgs) + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & git @GitArgs 2>&1 + $code = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prev + } + return [pscustomobject]@{ + ExitCode = $code + Lines = @(Remove-GitNoise -Lines @(Convert-GitOutputLines -Output $output)) + } +} + +function Invoke-Git { + param([Parameter(Mandatory = $true)][string[]]$GitArgs) + Write-Host (">> git " + ($GitArgs -join " ")) -ForegroundColor DarkGray + $r = Invoke-GitRaw -GitArgs $GitArgs + if ($r.ExitCode -ne 0) { + throw "git command failed: git $($GitArgs -join ' ')" + } +} + +function Invoke-GitWithMessageFile { + param( + [Parameter(Mandatory = $true)][string[]]$GitArgsPrefix, + [Parameter(Mandatory = $true)][string]$Message + ) + $msgFile = Join-Path ([System.IO.Path]::GetTempPath()) ("jiangchang-git-msg-" + [guid]::NewGuid().ToString("N") + ".txt") + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($msgFile, ($Message.TrimEnd() + "`n"), $utf8NoBom) + try { + Invoke-Git -GitArgs (@("-c", "i18n.commitEncoding=utf-8") + $GitArgsPrefix + @("-F", $msgFile)) + } finally { + Remove-Item -LiteralPath $msgFile -Force -ErrorAction SilentlyContinue + } +} + +function Get-GitOutput { + param([Parameter(Mandatory = $true)][string[]]$GitArgs) + $r = Invoke-GitRaw -GitArgs $GitArgs + if ($r.ExitCode -ne 0) { + throw "git command failed: git $($GitArgs -join ' ')" + } + return @($r.Lines) +} + +function Test-Repo { + $r = Invoke-GitRaw -GitArgs @("rev-parse", "--is-inside-work-tree") + if ($r.ExitCode -ne 0) { + return $false + } + return (($r.Lines | Select-Object -First 1) -eq "true") +} + +function Get-CurrentBranch { + $b = (Get-GitOutput -GitArgs @("branch", "--show-current") | Select-Object -First 1).Trim() + return $b +} + +function Get-StatusPorcelain { + $lines = @(Get-GitOutput -GitArgs @("status", "--porcelain")) + return $lines +} + +function Parse-SemVerTag { + param( + [string]$Tag, + [string]$TagPrefix + ) + $escaped = [regex]::Escape($TagPrefix) + $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") + if (-not $m.Success) { return $null } + + return [pscustomobject]@{ + Raw = $Tag + Major = [int]$m.Groups[1].Value + Minor = [int]$m.Groups[2].Value + Patch = [int]$m.Groups[3].Value + } +} + +function Get-NextTag { + param([string]$TagPrefix) + + $tags = Get-GitOutput -GitArgs @("tag", "--list") + $parsed = @() + + foreach ($t in $tags) { + $t = $t.Trim() + if (-not $t) { continue } + $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix + if ($null -ne $obj) { $parsed += $obj } + } + + if ($parsed.Count -eq 0) { + return "${TagPrefix}1.0.1" + } + + $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 + return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" +} + +function Read-SkillMdVersion { + param([Parameter(Mandatory = $true)][string]$SkillRoot) + + $path = Join-Path $SkillRoot "SKILL.md" + if (-not (Test-Path -LiteralPath $path)) { + return $null + } + $text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + $m = [regex]::Match($text, '(?m)^version:\s*["'']?([0-9]+\.[0-9]+\.[0-9]+)["'']?\s*$') + if (-not $m.Success) { + return $null + } + return $m.Groups[1].Value.Trim() +} + +function Read-SkillConstantsVersion { + param([Parameter(Mandatory = $true)][string]$SkillRoot) + + $path = Join-Path $SkillRoot "scripts\util\constants.py" + if (-not (Test-Path -LiteralPath $path)) { + return $null + } + $text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + $m = [regex]::Match($text, '(?m)^SKILL_VERSION\s*=\s*["'']([0-9]+\.[0-9]+\.[0-9]+)["'']') + if (-not $m.Success) { + return $null + } + return $m.Groups[1].Value.Trim() +} + +# POLICY-RELEASE-001: marketplace latest (git tag) must equal package manifest versions. +function Assert-SkillReleaseVersionAlignment { + param( + [Parameter(Mandatory = $true)][string]$SkillRoot, + [Parameter(Mandatory = $true)][string]$NextTag, + [Parameter(Mandatory = $true)][string]$TagPrefix + ) + + $skillMdPath = Join-Path $SkillRoot "SKILL.md" + if (-not (Test-Path -LiteralPath $skillMdPath)) { + return + } + + $expected = $NextTag + if ($TagPrefix -and $expected.StartsWith($TagPrefix)) { + $expected = $expected.Substring($TagPrefix.Length) + } + $expected = $expected.Trim() + if ($expected -notmatch '^\d+\.\d+\.\d+$') { + throw "Release check failed (POLICY-RELEASE-001): cannot derive semver from Next tag '$NextTag'." + } + + $mdVersion = Read-SkillMdVersion -SkillRoot $SkillRoot + $constVersion = Read-SkillConstantsVersion -SkillRoot $SkillRoot + + $problems = @() + if ([string]::IsNullOrWhiteSpace($mdVersion)) { + $problems += "SKILL.md missing top-level version: X.Y.Z" + } + elseif ($mdVersion -ne $expected) { + $problems += "SKILL.md version=$mdVersion (want $expected)" + } + if ([string]::IsNullOrWhiteSpace($constVersion)) { + $problems += "scripts/util/constants.py missing SKILL_VERSION = `"X.Y.Z`"" + } + elseif ($constVersion -ne $expected) { + $problems += "SKILL_VERSION=$constVersion (want $expected)" + } + + if ($problems.Count -gt 0) { + throw ( + "Release aborted (POLICY-RELEASE-001): Next tag=$NextTag requires manifest version $expected, but " + + ($problems -join "; ") + + ". Bump SKILL.md version and scripts/util/constants.py SKILL_VERSION to $expected (and CHANGELOG), then re-run release.ps1. " + + "release.ps1 does not auto-edit these fields." + ) + } + + Write-Host "Version alignment OK: tag=$NextTag == SKILL.md=$mdVersion == SKILL_VERSION=$constVersion" -ForegroundColor DarkGray +} + +function Assert-SkillReleasePackagingSources { + param([Parameter(Mandatory = $true)][string]$SkillRoot) + + $scriptsDir = Join-Path $SkillRoot "scripts" + $mainPy = Join-Path $scriptsDir "main.py" + if (-not (Test-Path -LiteralPath $mainPy)) { + return + } + + $text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($text)) { + return + } + + $need = @() + if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' } + if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' } + if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' } + if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' } + if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' } + if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' } + if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' } + if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' } + + foreach ($p in ($need | Select-Object -Unique)) { + $folder = Join-Path $scriptsDir $p + if (-not (Test-Path -LiteralPath $folder)) { + throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder" + } + } + + $pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue) + Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray +} + +function Invoke-DefaultTests { + param( + [Parameter(Mandatory = $true)][string]$SkillRoot, + [switch]$Skip + ) + + if ($Skip) { + Write-Host "WARNING: Skipping tests before release. This should only be used for emergency diagnostics." -ForegroundColor Yellow + return + } + + $runTests = Join-Path $SkillRoot "tests\run_tests.py" + if (-not (Test-Path -LiteralPath $runTests)) { + throw "Release check failed: tests/run_tests.py not found." + } + + Write-Host ">> python tests/run_tests.py -v" -ForegroundColor DarkGray + & python $runTests -v + if ($LASTEXITCODE -ne 0) { + throw "Release aborted: default tests failed (python tests/run_tests.py -v)." + } +} + +function Ensure-CleanOrAutoCommit { + param( + [switch]$DoAutoCommit, + [switch]$NeedClean, + [switch]$IsDryRun, + [string]$Msg + ) + + $status = @(Get-StatusPorcelain) + if ($status.Length -eq 0) { return } + + if ($NeedClean) { + Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow + (Invoke-GitRaw -GitArgs @("status", "--short")).Lines | ForEach-Object { Write-Host $_ } + throw "Abort: dirty working tree." + } + + $commitMsg = $Msg + if ([string]::IsNullOrWhiteSpace($commitMsg)) { + $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" + } + + if (-not $DoAutoCommit) { + Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow + } + + if ($IsDryRun) { + Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow + return + } + + Invoke-Git -GitArgs @("add", "-A") + Invoke-GitWithMessageFile -GitArgsPrefix @("commit") -Message $commitMsg +} + +try { + Write-Host "=== Release Script Start ===" -ForegroundColor Cyan + + if (-not (Test-Repo)) { + throw "Current directory is not a git repository." + } + + $branch = Get-CurrentBranch + if ([string]::IsNullOrWhiteSpace($branch)) { + throw "Unable to determine current branch." + } + + if ($branch -notin @("main", "master")) { + throw "Current branch is '$branch'. Release is only allowed from main/master." + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow + } else { + Invoke-Git -GitArgs @("fetch", "--tags", "--prune", "origin") + } + + $skillRoot = (Get-GitOutput -GitArgs @("rev-parse", "--show-toplevel") | Select-Object -First 1).Trim() + if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) { + Assert-SkillReleasePackagingSources -SkillRoot $skillRoot + } + + $nextTag = Get-NextTag -TagPrefix $Prefix + Write-Host "Next tag: $nextTag" -ForegroundColor Green + Assert-SkillReleaseVersionAlignment -SkillRoot $skillRoot -NextTag $nextTag -TagPrefix $Prefix + + $existing = @(Get-GitOutput -GitArgs @("tag", "--list", $nextTag)) + if ($existing.Length -gt 0) { + throw "Tag already exists: $nextTag" + } + + Invoke-DefaultTests -SkillRoot $skillRoot -Skip:$SkipTests + + Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage + + $upstream = Invoke-GitRaw -GitArgs @("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + $hasUpstream = ($upstream.ExitCode -eq 0) + + if ($DryRun) { + if ($hasUpstream) { + Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow + } else { + Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow + } + } else { + if ($hasUpstream) { + Invoke-Git -GitArgs @("push") + } else { + Invoke-Git -GitArgs @("push", "-u", "origin", $branch) + } + } + + if ($DryRun) { + Write-Host "[DryRun] Would run: git tag -a $nextTag -F <utf8-msg-file> ($Message)" -ForegroundColor Yellow + Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow + Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan + exit 0 + } + + Invoke-GitWithMessageFile -GitArgsPrefix @("tag", "-a", $nextTag) -Message $Message + Invoke-Git -GitArgs @("push", "origin", $nextTag) + + Write-Host "Release success: $nextTag" -ForegroundColor Green + Write-Host "=== Release Script Done ===" -ForegroundColor Cyan + exit 0 +} +catch { + Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f012641 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py new file mode 100644 index 0000000..882d772 --- /dev/null +++ b/scripts/cli/app.py @@ -0,0 +1,249 @@ +"""CLI:argparse 与分发(项目经费进度播报助手)。 + +只负责参数解析并转交 service;业务内核在 fund_service / project_service, +勿在此按入口复制逻辑。 +""" + +from __future__ import annotations + +import argparse +import sys +from typing import List, Optional + +from service.task_service import ( + cmd_add_project, + cmd_broadcast, + cmd_broadcast_monthly, + cmd_config_path, + cmd_health, + cmd_import_projects, + cmd_init_db, + cmd_list_projects, + cmd_list_reports, + cmd_render_html, + cmd_run, + cmd_set_rule, + cmd_show_report, + cmd_stats, + cmd_version, + cmd_view_rules, +) +from util.config_bootstrap import bootstrap_skill_config +from util.argparse_zh import ZhArgumentParser +from util.constants import LOG_LOGGER_NAME, SKILL_SLUG +from util.logging_config import get_skill_logger, setup_skill_logging + + +def _cli_str_or_none(raw: Optional[str]) -> Optional[str]: + if raw is None: + return None + v = str(raw).strip() + return v or None + + +def _cli_float_or_none(raw: Optional[str]) -> Optional[float]: + raw = _cli_str_or_none(raw) + if raw is None: + return None + try: + return float(raw) + except ValueError: + return None + + +def _handle_run(args: argparse.Namespace) -> int: + tail = [str(x).strip() for x in (args.run_tail or []) if str(x).strip()] + if len(tail) > 2: + print("参数过多。") + print("用法:python main.py run [target [input_id]] | run [-t target] [-i input_id]") + return 1 + + t_target: Optional[str] = None + t_input: Optional[str] = None + if len(tail) == 2: + t_target, t_input = tail[0], tail[1] + elif len(tail) == 1: + if tail[0].isdigit(): + t_input = tail[0] + else: + t_target = tail[0] + + pick_t = _cli_str_or_none(getattr(args, "target", None)) + pick_i = _cli_str_or_none(getattr(args, "input_id", None)) + target = pick_t or t_target + input_id = pick_i or t_input + return cmd_run(target=target, input_id=input_id) + + +def _handle_add_project(args: argparse.Namespace) -> int: + return cmd_add_project( + project_no=str(args.project_no or "").strip(), + name=_cli_str_or_none(args.name) or "", + project_type=_cli_str_or_none(args.project_type) or "", + pi=_cli_str_or_none(args.pi) or "", + department=_cli_str_or_none(args.department) or "", + total_approved=_cli_float_or_none(args.total_approved), + total_received=_cli_float_or_none(args.total_received), + total_spent=_cli_float_or_none(args.total_spent), + frozen=_cli_float_or_none(args.frozen), + frozen_since=_cli_str_or_none(args.frozen_since) or "", + start=_cli_str_or_none(args.start) or "", + end=_cli_str_or_none(args.end) or "", + status=_cli_str_or_none(args.status) or "", + remark=_cli_str_or_none(args.remark) or "", + ) + + +def _handle_broadcast(args: argparse.Namespace) -> int: + return cmd_broadcast( + project_no=_cli_str_or_none(args.project_no) or "", + pi=_cli_str_or_none(args.pi) or "", + as_of=_cli_str_or_none(args.as_of) or "", + include_all_status=bool(args.all_status), + ) + + +def _handle_list_reports(args: argparse.Namespace) -> int: + return cmd_list_reports(limit=int(args.limit or 20), status_tag=_cli_str_or_none(args.status_tag) or "") + + +def _handle_render_html(args: argparse.Namespace) -> int: + report_id = getattr(args, "report_id", None) + return cmd_render_html(report_id=int(report_id) if report_id else None) + + +def _handle_set_rule(args: argparse.Namespace) -> int: + return cmd_set_rule(rule_key=str(args.rule_key or "").strip(), value=float(args.value)) + + +def _print_full_usage() -> None: + print("项目经费进度播报助手(main.py)可用命令:") + print(" python main.py add-project --project-no X --name Y [--type 纵向] [--pi 张三] [--department 学院]") + print(" [--total-approved N] [--total-received N] [--total-spent N]") + print(" [--frozen N] [--frozen-since DATE] [--start DATE] [--end DATE] [--status 在研]") + print(" python main.py import-projects --projects-json <file|-> # 批量 upsert 台账") + print(" python main.py list-projects [--pi 张三] [--status 在研]") + print(" python main.py broadcast [--project-no X] [--pi 张三] [--as-of YYYY-MM-DD] [--all-status]") + print(" python main.py broadcast-monthly # 月度播报(定时 Action 入口)") + print(" python main.py list-reports [--limit N] [--status-tag 严重滞后]") + print(" python main.py show-report --id N") + print(" python main.py render-html [--report-id N]") + print(" python main.py view-rules") + print(" python main.py set-rule --rule-key lag_threshold --value 0.15") + print(" python main.py stats") + print(" python main.py run [target [input_id]] [-t target] [-i input_id]") + print(" python main.py health") + print(" python main.py config-path") + print(" python main.py version") + print(" python main.py init-db") + + +def build_parser() -> ZhArgumentParser: + p = ZhArgumentParser( + prog="main.py", + description="项目经费进度播报助手:按台账计算实际支出率与序时进度,识别严重滞后/支出异常偏快,生成三段式播报与《经费执行简报》。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) + + # add-project + sp = sub.add_parser("add-project", help="录入/更新项目台账(按项目编号幂等)") + sp.add_argument("--project-no", required=True, metavar="项目编号", dest="project_no") + sp.add_argument("--name", default=None, metavar="项目名称") + sp.add_argument("--type", default=None, metavar="项目类别(纵向/横向/其他)", dest="project_type") + sp.add_argument("--pi", default=None, metavar="负责人") + sp.add_argument("--department", default=None, metavar="所属学院") + sp.add_argument("--total-approved", default=None, metavar="总立项经费(元)", dest="total_approved") + sp.add_argument("--total-received", default=None, metavar="已到账(元)", dest="total_received") + sp.add_argument("--total-spent", default=None, metavar="已报销(元)", dest="total_spent") + sp.add_argument("--frozen", default=None, metavar="暂付款(元)") + sp.add_argument("--frozen-since", default=None, metavar="暂付款最早发生日", dest="frozen_since") + sp.add_argument("--start", default=None, metavar="执行期开始 YYYY-MM-DD") + sp.add_argument("--end", default=None, metavar="执行期结束 YYYY-MM-DD") + sp.add_argument("--status", default=None, metavar="状态(在研/已结题/暂缓)") + sp.add_argument("--remark", default=None, metavar="备注") + sp.set_defaults(handler=_handle_add_project) + + # import-projects + sp = sub.add_parser("import-projects", help="批量导入项目台账(JSON 文件/内联/stdin)") + sp.add_argument("--projects-json", required=True, metavar="JSON(file|-)", dest="projects_json") + sp.set_defaults(handler=lambda a: cmd_import_projects(str(a.projects_json))) + + # list-projects + sp = sub.add_parser("list-projects", help="查询项目台账") + sp.add_argument("--pi", default=None, metavar="负责人筛选") + sp.add_argument("--status", default=None, metavar="状态筛选") + sp.set_defaults(handler=lambda a: cmd_list_projects(pi=_cli_str_or_none(a.pi) or "", status=_cli_str_or_none(a.status) or "")) + + # broadcast + sp = sub.add_parser("broadcast", help="经费进度播报(主命令,缺省全部在研项目)") + sp.add_argument("--project-no", default=None, metavar="指定项目编号", dest="project_no") + sp.add_argument("--pi", default=None, metavar="按负责人筛选") + sp.add_argument("--as-of", default=None, metavar="评估基准日 YYYY-MM-DD", dest="as_of") + sp.add_argument("--all-status", action="store_true", help="包含非在研项目") + sp.set_defaults(handler=_handle_broadcast) + + # broadcast-monthly + sp = sub.add_parser("broadcast-monthly", help="月度播报(async Action 入口,含汇总简报)") + sp.set_defaults(handler=lambda _a: cmd_broadcast_monthly()) + + # list-reports + sp = sub.add_parser("list-reports", help="查询播报历史") + sp.add_argument("--limit", default=20, metavar="条数") + sp.add_argument("--status-tag", default=None, metavar="状态筛选", dest="status_tag") + sp.set_defaults(handler=_handle_list_reports) + + # show-report + sp = sub.add_parser("show-report", help="查看一份播报记录详情") + sp.add_argument("--id", required=True, type=int, metavar="记录编号", dest="report_id") + sp.set_defaults(handler=lambda a: cmd_show_report(report_id=int(a.report_id))) + + # render-html + sp = sub.add_parser("render-html", help="重建《经费执行简报》HTML(缺省最近 10 份)") + sp.add_argument("--report-id", default=None, metavar="记录编号", dest="report_id") + sp.set_defaults(handler=_handle_render_html) + + # rules + sp = sub.add_parser("view-rules", help="查看预警阈值") + sp.set_defaults(handler=lambda _a: cmd_view_rules()) + + sp = sub.add_parser("set-rule", help="更新预警阈值") + sp.add_argument("--rule-key", required=True, metavar="规则键", dest="rule_key") + sp.add_argument("--value", required=True, type=float, metavar="新阈值") + sp.set_defaults(handler=_handle_set_rule) + + # stats + sp = sub.add_parser("stats", help="播报统计") + sp.set_defaults(handler=lambda _a: cmd_stats()) + + sp = sub.add_parser("run", help="任务执行命令骨架") + sp.add_argument("--target", "-t", default=None, metavar="目标") + sp.add_argument("--input-id", "-i", default=None, metavar="输入id", dest="input_id") + sp.add_argument("run_tail", nargs="*", metavar="位置参数") + sp.set_defaults(handler=_handle_run) + + sp = sub.add_parser("health", help="健康检查") + sp.set_defaults(handler=lambda _a: cmd_health()) + + sp = sub.add_parser("config-path", help="输出用户 .env 与模板路径") + sp.set_defaults(handler=lambda _a: cmd_config_path()) + + sp = sub.add_parser("version", help="版本信息(JSON)") + sp.set_defaults(handler=lambda _a: cmd_version()) + + sp = sub.add_parser("init-db", help="幂等初始化本地数据库与数据管理元数据") + sp.set_defaults(handler=lambda _a: cmd_init_db()) + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + bootstrap_skill_config() + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) + get_skill_logger().info("cli_start argv=%s", sys.argv) + if not argv: + _print_full_usage() + return 1 + parser = build_parser() + args = parser.parse_args(argv) + return int(args.handler(args)) diff --git a/scripts/db/connection.py b/scripts/db/connection.py new file mode 100644 index 0000000..9a17076 --- /dev/null +++ b/scripts/db/connection.py @@ -0,0 +1,252 @@ +"""SQLite 连接与幂等初始化(项目经费进度播报助手)。 + +业务表 3 张: +- fund_projects(项目台账,金额/日期/状态字段可编辑) +- progress_reports(播报记录,只读,播报自动写入) +- alert_rules(预警规则,阈值可编辑) + +表间禁止 SQLite 外键,仅存业务 id(见 references/SCHEMA.md)。 +""" + +from __future__ import annotations + +import sqlite3 + +from db.display_metadata import ( + init_display_metadata, + seed_column_metadata_batch, + upsert_table_metadata, +) +from db.seed_data import seed_all +from db.timestamp_columns import init_updated_at_trigger +from util.runtime_paths import get_db_path + + +def get_conn() -> sqlite3.Connection: + return sqlite3.connect(get_db_path()) + + +_FUND_PROJECTS_DDL = """ +CREATE TABLE IF NOT EXISTS fund_projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_no TEXT NOT NULL UNIQUE, + project_name TEXT, + project_type TEXT, + pi_name TEXT, + department TEXT, + total_approved REAL, + total_received REAL, + total_spent REAL, + frozen_amount REAL, + frozen_since TEXT, + start_date TEXT, + end_date TEXT, + status TEXT NOT NULL DEFAULT '在研', + remark TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_PROGRESS_REPORTS_DDL = """ +CREATE TABLE IF NOT EXISTS progress_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL, + project_id INTEGER, + project_no TEXT, + project_name TEXT, + pi_name TEXT, + as_of TEXT, + spend_rate REAL, + time_progress REAL, + deviation REAL, + status_tag TEXT NOT NULL, + summary TEXT, + suggestions_json TEXT, + report_file TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_PROGRESS_REPORTS_INDEX = """ +CREATE INDEX IF NOT EXISTS idx_progress_reports_batch +ON progress_reports(batch_no) +""" + +_ALERT_RULES_DDL = """ +CREATE TABLE IF NOT EXISTS alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_key TEXT NOT NULL UNIQUE, + display_name TEXT, + threshold_value REAL NOT NULL, + description TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +_TS_COLUMN_NAMES = {"created_at", "updated_at"} + + +def _col(column_name: str, display_name: str, description: str, **extra: object) -> dict[str, object]: + base: dict[str, object] = { + "column_name": column_name, + "display_name": display_name, + "description": description, + "visible": 1, + "searchable": 0, + "editable": 0, + } + base.update(extra) + if column_name in _TS_COLUMN_NAMES: + base["display_type"] = "datetime_unix_seconds" + return base + + +_FUND_PROJECTS_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "编号", "主键", searchable=1), + _col("project_no", "项目编号", "项目唯一编号(财务/科研系统口径)。", searchable=1), + _col("project_name", "项目名称", "项目全称。", searchable=1, editable=1), + _col( + "project_type", + "项目类别", + "纵向 / 横向 / 其他。", + searchable=1, + editable=1, + options_json='[{"value":"纵向","label":"纵向"},{"value":"横向","label":"横向"},{"value":"其他","label":"其他"}]', + ), + _col("pi_name", "负责人", "项目负责人(PI)姓名。", searchable=1, editable=1), + _col("department", "所属学院", "二级学院/单位。", searchable=1, editable=1), + _col("total_approved", "总立项经费", "立项批复总经费(元)。", searchable=1, editable=1, display_type="number"), + _col("total_received", "已到账金额", "已到账经费(元),执行率分母。", searchable=1, editable=1, display_type="number"), + _col("total_spent", "已报销金额", "已报销支出(元),执行率分子。", searchable=1, editable=1, display_type="number"), + _col("frozen_amount", "冻结/暂付款", "冻结或暂付款金额(元)。", searchable=1, editable=1, display_type="number"), + _col("frozen_since", "暂付款最早发生日", "最早一笔暂付款发生日期(YYYY-MM-DD),用于账龄提醒。", editable=1), + _col("start_date", "执行期开始", "项目执行期开始日期(YYYY-MM-DD)。", searchable=1, editable=1), + _col("end_date", "执行期结束", "项目执行期结束日期(YYYY-MM-DD)。", searchable=1, editable=1), + _col( + "status", + "项目状态", + "在研 / 已结题 / 暂缓;播报默认只处理“在研”。", + searchable=1, + editable=1, + options_json='[{"value":"在研","label":"在研"},{"value":"已结题","label":"已结题"},{"value":"暂缓","label":"暂缓"}]', + ), + _col("remark", "备注", "维护说明。", editable=1), + _col("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳,自动生成)。"), + _col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳,自动维护)。"), +) + +_PROGRESS_REPORTS_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "报告编号", "主键,播报记录编号。", searchable=1), + _col("batch_no", "播报批次号", "同一次播报请求的批次号。", searchable=1), + _col("project_id", "项目编号(id)", "关联项目台账主键。", searchable=1), + _col("project_no", "项目编号", "项目唯一编号。", searchable=1), + _col("project_name", "项目名称", "项目全称。", searchable=1), + _col("pi_name", "负责人", "项目负责人姓名。", searchable=1), + _col("as_of", "评估基准日", "本次播报的数据截止日期(YYYY-MM-DD)。", searchable=1), + _col("spend_rate", "实际支出率", "已报销/已到账(小数)。", searchable=1, display_type="number"), + _col("time_progress", "序时进度", "已过天数/总执行天数(小数)。", searchable=1, display_type="number"), + _col("deviation", "偏差", "实际支出率 − 序时进度(小数,正=偏快,负=偏慢)。", searchable=1, display_type="number"), + _col( + "status_tag", + "执行状态", + "严重滞后 / 支出异常偏快 / 正常 / 待补录 / 未到账。", + searchable=1, + options_json='[{"value":"严重滞后","label":"🔴 严重滞后"},{"value":"支出异常偏快","label":"🟠 支出异常偏快"},{"value":"正常","label":"🟢 正常"},{"value":"待补录","label":"⚪ 待补录"},{"value":"未到账","label":"⚪ 未到账"}]', + ), + _col("summary", "播报摘要", "三段式播报文本(现状/诊断/建议)。", visible=0, display_type="textarea"), + _col("suggestions_json", "建议清单", "建议条目(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"), + _col("report_file", "简报文件", "《经费执行简报》HTML 文件路径。", visible=0), + _col("created_at", "创建时间", "报告创建时间(Unix 秒级时间戳,自动生成)。"), +) + +_ALERT_RULES_COLUMNS: tuple[dict[str, object], ...] = ( + _col("id", "编号", "主键", searchable=1), + _col("rule_key", "规则键", "阈值键名(lag_threshold / overshoot_threshold / pending_aging_days / closing_warn_days)。", searchable=1), + _col("display_name", "名称", "阈值中文名。", searchable=1), + _col("threshold_value", "阈值", "阈值数值(比例为小数,天数为正整数)。", searchable=1, editable=1, display_type="number"), + _col("description", "说明", "阈值口径说明。", searchable=1, editable=1), + _col("created_at", "创建时间", "记录创建时间(Unix 秒级,自动生成)。"), + _col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级,自动维护)。"), +) + + +def _init_fund_projects_metadata(cursor) -> None: + cursor.execute(_FUND_PROJECTS_DDL) + init_updated_at_trigger(cursor, "fund_projects") + upsert_table_metadata( + cursor, + table_name="fund_projects", + display_name="项目台账", + description="PI 名下项目的基础台账:立项/到账/报销/暂付款与执行周期;播报的数据源。", + grid_help="金额单位为元;顶栏按钮可录入项目,行内可直接编辑金额与日期;播报默认只处理“在研”项目。", + visible=1, + readonly=0, + default_sort_json='[{"column":"id","direction":"desc"}]', + default_visible_columns_json='["project_no","project_name","pi_name","department","total_approved","total_received","total_spent","status"]', + ) + seed_column_metadata_batch( + cursor, + table_name="fund_projects", + columns=_FUND_PROJECTS_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _FUND_PROJECTS_COLUMNS], + ) + + +def _init_progress_reports_metadata(cursor) -> None: + cursor.execute(_PROGRESS_REPORTS_DDL) + cursor.execute(_PROGRESS_REPORTS_INDEX) + upsert_table_metadata( + cursor, + table_name="progress_reports", + display_name="播报记录", + description="每次播报的项目执行状态、偏差与三段式摘要;可重建 HTML 简报。", + grid_help="“严重滞后/支出异常偏快”的行建议由科研秘书或财务处跟进;简报可用顶栏按钮重建。", + visible=1, + readonly=1, + default_sort_json='[{"column":"id","direction":"desc"}]', + default_visible_columns_json='["id","as_of","project_no","project_name","pi_name","spend_rate","time_progress","deviation","status_tag"]', + ) + seed_column_metadata_batch( + cursor, + table_name="progress_reports", + columns=_PROGRESS_REPORTS_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _PROGRESS_REPORTS_COLUMNS], + ) + + +def _init_alert_rules_metadata(cursor) -> None: + cursor.execute(_ALERT_RULES_DDL) + init_updated_at_trigger(cursor, "alert_rules") + upsert_table_metadata( + cursor, + table_name="alert_rules", + display_name="预警规则", + description="偏差诊断与提醒阈值(严重滞后/异常偏快/暂付款账龄/结题预警)。", + grid_help="比例为小数(0.10=10个百分点),天数为正整数;修改后下一次播报即生效。", + visible=1, + readonly=0, + ) + seed_column_metadata_batch( + cursor, + table_name="alert_rules", + columns=_ALERT_RULES_COLUMNS, + valid_column_names=[str(c["column_name"]) for c in _ALERT_RULES_COLUMNS], + ) + + +def init_db() -> dict[str, int]: + """幂等创建/迁移本地 SQLite、数据管理元数据与业务种子,返回种子统计。""" + conn = get_conn() + try: + cur = conn.cursor() + init_display_metadata(cur) + _init_fund_projects_metadata(cur) + _init_progress_reports_metadata(cur) + _init_alert_rules_metadata(cur) + seeded = seed_all(cur) + conn.commit() + return seeded + finally: + conn.close() diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py new file mode 100644 index 0000000..6fe3b3a --- /dev/null +++ b/scripts/db/display_metadata.py @@ -0,0 +1,324 @@ +"""匠厂数据管理展示元数据(_jiangchang_tables / _jiangchang_columns)。 + +SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。 +字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。 +""" + +from __future__ import annotations + +from typing import Iterable + +# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts) +INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) +LEGACY_METADATA_TABLES = frozenset({"_schema_meta"}) +METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES + +DATETIME_UNIX_SECONDS = "datetime_unix_seconds" +STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) + +_JIANGCHANG_TABLES_DDL = """ +CREATE TABLE IF NOT EXISTS _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + grid_help TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0, + default_sort_json TEXT, + default_visible_columns_json TEXT +) +""" + +_JIANGCHANG_COLUMNS_DDL = """ +CREATE TABLE IF NOT EXISTS _jiangchang_columns ( + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + grid_help TEXT, + display_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + searchable INTEGER NOT NULL DEFAULT 1, + editable INTEGER NOT NULL DEFAULT 1, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) +) +""" + +def _pragma_column_names(cursor, table_name: str) -> set[str]: + cursor.execute(f"PRAGMA table_info({table_name!r})") + return {str(row[1]) for row in cursor.fetchall()} + + +def _ensure_jiangchang_tables_columns(cursor) -> None: + """旧库补列:CREATE IF NOT EXISTS 不会给已有表加新字段。""" + cols = _pragma_column_names(cursor, "_jiangchang_tables") + if "default_sort_json" not in cols: + cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN default_sort_json TEXT") + if "default_visible_columns_json" not in cols: + cursor.execute( + "ALTER TABLE _jiangchang_tables ADD COLUMN default_visible_columns_json TEXT" + ) + if "grid_help" not in cols: + cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN grid_help TEXT") + + +def _ensure_jiangchang_columns_columns(cursor) -> None: + cols = _pragma_column_names(cursor, "_jiangchang_columns") + if "grid_help" not in cols: + cursor.execute("ALTER TABLE _jiangchang_columns ADD COLUMN grid_help TEXT") + + +def init_display_metadata_tables(cursor) -> None: + cursor.execute(_JIANGCHANG_TABLES_DDL) + cursor.execute(_JIANGCHANG_COLUMNS_DDL) + _ensure_jiangchang_tables_columns(cursor) + _ensure_jiangchang_columns_columns(cursor) + + +def upsert_table_metadata( + cursor, + *, + table_name: str, + display_name: str, + description: str | None = None, + grid_help: str | None = None, + visible: int = 1, + readonly: int = 0, + default_sort_json: str | None = None, + default_visible_columns_json: str | None = None, +) -> None: + """写入表级元数据。 + + default_sort_json: 宿主数据管理默认排序,JSON 数组,例如 + '[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]' + 为空则宿主回退到 updated_at → created_at → 主键。 + + default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如 + '["stat_type","period","stage"]' + 为空则全显;用户改过「列」后以本机偏好为准(软默认,区别于 visible=0 硬隐藏)。 + + grid_help: 数据管理表格上方说明;空则不出横幅。与表单用的 description 独立,禁止互拷。 + """ + cursor.execute( + """ + INSERT INTO _jiangchang_tables ( + table_name, display_name, description, grid_help, visible, readonly, + default_sort_json, default_visible_columns_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(table_name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + grid_help = excluded.grid_help, + visible = excluded.visible, + readonly = excluded.readonly, + default_sort_json = excluded.default_sort_json, + default_visible_columns_json = excluded.default_visible_columns_json + """, + ( + table_name, + display_name, + description, + grid_help, + visible, + readonly, + default_sort_json, + default_visible_columns_json, + ), + ) + + +def upsert_column_metadata( + cursor, + *, + table_name: str, + column_name: str, + display_name: str, + description: str | None = None, + grid_help: str | None = None, + visible: int = 1, + searchable: int = 1, + editable: int = 1, + display_type: str | None = None, + options_json: str | None = None, +) -> None: + # display_order 保留为 NULL:宿主按 PRAGMA table_info cid 展示字段,不读 display_order。 + # grid_help: 表头「?」;空则不出。与表单用的 description 独立,禁止互拷。 + cursor.execute( + """ + INSERT INTO _jiangchang_columns ( + table_name, column_name, display_name, description, grid_help, + display_order, visible, searchable, editable, display_type, options_json + ) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) + ON CONFLICT(table_name, column_name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + grid_help = excluded.grid_help, + display_order = NULL, + visible = excluded.visible, + searchable = excluded.searchable, + editable = excluded.editable, + display_type = excluded.display_type, + options_json = excluded.options_json + """, + ( + table_name, + column_name, + display_name, + description, + grid_help, + visible, + searchable, + editable, + display_type, + options_json, + ), + ) + + +def seed_column_metadata_batch( + cursor, + *, + table_name: str, + columns: Iterable[dict[str, object]], + valid_column_names: Iterable[str] | None = None, +) -> None: + """批量 upsert 字段元数据;传入 valid_column_names 时会在 upsert 后清理陈旧字段元数据。""" + for column in columns: + upsert_column_metadata( + cursor, + table_name=table_name, + column_name=str(column["column_name"]), + display_name=str(column["display_name"]), + description=str(column["description"]) if column.get("description") else None, + grid_help=str(column["grid_help"]) if column.get("grid_help") else None, + visible=int(column.get("visible", 1)), + searchable=int(column.get("searchable", 1)), + editable=int(column.get("editable", 1)), + display_type=str(column["display_type"]) if column.get("display_type") else None, + options_json=str(column["options_json"]) if column.get("options_json") else None, + ) + if valid_column_names is not None: + prune_stale_column_metadata(cursor, table_name, valid_column_names) + + +def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None: + """删除表中已不存在物理字段的元数据行(幂等初始化后不得残留旧字段)。""" + valid = set(valid_column_names) + cursor.execute( + "SELECT column_name FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + for (column_name,) in cursor.fetchall(): + if str(column_name) not in valid: + cursor.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?", + (table_name, column_name), + ) + + +def get_table_readonly(cursor, table_name: str) -> int | None: + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def get_column_editable(cursor, table_name: str, column_name: str) -> int | None: + cursor.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def list_editable_business_columns(cursor, table_name: str) -> list[str]: + """返回 editable=1 的业务字段名(不含宿主侧主键/generated 等二次过滤)。""" + physical = list_physical_column_names(cursor, table_name) + cursor.execute( + """ + SELECT column_name, editable + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + editable_set = { + str(column_name) + for column_name, editable in cursor.fetchall() + if editable is not None and int(editable) == 1 + } + return [name for name in physical if name in editable_set] + + +def table_allows_manual_create(cursor, table_name: str) -> bool: + """表级 readonly=0 且至少有一个 editable=1 字段时,宿主才应提供空白新增表单。""" + readonly = get_table_readonly(cursor, table_name) + if readonly is None or int(readonly) != 0: + return False + return bool(list_editable_business_columns(cursor, table_name)) + + +def init_display_metadata(cursor) -> None: + init_display_metadata_tables(cursor) + + +def drop_deprecated_user_table( + cursor, + table_name: str, + *, + triggers: Iterable[str] | None = None, +) -> None: + """永久退役用户可见表:删 trigger、DROP 物理表、清理展示元数据(幂等)。 + + 用于废弃 task_logs 等旧表。禁止只删元数据而保留物理表。 + 见 references/SCHEMA.md「表增删改与退役」。 + """ + for trigger_name in triggers or (): + cursor.execute(f"DROP TRIGGER IF EXISTS {trigger_name}") + default_updated_at = f"{table_name}_set_updated_at" + cursor.execute(f"DROP TRIGGER IF EXISTS {default_updated_at}") + cursor.execute(f"DROP TABLE IF EXISTS {_quote_identifier(table_name)}") + cursor.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + cursor.execute( + "DELETE FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + + +def iter_user_tables(cursor) -> Iterable[str]: + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + for (name,) in cursor.fetchall(): + if name in METADATA_TABLES: + continue + yield str(name) + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def list_physical_column_names(cursor, table_name: str) -> list[str]: + cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})") + rows = cursor.fetchall() + rows.sort(key=lambda row: int(row[0])) + return [str(row[1]) for row in rows] diff --git a/scripts/db/display_metadata_validator.py b/scripts/db/display_metadata_validator.py new file mode 100644 index 0000000..42db9d1 --- /dev/null +++ b/scripts/db/display_metadata_validator.py @@ -0,0 +1,592 @@ +"""数据管理展示元数据校验(模板开发自检,不依赖宿主)。""" + +from __future__ import annotations + +import json +import os +import re +import sqlite3 +from dataclasses import dataclass, field + +from db.display_metadata import ( + DATETIME_UNIX_SECONDS, + METADATA_TABLES, + STANDARD_TIMESTAMP_COLUMNS, + iter_user_tables, + list_editable_business_columns, + list_physical_column_names, + table_allows_manual_create, +) +from db.timestamp_columns import has_updated_at_trigger +from util.constants import SKILL_SLUG +from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics + +_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"}) +_ENGLISH_PLACEHOLDER_NAME_PATTERNS = ( + re.compile(r"^my\s+skill$", re.I), + re.compile(r"^your[-_\s]?skill", re.I), + re.compile(r"^skill[-_\s]?template$", re.I), + re.compile(r"^[a-z0-9_-]+$"), +) +_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_FK_DDL_PATTERN = re.compile(r"\bFOREIGN\s+KEY\b|\bREFERENCES\b", re.I) + + +@dataclass +class ValidationReport: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +def _has_cjk(text: str) -> bool: + return bool(_CJK_RE.search(text)) + + +def _looks_like_raw_physical_name(display_name: str, physical_name: str) -> bool: + if not display_name.strip(): + return True + if display_name.strip().lower() == physical_name.strip().lower(): + return True + compact = display_name.replace(" ", "").replace("_", "").lower() + if compact == physical_name.replace("_", "").lower(): + return True + return False + + +def _parse_frontmatter_field(text: str, field_name: str) -> str: + parts = text.split("---", 2) + if len(parts) < 2: + return "" + for line in parts[1].splitlines(): + match = re.match(rf"^{re.escape(field_name)}:\s*(.+)\s*$", line.strip()) + if match: + return match.group(1).strip().strip("\"'") + return "" + + +def _parse_openclaw_slug(text: str) -> str: + parts = text.split("---", 2) + if len(parts) < 2: + return "" + lines = parts[1].splitlines() + for index, line in enumerate(lines): + if re.match(r"^\s+openclaw:\s*$", line): + for inner in lines[index + 1 :]: + if inner.strip() and inner[0] not in (" ", "\t"): + break + match = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner) + if match: + return match.group(2).strip().strip("\"'") + return "" + + +def parse_skill_frontmatter(skill_root: str) -> dict[str, str]: + path = os.path.join(skill_root, "SKILL.md") + with open(path, encoding="utf-8") as f: + text = f.read() + return { + "name": _parse_frontmatter_field(text, "name"), + "slug": _parse_openclaw_slug(text), + } + + +def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: bool = True) -> ValidationReport: + report = ValidationReport() + data = parse_skill_frontmatter(skill_root) + name = str(data.get("name") or "").strip() + slug = str(data.get("slug") or "").strip() + + if not name: + report.errors.append("SKILL.md frontmatter.name 缺失") + elif not _has_cjk(name): + if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS: + report.warnings.append("SKILL.md frontmatter.name 建议使用清晰的中文业务名称(模板占位可暂保留)") + else: + report.errors.append("SKILL.md frontmatter.name 应使用用户可见的中文业务名称") + else: + for pattern in _ENGLISH_PLACEHOLDER_NAME_PATTERNS: + if pattern.fullmatch(name): + if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS: + report.warnings.append(f"SKILL.md frontmatter.name 仍为占位风格:{name!r}") + else: + report.errors.append(f"SKILL.md frontmatter.name 不应为英文占位名:{name!r}") + break + + if not slug: + report.errors.append("SKILL.md metadata.openclaw.slug 缺失") + elif not _KEBAB_SLUG.fullmatch(slug): + report.errors.append(f"metadata.openclaw.slug 必须为 kebab-case:{slug!r}") + elif slug in _TEMPLATE_PLACEHOLDER_SLUGS and not allow_template_placeholders: + report.errors.append(f"metadata.openclaw.slug 仍为模板占位:{slug!r}") + + constants_slug = SKILL_SLUG.strip() + if slug and constants_slug and slug != constants_slug: + report.warnings.append( + f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致" + ) + + if slug and slug not in LEGACY_EXEMPT_SLUGS and _KEBAB_SLUG.fullmatch(slug): + naming_errors, naming_warnings = validate_slug_semantics( + slug, + strict=not allow_template_placeholders, + ) + report.errors.extend(naming_errors) + report.warnings.extend(naming_warnings) + + return report + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]: + cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})") + info: dict[str, dict[str, object]] = {} + for row in cursor.fetchall(): + info[str(row[1])] = { + "cid": int(row[0]), + "name": str(row[1]), + "type": str(row[2] or ""), + "notnull": int(row[3] or 0), + "dflt_value": row[4], + "pk": int(row[5] or 0), + } + return info + + +def _table_column_xinfo(cursor, table_name: str) -> dict[str, dict[str, object]]: + """PRAGMA table_xinfo 可识别 generated/hidden 字段(SQLite 3.31+)。""" + cursor.execute(f"PRAGMA table_xinfo({_quote_identifier(table_name)})") + info: dict[str, dict[str, object]] = {} + for row in cursor.fetchall(): + hidden = int(row[6] or 0) if len(row) > 6 else 0 + info[str(row[1])] = { + "cid": int(row[0]), + "name": str(row[1]), + "type": str(row[2] or ""), + "notnull": int(row[3] or 0), + "dflt_value": row[4], + "pk": int(row[5] or 0), + "hidden": hidden, + } + return info + + +def _is_generated_column(xinfo: dict[str, object]) -> bool: + hidden = int(xinfo.get("hidden") or 0) + return hidden in (2, 3) + + +def _is_blob_column(column_info: dict[str, object]) -> bool: + return "BLOB" in str(column_info.get("type") or "").upper() + + +def _default_uses_unixepoch(default_value: object) -> bool: + if default_value is None: + return False + text = str(default_value).strip().lower() + return "unixepoch" in text + + +def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]: + """校验 options_json:静态枚举数组,或 lookup 对象。""" + errors: list[str] = [] + warnings: list[str] = [] + if options_json is None or str(options_json).strip() == "": + return errors, warnings + try: + parsed = json.loads(str(options_json)) + except json.JSONDecodeError as exc: + errors.append(f"options_json 不是合法 JSON:{exc}") + return errors, warnings + + if isinstance(parsed, dict): + for key in ("ref_table", "value_column", "label_column"): + if not str(parsed.get(key) or "").strip(): + errors.append(f"options_json lookup 必须包含非空 {key}") + ident = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + for key in ("ref_table", "value_column", "label_column", "filter_column"): + raw = parsed.get(key) + if raw is None or raw == "": + continue + if not ident.match(str(raw)): + errors.append(f"options_json.{key} 必须是安全标识符,当前为 {raw!r}") + if parsed.get("filter_column") not in (None, "") and "filter_value" not in parsed: + warnings.append("options_json lookup 声明了 filter_column 但未提供 filter_value") + if "multiple" in parsed and not isinstance(parsed.get("multiple"), bool): + errors.append("options_json lookup.multiple 必须是布尔值") + return errors, warnings + + if not isinstance(parsed, list): + errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)") + return errors, warnings + for index, item in enumerate(parsed): + if not isinstance(item, dict): + errors.append(f"options_json[{index}] 必须是对象") + continue + if "value" not in item or "label" not in item: + errors.append(f"options_json[{index}] 必须包含 value 与 label") + return errors, warnings + + +def validate_filter_search_metadata( + cursor, + table_name: str, +) -> tuple[list[str], list[str]]: + """对可见但未开启 searchable 的枚举 / boolean 列给出软建议。""" + warnings: list[str] = [] + cursor.execute( + """ + SELECT column_name, visible, searchable, display_type, options_json + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + for column_name, visible, searchable, display_type, options_json in cursor.fetchall(): + if visible is not None and int(visible) == 0: + continue + if searchable is not None and int(searchable) != 0: + continue + + display_type_norm = str(display_type or "").strip() + is_boolean = display_type_norm == "boolean" + + has_enum = False + if options_json is not None and str(options_json).strip(): + opt_errors, _ = validate_options_json(options_json) + if not opt_errors: + try: + parsed = json.loads(str(options_json)) + except json.JSONDecodeError: + parsed = None + has_enum = isinstance(parsed, list) and len(parsed) > 0 + + if not has_enum and not is_boolean: + continue + + warnings.append( + f"{table_name}.{column_name}: 枚举/状态类列建议 searchable=1," + "以便宿主列筛选与按中文 label 全局搜索(见 references/SCHEMA.md)" + ) + + return [], warnings + + +def validate_column_permissions( + cursor, + table_name: str, + *, + physical_columns: list[str] | None = None, +) -> tuple[list[str], list[str]]: + """校验常见系统字段 editable=0,并识别技术上可写但不适合手工新增的表。""" + errors: list[str] = [] + warnings: list[str] = [] + ordered = physical_columns or list_physical_column_names(cursor, table_name) + column_info = _table_column_info(cursor, table_name) + column_xinfo = _table_column_xinfo(cursor, table_name) + + cursor.execute( + """ + SELECT column_name, editable, options_json + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + meta_by_column = { + str(row[0]): {"editable": row[1], "options_json": row[2]} + for row in cursor.fetchall() + } + + for column_name, meta in meta_by_column.items(): + physical = column_info.get(column_name) or {} + xinfo = column_xinfo.get(column_name) or physical + if not physical and column_name not in column_xinfo: + continue + + pk = int(physical.get("pk") or xinfo.get("pk") or 0) + editable = meta["editable"] + if pk and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是主键,editable 应为 0") + + if column_name == "id" and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.id 的 editable 应为 0(自增主键)") + + if _is_generated_column(xinfo) and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是 generated 字段,editable 应为 0") + + if _is_blob_column(physical or xinfo) and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是 BLOB 字段,editable 应为 0") + + opt_errors, _ = validate_options_json(meta.get("options_json")) + for item in opt_errors: + errors.append(f"{table_name}.{column_name}: {item}") + + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + readonly_row = cursor.fetchone() + readonly = int(readonly_row[0]) if readonly_row else None + editable_columns = list_editable_business_columns(cursor, table_name) + if readonly == 0 and not editable_columns: + warnings.append( + f"{table_name}: 表级 readonly=0 但所有字段 editable=0," + "宿主不应打开空白新增表单;数据应由技能业务流程产生" + ) + if readonly == 1 and editable_columns: + warnings.append( + f"{table_name}: 表级 readonly=1 但存在 editable=1 字段," + "宿主应以表级只读为准" + ) + + return errors, warnings + + +def _expected_timestamp_display_name(column_name: str) -> str: + if column_name == "created_at": + return "创建时间" + if column_name == "updated_at": + return "更新时间" + return "" + + +def validate_standard_timestamp_columns( + cursor, + table_name: str, + *, + physical_columns: list[str] | None = None, +) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + ordered = physical_columns or list_physical_column_names(cursor, table_name) + column_info = _table_column_info(cursor, table_name) + + for column_name in STANDARD_TIMESTAMP_COLUMNS: + if column_name not in ordered: + continue + + physical = column_info.get(column_name) + if not physical: + errors.append(f"{table_name}.{column_name} 缺少物理字段定义") + continue + + if "INT" not in str(physical["type"]).upper(): + errors.append( + f"{table_name}.{column_name} 字段类型应为 INTEGER(Unix 秒级时间戳),当前为 {physical['type']!r}" + ) + + if column_name == "created_at" and not _default_uses_unixepoch(physical["dflt_value"]): + errors.append( + f"{table_name}.created_at 应设置数据库默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}" + ) + if column_name == "updated_at" and not _default_uses_unixepoch(physical["dflt_value"]): + errors.append( + f"{table_name}.updated_at 应设置数据库初始默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}" + ) + + cursor.execute( + """ + SELECT display_name, display_type, editable, searchable, display_order + FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + meta_row = cursor.fetchone() + if not meta_row: + errors.append(f"{table_name}.{column_name} 缺少 _jiangchang_columns 元数据") + continue + + display_name, display_type, editable, searchable, display_order = meta_row + expected_name = _expected_timestamp_display_name(column_name) + if str(display_name or "").strip() != expected_name: + errors.append( + f"{table_name}.{column_name} 的 display_name 应为 {expected_name!r},当前为 {display_name!r}" + ) + if str(display_type or "").strip() != DATETIME_UNIX_SECONDS: + errors.append( + f"{table_name}.{column_name} 的 display_type 应为 {DATETIME_UNIX_SECONDS!r},当前为 {display_type!r}" + ) + if editable is None or int(editable) != 0: + errors.append(f"{table_name}.{column_name} 的 editable 应为 0(系统时间字段不可编辑)") + if display_order is not None: + errors.append(f"{table_name}.{column_name} 的 display_order 应为 NULL") + if searchable is not None and int(searchable) != 0: + warnings.append(f"{table_name}.{column_name} 的时间字段通常不必开启 searchable") + + if "created_at" in ordered and "updated_at" in ordered: + if ordered.index("updated_at") < ordered.index("created_at"): + warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前") + + if "updated_at" in ordered and not has_updated_at_trigger(cursor, table_name): + errors.append( + f"{table_name}.updated_at 缺少自动维护 trigger(期望 {table_name}_set_updated_at)" + ) + + return errors, warnings + + +def column_order_warnings(table_name: str, ordered_names: list[str]) -> list[str]: + warnings: list[str] = [] + if "id" in ordered_names and ordered_names.index("id") != 0: + warnings.append(f"{table_name}: 字段 id 通常应定义在 CREATE TABLE 的第一列") + if "created_at" in ordered_names: + idx = ordered_names.index("created_at") + trailing = ordered_names[idx + 1 :] + if trailing and trailing != ["updated_at"]: + warnings.append( + f"{table_name}: created_at 之后通常只应跟 updated_at,不应再放置其他业务字段" + ) + if "updated_at" in ordered_names and "created_at" in ordered_names: + if ordered_names.index("updated_at") < ordered_names.index("created_at"): + warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前") + return warnings + + +def _table_exists(cursor, table_name: str) -> bool: + cursor.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + (table_name,), + ) + return cursor.fetchone() is not None + + +def validate_no_sqlite_foreign_keys(cursor) -> tuple[list[str], list[str]]: + """业务表 DDL 不得含 FOREIGN KEY / REFERENCES(关联走 repository/service)。""" + errors: list[str] = [] + cursor.execute( + """ + SELECT name, sql FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + """ + ) + for table_name, sql in cursor.fetchall(): + if table_name in METADATA_TABLES: + continue + ddl = str(sql or "") + if ddl and _FK_DDL_PATTERN.search(ddl): + errors.append( + f"业务表 {table_name!r} 的 DDL 含 SQLite FOREIGN KEY/REFERENCES;" + "请改为业务 id 列 + repository/service 逻辑关联(见 references/SCHEMA.md)" + ) + return errors, [] + + +def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport: + report = ValidationReport() + cur = conn.cursor() + + has_tables_meta = _table_exists(cur, "_jiangchang_tables") + has_columns_meta = _table_exists(cur, "_jiangchang_columns") + if not has_tables_meta: + report.errors.append("缺少数据管理元数据表 _jiangchang_tables") + if not has_columns_meta: + report.errors.append("缺少数据管理元数据表 _jiangchang_columns") + if not has_tables_meta or not has_columns_meta: + return report + + fk_errors, _ = validate_no_sqlite_foreign_keys(cur) + report.errors.extend(fk_errors) + + for table_name in iter_user_tables(cur): + cur.execute( + "SELECT display_name FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cur.fetchone() + if not row or not str(row[0] or "").strip(): + report.errors.append(f"业务表 {table_name!r} 缺少 _jiangchang_tables.display_name") + elif not _has_cjk(str(row[0])): + report.errors.append(f"业务表 {table_name!r} 的 display_name 应使用中文业务名称") + + physical_columns = list_physical_column_names(cur, table_name) + if check_column_order: + report.warnings.extend(column_order_warnings(table_name, physical_columns)) + + for column_name in physical_columns: + cur.execute( + """ + SELECT display_name, visible + FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + col_row = cur.fetchone() + if not col_row: + report.errors.append( + f"字段 {table_name}.{column_name} 缺少 _jiangchang_columns 元数据" + ) + continue + display_name = str(col_row[0] or "").strip() + visible = col_row[1] is None or int(col_row[1]) != 0 + if visible and not display_name: + report.errors.append(f"字段 {table_name}.{column_name} 的 display_name 为空") + elif visible and _looks_like_raw_physical_name(display_name, column_name): + report.errors.append( + f"字段 {table_name}.{column_name} 的 display_name 不应直接等于英文物理名:{display_name!r}" + ) + elif visible and not _has_cjk(display_name): + report.errors.append( + f"字段 {table_name}.{column_name} 的 display_name 应使用中文:{display_name!r}" + ) + + ts_errors, ts_warnings = validate_standard_timestamp_columns( + cur, + table_name, + physical_columns=physical_columns, + ) + report.errors.extend(ts_errors) + report.warnings.extend(ts_warnings) + + perm_errors, perm_warnings = validate_column_permissions( + cur, + table_name, + physical_columns=physical_columns, + ) + report.errors.extend(perm_errors) + report.warnings.extend(perm_warnings) + + _, filter_warnings = validate_filter_search_metadata(cur, table_name) + report.warnings.extend(filter_warnings) + + cur.execute("SELECT table_name, column_name FROM _jiangchang_columns") + for table_name, column_name in cur.fetchall(): + if table_name in METADATA_TABLES: + continue + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?", + (table_name,), + ) + if not cur.fetchone(): + report.errors.append(f"元数据引用了不存在的表:{table_name!r}") + continue + physical = list_physical_column_names(cur, str(table_name)) + if str(column_name) not in physical: + report.errors.append(f"元数据引用了不存在的字段:{table_name}.{column_name}") + + cur.execute("SELECT table_name FROM _jiangchang_tables") + for (table_name,) in cur.fetchall(): + if table_name in METADATA_TABLES: + continue + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?", + (table_name,), + ) + if not cur.fetchone(): + report.errors.append(f"元数据引用了不存在的表:{table_name!r}") + + return report + + +def validate_template_database(conn: sqlite3.Connection) -> ValidationReport: + """模板自检入口:校验展示元数据完整性,并扫描业务表 DDL 是否含 SQLite 外键。""" + return validate_display_metadata(conn) diff --git a/scripts/db/seed_data.py b/scripts/db/seed_data.py new file mode 100644 index 0000000..c884f23 --- /dev/null +++ b/scripts/db/seed_data.py @@ -0,0 +1,63 @@ +"""业务种子数据:预警规则阈值。 + +init-db 幂等写入(存在即跳过,不覆盖管理员修改)。 +阈值缺省口径来自《核心建设内容:各部门"Skill"深度需求规格书》财务处 Skill 002: +低于序时进度 10% 记"严重滞后",高于 20% 记"支出异常偏快";暂付款账龄 90 天。 +""" + +from __future__ import annotations + +import sqlite3 + +from util.timeutil import now_unix + +# (rule_key, display_name, threshold_value, description) +_SEED_ALERT_RULES: tuple[tuple[str, str, float, str], ...] = ( + ( + "lag_threshold", + "严重滞后阈值", + 0.10, + "实际支出率低于序时进度该比例(小数,0.10=10个百分点)时标记“严重滞后”。", + ), + ( + "overshoot_threshold", + "支出异常偏快阈值", + 0.20, + "实际支出率高于序时进度该比例(小数,0.20=20个百分点)时标记“支出异常偏快”,预警年底突击花钱。", + ), + ( + "pending_aging_days", + "暂付款账龄提醒天数", + 90.0, + "暂付款自最早发生日起超过该天数(天)时提醒及时冲销。", + ), + ( + "closing_warn_days", + "结题临近预警天数", + 90.0, + "距执行期结束不足该天数(天)且支出率低于 80% 时提醒尽快完成报销。", + ), +) + + +def seed_alert_rules(cursor: sqlite3.Cursor) -> int: + """幂等写入预警规则种子,返回新增条数。""" + inserted = 0 + for rule_key, display_name, threshold_value, description in _SEED_ALERT_RULES: + cursor.execute("SELECT COUNT(*) FROM alert_rules WHERE rule_key = ?", (rule_key,)) + if int(cursor.fetchone()[0]) > 0: + continue + cursor.execute( + """ + INSERT INTO alert_rules (rule_key, display_name, threshold_value, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (rule_key, display_name, threshold_value, description, now_unix(), now_unix()), + ) + inserted += 1 + return inserted + + +def seed_all(cursor: sqlite3.Cursor) -> dict[str, int]: + """写入全部业务种子,返回各类新增条数。""" + return {"alert_rules": seed_alert_rules(cursor)} diff --git a/scripts/db/timestamp_columns.py b/scripts/db/timestamp_columns.py new file mode 100644 index 0000000..95245ab --- /dev/null +++ b/scripts/db/timestamp_columns.py @@ -0,0 +1,50 @@ +"""标准时间字段:Unix 秒级时间戳、默认值与 updated_at 自动维护。 + +业务表若包含 created_at / updated_at,在 CREATE TABLE 后调用 +``init_updated_at_trigger(cursor, table_name)``。模板默认不建业务表。 +""" + +from __future__ import annotations + +STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) +DATETIME_UNIX_SECONDS = "datetime_unix_seconds" + + +def updated_at_trigger_name(table_name: str) -> str: + return f"{table_name}_set_updated_at" + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def init_updated_at_trigger(cursor, table_name: str) -> None: + """幂等创建 {table}_set_updated_at:业务字段 UPDATE 后刷新 updated_at。""" + trigger = updated_at_trigger_name(table_name) + quoted = _quote_identifier(table_name) + quoted_trigger = _quote_identifier(trigger) + cursor.execute( + f""" +CREATE TRIGGER IF NOT EXISTS {quoted_trigger} +AFTER UPDATE ON {quoted} +FOR EACH ROW +BEGIN + UPDATE {quoted} + SET updated_at = unixepoch() + WHERE id = NEW.id AND updated_at = OLD.updated_at; +END +""" + ) + + +def has_updated_at_trigger(cursor, table_name: str) -> bool: + trigger_name = updated_at_trigger_name(table_name) + cursor.execute( + """ + SELECT 1 FROM sqlite_master + WHERE type = 'trigger' AND name = ? AND tbl_name = ? + LIMIT 1 + """, + (trigger_name, table_name), + ) + return cursor.fetchone() is not None diff --git a/scripts/main.py b/scripts/main.py new file mode 100644 index 0000000..4084c53 --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +skill-template CLI 入口。 + +复制为新技能后,请修改注释与常量,但保留当前分层结构: +cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。 + +jiangchang_skill_core 来自宿主共享 Python runtime 安装的 jiangchang-platform-kit, +不在技能仓库内 vendored。 +""" +from __future__ import annotations + +import os +import sys + +if sys.platform == "win32": + import io + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +_scripts_dir = os.path.dirname(os.path.abspath(__file__)) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) + +from jiangchang_skill_core.runtime_env import apply_cli_local_defaults + +apply_cli_local_defaults() + +from util.config_bootstrap import bootstrap_skill_config + +bootstrap_skill_config() + +from cli.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/service/entitlement_service.py b/scripts/service/entitlement_service.py new file mode 100644 index 0000000..176ea11 --- /dev/null +++ b/scripts/service/entitlement_service.py @@ -0,0 +1,46 @@ +"""在线鉴权(可选模板)。""" + +from __future__ import annotations + +from typing import Tuple + +import requests +from jiangchang_skill_core import config + + +def check_entitlement(skill_slug: str) -> Tuple[bool, str]: + auth_base = (config.get("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/") + if not auth_base: + return True, "" + user_id = (config.get("JIANGCHANG_USER_ID") or "").strip() + if not user_id: + return False, "鉴权失败:缺少用户身份(JIANGCHANG_USER_ID)" + + auth_api_key = (config.get("JIANGCHANG_AUTH_API_KEY") or "").strip() + timeout = int((config.get("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip()) + headers = {"Content-Type": "application/json"} + if auth_api_key: + headers["Authorization"] = f"Bearer {auth_api_key}" + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": (config.get("JIANGCHANG_TRACE_ID") or "").strip(), + "context": {"entry": "main.py"}, + } + try: + res = requests.post(f"{auth_base}/api/entitlements/check", json=payload, headers=headers, timeout=timeout) + except requests.RequestException as exc: + return False, f"鉴权请求失败:{exc}" + if res.status_code != 200: + return False, f"鉴权服务异常:HTTP {res.status_code}" + try: + body = res.json() + except ValueError: + return False, "鉴权服务异常:返回非 JSON" + code = body.get("code") + data = body.get("data") or {} + if code != 200: + return False, str(body.get("msg") or "鉴权失败") + if not data.get("allow", False): + return False, str(data.get("reason") or "未购买或已过期") + return True, "" diff --git a/scripts/service/fund_service.py b/scripts/service/fund_service.py new file mode 100644 index 0000000..a939b99 --- /dev/null +++ b/scripts/service/fund_service.py @@ -0,0 +1,511 @@ +"""项目经费执行监测与播报内核(唯一业务内核)。 + +三步计算(对齐《核心建设内容:各部门"Skill"深度需求规格书》财务处 Skill 002): + Step 1 执行率计算:实际支出率 = 已报销/已到账;序时进度 = 已过天数/总执行天数 + Step 2 偏差诊断:低于序时进度超 lag_threshold → 严重滞后;高于 overshoot_threshold → 支出异常偏快 + Step 3 提醒与建议:暂付款账龄、结题临近、三段式播报摘要(规则模板,数字全部本地计算,不编造) + +阈值存 alert_rules 表(管理员可调);播报结果写 progress_reports 并生成《经费执行简报》。 +播报仅作提醒,请以财务系统账面为准。 +""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import date +from typing import Any, Optional + +from db.connection import get_conn, init_db +from util.logging_config import get_skill_logger +from util.timeutil import now_unix + +STATUS_LAG = "严重滞后" +STATUS_OVERSHOOT = "支出异常偏快" +STATUS_NORMAL = "正常" +STATUS_INCOMPLETE = "待补录" +STATUS_NO_FUND = "未到账" + +_DEFAULT_RULES = { + "lag_threshold": 0.10, + "overshoot_threshold": 0.20, + "pending_aging_days": 90.0, + "closing_warn_days": 90.0, +} + +_KEY_ALIASES = { + "projectNo": ("project_no", "项目编号", "编号"), + "projectName": ("project_name", "项目名称", "名称"), + "projectType": ("project_type", "项目类别", "类别"), + "piName": ("pi_name", "负责人", "pi"), + "department": ("department", "所属学院", "学院"), + "totalApproved": ("total_approved", "总立项经费", "立项经费"), + "totalReceived": ("total_received", "已到账金额", "已到账", "到账金额"), + "totalSpent": ("total_spent", "已报销金额", "已报销", "报销金额"), + "frozenAmount": ("frozen_amount", "冻结/暂付款", "暂付款金额", "冻结金额"), + "frozenSince": ("frozen_since", "暂付款最早发生日", "暂付款日期"), + "startDate": ("start_date", "执行期开始", "开始日期"), + "endDate": ("end_date", "执行期结束", "结束日期"), + "status": ("status", "项目状态", "状态"), + "remark": ("remark", "备注"), +} + +_STATUS_RANK = {STATUS_INCOMPLETE: 0, STATUS_NO_FUND: 1, STATUS_NORMAL: 2, STATUS_OVERSHOOT: 3, STATUS_LAG: 4} + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def _norm_project(raw: dict[str, Any]) -> dict[str, Any]: + """归一化台账键(兼容 camelCase / snake_case / 常见中文键)。""" + out: dict[str, Any] = {} + for key, value in raw.items(): + camel = _camel(key) + if camel in _KEY_ALIASES: + out.setdefault(camel, value) + continue + matched = False + for canonical, aliases in _KEY_ALIASES.items(): + if key in aliases: + out.setdefault(canonical, value) + matched = True + break + if not matched: + out.setdefault(camel, value) + return out + + +def _camel(name: str) -> str: + parts = re.split(r"[\s_]+", str(name).strip()) + if len(parts) <= 1: + return str(name) + head, *rest = parts + return head + "".join(p[:1].upper() + p[1:] for p in rest) + + +def _to_float(value: Any) -> Optional[float]: + if value is None or value == "": + return None + if isinstance(value, (int, float)): + return float(value) + text = re.sub(r"[元,,\s]", "", str(value).strip()) + try: + return float(text) + except ValueError: + return None + + +def _parse_date(value: Any) -> Optional[date]: + if value is None or value == "": + return None + if isinstance(value, date): + return value + text = str(value).strip() + for pattern in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y年%m月%d日"): + try: + from datetime import datetime + + return datetime.strptime(text, pattern).date() + except ValueError: + continue + return None + + +def _fmt_date(value: Optional[date]) -> str: + return value.isoformat() if value else "" + + +def _wan(value: Optional[float]) -> str: + if value is None: + return "—" + return f"{value / 10000:.2f} 万元" + + +def _pct(value: Optional[float]) -> str: + return "—" if value is None else f"{value * 100:.1f}%" + + +def load_rules() -> dict[str, float]: + """读取预警阈值(缺失时回退缺省值)。""" + rules = dict(_DEFAULT_RULES) + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT rule_key, threshold_value FROM alert_rules") + for rule_key, threshold_value in cur.fetchall(): + try: + rules[str(rule_key)] = float(threshold_value) + except (TypeError, ValueError): + continue + finally: + conn.close() + return rules + + +def _item(rule_id: str, name: str, level: str, detail: str, suggestion: str = "") -> dict[str, str]: + return {"rule_id": rule_id, "name": name, "level": level, "detail": detail, "suggestion": suggestion} + + +def _time_progress(start: Optional[date], end: Optional[date], as_of: date) -> Optional[float]: + if not start or not end or end <= start: + return None + if as_of <= start: + return 0.0 + if as_of >= end: + return 1.0 + return round((as_of - start).days / (end - start).days, 4) + + +def compute_project(project: dict[str, Any], rules: dict[str, float], as_of: date) -> dict[str, Any]: + """对单个项目执行三步计算,返回播报结果(不落库)。""" + items: list[dict[str, str]] = [] + project_no = str(project.get("projectNo") or "").strip() or "未编号" + project_name = str(project.get("projectName") or "").strip() or project_no + + total_received = _to_float(project.get("totalReceived")) + total_spent = _to_float(project.get("totalSpent")) + total_approved = _to_float(project.get("totalApproved")) + start = _parse_date(project.get("startDate")) + end = _parse_date(project.get("endDate")) + + # C3 台账完整性 + missing = [ + label + for key, label in ( + ("totalApproved", "总立项经费"), + ("totalReceived", "已到账金额"), + ("totalSpent", "已报销金额"), + ("startDate", "执行期开始"), + ("endDate", "执行期结束"), + ) + if ( + _to_float(project.get(key)) is None + if key.startswith("total") + else project.get(key) in (None, "") + ) + ] + if missing: + items.append( + _item( + "C3", + "台账完整性", + "warn", + f"以下台账字段缺失:{'、'.join(missing)},本期数字可能失真,请补录。", + "请在数据管理“项目台账”中补齐后重新播报。", + ) + ) + + # C1 实际支出率 + spend_rate: Optional[float] = None + if total_received is not None and total_received <= 0: + items.append( + _item( + "C1", + "实际支出率", + "warn", + "经费尚未到账,无法计算执行率。", + "到账后自动纳入正常播报;如长期未到账请联系财务处核实拨款进度。", + ) + ) + elif total_received is not None and total_spent is not None: + spend_rate = round(total_spent / total_received, 4) + + # C2 序时进度 + time_progress = _time_progress(start, end, as_of) + if time_progress is None: + items.append( + _item( + "C2", + "序时进度", + "warn", + "执行期起止缺失或无效,无法计算序时进度。", + "请在项目台账补录执行期起止日期。", + ) + ) + + # D1/D2/D3 偏差诊断 + status_tag = STATUS_NORMAL + deviation: Optional[float] = None + if spend_rate is not None and time_progress is not None: + deviation = round(spend_rate - time_progress, 4) + lag = float(rules.get("lag_threshold", _DEFAULT_RULES["lag_threshold"])) + overshoot = float(rules.get("overshoot_threshold", _DEFAULT_RULES["overshoot_threshold"])) + if deviation < -lag: + status_tag = STATUS_LAG + items.append( + _item( + "D1", + "严重滞后", + "alert", + f"实际支出率 {_pct(spend_rate)} 低于序时进度 {_pct(time_progress)} 超过 {_pct(lag)},偏差 {_pct(deviation)}。", + "请对照研究计划排查滞后原因;如确需调整,请尽早与财务处沟通预算调剂或申请延期。", + ) + ) + elif deviation > overshoot: + status_tag = STATUS_OVERSHOOT + items.append( + _item( + "D2", + "支出异常偏快", + "warn", + f"实际支出率 {_pct(spend_rate)} 高于序时进度 {_pct(time_progress)} 超过 {_pct(overshoot)},偏差 +{_pct(deviation)},存在年底经费不足风险。", + "请合理安排后续支出节奏,避免年底突击花钱或经费提前耗尽。", + ) + ) + else: + status_tag = STATUS_NORMAL + items.append( + _item( + "D3", + "执行正常", + "pass", + f"实际支出率 {_pct(spend_rate)} 与序时进度 {_pct(time_progress)} 基本匹配(偏差 {_pct(deviation)})。", + "", + ) + ) + + # N1 暂付款账龄 + frozen_amount = _to_float(project.get("frozenAmount")) + frozen_since = _parse_date(project.get("frozenSince")) + if frozen_amount is not None and frozen_amount > 0: + aging_days = (as_of - frozen_since).days if frozen_since else None + pending_days = float(rules.get("pending_aging_days", _DEFAULT_RULES["pending_aging_days"])) + if aging_days is None: + items.append( + _item( + "N1", + "暂付款冲销提醒", + "info", + f"项目有暂付款 {_wan(frozen_amount)},但台账未登记最早发生日,无法计算账龄。", + "请补录暂付款发生日期,以便跟踪冲销时限。", + ) + ) + elif aging_days > pending_days: + items.append( + _item( + "N1", + "暂付款冲销提醒", + "info", + f"暂付款 {_wan(frozen_amount)} 最早发生于 {frozen_since.isoformat()},账龄已 {aging_days} 天(超 {pending_days:.0f} 天)。", + "请及时办理暂付款冲销或核销手续。", + ) + ) + + # N2 结题临近 + if end: + days_left = (end - as_of).days + closing_days = float(rules.get("closing_warn_days", _DEFAULT_RULES["closing_warn_days"])) + if days_left < 0: + items.append( + _item( + "N2", + "结题决算提醒", + "info", + f"项目执行期已于 {end.isoformat()} 结束。", + "请尽快完成支出归集与结题决算。", + ) + ) + elif days_left <= closing_days and (spend_rate is None or spend_rate < 0.8): + rate_text = _pct(spend_rate) if spend_rate is not None else "暂无支出率" + items.append( + _item( + "N2", + "结题临近提醒", + "info", + f"距执行期结束({end.isoformat()})仅剩 {days_left} 天,当前支出率 {rate_text}。", + "请尽快完成剩余科目报销,避免结题时经费大量沉淀。", + ) + ) + + # 状态修正:无法计算时归为未到账/待补录 + if spend_rate is None and status_tag == STATUS_NORMAL: + has_no_fund = any(i["rule_id"] == "C1" for i in items) + status_tag = STATUS_NO_FUND if has_no_fund else STATUS_INCOMPLETE + + suggestions = [i["suggestion"] for i in items if i.get("suggestion")] + summary = _build_summary(project_no, project_name, total_approved, total_received, total_spent, as_of, spend_rate, time_progress, status_tag, items) + + return { + "project_no": project_no, + "project_name": project_name, + "pi_name": str(project.get("piName") or "").strip(), + "department": str(project.get("department") or "").strip(), + "project_type": str(project.get("projectType") or "").strip(), + "total_approved": total_approved, + "total_received": total_received, + "total_spent": total_spent, + "frozen_amount": frozen_amount, + "frozen_since": _fmt_date(frozen_since), + "start_date": _fmt_date(start), + "end_date": _fmt_date(end), + "as_of": as_of.isoformat(), + "spend_rate": spend_rate, + "time_progress": time_progress, + "deviation": deviation, + "status_tag": status_tag, + "items": items, + "suggestions": suggestions, + "summary": summary, + } + + +def _join_sentences(segments: list[str]) -> str: + """拼接句子:去掉各段尾部句号,统一以“;”分隔并以“。”结尾。""" + cleaned = [s.strip().rstrip("。").strip() for s in segments if s and s.strip()] + return ";".join(cleaned) + "。" if cleaned else "" + + +def _build_summary( + project_no: str, + project_name: str, + total_approved: Optional[float], + total_received: Optional[float], + total_spent: Optional[float], + as_of: date, + spend_rate: Optional[float], + time_progress: Optional[float], + status_tag: str, + items: list[dict[str, str]], +) -> str: + """三段式播报摘要(模板注入,数字全部来自计算)。""" + part1 = ( + f"您负责的《{project_name}》({project_no}):总立项经费 {_wan(total_approved)}," + f"已到账 {_wan(total_received)},已报销 {_wan(total_spent)}," + f"截至 {as_of.isoformat()} 实际支出率 {_pct(spend_rate)},序时进度 {_pct(time_progress)}," + f"执行状态{status_tag}。" + ) + diagnostics = [i for i in items if i["rule_id"] in ("D1", "D2", "D3", "N1", "N2", "C1", "C3")] + if diagnostics: + part2 = "诊断:" + _join_sentences([i["detail"] for i in diagnostics]) + else: + part2 = "诊断:暂无特别事项。" + suggestions = [i["suggestion"] for i in items if i.get("suggestion")] + if suggestions: + part3 = "建议:" + _join_sentences(suggestions[:3]) + else: + part3 = "建议:按研究计划均衡安排支出,保持执行进度与时间进度匹配。" + return f"{part1}\n{part2}\n{part3}" + + +def _validate_projects(projects: list[dict[str, Any]]) -> None: + if not projects: + raise ValueError("项目台账为空:请先录入项目(add-project / import-projects)") + if len(projects) > 500: + raise ValueError(f"单次播报项目数过多({len(projects)} 个),上限 500 个") + for idx, project in enumerate(projects): + if not isinstance(project, dict): + raise ValueError(f"第 {idx + 1} 个项目不是 JSON 对象") + + +def _write_report(result: dict[str, Any], batch_no: str) -> str: + from service.report_service import write_report_for_kernel + + return write_report_for_kernel(result=result, batch_no=batch_no) + + +def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no: str = "") -> dict[str, Any]: + """播报内核:逐项目三步计算 → 写库 → 生成《经费执行简报》。""" + from service.report_service import build_monthly_brief + + log = _log() + init_db() + projects = [_norm_project(p) for p in projects_raw] + _validate_projects(projects) + + batch_no = batch_no or now_unix().to_bytes(5, "big").hex() + uuid.uuid4().hex[:6] + rules = load_rules() + + if log: + log.info("broadcast_kernel_start batch_no=%s count=%s as_of=%s stage=kernel", batch_no, len(projects), as_of.isoformat()) + + results = [] + conn = get_conn() + try: + cur = conn.cursor() + for project in projects: + result = compute_project(project, rules, as_of) + cur.execute( + """ + INSERT INTO progress_reports + (batch_no, project_no, project_name, pi_name, as_of, spend_rate, + time_progress, deviation, status_tag, summary, suggestions_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + batch_no, + result["project_no"], + result["project_name"], + result["pi_name"], + result["as_of"], + result["spend_rate"], + result["time_progress"], + result["deviation"], + result["status_tag"], + result["summary"], + json.dumps(result["suggestions"], ensure_ascii=False), + ), + ) + report_id = int(cur.lastrowid) + result["report_id"] = report_id + result["batch_no"] = batch_no + report_file = _write_report(result, batch_no) + cur.execute("UPDATE progress_reports SET report_file = ? WHERE id = ?", (report_file, report_id)) + result["report_file"] = report_file + results.append(result) + conn.commit() + finally: + conn.close() + + counts: dict[str, int] = {} + for result in results: + counts[result["status_tag"]] = counts.get(result["status_tag"], 0) + 1 + brief_file = build_monthly_brief(batch_no=batch_no, results=results, as_of=as_of) + + payload = { + "ok": True, + "batch_no": batch_no, + "as_of": as_of.isoformat(), + "count": len(results), + "status_counts": counts, + "brief_file": brief_file, + "disclaimer": "播报仅作提醒,金额与执行进度请以财务系统账面为准。", + "reports": results, + } + if log: + log.info( + "broadcast_kernel_done batch_no=%s count=%s lag=%s overshoot=%s stage=kernel", + batch_no, + len(results), + counts.get(STATUS_LAG, 0), + counts.get(STATUS_OVERSHOOT, 0), + ) + return payload + + +def query_projects(project_no: str = "", pi_name: str = "", include_all_status: bool = False) -> list[dict[str, Any]]: + """从台账取待播报项目(缺省全部在研;可按项目编号/负责人筛选)。""" + conn = get_conn() + try: + cur = conn.cursor() + sql = "SELECT * FROM fund_projects WHERE 1=1" + params: list[Any] = [] + if not include_all_status: + sql += " AND status = '在研'" + if project_no: + sql += " AND project_no = ?" + params.append(project_no) + if pi_name: + sql += " AND pi_name LIKE ?" + params.append(f"%{pi_name}%") + sql += " ORDER BY id" + cur.execute(sql, params) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows diff --git a/scripts/service/html_page_service.py b/scripts/service/html_page_service.py new file mode 100644 index 0000000..4123b6e --- /dev/null +++ b/scripts/service/html_page_service.py @@ -0,0 +1,78 @@ +"""技能 HTML 报告页:写入 `{skill_data}/html/*.html`(宿主扫描预览)。 + +契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」): + +- 目录:仅一层 `html/*.html`(非递归) +- 重新生成 Action id 必须为 `render-html`(宿主硬编码) +- 页面布局底线见 `references/ACTIONS.md`「宿主预览布局」;模板壳 `assets/html/example-report.html` +- 成功后建议 `steps: refreshCatalog` 以重扫侧栏 +- 本模块只提供路径/模板/原子写;业务 KPI/表格由各技能自行填充 +""" + +from __future__ import annotations + +import os +import re +from typing import Mapping + +from util.logging_config import get_skill_logger +from util.runtime_paths import get_html_dir, get_skill_root + +# Host Data Management looks up this exact Skill Action id for「重新生成」. +HOST_HTML_RENDER_ACTION_ID = "render-html" + +_UNSAFE_NAME = re.compile(r"[\\/]|^\.\.|\.\.$") + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def assert_html_filename(filename: str) -> str: + name = (filename or "").strip() + if not name.lower().endswith(".html"): + raise ValueError(f"html filename must end with .html: {filename!r}") + if _UNSAFE_NAME.search(name) or "/" in name or "\\" in name: + raise ValueError(f"html filename must be a basename only: {filename!r}") + if len(name) > 200: + raise ValueError(f"html filename too long: {filename!r}") + return name + + +def html_page_path(filename: str, *, create_dir: bool = True) -> str: + return os.path.join(get_html_dir(create=create_dir), assert_html_filename(filename)) + + +def load_html_template(relative_under_skill_root: str) -> str: + """Load UTF-8 template from skill root (e.g. ``assets/html/example-report.html``).""" + rel = (relative_under_skill_root or "").strip().replace("\\", "/") + if not rel or rel.startswith("/") or ".." in rel.split("/"): + raise ValueError(f"invalid template path: {relative_under_skill_root!r}") + path = os.path.join(get_skill_root(), *rel.split("/")) + with open(path, encoding="utf-8") as f: + return f.read() + + +def render_html_placeholders(template: str, mapping: Mapping[str, str]) -> str: + """Replace ``{{KEY}}`` tokens. Unknown keys are left as-is.""" + page = template + for key, value in mapping.items(): + page = page.replace("{{" + str(key) + "}}", str(value)) + return page + + +def write_html_page(filename: str, html: str) -> str: + """Atomically write UTF-8 HTML under ``html/``. Returns absolute path.""" + dest = html_page_path(filename, create_dir=True) + tmp = dest + ".tmp" + text = html if isinstance(html, str) else str(html) + with open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + os.replace(tmp, dest) + log = _log() + if log: + log.info("html_page_written path=%s bytes=%s", dest, len(text.encode("utf-8"))) + return dest diff --git a/scripts/service/project_service.py b/scripts/service/project_service.py new file mode 100644 index 0000000..4dee6da --- /dev/null +++ b/scripts/service/project_service.py @@ -0,0 +1,322 @@ +"""项目台账维护:录入(upsert)、批量导入、查询。 + +台账是播报的唯一数据源;金额单位元,日期 YYYY-MM-DD。 +project_no 唯一,重复导入按项目编号幂等更新(不覆盖未提供字段)。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from db.connection import get_conn, init_db +from service.fund_service import _norm_project, _parse_date, _to_float +from util.logging_config import get_skill_logger +from util.timeutil import now_unix + +_DATE_FIELDS = ("frozen_since", "start_date", "end_date") +_NUMERIC_FIELDS = ("total_approved", "total_received", "total_spent", "frozen_amount") +_TEXT_FIELDS = ("project_name", "project_type", "pi_name", "department", "status", "remark") + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def _snake(name: str) -> str: + """camelCase → snake_case(_norm_project 输出 canonical camelCase 键)。""" + return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_") + + +def upsert_project(fields: dict[str, Any]) -> dict[str, Any]: + """按 project_no 幂等写入台账;未提供的字段不覆盖已有值。""" + project_no = str(fields.get("project_no") or "").strip() + if not project_no: + raise ValueError("项目编号(project_no)不能为空") + + values: dict[str, Any] = {"project_no": project_no} + for key in _NUMERIC_FIELDS: + if key in fields and fields[key] not in (None, ""): + number = _to_float(fields[key]) + if number is None or number < 0: + raise ValueError(f"字段 {key} 必须是非负数字:{fields[key]}") + values[key] = number + for key in _DATE_FIELDS: + if key in fields and fields[key] not in (None, ""): + parsed = _parse_date(fields[key]) + if parsed is None: + raise ValueError(f"字段 {key} 日期格式无效(应为 YYYY-MM-DD):{fields[key]}") + values[key] = parsed.isoformat() + for key in _TEXT_FIELDS: + if key in fields and fields[key] is not None: + values[key] = str(fields[key]).strip() + if values.get("status") and values["status"] not in ("在研", "已结题", "暂缓"): + raise ValueError(f"status 只接受 在研/已结题/暂缓:{values['status']}") + if values.get("start_date") and values.get("end_date") and values["end_date"] <= values["start_date"]: + raise ValueError("执行期结束日期必须晚于开始日期") + + init_db() + log = _log() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM fund_projects WHERE project_no = ?", (project_no,)) + row = cur.fetchone() + if row: + project_id = int(row[0]) + set_clause = ", ".join(f"{key} = ?" for key in values if key != "project_no") + params = [values[key] for key in values if key != "project_no"] + [now_unix(), project_id] + cur.execute( + f"UPDATE fund_projects SET {set_clause}, updated_at = ? WHERE id = ?", + params, + ) + action = "updated" + else: + columns = ", ".join(values.keys()) + placeholders = ", ".join("?" for _ in values) + cur.execute( + f"INSERT INTO fund_projects ({columns}, created_at, updated_at) VALUES ({placeholders}, ?, ?)", + list(values.values()) + [now_unix(), now_unix()], + ) + project_id = int(cur.lastrowid) + action = "created" + conn.commit() + finally: + conn.close() + if log: + log.info("project_upserted project_no=%s action=%s stage=project", project_no, action) + return {"ok": True, "project_no": project_no, "id": project_id, "action": action} + + +def import_projects(projects_raw: list[dict[str, Any]]) -> dict[str, Any]: + """批量导入台账(upsert),返回统计。""" + if not isinstance(projects_raw, list) or not projects_raw: + raise ValueError("导入内容应为非空 JSON 数组") + if len(projects_raw) > 500: + raise ValueError(f"单次导入项目数过多({len(projects_raw)}),上限 500 个") + results = [] + for idx, raw in enumerate(projects_raw): + if not isinstance(raw, dict): + raise ValueError(f"第 {idx + 1} 个项目不是 JSON 对象") + normalized = {_snake(canonical): value for canonical, value in _norm_project(raw).items()} + results.append(upsert_project(normalized)) + return { + "ok": True, + "count": len(results), + "created": sum(1 for r in results if r["action"] == "created"), + "updated": sum(1 for r in results if r["action"] == "updated"), + "projects": results, + } + + +def list_projects(pi_name: str = "", status: str = "") -> list[dict[str, Any]]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + sql = "SELECT * FROM fund_projects WHERE 1=1" + params: list[Any] = [] + if pi_name: + sql += " AND pi_name LIKE ?" + params.append(f"%{pi_name}%") + if status: + sql += " AND status = ?" + params.append(status) + sql += " ORDER BY id" + cur.execute(sql, params) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +def get_project_by_id(project_id: int) -> dict[str, Any]: + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM fund_projects WHERE id = ?", (project_id,)) + row = cur.fetchone() + if not row: + raise ValueError(f"项目不存在:{project_id}") + columns = [d[0] for d in cur.description] + return dict(zip(columns, row)) + finally: + conn.close() + + +def parse_projects_json(raw: str) -> list[dict[str, Any]]: + """解析导入 JSON:'-'(stdin)/ 文件路径 / 内联数组或 {"projects": [...]} 包装。""" + import os + import sys + + text = (raw or "").strip() + if not text: + raise ValueError("缺少导入 JSON(--projects-json)") + if text == "-": + text = sys.stdin.read().strip() + elif text.startswith(("[", "{")): + pass + elif os.path.isfile(text): + with open(text, encoding="utf-8") as f: + text = f.read().strip() + else: + raise ValueError(f"--projects-json 不是有效文件路径或 JSON:{text[:80]}") + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"导入 JSON 解析失败:{exc}") from exc + if isinstance(payload, dict) and isinstance(payload.get("projects"), list): + payload = payload["projects"] + if not isinstance(payload, list): + raise ValueError("导入 JSON 应为对象数组或 {\"projects\": [...]}") + return payload + + +def list_reports(limit: int = 20, status_tag: str = "") -> list[dict[str, Any]]: + init_db() + limit = max(1, min(int(limit), 200)) + conn = get_conn() + try: + cur = conn.cursor() + if status_tag: + cur.execute( + """ + SELECT id, batch_no, as_of, project_no, project_name, pi_name, + spend_rate, time_progress, deviation, status_tag, summary, report_file, created_at + FROM progress_reports WHERE status_tag = ? ORDER BY id DESC LIMIT ? + """, + (status_tag, limit), + ) + else: + cur.execute( + """ + SELECT id, batch_no, as_of, project_no, project_name, pi_name, + spend_rate, time_progress, deviation, status_tag, summary, report_file, created_at + FROM progress_reports ORDER BY id DESC LIMIT ? + """, + (limit,), + ) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +def get_report(report_id: int) -> dict[str, Any]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM progress_reports WHERE id = ?", (report_id,)) + row = cur.fetchone() + if not row: + raise ValueError(f"播报记录不存在:{report_id}") + columns = [d[0] for d in cur.description] + report = dict(zip(columns, row)) + finally: + conn.close() + try: + report["suggestions"] = json.loads(report.get("suggestions_json") or "[]") + except (TypeError, ValueError): + report["suggestions"] = [] + return report + + +def view_rules() -> list[dict[str, Any]]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, rule_key, display_name, threshold_value, description, updated_at FROM alert_rules ORDER BY id" + ) + columns = [d[0] for d in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + return rows + + +_KNOWN_RULE_KEYS = ("lag_threshold", "overshoot_threshold", "pending_aging_days", "closing_warn_days") + + +def set_rule(rule_key: str, threshold_value: float) -> dict[str, Any]: + """按 rule_key 更新阈值(须为已知规则键,数值须为正)。""" + rule_key = (rule_key or "").strip() + if rule_key not in _KNOWN_RULE_KEYS: + raise ValueError(f"未知规则键:{rule_key}(可选:{'、'.join(_KNOWN_RULE_KEYS)})") + threshold_value = float(threshold_value) + if threshold_value <= 0 or threshold_value >= 1.5: + raise ValueError(f"阈值必须是 0~1.5 之间的正数:{threshold_value}") + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM alert_rules WHERE rule_key = ?", (rule_key,)) + row = cur.fetchone() + if not row: + raise ValueError(f"规则键尚未初始化:{rule_key}(请先执行 init-db)") + cur.execute( + "UPDATE alert_rules SET threshold_value = ?, updated_at = ? WHERE id = ?", + (threshold_value, now_unix(), int(row[0])), + ) + conn.commit() + finally: + conn.close() + log = _log() + if log: + log.info("rule_updated rule_key=%s value=%s stage=rules", rule_key, threshold_value) + return {"ok": True, "rule_key": rule_key, "threshold_value": threshold_value} + + +def stats() -> dict[str, Any]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM fund_projects") + project_total = int(cur.fetchone()[0]) + cur.execute("SELECT status, COUNT(*) FROM fund_projects GROUP BY status") + by_status = {str(r[0]): int(r[1]) for r in cur.fetchall()} + cur.execute("SELECT status_tag, COUNT(*) FROM progress_reports GROUP BY status_tag") + reports_by_tag = {str(r[0]): int(r[1]) for r in cur.fetchall()} + cur.execute("SELECT COUNT(*) FROM progress_reports") + report_total = int(cur.fetchone()[0]) + cur.execute( + """ + SELECT project_no, project_name, pi_name, status_tag, spend_rate, time_progress, as_of + FROM progress_reports + WHERE status_tag = '严重滞后' + ORDER BY deviation ASC LIMIT 5 + """ + ) + lag_top = [ + { + "project_no": r[0], + "project_name": r[1], + "pi_name": r[2], + "status_tag": r[3], + "spend_rate": r[4], + "time_progress": r[5], + "as_of": r[6], + } + for r in cur.fetchall() + ] + cur.execute("SELECT COALESCE(SUM(frozen_amount), 0) FROM fund_projects WHERE status = '在研'") + frozen_total = float(cur.fetchone()[0]) + finally: + conn.close() + return { + "ok": True, + "project_total": project_total, + "projects_by_status": by_status, + "report_total": report_total, + "reports_by_tag": reports_by_tag, + "lag_top": lag_top, + "frozen_total": frozen_total, + } diff --git a/scripts/service/report_service.py b/scripts/service/report_service.py new file mode 100644 index 0000000..c15fe52 --- /dev/null +++ b/scripts/service/report_service.py @@ -0,0 +1,251 @@ +"""《经费执行简报》HTML 组装。 + +- 每项目一份 `report-{report_id}.html`(含执行率 vs 序时进度 CSS 柱状图) +- 月度播报另生成汇总页 `brief-{batch_no}.html` +写入 `{skill_data}/html/`,宿主数据管理侧栏可预览;重建走固定 Action id `render-html`。 +`{{BODY}}` 内数据一律经 html.escape 转义。 +""" + +from __future__ import annotations + +import html +import json +from typing import Any + +from service.html_page_service import load_html_template, render_html_placeholders, write_html_page +from util.logging_config import get_skill_logger +from util.timeutil import unix_to_iso + +_PROGRESS_TEMPLATE_REL = "assets/html/progress-report.html" +_BRIEF_TEMPLATE_REL = "assets/html/monthly-brief.html" + +_STATUS_BADGE = { + "严重滞后": "tag-lag", + "支出异常偏快": "tag-overshoot", + "正常": "tag-normal", + "待补录": "tag-gray", + "未到账": "tag-gray", +} + + +def _esc(value: Any) -> str: + return html.escape(str(value if value is not None else "—"), quote=True) + + +def _pct(value: Any) -> str: + try: + return f"{float(value) * 100:.1f}%" + except (TypeError, ValueError): + return "—" + + +def _wan(value: Any) -> str: + try: + return f"{float(value) / 10000:.2f} 万元" + except (TypeError, ValueError): + return "—" + + +def _bar(label: str, value: Any, fill_class: str) -> str: + """CSS 柱状图行:纯 div 宽度百分比,iframe 安全无 JS。""" + try: + ratio = max(0.0, min(float(value), 1.0)) + width = f"{ratio * 100:.1f}%" + except (TypeError, ValueError): + width = "0%" + return ( + f'<div class="bar-row"><span class="bar-label">{_esc(label)}</span>' + f'<div class="bar-track"><div class="bar-fill {fill_class}" style="width:{width}"></div></div>' + f'<span class="bar-value">{_pct(value)}</span></div>' + ) + + +def _suggestion_list(suggestions: Any) -> str: + if isinstance(suggestions, str): + try: + suggestions = json.loads(suggestions) + except (TypeError, ValueError): + suggestions = [] + if not suggestions: + return '<p class="muted">暂无特别建议。</p>' + rows = "".join(f"<li>{_esc(s)}</li>" for s in suggestions) + return f"<ol>{rows}</ol>" + + +def _project_info_table(result: dict[str, Any]) -> str: + fields = ( + ("项目编号", "project_no"), + ("项目类别", "project_type"), + ("负责人", "pi_name"), + ("所属学院", "department"), + ("总立项经费", None), + ("已到账金额", None), + ("已报销金额", None), + ("冻结/暂付款", "frozen_amount"), + ("执行期", None), + ) + rows = [] + for label, key in fields: + if label == "总立项经费": + value = _wan(result.get("total_approved")) + elif label == "已到账金额": + value = _wan(result.get("total_received")) + elif label == "已报销金额": + value = _wan(result.get("total_spent")) + elif label == "执行期": + value = f"{result.get('start_date') or '—'} ~ {result.get('end_date') or '—'}" + else: + value = result.get(key) or "—" + rows.append(f"<tr><th>{_esc(label)}</th><td>{_esc(value)}</td></tr>") + return f'<table class="info">{"".join(rows)}</table>' + + +def build_progress_html(result: dict[str, Any], batch_no: str = "") -> str: + """组装单个项目的《经费执行简报》HTML 文本。""" + status = str(result.get("status_tag") or "") + badge = _STATUS_BADGE.get(status, "tag-gray") + mark = {"严重滞后": "🔴 严重滞后", "支出异常偏快": "🟠 支出异常偏快", "正常": "🟢 正常"}.get(status, f"⚪ {status}") + summary = str(result.get("summary") or "") + parts = [p for p in summary.split("\n") if p.strip()] + summary_html = "".join(f'<p class="para">{_esc(p)}</p>' for p in parts) + + body = f""" + <div class="verdict-line"> + <span class="tag {badge}">{mark}</span> + <span class="muted">批次 {_esc(batch_no or result.get("batch_no"))} · 评估基准日 {_esc(result.get("as_of"))}</span> + </div> + <div class="section"><h2>执行进度对比</h2> + {_bar("实际支出率", result.get("spend_rate"), "fill-spend")} + {_bar("序时进度", result.get("time_progress"), "fill-time")} + <p class="muted">偏差 = 实际支出率 − 序时进度 = {_esc(_pct(result.get("deviation")))}</p> + </div> + <div class="section"><h2>项目信息</h2>{_project_info_table(result)}</div> + <div class="section"><h2>播报摘要</h2>{summary_html}</div> + <div class="section"><h2>建议</h2>{_suggestion_list(result.get("suggestions"))}</div> + <footer>本简报由项目台账数据自动生成,仅作提醒;金额与执行进度请以财务系统账面为准。</footer> + """ + + template = load_html_template(_PROGRESS_TEMPLATE_REL) + return render_html_placeholders( + template, + { + "TITLE": f"经费执行简报 · {result.get('project_name') or result.get('project_no') or ''}", + "GENERATED_AT": "", + "BODY": body, + }, + ) + + +def write_report_for_kernel(*, result: dict[str, Any], batch_no: str) -> str: + """内核路径:用内存数据直接组装并写出单项目简报,返回文件路径。""" + html_text = build_progress_html(result, batch_no) + log = get_skill_logger() + path = write_html_page(f"report-{result['report_id']}.html", html_text) + log.info("progress_report_written report_id=%s path=%s stage=report", result["report_id"], path) + return path + + +def build_report_for_report_id(report_id: int) -> str: + """按播报记录 id 从库中取数并重写 HTML(重建入口用)。""" + from db.connection import get_conn + + log = get_skill_logger() + log.info("report_render_start report_id=%s stage=render", report_id) + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM progress_reports WHERE id = ?", (report_id,)) + row = cur.fetchone() + if not row: + raise ValueError(f"播报记录不存在:{report_id}") + columns = [d[0] for d in cur.description] + record = dict(zip(columns, row)) + finally: + conn.close() + result = _record_to_result(record) + html_text = build_progress_html(result) + path = write_html_page(f"report-{report_id}.html", html_text) + log.info("report_render_done report_id=%s path=%s stage=render", report_id, path) + return path + + +def _record_to_result(record: dict[str, Any]) -> dict[str, Any]: + """把播报记录行还原为简报渲染所需的结果结构(金额从台账补齐)。""" + from service.project_service import get_project_by_id + + result = dict(record) + try: + project = get_project_by_id(int(record.get("project_id"))) + except (TypeError, ValueError): + project = {} + for key in ( + "project_type", + "department", + "total_approved", + "total_received", + "total_spent", + "frozen_amount", + "start_date", + "end_date", + ): + result.setdefault(key, project.get(key)) + try: + result["suggestions"] = json.loads(record.get("suggestions_json") or "[]") + except (TypeError, ValueError): + result["suggestions"] = [] + return result + + +def build_monthly_brief(*, batch_no: str, results: list[dict[str, Any]], as_of: Any) -> str: + """月度播报汇总页:全部项目的执行率/序时进度柱状图一览。""" + log = get_skill_logger() + rows = [] + order = {"严重滞后": 0, "支出异常偏快": 1, "正常": 2, "待补录": 3, "未到账": 4} + for result in sorted(results, key=lambda r: order.get(str(r.get("status_tag")), 9)): + status = str(result.get("status_tag") or "") + badge = _STATUS_BADGE.get(status, "tag-gray") + rows.append( + "<tr>" + f'<td class="nowrap">{_esc(result.get("project_no"))}</td>' + f'<td>{_esc(result.get("project_name"))}<br/><span class="muted">{_esc(result.get("pi_name"))}</span></td>' + f'<td class="nowrap"><span class="tag {badge}">{_esc(status)}</span></td>' + f'<td>{_bar("支出", result.get("spend_rate"), "fill-spend")}{_bar("序时", result.get("time_progress"), "fill-time")}</td>' + f'<td class="nowrap">{_esc(_pct(result.get("deviation")))}</td>' + "</tr>" + ) + if not rows: + rows.append('<tr><td colspan="5" class="muted">本期无可播报项目。</td></tr>') + + counts: dict[str, int] = {} + for result in results: + counts[str(result.get("status_tag"))] = counts.get(str(result.get("status_tag")), 0) + 1 + chips = " ".join( + f'<span class="tag {_STATUS_BADGE.get(tag, "tag-gray")}">{_esc(tag)} × {num}</span>' + for tag, num in sorted(counts.items(), key=lambda kv: order.get(kv[0], 9)) + ) + + body = f""" + <div class="verdict-line"> + {chips or '<span class="muted">无</span>'} + <span class="muted">批次 {_esc(batch_no)} · 评估基准日 {_esc(as_of)}</span> + </div> + <div class="section"><h2>全部项目进度一览(按状态排序)</h2> + <table> + <tr><th>项目编号</th><th>项目 / 负责人</th><th>状态</th><th>进度对比</th><th>偏差</th></tr> + {"".join(rows)} + </table> + </div> + <footer>本汇总由月度播报自动生成,仅作提醒;金额与执行进度请以财务系统账面为准。</footer> + """ + template = load_html_template(_BRIEF_TEMPLATE_REL) + page = render_html_placeholders( + template, + { + "TITLE": "月度经费执行汇总简报", + "GENERATED_AT": str(as_of), + "BODY": body, + }, + ) + path = write_html_page(f"brief-{batch_no}.html", page) + log.info("monthly_brief_written batch_no=%s path=%s stage=report", batch_no, path) + return path diff --git a/scripts/service/sibling_bridge.py b/scripts/service/sibling_bridge.py new file mode 100644 index 0000000..521daae --- /dev/null +++ b/scripts/service/sibling_bridge.py @@ -0,0 +1,57 @@ +"""兄弟技能 CLI 调用工具。 + +通过子进程调用同级其他 skill 的 main.py,并解析 JSON 输出。 +具体调用哪些兄弟技能,由复制后的业务 skill 在 service 层按需决定。 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional + +from util.logging_config import subprocess_env_with_trace +from util.runtime_paths import get_skills_root + + +def call_sibling_json(skill_slug: str, args: List[str]) -> Optional[Dict[str, Any]]: + """通用兄弟技能调用器。 + + Args: + skill_slug: 兄弟技能的 slug(即同级目录名)。 + args: 传给 main.py 的命令行参数列表。 + + Returns: + 若兄弟技能输出可解析的 JSON 对象则返回 dict;否则返回 None。 + """ + script_path = os.path.join(get_skills_root(), skill_slug, "scripts", "main.py") + if not os.path.isfile(script_path): + return None + proc = subprocess.run( + [sys.executable, script_path, *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=subprocess_env_with_trace(), + ) + raw = (proc.stdout or "").strip() + if not raw or raw.startswith("ERROR"): + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def get_sibling_main_path(skill_slug: str) -> str: + """获取兄弟技能的 main.py 绝对路径。 + + 使用示例(在 task_service.py 中): + from service.sibling_bridge import get_sibling_main_path, call_sibling_json + result = call_sibling_json("account-manager", ["list", "--limit", "10"]) + """ + return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py") diff --git a/scripts/service/task_run_support.py b/scripts/service/task_run_support.py new file mode 100644 index 0000000..f3c2ee1 --- /dev/null +++ b/scripts/service/task_run_support.py @@ -0,0 +1,84 @@ +"""任务运行辅助:录屏信息组装与 CLI 输出(通用模板,无业务逻辑)。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def build_video_info( + video_summary: dict[str, Any], + compose_context_before: Optional[dict[str, Any]] = None, + compose_context_after: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """将 RpaVideoSession.summary() 转为 finish() / CLI 可用的 video 块。""" + return { + "enabled": video_summary.get("enabled", False), + "path": video_summary.get("path"), + "capture_path": video_summary.get("capture_path"), + "record_log_path": video_summary.get("record_log_path"), + "warnings": list(video_summary.get("warnings") or []), + "voiceover_path": video_summary.get("voiceover_path"), + "music_path": video_summary.get("music_path"), + "audio_warnings": list(video_summary.get("audio_warnings") or []), + "compose_context": { + **(compose_context_before or {}), + "after_compose": compose_context_after or {}, + }, + } + + +def merge_video_into_result_summary( + payload: dict[str, Any], + video_info: Optional[dict[str, Any]], +) -> dict[str, Any]: + """把 video artifact 字段写入 finish() 结果对象(video / video_path 等)。""" + if not video_info: + return payload + payload = dict(payload) + payload["video"] = video_info + payload["video_path"] = video_info.get("path") + payload["raw_video"] = video_info.get("capture_path") + payload["video_log"] = video_info.get("record_log_path") + payload["video_warnings"] = list(video_info.get("warnings") or []) + payload["voiceover_path"] = video_info.get("voiceover_path") + payload["music_path"] = video_info.get("music_path") + payload["audio_warnings"] = list(video_info.get("audio_warnings") or []) + return payload + + +def _print_video_summary(video_block: Optional[dict[str, Any]]) -> None: + """任务完成后在 CLI 打印录屏路径、日志与诊断信息。""" + if not isinstance(video_block, dict) or not video_block.get("enabled"): + return + record_log = video_block.get("record_log_path") + if record_log: + print(f"录屏日志:{record_log}") + path = video_block.get("path") + if path: + print(f"录屏路径:{path}") + else: + print("录屏路径:未生成") + warnings = video_block.get("warnings") or [] + if warnings: + print(f"视频诊断:{'; '.join(str(w) for w in warnings)}") + capture = video_block.get("capture_path") + if capture: + print(f"原始录屏路径:{capture}") + voiceover = video_block.get("voiceover_path") + if voiceover: + print(f"旁白路径:{voiceover}") + music = video_block.get("music_path") + if music: + print(f"背景音乐:{music}") + audio_warnings = video_block.get("audio_warnings") or [] + if audio_warnings: + print(f"音频诊断:{'; '.join(str(w) for w in audio_warnings)}") + compose_ctx = video_block.get("compose_context") + if isinstance(compose_ctx, dict): + print( + "视频合成上下文:" + f"media_assets_root={compose_ctx.get('media_assets_root', '')}; " + f"ffmpeg_path={compose_ctx.get('ffmpeg_path', '')}; " + f"background_music_issue={compose_ctx.get('background_music_issue') or ''}; " + f"background_music_sample_path={compose_ctx.get('background_music_sample_path', '')}" + ) diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py new file mode 100644 index 0000000..c12a9d4 --- /dev/null +++ b/scripts/service/task_service.py @@ -0,0 +1,620 @@ +"""任务编排层:job_context / emit / finish、鉴权与错误转换。 + +领域业务在 ``service/fund_service.py``(播报内核)、 +``service/project_service.py``(台账/规则/统计)、 +``service/report_service.py``(HTML 简报),由本模块调用; +多入口(CLI / Action / 对话 / cron)复用同一内核,禁止按入口分叉业务。 +""" + +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from typing import Any, Optional + +from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines +from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context + +from jiangchang_skill_core.rpa.video_session import RpaVideoSession +from service.entitlement_service import check_entitlement +from service.task_run_support import ( + _print_video_summary, + build_video_info, + merge_video_into_result_summary, +) +from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION +from util.logging_config import get_skill_logger, setup_skill_logging +from util.runtime_paths import get_db_path, get_skill_root +from db.connection import init_db + + +def _get_task_logger(): + try: + return get_skill_logger() + except RuntimeError: + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) + return get_skill_logger() + + +def _print_json(payload: dict[str, Any]) -> None: + print(json.dumps(payload, ensure_ascii=False)) + + +# --------------------------------------------------------------------------- +# 模板演示入口(保留:满足 POLICY-RPA-003 录屏集成与 CLI run 契约;不承载本技能业务) +# --------------------------------------------------------------------------- + +async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: + """最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。""" + batch_id = uuid.uuid4().hex[:12] + data_dir = get_skill_root() + + async with RpaVideoSession( + skill_slug=SKILL_SLUG, + skill_data_dir=data_dir, + batch_id=batch_id, + title="开始执行示例任务", + closing_title="示例任务执行完成", + ) as video: + video.add_step("准备执行示例任务") + if target: + video.add_step(f"接收目标参数:{target}") + if input_id: + video.add_step(f"接收输入标识:{input_id}") + video.add_step("示例任务执行完成") + + video_summary = video.summary() + video_info = build_video_info(video_summary, {}, {}) + return 1, video_info + + +def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: + """模板演示入口:本技能主能力请使用 broadcast 等业务命令。""" + log = _get_task_logger() + task_type = "demo" + + with job_context(skill=SKILL_SLUG): + log.info( + "task_start task_type=%s target_id=%s input_id=%s stage=run", + task_type, + target, + input_id, + ) + emit("开始处理任务", skill=SKILL_SLUG, stage="run") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning( + "entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED", + task_type, + ) + emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") + finish( + status="failed", + message=reason, + skill=SKILL_SLUG, + error_code="ENTITLEMENT_DENIED", + stage="auth", + ) + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + + log.info("entitlement_ok task_type=%s stage=auth", task_type) + log.info("rpa_start stage=demo_prepare task_type=%s", task_type) + log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id) + rc, video_info = asyncio.run(_run_template_demo(target, input_id)) + log.info("rpa_done stage=demo_prepare status=ok") + log.info( + "task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed", + target, + input_id, + ) + _print_video_summary(video_info) + + summary_payload = merge_video_into_result_summary( + { + "template_demo": True, + "target": target, + "input_id": input_id, + }, + video_info, + ) + + log.warning( + "task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB", + task_type, + ) + finish( + status="failed", + message="模板演示入口未承载本技能业务", + skill=SKILL_SLUG, + error_code="TEMPLATE_STUB", + **summary_payload, + ) + print("ERROR:TEMPLATE_STUB 这是模板演示入口,请使用 broadcast 命令执行经费进度播报。") + return rc + except JobStopped: + raise + except Exception: + log.exception( + "task_failed task_type=%s target_id=%s input_id=%s", + task_type, + target, + input_id, + ) + emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") + finish( + status="failed", + message="任务执行异常,详见统一日志", + skill=SKILL_SLUG, + stage="run", + ) + return 1 + + +# --------------------------------------------------------------------------- +# 基础命令 +# --------------------------------------------------------------------------- + +def cmd_config_path() -> int: + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.get_env_file_path() or "" + _print_json( + { + "skill": SKILL_SLUG, + "env_path": env_path, + "example_path": os.path.abspath(example_path), + } + ) + return 0 + + +def cmd_health() -> int: + runtime = collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=get_skill_root(), + ) + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.get_env_file_path() or "" + env_exists = bool(env_path and os.path.isfile(env_path)) + + health_status = "failed" if runtime.has_fatal_issues else "ok" + lines = [ + f"{SKILL_SLUG} health: {health_status}", + *format_runtime_health_lines(runtime), + f"env_path: {env_path}", + f"env_exists: {env_exists}", + f"example_path: {os.path.abspath(example_path)}", + ] + for line in lines: + print(line) + return 1 if runtime.has_fatal_issues else 0 + + +def cmd_version() -> int: + _print_json({"version": SKILL_VERSION, "skill": SKILL_SLUG}) + return 0 + + +def cmd_init_db() -> int: + """幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。""" + log = _get_task_logger() + try: + seeded = init_db() + except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败 + print( + json.dumps( + {"ok": False, "skill": SKILL_SLUG, "error": str(exc)}, + ensure_ascii=False, + ) + ) + return 1 + _print_json( + { + "ok": True, + "skill": SKILL_SLUG, + "db_path": get_db_path(), + "seeded": seeded, + } + ) + return 0 + + +# --------------------------------------------------------------------------- +# 项目台账 +# --------------------------------------------------------------------------- + +def cmd_add_project( + *, + project_no: str, + name: str = "", + project_type: str = "", + pi: str = "", + department: str = "", + total_approved: Optional[float] = None, + total_received: Optional[float] = None, + total_spent: Optional[float] = None, + frozen: Optional[float] = None, + frozen_since: str = "", + start: str = "", + end: str = "", + status: str = "", + remark: str = "", +) -> int: + log = _get_task_logger() + from service.project_service import upsert_project + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=add_project project_no=%s stage=project", project_no) + emit("开始写入项目台账", skill=SKILL_SLUG, stage="project") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=add_project stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + log.error("add_project_denied stage=auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + fields: dict[str, Any] = {"project_no": project_no} + if name: + fields["project_name"] = name + if project_type: + fields["project_type"] = project_type + if pi: + fields["pi_name"] = pi + if department: + fields["department"] = department + for key, value in ( + ("total_approved", total_approved), + ("total_received", total_received), + ("total_spent", total_spent), + ("frozen_amount", frozen), + ): + if value is not None: + fields[key] = value + for key, value in (("frozen_since", frozen_since), ("start_date", start), ("end_date", end), ("status", status), ("remark", remark)): + if value: + fields[key] = value + payload = upsert_project(fields) + _print_json(payload) + finish(status="success", message=f"项目 {project_no} 已{'更新' if payload['action'] == 'updated' else '录入'}", skill=SKILL_SLUG, stage="project") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=add_project error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="project") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=add_project stage=project") + finish(status="failed", message="台账写入异常,详见统一日志", skill=SKILL_SLUG, stage="project") + return 1 + + +def cmd_import_projects(projects_json: str) -> int: + log = _get_task_logger() + from service.project_service import import_projects, parse_projects_json + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=import_projects stage=project") + emit("开始批量导入项目台账", skill=SKILL_SLUG, stage="project") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=import_projects stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + log.error("import_projects_denied stage=auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + projects = parse_projects_json(projects_json) + payload = import_projects(projects) + _print_json(payload) + finish(status="success", message=f"导入完成:新增 {payload['created']},更新 {payload['updated']}", skill=SKILL_SLUG, stage="project") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=import_projects error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="project") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=import_projects stage=project") + finish(status="failed", message="台账导入异常,详见统一日志", skill=SKILL_SLUG, stage="project") + return 1 + + +def cmd_list_projects(pi: str = "", status: str = "") -> int: + log = _get_task_logger() + from service.project_service import list_projects + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=list_projects pi=%s status=%s", pi, status) + try: + projects = list_projects(pi_name=pi, status=status) + _print_json({"ok": True, "count": len(projects), "projects": projects}) + return 0 + except Exception: + log.exception("task_failed task_type=list_projects") + print("ERROR:QUERY_FAILED 台账查询异常,详见统一日志") + return 1 + + +# --------------------------------------------------------------------------- +# 播报主命令 +# --------------------------------------------------------------------------- + +def cmd_broadcast( + *, + project_no: str = "", + pi: str = "", + as_of: str = "", + include_all_status: bool = False, +) -> int: + """经费进度播报(同步):全部在研项目或指定项目。""" + log = _get_task_logger() + from datetime import date + + from service.fund_service import query_projects, run_broadcast + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=broadcast project_no=%s pi=%s stage=broadcast", project_no, pi) + emit("开始经费进度播报", skill=SKILL_SLUG, stage="broadcast") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=broadcast stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + log.error("broadcast_denied stage=auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + + as_of_date = date.fromisoformat(as_of) if as_of else date.today() + projects = query_projects(project_no=project_no, pi_name=pi, include_all_status=include_all_status) + if not projects: + message = "没有可播报的项目:请先录入台账(在研项目为空或筛选条件不匹配)" + log.warning("task_failed task_type=broadcast error_code=NO_PROJECTS stage=broadcast") + finish(status="failed", message=message, skill=SKILL_SLUG, error_code="NO_PROJECTS", stage="broadcast") + log.error("broadcast_no_projects stage=broadcast") + print(f"ERROR:NO_PROJECTS {message}") + return 1 + + result = run_broadcast(projects, as_of_date) + payload = {k: v for k, v in result.items() if k != "reports"} + payload["reports_brief"] = [ + {k: r[k] for k in ("report_id", "project_no", "project_name", "pi_name", "status_tag", "spend_rate", "time_progress", "deviation", "report_file")} + for r in result["reports"] + ] + _print_json(payload) + emit( + f"播报完成 {result['count']} 个项目:严重滞后 {result['status_counts'].get('严重滞后', 0)}," + f"异常偏快 {result['status_counts'].get('支出异常偏快', 0)}", + skill=SKILL_SLUG, + stage="broadcast", + ) + finish( + status="success", + message=f"播报完成 {result['count']} 个项目,简报已生成", + skill=SKILL_SLUG, + stage="broadcast", + batch_no=result["batch_no"], + ) + return 0 + except JobStopped: + raise + except ValueError as exc: + log.warning("task_failed task_type=broadcast error_code=PROJECT_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="broadcast") + print(f"ERROR:PROJECT_INVALID {exc}") + return 1 + except Exception: + log.exception("task_failed task_type=broadcast stage=broadcast") + emit("播报失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="broadcast") + finish(status="failed", message="播报执行异常,详见统一日志", skill=SKILL_SLUG, stage="broadcast") + return 1 + + +def cmd_broadcast_monthly() -> int: + """月度播报(async Action 入口):全部在研项目逐项播报 + 汇总简报,任务中心可见进度。""" + log = _get_task_logger() + from datetime import date + + from service.fund_service import query_projects, run_broadcast + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=broadcast_monthly stage=monthly") + emit("开始月度经费播报", skill=SKILL_SLUG, stage="monthly") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=broadcast_monthly stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + log.error("broadcast_monthly_denied stage=auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + + projects = query_projects() + if not projects: + message = "台账中没有“在研”项目,月度播报跳过" + log.warning("task_failed task_type=broadcast_monthly error_code=NO_PROJECTS stage=monthly") + finish(status="failed", message=message, skill=SKILL_SLUG, error_code="NO_PROJECTS", stage="monthly") + log.error("broadcast_monthly_no_projects stage=monthly") + print(f"ERROR:NO_PROJECTS {message}") + return 1 + + result = run_broadcast(projects, date.today()) + for index, report in enumerate(result["reports"], start=1): + emit( + f"[{index}/{len(result['reports'])}] {report['project_name']}:{report['status_tag']}", + skill=SKILL_SLUG, + stage="monthly", + ) + _print_json( + { + "ok": True, + "batch_no": result["batch_no"], + "as_of": result["as_of"], + "count": result["count"], + "status_counts": result["status_counts"], + "brief_file": result["brief_file"], + "reports_brief": [ + {k: r[k] for k in ("report_id", "project_no", "project_name", "pi_name", "status_tag", "summary")} + for r in result["reports"] + ], + } + ) + finish( + status="success", + message=f"月度播报完成:{result['count']} 个项目,严重滞后 {result['status_counts'].get('严重滞后', 0)} 个", + skill=SKILL_SLUG, + stage="monthly", + batch_no=result["batch_no"], + ) + return 0 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=broadcast_monthly stage=monthly") + emit("月度播报失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="monthly") + finish(status="failed", message="月度播报执行异常,详见统一日志", skill=SKILL_SLUG, stage="monthly") + return 1 + + +# --------------------------------------------------------------------------- +# 播报记录与阈值 +# --------------------------------------------------------------------------- + +def cmd_list_reports(limit: int = 20, status_tag: str = "") -> int: + log = _get_task_logger() + from service.project_service import list_reports + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=list_reports limit=%s status_tag=%s", limit, status_tag) + try: + reports = list_reports(limit=limit, status_tag=status_tag) + _print_json({"ok": True, "count": len(reports), "reports": reports}) + return 0 + except Exception: + log.exception("task_failed task_type=list_reports") + print("ERROR:QUERY_FAILED 播报记录查询异常,详见统一日志") + return 1 + + +def cmd_show_report(report_id: int) -> int: + log = _get_task_logger() + from service.project_service import get_report + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=show_report report_id=%s", report_id) + try: + report = get_report(int(report_id)) + _print_json({"ok": True, "report": report}) + return 0 + except ValueError as exc: + log.warning("task_failed task_type=show_report error_code=REPORT_NOT_FOUND detail=%s", exc) + print(f"ERROR:REPORT_NOT_FOUND {exc}") + return 1 + except Exception: + log.exception("task_failed task_type=show_report") + print("ERROR:QUERY_FAILED 播报记录查询异常,详见统一日志") + return 1 + + +def cmd_render_html(report_id: Optional[int] = None) -> int: + log = _get_task_logger() + from service.report_service import build_report_for_report_id + from service.project_service import list_reports + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=render_html report_id=%s stage=render", report_id) + try: + if report_id: + targets = [int(report_id)] + else: + targets = [r["id"] for r in list_reports(limit=10)] + if not targets: + log.warning("task_failed task_type=render_html error_code=REPORT_NOT_FOUND stage=render") + finish(status="failed", message="暂无可重建的播报记录", skill=SKILL_SLUG, error_code="REPORT_NOT_FOUND", stage="render") + print("ERROR:REPORT_NOT_FOUND 暂无可重建的播报记录") + return 1 + written = [{"report_id": rid, "path": build_report_for_report_id(rid)} for rid in targets] + _print_json({"ok": True, "count": len(written), "reports": written}) + finish(status="success", message=f"已重建 {len(written)} 份经费执行简报", skill=SKILL_SLUG, stage="render") + return 0 + except JobStopped: + raise + except ValueError as exc: + log.warning("task_failed task_type=render_html error_code=REPORT_NOT_FOUND detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="REPORT_NOT_FOUND", stage="render") + print(f"ERROR:REPORT_NOT_FOUND {exc}") + return 1 + except Exception: + log.exception("task_failed task_type=render_html stage=render") + finish(status="failed", message="简报重建异常,详见统一日志", skill=SKILL_SLUG, stage="render") + return 1 + + +def cmd_view_rules() -> int: + log = _get_task_logger() + from service.project_service import view_rules + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=view_rules stage=rules") + try: + _print_json({"ok": True, "rules": view_rules()}) + return 0 + except Exception: + log.exception("task_failed task_type=view_rules") + print("ERROR:QUERY_FAILED 预警规则查询异常,详见统一日志") + return 1 + + +def cmd_set_rule(rule_key: str, value: float) -> int: + log = _get_task_logger() + from service.project_service import set_rule + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=set_rule rule_key=%s value=%s stage=rules", rule_key, value) + emit("开始更新预警阈值", skill=SKILL_SLUG, stage="rules") + try: + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + log.warning("entitlement_failed task_type=set_rule stage=auth") + finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") + log.error("set_rule_denied stage=auth") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") + return 1 + payload = set_rule(rule_key, float(value)) + _print_json(payload) + finish(status="success", message=f"阈值 {rule_key} 已更新为 {value}", skill=SKILL_SLUG, stage="rules") + return 0 + except ValueError as exc: + log.warning("task_failed task_type=set_rule error_code=RULE_INVALID detail=%s", exc) + finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="RULE_INVALID", stage="rules") + print(f"ERROR:RULE_INVALID {exc}") + return 1 + except JobStopped: + raise + except Exception: + log.exception("task_failed task_type=set_rule stage=rules") + finish(status="failed", message="阈值更新异常,详见统一日志", skill=SKILL_SLUG, stage="rules") + return 1 + + +def cmd_stats() -> int: + log = _get_task_logger() + from service.project_service import stats + + with job_context(skill=SKILL_SLUG): + log.info("task_start task_type=stats") + try: + _print_json(stats()) + return 0 + except Exception: + log.exception("task_failed task_type=stats") + print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志") + return 1 diff --git a/scripts/util/argparse_zh.py b/scripts/util/argparse_zh.py new file mode 100644 index 0000000..8ef2a0b --- /dev/null +++ b/scripts/util/argparse_zh.py @@ -0,0 +1,12 @@ +"""argparse 中文错误说明。""" + +from __future__ import annotations + +import argparse +import sys + + +class ZhArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + print(f"参数错误:{message}\n请执行:python main.py -h 查看帮助", file=sys.stderr) + self.exit(2) diff --git a/scripts/util/config_bootstrap.py b/scripts/util/config_bootstrap.py new file mode 100644 index 0000000..475aeeb --- /dev/null +++ b/scripts/util/config_bootstrap.py @@ -0,0 +1,19 @@ +"""技能配置初始化:.env.example 落盘与用户 .env 缺失项合并。""" + +from __future__ import annotations + +import os + +from jiangchang_skill_core import config + +from util.constants import SKILL_SLUG, SKILL_VERSION +from util.runtime_paths import get_skill_root + + +def bootstrap_skill_config() -> str: + """确保用户数据目录 .env 存在,并追加 .env.example 中的新配置项。""" + example_path = os.path.join(get_skill_root(), ".env.example") + env_path = config.ensure_env_file(SKILL_SLUG, example_path) + comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None + config.merge_missing_env_keys(example_path, env_path, comment_skill=comment) + return env_path diff --git a/scripts/util/constants.py b/scripts/util/constants.py new file mode 100644 index 0000000..4faeeea --- /dev/null +++ b/scripts/util/constants.py @@ -0,0 +1,6 @@ +"""技能标识、版本与平台公共库约束(项目经费进度播报助手)。""" + +SKILL_SLUG = "track-fund-progress-default" +SKILL_VERSION = "1.0.1" +LOG_LOGGER_NAME = "openclaw.skill.track_fund_progress_default" +PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/scripts/util/logging_config.py b/scripts/util/logging_config.py new file mode 100644 index 0000000..3328400 --- /dev/null +++ b/scripts/util/logging_config.py @@ -0,0 +1,21 @@ +"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging).""" + +from jiangchang_skill_core.unified_logging import ( + attach_unified_file_handler, + ensure_trace_for_process, + get_skill_log_file_path, + get_skill_logger, + get_unified_logs_dir, + setup_skill_logging, + subprocess_env_with_trace, +) + +__all__ = [ + "attach_unified_file_handler", + "ensure_trace_for_process", + "get_skill_log_file_path", + "get_skill_logger", + "get_unified_logs_dir", + "setup_skill_logging", + "subprocess_env_with_trace", +] diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py new file mode 100644 index 0000000..f38a631 --- /dev/null +++ b/scripts/util/runtime_paths.py @@ -0,0 +1,155 @@ +"""数据根、技能目录、兄弟技能根路径与用户数据子目录解析。""" + +from __future__ import annotations + +import os + +from jiangchang_skill_core.runtime_env import get_data_root, get_sibling_skills_root, get_user_id + +from util.constants import SKILL_SLUG + +_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# 用户可见的上行/下行目录(数据管理「输入/输出」文件浏览器) +STANDARD_SUBDIR_INPUT = "input" +STANDARD_SUBDIR_OUTPUT = "output" + +# 宿主扫描的 HTML 报告页(数据管理侧栏 htmlPages,非 input/output 文件浏览器) +STANDARD_SUBDIR_HTML = "html" + +# 内部/可选目录(不上侧栏) +STANDARD_SUBDIR_UPLOADS = "uploads" +STANDARD_SUBDIR_CACHE = "cache" +STANDARD_SUBDIR_TEMP = "temp" +STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts" +STANDARD_SUBDIR_VIDEOS = "videos" + +CONFIG_KEY_INPUT_DIR = "SKILL_INPUT_DIR" +CONFIG_KEY_OUTPUT_DIR = "SKILL_OUTPUT_DIR" +CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR" +CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR" +CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR" + + +def get_skill_root() -> str: + return os.path.dirname(_SCRIPTS_DIR) + + +def get_openclaw_root() -> str: + return get_sibling_skills_root(_SCRIPTS_DIR) + + +def get_skills_root() -> str: + return get_sibling_skills_root(_SCRIPTS_DIR) + + +def get_skill_data_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) + os.makedirs(path, exist_ok=True) + return path + + +def get_db_path(filename: str | None = None) -> str: + name = filename or f"{SKILL_SLUG}.db" + return os.path.join(get_skill_data_dir(), name) + + +def _normalize_subdir(value: str) -> str: + cleaned = value.strip().strip("/\\") + if not cleaned or cleaned.startswith(".."): + raise ValueError(f"invalid skill data subdir: {value!r}") + return cleaned.replace("/", os.sep).replace("\\", os.sep) + + +def resolve_data_path( + config_key: str | None, + default_subdir: str, + *, + create: bool = True, +) -> str: + """解析技能拥有路径:空配置 → 数据目录下 default_subdir;相对 → 相对数据目录;绝对 → 原样。""" + from jiangchang_skill_core import config + + base = get_skill_data_dir() + raw = (config.get(config_key) or "").strip() if config_key else "" + if not raw: + path = os.path.join(base, _normalize_subdir(default_subdir)) + elif os.path.isabs(raw): + path = os.path.abspath(os.path.expanduser(raw)) + else: + path = os.path.join(base, _normalize_subdir(raw)) + + if create: + os.makedirs(path, exist_ok=True) + return path + + +def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str: + """解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/input/。""" + text = (raw_path or "").strip() + if not text: + return text + expanded = os.path.expanduser(text) + if os.path.isabs(expanded): + return os.path.abspath(expanded) + input_dir = get_input_dir(create=create_parent) + return os.path.abspath(os.path.join(input_dir, os.path.normpath(expanded))) + + +def get_input_dir(*, create: bool = True) -> str: + """上行:用户/Agent 放入的待处理文件(清单、CSV、产品表等)。""" + return resolve_data_path(CONFIG_KEY_INPUT_DIR, STANDARD_SUBDIR_INPUT, create=create) + + +def get_output_dir(*, create: bool = True) -> str: + """下行:技能/宿主交给用户拿走的文件(视频、报表、导出 CSV 等)。""" + return resolve_data_path(CONFIG_KEY_OUTPUT_DIR, STANDARD_SUBDIR_OUTPUT, create=create) + + +def get_uploads_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_UPLOAD_DIR, STANDARD_SUBDIR_UPLOADS, create=create) + + +def get_cache_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_CACHE_DIR, STANDARD_SUBDIR_CACHE, create=create) + + +def get_temp_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_TEMP_DIR, STANDARD_SUBDIR_TEMP, create=create) + + +def get_rpa_artifacts_dir(batch_id: str, *, create: bool = True) -> str: + batch = (batch_id or "").strip() or "default" + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_RPA_ARTIFACTS, batch) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def get_videos_dir(*, create: bool = True) -> str: + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_VIDEOS) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def get_html_dir(*, create: bool = True) -> str: + """HTML 报告页目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。""" + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_HTML) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def list_resolved_data_paths() -> dict[str, str]: + """供 health / config-path 输出已解析的数据子目录(不创建目录)。""" + return { + "skill_data_dir": get_skill_data_dir(), + "input_dir": get_input_dir(create=False), + "output_dir": get_output_dir(create=False), + "html_dir": get_html_dir(create=False), + "uploads_dir": get_uploads_dir(create=False), + "cache_dir": get_cache_dir(create=False), + "temp_dir": get_temp_dir(create=False), + "videos_dir": get_videos_dir(create=False), + } diff --git a/scripts/util/slug_naming.py b/scripts/util/slug_naming.py new file mode 100644 index 0000000..0dda7cd --- /dev/null +++ b/scripts/util/slug_naming.py @@ -0,0 +1,227 @@ +"""技能 slug 语义校验(verb-noun-platform 规范,见 development/NAMING.md)。""" + +from __future__ import annotations + +import os +import re + +_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_MAX_SLUG_LENGTH = 48 +_MIN_SEGMENTS = 3 +_MAX_SEGMENTS = 5 +_MAX_NOUN_SEGMENTS = 3 + +_SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_NAMING_DIR = os.path.join(_SKILL_ROOT, "assets", "naming") + +_BUILTIN_VERBS = frozenset( + { + "scrape", + "receive", + "download", + "export", + "add", + "fill", + "enter", + "bind", + "withdraw", + "reconcile", + "verify", + "submit", + "query", + "process", + "reply", + "generate", + "draft", + "review", + "confirm", + "track", + "upload", + "match", + "triage", + "ship", + "reprice", + } +) +_BUILTIN_PLATFORMS = frozenset( + { + "amazon", + "shopee", + "xinghang", + "alibaba", + "kingdee", + "icbc", + "pingpong", + "worldfirst", + "paypal", + "lianlian", + "single-window", + "export-rebate", + "etax", + } +) +_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"}) + +LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset( + { + "account-manager", + "skill-template", + "your-skill-slug", + "your_skill_slug", + } +) + + +def load_wordlist(filename: str) -> frozenset[str]: + """从 assets/naming/{filename} 加载词表;文件不存在时 fallback 内置集合。""" + fallbacks = { + "verbs.txt": _BUILTIN_VERBS, + "platforms.txt": _BUILTIN_PLATFORMS, + "scopes.txt": _BUILTIN_SCOPES, + } + fallback = fallbacks.get(filename, frozenset()) + path = os.path.join(_NAMING_DIR, filename) + if not os.path.isfile(path): + return fallback + words: set[str] = set() + with open(path, encoding="utf-8") as f: + for line in f: + word = line.strip() + if word and not word.startswith("#"): + words.add(word) + return frozenset(words) if words else fallback + + +def parse_slug_segments(slug: str) -> list[str]: + """按 '-' 分割;非法字符由上层 kebab 校验处理。""" + slug = slug.strip() + if not slug: + return [] + return slug.split("-") + + +def _match_platform_suffix( + segments: list[str], + platforms: frozenset[str], +) -> tuple[str, int] | None: + """返回 (platform_name, platform_part_count) 或 None。""" + for platform in sorted(platforms, key=lambda item: (-item.count("-"), item)): + parts = platform.split("-") + part_count = len(parts) + if len(segments) < part_count + 2: + continue + if segments[-part_count:] == parts: + return platform, part_count + return None + + +def classify_slug_form(segments: list[str], *, slug: str = "") -> str: + """ + 返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid' + + - legacy_exempt: slug in LEGACY_EXEMPT_SLUGS + - standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5 + - scope: 末段 in scopes, 首段 in verbs, 3<=len<=5 + - invalid: 其他 + """ + if slug in LEGACY_EXEMPT_SLUGS: + return "legacy_exempt" + + if not segments or not (_MIN_SEGMENTS <= len(segments) <= _MAX_SEGMENTS): + return "invalid" + + verbs = load_wordlist("verbs.txt") + platforms = load_wordlist("platforms.txt") + scopes = load_wordlist("scopes.txt") + + if segments[0] not in verbs: + return "invalid" + + if segments[-1] in scopes: + return "scope" + + if _match_platform_suffix(segments, platforms) is not None: + return "standard" + + return "invalid" + + +def validate_slug_semantics( + slug: str, + *, + strict: bool = True, +) -> tuple[list[str], list[str]]: + """返回 (errors, warnings)。""" + slug = slug.strip() + if slug in LEGACY_EXEMPT_SLUGS: + return [], [] + + errors: list[str] = [] + warnings: list[str] = [] + + if not _KEBAB_SLUG.fullmatch(slug): + msg = f"slug 必须为 kebab-case(小写字母、数字、连字符):{slug!r}" + (errors if strict else warnings).append(msg) + return errors, warnings + + if len(slug) > _MAX_SLUG_LENGTH: + msg = f"slug 长度不得超过 {_MAX_SLUG_LENGTH} 字符(当前 {len(slug)}):{slug!r}" + (errors if strict else warnings).append(msg) + + segments = parse_slug_segments(slug) + segment_count = len(segments) + + if segment_count < _MIN_SEGMENTS: + msg = ( + f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(verb-noun-platform)," + f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance)" + ) + (errors if strict else warnings).append(msg) + return errors, warnings + + if segment_count > _MAX_SEGMENTS: + msg = f"slug 段数不得超过 {_MAX_SEGMENTS}(当前 {segment_count}):{slug!r}" + (errors if strict else warnings).append(msg) + + verbs = load_wordlist("verbs.txt") + platforms = load_wordlist("platforms.txt") + scopes = load_wordlist("scopes.txt") + + if segments[0] in platforms and segments[0] not in verbs: + msg = f"平台名不应放在 slug 最前:{slug!r}(应为 verb-noun-platform)" + (errors if strict else warnings).append(msg) + + if segments[0] not in verbs: + msg = f"slug 首段应为动词白名单中的词:{segments[0]!r}(slug={slug!r})" + (errors if strict else warnings).append(msg) + + form = classify_slug_form(segments, slug=slug) + if form == "scope": + if segments[-1] not in scopes: + msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}(slug={slug!r})" + (errors if strict else warnings).append(msg) + noun_count = segment_count - 2 + elif form == "standard": + platform_match = _match_platform_suffix(segments, platforms) + if platform_match is None: + msg = f"slug 末段应匹配平台白名单:{slug!r}" + (errors if strict else warnings).append(msg) + noun_count = 0 + else: + _platform_name, platform_part_count = platform_match + noun_count = segment_count - 1 - platform_part_count + else: + if segment_count >= _MIN_SEGMENTS: + if segments[-1] not in scopes: + platform_match = _match_platform_suffix(segments, platforms) + if platform_match is None: + msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}" + (errors if strict else warnings).append(msg) + noun_count = 0 + + if noun_count > _MAX_NOUN_SEGMENTS: + warnings.append( + f"slug 中间名词段建议不超过 {_MAX_NOUN_SEGMENTS} 个词(当前 {noun_count}):{slug!r}" + ) + + return errors, warnings diff --git a/scripts/util/timeutil.py b/scripts/util/timeutil.py new file mode 100644 index 0000000..318191f --- /dev/null +++ b/scripts/util/timeutil.py @@ -0,0 +1,20 @@ +"""时间戳与 ISO 展示。""" + +from __future__ import annotations + +import time +from datetime import datetime +from typing import Optional + + +def now_unix() -> int: + return int(time.time()) + + +def unix_to_iso(ts: Optional[int]) -> Optional[str]: + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except (ValueError, OSError, OverflowError): + return None diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..e6fce5d --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,10 @@ +# 测试运行产物与本地密钥,勿提交 +__pycache__/ +*.py[cod] +.pytest_cache/ +desktop/artifacts/ +integration/artifacts/ +*.log +.env +.env.* +!.env.sample diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cd67d4a --- /dev/null +++ b/tests/README.md @@ -0,0 +1,174 @@ +# skill-template 测试说明(企业数字员工技能) + +本目录提供面向 **ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA** 等外联场景的**分层测试骨架**:默认仅内存与本地 SQLite;**真实 API / 真实 RPA** 必须显式设置 ``OPENCLAW_TEST_TARGET``,且仅以 ``*.sample`` 或复制后的文件提供范式。 + +复制为新技能后,请保留隔离数据根与 profile 策略,再按域扩展用例。 + +--- + +## 我该把测试写在哪里? + +人类开发者与 Cursor 等 AI 工具在加用例前,先对照下表决定**文件落点**与**是否默认执行**: + +| 场景 | 放在哪里 | 默认是否运行 | 说明 | +|------|----------|--------------|------| +| CLI / health / version / init-db / metadata / runtime 路径 | `tests/test_*.py` | 是 | 默认必跑,保持轻量、无外联 | +| 纯函数 / 业务规则 | `tests/test_*.py` | 是 | 不依赖真实外部系统 | +| service 层契约 | 从 `tests/samples/test_service_contract.py.sample` 复制到 `tests/test_service_contract.py` | 是,复制后 | 用 `FakeAdapter` / stub 注入外部依赖 | +| 外源同步契约 | 从 `tests/samples/test_sync_contract.py.sample` 复制 | 是,复制后 | 幂等 / 事务 / 空结果保护 / 不导出文件 | +| 单内核多入口 | 从 `tests/samples/test_action_core_contract.py.sample` 复制 | 是,复制后 | CLI/task/兼容命令复用同一 domain service | +| golden fixture 回归 | 从 `tests/samples/test_golden_cases.py.sample` 复制到 `tests/test_golden_cases.py` | 是,复制后 | 输入样例 + 期望输出,适合解析、计算、校验类技能 | +| 仿真 API | `tests/integration/test_simulator_api_contract.py.sample` | 否 | 复制并设置 `OPENCLAW_TEST_TARGET=simulator_api` 后按需运行 | +| 仿真 RPA / 页面操作 | `tests/integration/test_simulator_rpa_contract.py.sample` | 否 | 用 localhost / 仿真页面,不碰真实系统 | +| 真实 API | `tests/integration/test_real_api_contract.py.sample` | 否 | 必须 `OPENCLAW_TEST_TARGET=real_api` | +| 真实 RPA | `tests/integration/test_real_rpa_contract.py.sample` | 否 | 最高风险,只能手动触发 | +| 桌面宿主 E2E | `tests/desktop/test_desktop_smoke.py.sample` | 否 | 需要 pytest + `jiangchang_desktop_sdk` | + +说明:`python tests/run_tests.py` **只**收集 `tests/` **根目录**下的 `test_*.py`;子目录里的 `*.sample` 与 desktop 用例**不会**被默认发现。 + +--- + +## 新技能最小测试清单 + +从本模板复制出新技能仓库后,建议至少逐项确认: + +- [ ] `python tests/run_tests.py -v` 能通过(含 platform-kit 导入与无 vendored core 守护)。 +- [ ] `python scripts/main.py health` 能通过(输出 runtime diagnostics;warning 不导致非零退出)。 +- [ ] `python scripts/main.py version` 输出 JSON,且 `skill` 与目录名 / `SKILL.md` / `constants.SKILL_SLUG` 一致。 +- [ ] `python scripts/main.py init-db` 幂等创建本地库并输出 JSON(`ok` / `skill` / `db_path`)。 +- [ ] 所有 DB / 文件写入都在 `IsolatedDataRoot`(或等价隔离)下测试,不写真实数据目录。 +- [ ] 外部系统默认使用 mock / `FakeAdapter`,不访问真实 API,不打开真实 RPA。 +- [ ] 至少有 1 个成功路径测试。 +- [ ] 至少有 1 个缺必填字段 / 非法输入测试。 +- [ ] 如果有 adapter,至少覆盖 timeout / unauthorized / invalid response(可用 fake 模拟)。 +- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture(脱敏)。 +- [ ] 如果技能有**特有** Python 三方依赖,`requirements.txt` 已声明且版本约束合理(尽量收窄范围);公共依赖(platform-kit、playwright)由宿主共享 runtime 提供,**不要**写入技能 requirements。 +- [ ] `health` 能在依赖缺失或原生运行库未就绪时给出清晰、可操作的错误(非裸 traceback)。 +- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 + +--- + +## 1. 默认必跑(unittest) + +在**技能仓库根目录**执行: + +```powershell +$env:PYTHONDONTWRITEBYTECODE = "1" +python tests/run_tests.py +python tests/run_tests.py -v +python tests/run_tests.py cli_smoke +python tests/run_tests.py test_cli_smoke +``` + +### 1.1 收集范围 + +- ``run_tests.py`` **只**发现 ``tests/`` **根目录**下 ``test_*.py``。 +- **不递归**:``tests/samples/``、``tests/integration/``、``tests/desktop/``(其中文件为 ``*.sample`` 或需 pytest 单独跑)。 +- 启动时把 ``scripts/`` 与 ``tests/`` 加入 ``sys.path``。 +- Windows 下将 **stdout/stderr** 包装为 **UTF-8**。 + +### 1.2 默认套件覆盖 + +| 能力 | 文件 | +|------|------| +| CLI 冒烟(import ``cli.app.main``) | ``test_cli_smoke.py`` | +| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` | +| 运行路径与 **JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` | +| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` | +| DB / 元数据骨架冒烟 | ``test_db_smoke.py`` | +| 数据管理元数据 / 字段顺序 | ``test_display_metadata.py`` | +| 标准时间字段(Unix 秒 / trigger / 元数据) | ``test_timestamp_columns.py`` | +| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) | +| **无 vendored ``jiangchang_skill_core``** | ``test_platform_import.py`` | +| **runtime diagnostics 架构守护** | ``test_runtime_diagnostics_integration.py`` | +| **文档/runtime 标准守护** | ``test_template_runtime_standard.py`` | +| **配置 bootstrap / config-path** | ``test_config_bootstrap.py`` | +| **health runtime diagnostics** | ``test_health_runtime.py`` | + +复制为新技能后**不得删除**上述架构守护测试,除非同步更新模板标准并理解影响。 + +### 1.3 数据隔离(``_support.IsolatedDataRoot``) + +进入同一临时目录 / 用户: + +| 变量 | 值 | +|------|-----| +| ``JIANGCHANG_DATA_ROOT`` | ``tempfile.mkdtemp(prefix="skill-test-")`` | +| ``JIANGCHANG_USER_ID`` | ``_test`` | + +退出时两个键均恢复(原不存在则 ``pop``),并删除临时目录。 + +### 1.4 产物与密钥 + +- ``tests/.gitignore`` 忽略 ``__pycache__``、``.pytest_cache``、``*.pyc``、``desktop/artifacts/``、``integration/artifacts/``、``.env`` 等。 +- 推荐运行前设置 ``PYTHONDONTWRITEBYTECODE=1`` 减少 ``.pyc``。 + +--- + +## 2. 可选:Service / Golden(``tests/samples/*.sample``) + +- 展示 **service 契约** 与 **fixture 回归** 写法;**默认不执行**。 +- 启用:复制 ``tests/samples/test_service_contract.py.sample`` 等为 ``tests/test_*.py``(去掉 ``.sample``),按业务修改后再 ``python tests/run_tests.py``。 +- 脱敏样例数据见 ``tests/fixtures/README.md`` 与 ``sample_request.json`` / ``expected_response.json``;profile 结构见 ``adapter_profiles.sample.yaml``。 + +--- + +## 3. 可选:Integration(``tests/integration/*.sample``) + +- **默认不跑**;文件均为 ``*.py.sample``。 +- 说明与开关见 **`tests/integration/README.md`**。 +- **禁止**在样例中硬编码真实 token、密码、生产 URL;凭证须来自密钥库或受控环境变量。 + +--- + +## 4. 可选:Desktop E2E(pytest) + +见 **`tests/desktop/README.md`**。示例为 `test_desktop_smoke.py.sample`,需复制为 `test_desktop_smoke.py` 后用 pytest 运行。 + +--- + +## 5. 环境变量:测试档位 + +### 5.1 测试档位 + +- ``OPENCLAW_TEST_TARGET`` + +**合法取值**(非法值将使 ``get_test_target()`` 抛 ``ValueError``): + +| 取值 | 含义 | +|------|------| +| ``unit`` | **默认**:单元 / 内存 / mock,不跑仿真与真实外联 | +| ``mock`` | 与 ``unit`` 类似的安全档位(显式语义) | +| ``simulator_api`` | 仅允许 **仿真 HTTP** 类集成(如 localhost) | +| ``simulator_rpa`` | 仅允许 **仿真页面 / 录播 RPA** | +| ``real_api`` | 真实 API(显式设 ``OPENCLAW_TEST_TARGET=real_api``) | +| ``real_rpa`` | 真实 RPA(显式设 ``OPENCLAW_TEST_TARGET=real_rpa``) | + +未设置时等价 ``unit``。 + +### 5.2 默认安全策略(``adapter_test_utils``) + +- 默认 ``unit``:不访问真实 API、不跑真实 RPA、不做写操作、不读取真实密钥、不写真实数据根。 +- ``assert_no_real_network_env()`` 断言默认未误设 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``(用于必跑用例自检)。 + +--- + +## AI 编程工具注意事项(Cursor 等) + +以下约束适用于为本仓库或复制出的技能编写/修改测试时,**避免误伤业务与生产**: + +- **不要为了**让测试通过去改业务代码,除非用户明确要求修改实现。 +- **不要**把真实外部系统调用写进默认的 `tests/test_*.py`(默认套件须无外联、无真实浏览器)。 +- **不要**把 `*.sample` 改名为可执行测试或移入根目录并去掉 `.sample`,除非用户明确要求启用 integration / 真实联调。 +- **不要**硬编码真实凭证、token、cookie、生产 URL;占位请用明显虚构域名(如 `example.invalid`)或文档约定的 `credential_ref`。 +- 默认测试只能使用 **mock、stub、`FakeAdapter`、fixtures、隔离 SQLite** 等安全手段。 +- 业务逻辑应优先在 **service 层** 编写与测试,不要只断言 CLI 文案。 +- 异常路径应返回**结构化错误**(如统一错误码/字段),避免裸异常直接穿透到 CLI。 +- 有真实系统联调需求时,测试应写在 `tests/integration/`,并显式设置 ``OPENCLAW_TEST_TARGET`` 为 ``real_api`` / ``real_rpa``;不得默认开启。 + +--- + +## 6. 与 content-manager 的关系 + +- **未**复制 article / media / prompt / NotebookLM 等业务测试。 +- 本骨架面向**通用外联技能**;业务契约请放在各自技能的 ``tests/samples`` / ``tests/integration`` 复制件中实现。 diff --git a/tests/_support.py b/tests/_support.py new file mode 100644 index 0000000..ec9b7fb --- /dev/null +++ b/tests/_support.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +测试公共支撑。 + +1. 将 scripts/ 加入 sys.path,便于 `from cli…` / `from db…` / `from util…` 等导入。 +2. `IsolatedDataRoot`:设置 `JIANGCHANG_DATA_ROOT` 与 `JIANGCHANG_USER_ID`, + 退出时恢复原值并删除临时目录,避免污染真实用户数据目录。 +""" +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +from typing import Optional + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, "..")) +_SCRIPTS_DIR = os.path.join(_SKILL_ROOT, "scripts") + +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +_ISOLATION_KEYS = ( + "JIANGCHANG_DATA_ROOT", + "JIANGCHANG_USER_ID", +) + + +def get_scripts_dir() -> str: + return _SCRIPTS_DIR + + +def get_skill_root() -> str: + return _SKILL_ROOT + + +def platform_kit_version_patch(version: str = "1.2.11"): + """Mock installed jiangchang-platform-kit version for health/diagnostics tests.""" + from unittest.mock import patch + + return patch( + "jiangchang_skill_core.runtime_diagnostics._platform_kit_version", + return_value=version, + ) + + +class IsolatedDataRoot: + """ + 在独立临时目录下模拟数据根,避免污染本机默认数据目录。 + + 进入上下文时: + - JIANGCHANG_DATA_ROOT -> tempfile.mkdtemp(prefix='skill-test-') + - JIANGCHANG_USER_ID -> _test(与业务默认用户区分) + + 退出时:两个变量均恢复为进入前状态(若原先未设置则 pop),并删除临时目录。 + """ + + def __init__(self, user_id: str = "_test") -> None: + self._tmp: Optional[str] = None + self._old: dict[str, Optional[str]] = {} + self._user_id = user_id + + def __enter__(self) -> str: + self._tmp = tempfile.mkdtemp(prefix="skill-test-") + for k in _ISOLATION_KEYS: + self._old[k] = os.environ.get(k) + os.environ["JIANGCHANG_DATA_ROOT"] = self._tmp + os.environ["JIANGCHANG_USER_ID"] = self._user_id + return self._tmp + + def __exit__(self, *exc_info: object) -> None: + for k in _ISOLATION_KEYS: + v = self._old.get(k) + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + if self._tmp and os.path.isdir(self._tmp): + shutil.rmtree(self._tmp, ignore_errors=True) + self._tmp = None + + +DEMO_ITEMS_TABLE = "demo_items" + +DEMO_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) +""" + +DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + {"column_name": "id", "display_name": "编号", "description": "主键", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "title", "display_name": "标题", "description": "标题", "visible": 1, "searchable": 1, "editable": 1}, + {"column_name": "status", "display_name": "状态", "description": "状态", "visible": 1, "searchable": 1, "editable": 1}, + { + "column_name": "created_at", + "display_name": "创建时间", + "description": "记录创建时间(Unix 秒级时间戳,数据库自动生成)", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, + { + "column_name": "updated_at", + "display_name": "更新时间", + "description": "记录最后更新时间(Unix 秒级时间戳,自动维护)", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, +) + + +def seed_demo_items(cursor, *, readonly: int = 0) -> None: + """测试夹具:创建 demo_items 物理表、时间戳 trigger 与展示元数据。""" + from db.display_metadata import seed_column_metadata_batch, upsert_table_metadata + from db.timestamp_columns import init_updated_at_trigger + + cursor.execute(DEMO_ITEMS_DDL) + init_updated_at_trigger(cursor, DEMO_ITEMS_TABLE) + upsert_table_metadata( + cursor, + table_name=DEMO_ITEMS_TABLE, + display_name="示例记录", + description="模板测试用业务表示例,不是默认交付表", + visible=1, + readonly=readonly, + ) + seed_column_metadata_batch( + cursor, + table_name=DEMO_ITEMS_TABLE, + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=[str(column["column_name"]) for column in DEMO_ITEMS_COLUMN_METADATA], + ) diff --git a/tests/adapter_test_utils.py b/tests/adapter_test_utils.py new file mode 100644 index 0000000..042775e --- /dev/null +++ b/tests/adapter_test_utils.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" +企业技能对接外部系统(ERP / TMS / WMS / 船司 / 报关 / 邮件 / 浏览器 / LLM / RPA)时, +测试侧共用的 **profile / 禁止真实外呼** 工具。 + +- 仅标准库 + jiangchang_skill_core.config;不发起 HTTP;不启动浏览器。 +- 真实 API / RPA 契约测试请放在 ``tests/integration/*.sample``,并显式设置 ``OPENCLAW_TEST_TARGET``。 +""" +from __future__ import annotations + +from typing import Any, Final + +from jiangchang_skill_core import config + +ALLOWED_TEST_TARGETS: Final[frozenset[str]] = frozenset( + { + "unit", + "mock", + "simulator_api", + "simulator_rpa", + "real_api", + "real_rpa", + } +) + + +def _raw_test_target_env() -> str: + return (config.get("OPENCLAW_TEST_TARGET") or "").strip() + + +def get_test_target() -> str: + """ + 读取 ``OPENCLAW_TEST_TARGET``(经 ``config.get`` 三级合并)。 + + 未设置时返回 ``unit``。非法取值抛出 ``ValueError``,便于尽早发现拼写错误。 + """ + raw = _raw_test_target_env() + if not raw: + return "unit" + if raw not in ALLOWED_TEST_TARGETS: + raise ValueError( + f"Invalid test target {raw!r}. Allowed: {sorted(ALLOWED_TEST_TARGETS)}" + ) + return raw + + +def real_access_allowed(profile: str) -> bool: + """ + 判断当前 ``OPENCLAW_TEST_TARGET`` 是否允许运行 ``profile`` 档位测试。 + """ + target = get_test_target() + if profile in ("mock", "simulator_api", "simulator_rpa"): + return True + if profile == "real_api": + return target == "real_api" + if profile == "real_rpa": + return target == "real_rpa" + raise ValueError(f"unknown profile for real_access_allowed: {profile!r}") + + +def should_skip_profile(profile: str, write: bool = False) -> tuple[bool, str]: + """ + 返回 ``(should_skip, reason)``。 + + 策略摘要: + - ``mock``:永不跳过(内存 / FakeAdapter,无外呼)。 + - ``simulator_api`` / ``simulator_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。 + - ``real_api`` / ``real_rpa``:仅当 ``get_test_target()`` 与 profile 一致时不跳过。 + - ``write`` 参数保留以兼容 integration 样例签名;档位一致即允许写操作测试。 + """ + _ = write + target = get_test_target() + + if profile == "mock": + return (False, "") + + if profile in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"): + if target != profile: + return (True, f"OPENCLAW_TEST_TARGET must be {profile} (got {target!r})") + return (False, "") + + raise ValueError(f"unknown profile: {profile!r}") + + +class FakeAdapter: + """ + 模拟外部系统 adapter,用于单元测试中的超时 / 鉴权 / 畸形响应等分支。 + + ``mode``:``success`` | ``timeout`` | ``invalid_response`` | ``unauthorized`` + """ + + def __init__(self, mode: str = "success") -> None: + if mode not in ("success", "timeout", "invalid_response", "unauthorized"): + raise ValueError(f"unsupported FakeAdapter mode: {mode!r}") + self._mode = mode + + def call(self, payload: Any) -> Any: + if self._mode == "success": + return {"success": True, "data": {"echo": payload}} + if self._mode == "timeout": + raise TimeoutError("simulated upstream timeout") + if self._mode == "invalid_response": + return object() # 非 dict,模拟无法解析的响应体 + if self._mode == "unauthorized": + raise PermissionError("simulated 401/403") + raise RuntimeError("unreachable") + + +def assert_no_real_network_env() -> None: + """ + 断言默认必跑套件未误设真实外联档位(``real_api`` / ``real_rpa``)。 + + 若需在受控环境跑真实集成,请显式设置 ``OPENCLAW_TEST_TARGET`` 并使用 ``tests/integration/*.sample``。 + """ + target = get_test_target() + assert target not in ("real_api", "real_rpa"), ( + f"OPENCLAW_TEST_TARGET must not be {target!r} in default unit tests" + ) diff --git a/tests/desktop/README.md b/tests/desktop/README.md new file mode 100644 index 0000000..a8ecf3e --- /dev/null +++ b/tests/desktop/README.md @@ -0,0 +1,63 @@ +# 可选桌面 E2E(pytest) + +本目录用于**匠厂桌面 + jiangchang_desktop_sdk** 的端到端冒烟,**不是** `python tests/run_tests.py` 默认路径的一部分。 + +## 依赖 + +- `pytest` +- `jiangchang_desktop_sdk`(由 `conftest.py` 按多级路径尝试加入 `sys.path`,见该文件说明) +- 匠厂桌面已安装或开发态可启动,且 `jiangchang://` 协议可用 + +## 为什么不默认跑 + +- 需要真实宿主环境,无法在纯 CI/无头环境稳定复现。 +- `unittest` 的 `tests/run_tests.py` **只发现** `tests/` 根目录下 `test_*.py`,**不递归**本目录。 +- 示例文件为 **`test_desktop_smoke.py.sample`**,避免被误收集。 + +## 如何启用 + +1. 将 `test_desktop_smoke.py.sample` **复制或重命名**为 `test_desktop_smoke.py`。 +2. 按你的技能名称、路由关键词、期望输出修改 `ask` 与断言。 +3. 运行: + +```powershell +pytest tests/desktop/test_desktop_smoke.py -v -s +``` + +失败时,`conftest.py` 中的 `failure_snapshot_dir` fixture 会将现场目录指向 `tests/desktop/artifacts/`(需自行避免将大文件提交进仓库)。 + +## 与后端测试的关系 + +- **后端 / CLI / DB**:请使用仓库根目录下 `python tests/run_tests.py`(见 `tests/README.md`)。 +- **桌面 E2E**:仅在本目录用 **pytest** 维护,二者互补、互不替代。 + +若仅需验证 **service 业务逻辑**、契约与 golden 回归,**不要**写 desktop E2E;desktop 只用于验证**宿主 UI、路由、`jiangchang://` 协议、真实用户交互链路**等与桌面宿主强相关的行为。 + +## 模板选择指南 + +| 场景 | 用哪个模板 | +|---|---| +| 纯问答 skill(不上传附件、不跑长流程) | `test_desktop_smoke.py.sample` | +| 需要上传附件 / RPA 长流程 / Agent 多轮工具调用 | `test_desktop_smoke_with_attachment.py.sample` | + +录屏入口都用 `record_screencast.py.sample`,里面 `TEST_FILE_NAME` 常量切换测试文件。 + +## 已知 SDK 坑(来自 disburse-payroll-icbc 实战) + +1. **`client.send_file()` 在 v2.0.17+ 抛 NotImplementedError**——附件场景必须用 + `jiangchang_desktop_sdk.e2e_helpers.drop_file_into_composer`(DataTransfer 拖拽)。 +2. **`client.ask()` 内部 `user_msg_index<0` 死循环**——如果匠厂启动慢 / gateway 握手有 + 延迟,导致 15s 内没抓到 user 节点,ask() 会锁死后续 900s 等待。建议长流程 / 附件场景 + 绕开 ask(),用 `jiangchang_desktop_sdk.e2e_helpers` 里的 `send_prompt_via_composer` + + `wait_for_user_message` + `wait_for_agent_complete` 自己控时。 +3. **字幕关键词建议用 ASCII**——虽然 platform-kit 已修了 subprocess 编码乱码, + 保险起见字幕脚本里关键词用 `step: 1/` 这种 ASCII 锚而非中文短语。 +4. **`client.bring_to_front()` 已升级**——v?? 起会 ctypes ShowWindow(SW_MAXIMIZE) + + SetForegroundWindow,能真正铺满桌面。dev 模式下也能用(不依赖 jiangchang:// 协议)。 + +## 录屏新能力(platform-kit v??) + +- `run_screencast(..., activate_window=True)` 自动激活+最大化匠厂窗口 +- `run_screencast(..., monitor_index=1)` 多显示器场景显式录主屏 +- 字幕脚本支持三元组 `(keyword, text, duration)` 给个别字幕指定独立时长, + 覆盖长等待场景中间没日志输出的"字幕真空" diff --git a/tests/desktop/conftest.py b/tests/desktop/conftest.py new file mode 100644 index 0000000..09811f4 --- /dev/null +++ b/tests/desktop/conftest.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +""" +可选桌面 E2E:pytest 收集时自动加载,负责把 jiangchang_desktop_sdk 加入 sys.path。 + +查找顺序: +1. 已 pip install 则直接 import; +2. 环境变量 JIANGCHANG_KIT_ROOT → sdk/jiangchang-desktop-sdk/src(旧版布局); +3. 环境变量 JIANGCHANG_KIT_ROOT → src(新版 platform-kit 布局); +4. 自本文件上溯:skill 根 → workspace 根 → OpenClaw 根,拼路径; +5. 兜底 D:\\AI\\jiangchang-platform-kit\\src(若存在); +6. 都找不到则 pytest.exit 给出可操作的排障说明。 +""" +from __future__ import annotations + +import importlib.util +import os +import sys + +import pytest + + +def _bootstrap_jiangchang_desktop_sdk() -> None: + if importlib.util.find_spec("jiangchang_desktop_sdk") is not None: + return + + env_kit_root = (os.environ.get("JIANGCHANG_KIT_ROOT") or "").strip() + + here = os.path.dirname(os.path.abspath(__file__)) + skill_root = os.path.abspath(os.path.join(here, "..", "..")) + workspace_root = os.path.abspath(os.path.join(skill_root, "..")) + open_claw_root = os.path.abspath(os.path.join(workspace_root, "..")) + + candidates = [] + if env_kit_root: + candidates.append(os.path.join(env_kit_root, "sdk", "jiangchang-desktop-sdk", "src")) + candidates.append(os.path.join(env_kit_root, "src")) + candidates.append( + os.path.join(workspace_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src") + ) + candidates.append( + os.path.join(open_claw_root, "jiangchang-platform-kit", "sdk", "jiangchang-desktop-sdk", "src") + ) + candidates.append( + os.path.join(workspace_root, "jiangchang-platform-kit", "src") + ) + candidates.append( + os.path.join(open_claw_root, "jiangchang-platform-kit", "src") + ) + default_kit_src = r"D:\AI\jiangchang-platform-kit\src" + if os.path.isdir(default_kit_src): + candidates.append(default_kit_src) + + tried = [] + for cand in candidates: + cand_abs = os.path.abspath(cand) + tried.append(cand_abs) + if os.path.isdir(cand_abs) and os.path.isdir(os.path.join(cand_abs, "jiangchang_desktop_sdk")): + if cand_abs not in sys.path: + sys.path.insert(0, cand_abs) + return + + pytest.exit( + "无法定位 jiangchang_desktop_sdk。请任选一种方式解决:\n" + " 1) pip install -e <path-to>/jiangchang-platform-kit\n" + " 2) 设置环境变量 JIANGCHANG_KIT_ROOT=<path-to>/jiangchang-platform-kit\n" + " (SDK 新版位置:<root>\\src;旧版:<root>\\sdk\\jiangchang-desktop-sdk\\src)\n" + " 3) 将 jiangchang-platform-kit 与本仓库放在同一 workspace 父级可推断的位置\n" + "已尝试的候选路径:\n - " + "\n - ".join(tried), + returncode=4, + ) + + +_bootstrap_jiangchang_desktop_sdk() + +_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts") + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) + + +@pytest.fixture +def failure_snapshot_dir() -> str: + os.makedirs(_ARTIFACT_DIR, exist_ok=True) + return _ARTIFACT_DIR diff --git a/tests/desktop/record_screencast.py.sample b/tests/desktop/record_screencast.py.sample new file mode 100644 index 0000000..58c01c2 --- /dev/null +++ b/tests/desktop/record_screencast.py.sample @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +""" +Screencast 录制入口(模板)。 + +启用方式: + 1. 复制为 record_screencast.py + 2. 修改 SKILL_SLUG 和 SUBTITLE_SCRIPT + 3. 决定 test_file 指向哪个测试(默认 test_desktop_smoke.py; + 有附件场景的用 test_desktop_smoke_with_attachment.py) + 4. 运行:python tests/desktop/record_screencast.py + +依赖: + 1. pip install mss + 2. 系统已安装 ffmpeg 并在 PATH 中 + 3. jiangchang-platform-kit 可推断路径(见 _bootstrap 逻辑) + 4. MEDIA_ASSETS_ROOT 环境变量指向 media-assets 仓库(或使用默认路径) + +平台 kit v?? 之后 run_screencast 自动激活并最大化匠厂窗口、支持指定显示器、 +字幕支持每条独立时长。本模板已经利用这些能力,新 skill 复制后无需再内嵌 +ctypes 激活代码。 +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _bootstrap_screencast() -> None: + """将 jiangchang-platform-kit/tools 加入 sys.path,使 screencast 包可导入。""" + here = Path(__file__).resolve() + skill_root = here.parents[2] + workspace_root = skill_root.parent + open_claw_root = workspace_root.parent + + candidates = [ + workspace_root / "jiangchang-platform-kit" / "tools", + open_claw_root / "jiangchang-platform-kit" / "tools", + Path(r"D:\AI\jiangchang-platform-kit") / "tools", + ] + env_kit = os.environ.get("JIANGCHANG_KIT_ROOT", "") + if env_kit: + candidates.insert(0, Path(env_kit) / "tools") + + for cand in candidates: + if (cand / "screencast" / "__init__.py").exists(): + if str(cand) not in sys.path: + sys.path.insert(0, str(cand)) + return + + raise RuntimeError( + "找不到 jiangchang-platform-kit/tools/screencast。\n" + "请设置 JIANGCHANG_KIT_ROOT 环境变量,或将 platform-kit 放在 workspace " + "同级目录。" + ) + + +_bootstrap_screencast() + +from screencast import run_screencast # noqa: E402 + +# ============================================================ +# ↓↓↓ 复制后只需修改下面这些 ↓↓↓ +# ============================================================ + +SKILL_SLUG = "track-fund-progress-default" # 替换为本技能 slug + +# 字幕脚本设计要点(来自 disburse-payroll-icbc 实战经验): +# 1. **关键词全用 ASCII**:保险起见绕开 Windows cp936/UTF-8 编码偶发问题。 +# 虽然 platform-kit 已修了 subprocess 编码,ASCII 关键词仍是最稳的。 +# 2. **用 "step: N/X" 模式作触发锚**:在测试代码里加 _logger.info("step: 1/5 ...") +# 日志作为字幕触发点,能精确控制字幕出现时机,比依赖 SDK 内部日志更可靠。 +# 3. **关键词每条只触发一次**,所以"step: 1/" 这种唯一前缀比"准备数据"更稳。 +# 4. **三元组支持每条独立时长**:(关键词, 文案, 显示秒数); +# long wait 场景可以让某条字幕停留更久(如 30s)覆盖中间无日志输出的等待期。 +SUBTITLE_SCRIPT = [ + # (keyword, text) 二元组用默认时长(5s) + ("ensure_app_running", "启动匠厂智能体平台"), + ("wait_gateway_ready", "网关就绪"), + ("step: 1/", "准备测试数据"), + ("step: 2/", "执行技能操作"), + # (keyword, text, duration) 三元组指定独立时长 + ("step: 3/", "技能在后端持续执行,请稍候...", 30.0), + ("step: 4/", "结果汇总返回"), + ("PASSED", "测试通过 ✓"), +] + +# 测试文件选择(按需切换): +# - test_desktop_smoke.py: 纯问答 skill 用这个 +# - test_desktop_smoke_with_attachment.py: 需要上传附件的 skill 用这个 +TEST_FILE_NAME = "test_desktop_smoke.py" + +# 音乐子目录(按 skill 风格选): +# - "music/corporate": 商务严肃(金融、办公场景) +# - "music/calm": 舒缓(流程演示、配置类) +# - "music/upbeat": 活力(成功汇报、营销视频) +# - "music"(默认): 三个子目录混选 +MUSIC_SUBDIR = "music/corporate" + +# ============================================================ + +if __name__ == "__main__": + _here = Path(__file__).resolve() + test_file = str(_here.parent / TEST_FILE_NAME) + output_dir = str(_here.parent / "artifacts" / "recordings") + + run_screencast( + skill_slug=SKILL_SLUG, + subtitle_script=SUBTITLE_SCRIPT, + pytest_args=[test_file, "-v", "-s"], + output_dir=output_dir, + music_subdir=MUSIC_SUBDIR, + # platform-kit 自动激活+最大化窗口(Windows ctypes / 其他系统协议), + # 想关掉传 activate_window=False + # monitor_index=1 显式录主屏(多显示器场景推荐) + ) diff --git a/tests/desktop/test_desktop_smoke.py.sample b/tests/desktop/test_desktop_smoke.py.sample new file mode 100644 index 0000000..e099851 --- /dev/null +++ b/tests/desktop/test_desktop_smoke.py.sample @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample)—— **简单问答场景**。 + +启用方式: + 复制本文件为 test_desktop_smoke.py,按目标技能修改提问文案与断言。 + 运行:pytest tests/desktop/test_desktop_smoke.py -v -s + +依赖:pytest、jiangchang_desktop_sdk、匠厂桌面宿主已就绪。 + +【何时用此模板】 +- 技能只需要发一句话给 Main Agent,等回复,做断言(纯问答) +- 不涉及上传附件 / 不涉及 RPA 长流程 + +【何时不要用此模板,改用 test_desktop_smoke_with_attachment.py.sample】 +- 需要上传文件(Excel / PDF / 图片)作为附件 +- 技能内部跑 RPA / 长流程(> 2 分钟),需要精细等待 + +【已知 SDK 限制(来自 disburse-payroll-icbc 实战)】 +- client.ask() 内部对"找到 user 消息节点"只给 15 秒窗口,找不到就锁死 + user_msg_index=-1,后续等待 assistant 永远 return None。如果你的场景里 + 匠厂启动较慢 / gateway 握手有延迟,client.ask() 可能假死到 timeout。 + 此模板仍用 ask(),因为它对**简单场景**够用。复杂场景请用 _with_attachment.py.sample + 里的"绕开 ask()"模式。 +- client.send_file() 在 v2.0.17+ 抛 NotImplementedError,**绝不要调用它**。 + 需要附件请用 _with_attachment.py.sample 里的拖拽方案。 +""" +from __future__ import annotations + +import logging +import os + +import pytest + +from jiangchang_desktop_sdk import AskOptions, JiangchangDesktopClient + +_logger = logging.getLogger("skill-template-desktop-e2e-sample") +if not _logger.handlers: + _logger.setLevel(logging.INFO) + _h = logging.StreamHandler() + _h.setFormatter(logging.Formatter("[E2E-sample] %(message)s")) + _logger.addHandler(_h) + +_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts") + + +class TestDesktopSmokeSample: + """最小 smoke:拉起桌面、等待网关、发一条与技能路由相关的问句并做宽松断言。""" + + @pytest.fixture(autouse=True) + def connect_and_teardown(self, request): + client = JiangchangDesktopClient() + _logger.info("ensure_app_running") + client.ensure_app_running(wait_timeout_s=30) + client.bring_to_front() + client.wait_gateway_ready(timeout_ms=30000) + self.client = client + yield + try: + rep = getattr(request.node, "rep_call", None) + if rep is not None and rep.failed: + os.makedirs(_ARTIFACT_DIR, exist_ok=True) + paths = client.snapshot(_ARTIFACT_DIR, tag=request.node.name) + _logger.error("snapshot: %s", paths) + finally: + client.disconnect() + + def test_gateway_and_simple_ask(self) -> None: + """请将 SKILL 名与路由关键词改成你复制后的真实技能。""" + answer = self.client.ask( + "请使用当前已安装技能执行一次 health 检查并返回 OK 或等价结果。", + AskOptions(timeout=120000, new_task=True), + ) + assert answer + assert "OK" in answer or "ok" in answer.lower() or "健康" in answer diff --git a/tests/desktop/test_desktop_smoke_with_attachment.py.sample b/tests/desktop/test_desktop_smoke_with_attachment.py.sample new file mode 100644 index 0000000..a79d85f --- /dev/null +++ b/tests/desktop/test_desktop_smoke_with_attachment.py.sample @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +可选桌面 E2E 示例(默认不执行:文件名后缀为 .sample)—— **附件上传 + 长流程场景**。 + +启用方式: + 1. 复制为 test_desktop_smoke_with_attachment.py + 2. 修改 SKILL_SLUG / PROMPT / 断言为你技能的真实业务 + 3. 准备样例附件,或动态生成(参考 _prepare_sample_attachment) + 4. 运行:pytest tests/desktop/test_desktop_smoke_with_attachment.py -v -s + +依赖: + - pytest / jiangchang_desktop_sdk / 匠厂桌面宿主已就绪 + - 通过 conftest.py 自动 bootstrap,jiangchang_desktop_sdk.e2e_helpers 模块在 sys.path + +【为什么用此模板而不是 test_desktop_smoke.py.sample】 +- client.send_file() 在 v2.0.17+ 抛 NotImplementedError,必须用拖拽方式 +- client.ask() 在 user_idx<0 时会锁死(已知 SDK 缺陷),本模板绕开 ask(), + 在测试侧自己用 jiangchang_desktop_sdk.e2e_helpers 做等待 + +【字幕配合】 +- 关键节点都打了 _logger.info("step: N/7 ..."),对应 record_screencast.py 的 + SUBTITLE_SCRIPT 用 step: 关键词触发字幕 +""" +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import pytest + +from jiangchang_desktop_sdk import JiangchangDesktopClient +from jiangchang_desktop_sdk.e2e_helpers import ( + drop_file_into_composer, + send_prompt_via_composer, + wait_for_agent_complete, + wait_for_attachment_ready, + wait_for_composer_enabled, + wait_for_user_message, +) + +# ============================================================ +# ↓↓↓ 复制后修改这些 ↓↓↓ +# ============================================================ + +SKILL_SLUG = "track-fund-progress-default" + +PROMPT = ( + "请使用 /track-fund-progress-default 处理我刚才上传的文件。" + "完成后请用清单方式汇总:[改成你技能要求的字段]。" +) + +# 业务断言:根据你的技能输出修改 +EXPECTED_KEYWORDS_IN_REPLY = ["关键词1", "关键词2"] + +# ============================================================ + +_logger = logging.getLogger("desktop-e2e-with-attachment-sample") +if not _logger.handlers: + _logger.setLevel(logging.INFO) + _h = logging.StreamHandler() + _h.setFormatter(logging.Formatter("[E2E] %(message)s")) + _logger.addHandler(_h) + +_ARTIFACT_DIR = os.path.join(os.path.dirname(__file__), "artifacts") + + +def _prepare_sample_attachment(tmp_path: Path) -> Path: + """在 tmp_path 下生成一个样例附件(按 skill 真实需要修改)。 + + 示例:生成一个 5 行的 xlsx。你的 skill 可能需要 CSV / PDF / 图片等其他格式。 + """ + from openpyxl import Workbook # noqa: WPS433 + xlsx = tmp_path / "sample.xlsx" + wb = Workbook() + ws = wb.active + ws.append(["列1", "列2", "列3"]) + for i in range(1, 6): + ws.append([f"值{i}A", f"值{i}B", i * 100]) + wb.save(str(xlsx)) + return xlsx + + +@pytest.fixture +def client(request): + c = JiangchangDesktopClient() + _logger.info("ensure_app_running") + c.ensure_app_running(wait_timeout_s=45) + c.bring_to_front() + c.wait_gateway_ready(timeout_ms=30000) + yield c + try: + rep = getattr(request.node, "rep_call", None) + if rep is not None and rep.failed: + os.makedirs(_ARTIFACT_DIR, exist_ok=True) + paths = c.snapshot(_ARTIFACT_DIR, tag=request.node.name) + _logger.error("failure snapshot: %s", paths) + finally: + c.disconnect() + + +def test_desktop_with_attachment(client: JiangchangDesktopClient, tmp_path: Path) -> None: + _logger.info("step: 1/7 准备样例附件") + attachment_path = _prepare_sample_attachment(tmp_path) + + client.new_task() + page = client.get_page() + client.bring_to_front() + + _logger.info("step: 2/7 拖拽附件到聊天框") + drop_file_into_composer(page, attachment_path) + + _logger.info("step: 3/7 等附件 stage-buffer 完成") + wait_for_attachment_ready(page, attachment_path.name) + + wait_for_composer_enabled(page) + client.bring_to_front() + _logger.info("step: 4/7 键入并发送指令") + send_prompt_via_composer(page, PROMPT) + + _logger.info("step: 5/7 Main Agent 接收,等待路由到技能") + user_idx = wait_for_user_message(page, PROMPT, timeout_s=60.0) + + _logger.info("step: 6/7 技能在后端执行(可能 2-3 分钟)") + client.bring_to_front() + reply = wait_for_agent_complete( + page, user_idx, + overall_timeout_s=1200.0, + stable_seconds=5.0, + ) + + _logger.info("step: 7/7 校验回复内容") + _logger.info("reply length=%d", len(reply)) + + assert reply, "assistant 回复为空" + for kw in EXPECTED_KEYWORDS_IN_REPLY: + assert kw in reply, f"期望关键词 {kw!r} 未出现在回复中;片段:{reply[:400]!r}" diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..34b29ac --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,27 @@ +# fixtures(脱敏样例) + +本目录存放 **golden case / contract test** 用的输入、期望输出与 **adapter profile 样例**。 + +## 规则 + +- 只能放**虚构、脱敏、可公开**的数据(演示租户、演示 request_id 等)。 +- **禁止**:真实客户名、真实订单/提单号、真实报关单、真实 token/cookie、真实内网 URL、生产环境凭证。 +- `adapter_profiles.sample.yaml` 中的 URL 须使用 `localhost`、`example.invalid` 等明显占位;`credential_ref` 仅表达「从密钥库引用」的约定,**不得**内嵌密钥。 + +## 与本仓库测试的对应关系 + +| 文件 | 用途 | +|------|------| +| `sample_request.json` | 通用请求体样例,供 ``tests/samples/test_golden_cases.py.sample`` 演示读取。 | +| `expected_response.json` | 与上配套的期望摘要字段(业务复制后按域替换)。 | +| `adapter_profiles.sample.yaml` | 五类运行方式(mock / simulator_api / simulator_rpa / real_api / real_rpa)的配置表达样例。 | + +--- + +## 给 AI 工具的替换规则 + +- 可以按业务**重命名字段**、增减键名,但必须保持**虚构、脱敏**;不得把真实客户标识原样搬进仓库。 +- 可以**新增**更多 fixture 文件,例如 `case_valid_001.json`、`case_missing_required_001.json`,并在测试中通过相对路径读取。 +- **禁止**将真实企业数据、真实单证截图中的文字、真实报关要素未经处理直接复制进 `fixtures/`。 +- 若用例来自真实客户样本,须先**脱敏、泛化、替换编号与地名**,再作为模板数据提交。 + diff --git a/tests/fixtures/adapter_profiles.sample.yaml b/tests/fixtures/adapter_profiles.sample.yaml new file mode 100644 index 0000000..9a30db2 --- /dev/null +++ b/tests/fixtures/adapter_profiles.sample.yaml @@ -0,0 +1,40 @@ +# 样例:五类 adapter 运行方式(复制到业务仓后按环境改名;勿提交真实凭证) +runtime: + profile: mock + +adapters: + demo_external_system: + interface: demo + profile: mock + + mock: + target: mock + channel: none + + simulator_api: + target: simulator + channel: api + base_url: http://localhost:5180/api/demo + timeout_seconds: 10 + + simulator_rpa: + target: simulator + channel: rpa + entry_url: http://localhost:5180/select + timeout_seconds: 30 + + real_api: + target: real + channel: api + base_url: https://example.invalid/api + credential_ref: vault://tenant/demo/external-system/api + write_enabled: false + timeout_seconds: 20 + + real_rpa: + target: real + channel: rpa + entry_url: https://example.invalid/portal + credential_ref: vault://tenant/demo/external-system/rpa + write_enabled: false + timeout_seconds: 60 diff --git a/tests/fixtures/expected_response.json b/tests/fixtures/expected_response.json new file mode 100644 index 0000000..d031ce4 --- /dev/null +++ b/tests/fixtures/expected_response.json @@ -0,0 +1,6 @@ +{ + "success": true, + "data": { + "status": "ok" + } +} diff --git a/tests/fixtures/sample_request.json b/tests/fixtures/sample_request.json new file mode 100644 index 0000000..96ad266 --- /dev/null +++ b/tests/fixtures/sample_request.json @@ -0,0 +1,11 @@ +{ + "request_id": "req_test_001", + "tenant_id": "tenant_demo", + "actor": "tester", + "parameters": { + "input_text": "demo input", + "options": { + "dry_run": true + } + } +} diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..2c0939f --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,48 @@ +# integration(可选集成 / 真实外联) + +本目录**默认不执行**: + +- ``python tests/run_tests.py`` 只收集 ``tests/`` 根目录下 ``test_*.py``,**不会**进入本目录。 +- 本目录下文件一律为 ``*.py.sample``,pytest / unittest **默认也不会**按普通 ``test_*.py`` 收集。 + +## 与 samples 的区别 + +| 维度 | `tests/samples/` | `tests/integration/` | +|------|------------------|------------------------| +| 目的 | 默认可**升级**为根目录 `test_*.py` 的 **业务单元 / service 契约 / golden** 范式 | **外联联调**范式:网络、浏览器、仿真或真实系统 | +| 默认执行 | `.sample` 不执行;复制到根目录并改名后才进入默认套件 | **永远不**作为默认 unittest 的一部分;复制后仍需显式环境变量与手动/CI 任务触发 | +| 典型内容 | `FakeAdapter`、fixture、隔离 DB | `localhost` 仿真、真实 API、真实 RPA(最高风险) | + +**原则**:只要会访问**网络**、**浏览器**、**真实或仿真外部系统**,应优先放在 `integration`(并保持 `.sample` 直至团队同意启用),而不是写进默认 `tests/test_*.py`。 + +## 何时使用 + +| 场景 | 建议 ``OPENCLAW_TEST_TARGET`` | 其它条件 | +|------|------------------------------|----------| +| 本地 / CI 对接**仿真 HTTP** | ``simulator_api`` | 仿真服务如 ``http://localhost:5180`` | +| **仿真页面** / 录播 RPA | ``simulator_rpa`` | 本地页面或沙箱浏览器 profile | +| **真实 API** | ``real_api`` | **仅手动触发** | +| **真实 RPA**(最高风险) | ``real_rpa`` | **仅手动触发** | + +## 环境变量(与 ``adapter_test_utils`` 一致) + +- **目标档位**:``OPENCLAW_TEST_TARGET`` + 取值:``unit`` | ``mock`` | ``simulator_api`` | ``simulator_rpa`` | ``real_api`` | ``real_rpa`` + 默认未设置等价于 ``unit``。 + +## 安全约束 + +- **禁止**在样例或复制后的测试代码中硬编码真实账号、密码、token、cookie、生产 URL。 +- 凭证应来自**平台密钥库**、受控环境变量或本机安全配置(``.env`` 已列入 ``tests/.gitignore``,勿提交)。 +- 真实 RPA 可能对 ERP/TMS/WMS 等产生真实操作;**务必**沙箱账号、只读权限,并由人工触发 CI job。 + +## 如何运行某个样例 + +1. 将目标 ``*.sample`` 复制为 ``test_*.py``(去掉 ``.sample``)。 +2. 按技能修改其中的占位 URL / 路由 / 断言。 +3. 使用 **pytest** 指向该文件(或整目录),并导出上表所需环境变量。 + +```powershell +$env:OPENCLAW_TEST_TARGET = "simulator_api" +pytest tests/integration/test_simulator_api_contract.py -v +``` diff --git a/tests/integration/test_real_api_contract.py.sample b/tests/integration/test_real_api_contract.py.sample new file mode 100644 index 0000000..6eec96a --- /dev/null +++ b/tests/integration/test_real_api_contract.py.sample @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_real_api_contract.py;需 OPENCLAW_TEST_TARGET=real_api。 +"""真实 HTTP API(只读优先;默认跳过)。""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestRealApiContractSample(unittest.TestCase): + def test_skip_by_default(self) -> None: + skip, reason = should_skip_profile("real_api", write=False) + if skip: + raise unittest.SkipTest(reason) + # 写操作示例(复制后使用): + # skip_w, _ = should_skip_profile("real_api", write=True) + # 禁止在此文件写入真实 token;从 vault / 环境注入读取 endpoint。 + self.assertFalse(skip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_real_rpa_contract.py.sample b/tests/integration/test_real_rpa_contract.py.sample new file mode 100644 index 0000000..4db607e --- /dev/null +++ b/tests/integration/test_real_rpa_contract.py.sample @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_real_rpa_contract.py;需 OPENCLAW_TEST_TARGET=real_rpa。 +""" +真实 RPA / 桌面自动化(**最高风险**,仅手动触发)。 + +默认只读思路示例:打开沙箱门户、读取标题、**不提交表单**。 +写操作测试同样须显式设 ``OPENCLAW_TEST_TARGET=real_rpa`` 后手动触发。 +""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestRealRpaContractSample(unittest.TestCase): + def test_skip_by_default(self) -> None: + skip, reason = should_skip_profile("real_rpa", write=False) + if skip: + raise unittest.SkipTest(reason) + # 真实技能中:连接企业沙箱门户,执行只读断言;禁止默认操作生产系统。 + self.assertFalse(skip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_simulator_api_contract.py.sample b/tests/integration/test_simulator_api_contract.py.sample new file mode 100644 index 0000000..f512e36 --- /dev/null +++ b/tests/integration/test_simulator_api_contract.py.sample @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_simulator_api_contract.py 后,在 OPENCLAW_TEST_TARGET=simulator_api 下用 pytest 或 unittest 运行。 +"""仿真 HTTP API 契约(默认跳过;不引入 requests 运行时依赖)。""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestSimulatorApiContractSample(unittest.TestCase): + """演示:仅当 ``OPENCLAW_TEST_TARGET=simulator_api`` 时不跳过。""" + + def test_skip_unless_simulator_api_target(self) -> None: + skip, reason = should_skip_profile("simulator_api") + if skip: + raise unittest.SkipTest(reason) + # 真实技能中:在此使用 urllib 访问 localhost 仿真,或通过 adapter factory 注入 stub。 + # 禁止默认连接生产 ERP/TMS。 + self.assertTrue(True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_simulator_rpa_contract.py.sample b/tests/integration/test_simulator_rpa_contract.py.sample new file mode 100644 index 0000000..1815483 --- /dev/null +++ b/tests/integration/test_simulator_rpa_contract.py.sample @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 test_simulator_rpa_contract.py 后按需运行;默认跳过。 +"""仿真页面 / RPA 契约(不默认启动浏览器)。""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import should_skip_profile + + +class TestSimulatorRpaContractSample(unittest.TestCase): + def test_skip_unless_simulator_rpa_target(self) -> None: + skip, reason = should_skip_profile("simulator_rpa") + if skip: + raise unittest.SkipTest(reason) + # 真实技能中:可替换为 Playwright 连接 http://localhost:5180/select 等仿真入口。 + self.assertFalse(skip) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..0bfb501 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""后端 / CLI / DB 单元测试入口(unittest,不强制 pytest)。 + +用法: + python tests/run_tests.py # 发现 tests/ 根目录下全部 test_*.py + python tests/run_tests.py -v # 详细输出 + python tests/run_tests.py cli_smoke # 仅匹配 test_cli_smoke.py + python tests/run_tests.py test_cli_smoke + +说明: + 只发现本目录(tests/)下一层的 test_*.py;不递归 ``samples/``、``integration/``、 + ``desktop/``(后三者提供 ``*.sample`` 或 pytest 专用用例,见 ``tests/README.md``)。 +""" +from __future__ import annotations + +import os +import sys +import unittest + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SKILL_ROOT = os.path.abspath(os.path.join(_TESTS_DIR, "..")) +_SCRIPTS_DIR = os.path.abspath(os.path.join(_SKILL_ROOT, "scripts")) + +for _p in (_SCRIPTS_DIR, _TESTS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +# Windows 下统一 stdout/stderr 为 UTF-8,避免 GBK 控制台中文/emoji 报错 +if sys.platform == "win32": + import io + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +def _pattern_from_argv(argv: list[str]) -> str: + for a in argv[1:]: + if a.startswith("-"): + continue + s = a if a.startswith("test_") else f"test_{a}" + if not s.endswith(".py"): + s = f"{s}.py" + return s + return "test_*.py" + + +def _verbosity_from_argv(argv: list[str]) -> int: + return 2 if ("-v" in argv or "--verbose" in argv) else 1 + + +def main() -> int: + pattern = _pattern_from_argv(sys.argv) + verbosity = _verbosity_from_argv(sys.argv) + loader = unittest.TestLoader() + suite = loader.discover(_TESTS_DIR, pattern=pattern) + runner = unittest.TextTestRunner(verbosity=verbosity) + result = runner.run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/samples/README.md b/tests/samples/README.md new file mode 100644 index 0000000..5880813 --- /dev/null +++ b/tests/samples/README.md @@ -0,0 +1,128 @@ +# samples(业务测试样例,默认不执行) + +本目录存放**可复制到根目录的业务测试范式**,面向「企业数字员工技能」中 **ERP、TMS、WMS、船司、报关、邮件、LLM、RPA** 等外联场景下的 **service / golden** 测法说明。 + +--- + +## 作用 + +- 这里的文件是 **`.py.sample`**:`python tests/run_tests.py` **不会**收集本目录(`run_tests.py` 只发现 `tests/` 根目录下的 `test_*.py`)。 +- 人类或 AI 需要启用范式时:**复制**到 `tests/test_<名称>.py`(去掉 `.sample`),再按业务改 import、断言与 fixture 路径。 +- **禁止**把真实联调、真实网络、真实 RPA 写进复制后的默认用例;此类内容应放在 `tests/integration/*.sample` 并配合环境变量开关。 + +--- + +## 样例文件 + +### 1. `test_service_contract.py.sample` + +**适合:** + +- 技能有清晰的 **service 层入口**,例如 `run(...)`、`handle(...)`、`execute(...)`。 +- 需要验证:**成功路径**、**参数/校验错误**、**adapter 异常被转换为业务可读错误**。 +- 需要用 **`FakeAdapter`**(见 `tests/adapter_test_utils.py`)或自建 **stub** 替代 ERP、TMS、WMS、船司、报关、LLM、RPA 等,**不**发真实 HTTP、**不**开真实浏览器。 + +### 2. `test_sync_contract.py.sample` + +**适合:** 外源 → 本地库的同步 / refresh / collect 能力。 + +至少应覆盖:首次插入、第二次幂等、更新、失效数据处理、不可信空结果保护 vs 完整空快照失效、遗漏 `snapshot_complete` 立即失败、部分写入后故障注入与回滚(`fail_after_writes`)、统计字段 `inserted/updated/unchanged/removed`、同步路径不调用 CSV/Excel 导出器。`snapshot_complete` 为调用方**必须显式传入**的 keyword-only 参数(无默认值),禁止默认把结果当成完整快照,也禁止靠条数猜测完整性。使用通用表名(如 `business_key`),不要写具体平台业务名。 + +### 3. `test_action_core_contract.py.sample` + +**适合:** 校验「单业务内核、多入口」——task/CLI/兼容命令调用同一 domain service;不按 toolbar/cron/agent 来源改变业务结果。 + +### 4. `test_golden_cases.py.sample` + +**适合:** + +- **解析类**:邮件/单证/PDF/Excel 等结构化输出。 +- **计算类**:费用、税费、分摊、账单字段。 +- **校验类**:合规、字段一致性、状态机规则。 + +推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。 + +### 5. `test_logging_contract.py.sample` + +**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。 + +--- + +## 如何启用 service contract 样例 + +1. **复制文件** + `tests/samples/test_service_contract.py.sample` + → `tests/test_service_contract.py`(或 `tests/test_<domain>_service_contract.py`,须符合根目录 `test_*.py` 命名以便 `run_tests.py` 收集)。 + +2. **替换示例 import** + 把注释中的示意: + `# from service.your_service import run` + 改为真实模块,例如: + `from service.quote_service import run_quote` + (具体路径以技能代码为准。) + +3. **注入假外部依赖** + - **不要**在默认用例里直接 `requests` 访问 ERP/TMS/WMS 等。 + - **不要**打开真实网页或驱动真实 RPA。 + - **不要**调用真实 LLM;可 stub 返回固定 JSON。 + - 对 **timeout / unauthorized / invalid_response** 等分支,用 `FakeAdapter` 或自建 fake 模拟(与 `adapter_test_utils` 思路一致)。 + +4. **至少保留三类断言习惯** + - **成功路径**:返回 `success: true` 或与技能约定的成功结构。 + - **参数错误**:缺必填字段时返回**结构化** error(字段/码),而非未捕获裸异常。 + - **外部异常**:adapter 超时、鉴权失败、脏响应等被转换为**可读、可重试标识明确**的错误形态(按产品约定)。 + +5. **给 AI 的提示** + 复制后全文搜索 `your_service`、`TODO`、`伪代码` 等占位符并替换;不要留下指向真实客户的示例数据。 + +--- + +## 如何启用 golden case 样例 + +1. **复制文件** + `tests/samples/test_golden_cases.py.sample` + → `tests/test_golden_cases.py`(或 `tests/test_<domain>_golden.py`)。 + +2. **按业务修改 fixtures**(仍须脱敏) + - `tests/fixtures/sample_request.json`:请求或输入快照。 + - `tests/fixtures/expected_response.json`:期望摘要或关键字段。 + 可新增多组文件,如 `case_valid_001.json`,并在测试中显式读取路径。 + +3. **断言建议(避免脆弱用例)** + - 比较**关键字段**,避免对整段 JSON 做机械全文 diff。 + - **忽略**时间戳、随机 id、`trace_id` 等非确定字段。 + - 对列表:断言**长度**、**关键元素**、**排序规则**(若业务保证顺序)。 + - 对金额/数量:断言 Decimal 字符串、或允许文档约定的小范围误差。 + +4. **与 service 的关系** + golden 用例仍应通过 **service 层**(或纯函数)得到输出,再与 fixture 对比,而不是从 CLI 字符串 scrape。 + +--- + +## 不应该写在 samples 复制件里的内容 + +以下若出现,应移到 **`tests/integration/*.sample`** 或 **`tests/desktop/*.sample`**,并加环境变量授权说明: + +- 访问**真实** API 或内网服务。 +- 打开**真实** RPA 页面或生产门户。 +- 读取**真实**客户文件、生产数据库。 +- 在测试中写入**真实**数据根或共享盘。 +- 任何真实 **token / cookie / 密码** 字面量。 +- 耗时极长、强依赖宿主 UI 抖动、无法在 CI 稳定复现的用例(不要塞进默认 `tests/test_*.py`)。 + +--- + +## 推荐命名(复制到根目录后) + +| 用途 | 示例文件名 | +|------|------------| +| Service 契约 | `tests/test_service_contract.py` | +| 同步契约 | `tests/test_sync_contract.py` | +| 单内核多入口 | `tests/test_action_core_contract.py` | +| Golden / fixture 回归 | `tests/test_golden_cases.py` | +| 领域规则 | `tests/test_<domain>_rules.py` | +| 校验逻辑 | `tests/test_<domain>_validation.py` | + +命名需满足:`tests/` 根目录下 `test_*.py`,以便 `python tests/run_tests.py` 默认发现。 + +更多分层说明见 **`tests/README.md`**(「我该把测试写在哪里?」与「新技能最小测试清单」)。 diff --git a/tests/samples/test_action_core_contract.py.sample b/tests/samples/test_action_core_contract.py.sample new file mode 100644 index 0000000..5bcc8c0 --- /dev/null +++ b/tests/samples/test_action_core_contract.py.sample @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# 样例:复制到 ``tests/test_action_core_contract.py`` 后按实际模块改名运行。 +# 默认不会被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。 +""" +单业务内核、多入口契约范式(通用)。 + +覆盖: +- task / command adapter 调用唯一 domain service +- 兼容命令复用同一个核心函数 +- CLI handler 不复制业务逻辑 +- 不根据 toolbar / cron / agent 来源改变业务处理 + +复制后把示意函数换成真实 import;禁止按入口维护多套实现。 +""" +from __future__ import annotations + +import unittest +from typing import Any, Callable + + +class DomainService: + """唯一业务内核示意。""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def sync_records(self, *, limit: int = 100) -> dict[str, Any]: + self.calls.append({"op": "sync_records", "limit": limit}) + return {"ok": True, "limit": limit, "inserted": 1} + + +def cmd_sync_records(domain: DomainService, *, limit: int = 100) -> dict[str, Any]: + """task_service 编排层:可加 job_context,但不按 source 分叉业务。""" + return domain.sync_records(limit=limit) + + +def cmd_export_compat_refresh(domain: DomainService, *, limit: int = 100) -> dict[str, Any]: + """兼容旧 export:刷新必须复用同一 sync 内核,禁止另写采集逻辑。""" + sync_result = domain.sync_records(limit=limit) + return {"export": "delegated-to-host-or-report", "sync": sync_result} + + +def cli_handle_sync(domain: DomainService, argv_limit: int) -> dict[str, Any]: + """CLI handler:只解析参数后转发,不内嵌业务。""" + return cmd_sync_records(domain, limit=argv_limit) + + +def run_from_source( + domain: DomainService, + *, + source: str, + limit: int = 10, +) -> dict[str, Any]: + """ + 宿主可能传入 source=toolbar|cron|agent|skill-detail。 + 正确实现:忽略 source,业务结果一致。 + """ + _ = source # 不得用于分支业务语义 + return cmd_sync_records(domain, limit=limit) + + +class TestActionCoreContractSample(unittest.TestCase): + def test_task_adapter_calls_single_domain_service(self) -> None: + domain = DomainService() + out = cmd_sync_records(domain, limit=5) + self.assertTrue(out["ok"]) + self.assertEqual(domain.calls, [{"op": "sync_records", "limit": 5}]) + + def test_compat_command_reuses_same_core(self) -> None: + domain = DomainService() + cmd_export_compat_refresh(domain, limit=3) + self.assertEqual(len(domain.calls), 1) + self.assertEqual(domain.calls[0]["op"], "sync_records") + + def test_cli_handler_does_not_duplicate_business_logic(self) -> None: + domain = DomainService() + # CLI 与 task 编排应落到同一函数对象(示意) + self.assertIs(cli_handle_sync.__wrapped__ if hasattr(cli_handle_sync, "__wrapped__") else None, None) + out = cli_handle_sync(domain, 7) + self.assertEqual(out["limit"], 7) + self.assertEqual(domain.calls[-1], {"op": "sync_records", "limit": 7}) + + # 强化:CLI 路径与 cmd 路径结果一致 + domain2 = DomainService() + a = cli_handle_sync(domain2, 2) + domain3 = DomainService() + b = cmd_sync_records(domain3, limit=2) + self.assertEqual(a, b) + + def test_business_result_independent_of_entry_source(self) -> None: + domain = DomainService() + results = [ + run_from_source(domain, source=src, limit=4) + for src in ("toolbar", "cron", "agent", "skill-detail") + ] + self.assertEqual(results[0], results[1]) + self.assertEqual(results[1], results[2]) + self.assertEqual(results[2], results[3]) + self.assertEqual(len(domain.calls), 4) + self.assertTrue(all(c["op"] == "sync_records" and c["limit"] == 4 for c in domain.calls)) + + def test_entrypoint_wiring_points_to_same_callable(self) -> None: + """示意:manifest 多 placements 应对应同一 Python 入口函数。""" + domain = DomainService() + handlers: dict[str, Callable[..., dict[str, Any]]] = { + "toolbar": lambda: cmd_sync_records(domain, limit=1), + "cron": lambda: cmd_sync_records(domain, limit=1), + "agent": lambda: cmd_sync_records(domain, limit=1), + } + outs = [handlers[k]() for k in handlers] + self.assertEqual(outs[0], outs[1]) + self.assertEqual(outs[1], outs[2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_golden_cases.py.sample b/tests/samples/test_golden_cases.py.sample new file mode 100644 index 0000000..6a35750 --- /dev/null +++ b/tests/samples/test_golden_cases.py.sample @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# 样例:复制为 ``tests/test_golden_cases.py`` 后运行;默认不收集。 +""" +Golden fixture 回归(示意)。 + +- 从 ``tests/fixtures/`` 读取脱敏 JSON。 +- 调用 service 层(复制后替换为真实 import)。 +- 断言关键字段,而非全文 diff(外部系统常带时间戳/非确定性字段)。 +""" +from __future__ import annotations + +import json +import os +import unittest + + +def _fixtures_dir() -> str: + return os.path.join(os.path.dirname(__file__), "..", "fixtures") + + +class TestGoldenCasesSample(unittest.TestCase): + def test_sample_request_matches_expected_shape(self) -> None: + req_path = os.path.join(_fixtures_dir(), "sample_request.json") + exp_path = os.path.join(_fixtures_dir(), "expected_response.json") + with open(req_path, encoding="utf-8") as f: + req = json.load(f) + with open(exp_path, encoding="utf-8") as f: + exp = json.load(f) + self.assertEqual(req["request_id"], "req_test_001") + self.assertTrue(req["parameters"]["options"]["dry_run"]) + # 示意:out = run(req); 然后 assert out["success"] == exp["success"] 等关键字段 + self.assertTrue(exp["success"]) + self.assertEqual(exp["data"]["status"], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_logging_contract.py.sample b/tests/samples/test_logging_contract.py.sample new file mode 100644 index 0000000..d8c165a --- /dev/null +++ b/tests/samples/test_logging_contract.py.sample @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""日志契约样例:复制为 tests/test_logging_contract.py 后按业务改入口。 + +验证主任务 mock/离线路径写入统一日志事件词(见 development/LOGGING.md §3)。 +默认套件不收集本文件(.sample)。 +""" +from __future__ import annotations + +import io +import logging +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import patch + +# 复制到 tests/ 根目录后取消下一行注释,并按技能改 import +# from _support import IsolatedDataRoot + + +class TestLoggingContractSample(unittest.TestCase): + """示范:捕获 skill logger,断言关键事件词出现。""" + + def test_main_path_emits_task_start_and_terminal_event(self) -> None: + self.skipTest( + "复制本文件为 tests/test_logging_contract.py 后:" + "用 IsolatedDataRoot + patch 域 RPA,调用 cmd_run/cmd_add_one," + "对 logging.Handler 断言消息含 task_start 与 task_success|task_failed" + ) + # 示例骨架(复制后实现): + # with IsolatedDataRoot(): + # log = logging.getLogger("openclaw.skill.track_fund_progress_default") + # records: list[str] = [] + # class _H(logging.Handler): + # def emit(self, record): + # records.append(record.getMessage()) + # h = _H() + # log.addHandler(h) + # try: + # with patch(...), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + # rc = cmd_add_one(...) # 或 cmd_run + # finally: + # log.removeHandler(h) + # blob = "\n".join(records) + # self.assertIn("task_start", blob) + # self.assertTrue("task_failed" in blob or "task_success" in blob) + # if "task_failed" in blob: + # self.assertIn("error_code=", blob) + + def test_failure_path_logs_error_code_before_print(self) -> None: + self.skipTest( + "复制后:注入业务错误(如 ELEMENT_NOT_FOUND),断言 logger 含 " + "task_failed error_code=...,且 stdout 含 ERROR:CODE" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_service_contract.py.sample b/tests/samples/test_service_contract.py.sample new file mode 100644 index 0000000..258b8cf --- /dev/null +++ b/tests/samples/test_service_contract.py.sample @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# 本文件为样例:复制到 ``tests/test_service_contract.py``(或业务仓 tests 根)后按实际 service 改名运行。 +# 默认 **不会** 被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。 +""" +Service 层契约测试范式(示意)。 + +复制为真实技能后: +1. 将 ``from service.your_service import run`` 替换为实际模块与入口函数。 +2. 用 ``FakeAdapter`` / 内存 stub 替代真实 ERP/TMS/WMS 等外呼。 +3. 需要真实外网或真实 RPA 时,勿在本文件直接写死凭证;改用 ``tests/integration/*.sample`` + 环境开关。 +""" +from __future__ import annotations + +import unittest + +from adapter_test_utils import FakeAdapter + + +# from service.your_service import run # noqa: ERA001 — 复制后取消注释并实现 + + +class TestServiceContractSample(unittest.TestCase): + """三类常见契约:成功路径、结构化错误、上游超时转可重试错误。""" + + def test_success_path_returns_success_true(self) -> None: + # 示意:run(payload, adapter=FakeAdapter("success")) -> {"success": True, ...} + ad = FakeAdapter("success") + out = ad.call({"op": "ping"}) + self.assertTrue(out.get("success")) + + def test_missing_required_field_returns_structured_error_not_bare_exception(self) -> None: + # 示意:业务层应对必填字段做校验,返回 {"success": False, "error": {"code": "...", "message": "..."}} + err = {"success": False, "error": {"code": "VALIDATION", "message": "missing shipment_id"}} + self.assertFalse(err["success"]) + self.assertIn("code", err["error"]) + + def test_adapter_timeout_maps_to_retriable_error(self) -> None: + # 示意:捕获 TimeoutError 后映射为业务可重试错误码,而不是让裸异常穿透 CLI + ad = FakeAdapter("timeout") + with self.assertRaises(TimeoutError): + ad.call({}) + # 真实实现中:except TimeoutError: return {"success": False, "error": {"code": "UPSTREAM_TIMEOUT", "retriable": True}} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_sync_contract.py.sample b/tests/samples/test_sync_contract.py.sample new file mode 100644 index 0000000..806f164 --- /dev/null +++ b/tests/samples/test_sync_contract.py.sample @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +# 样例:复制到 ``tests/test_sync_contract.py`` 后按实际 domain service 改名运行。 +# 默认不会被 ``python tests/run_tests.py`` 收集(扩展名为 .sample)。 +""" +外源 → 本地库同步契约范式(通用,无具体平台业务名)。 + +覆盖: +- 首次同步插入 +- 第二次同步幂等 +- 更新已有数据 +- 处理失效数据 +- 不可信空结果保护 vs 完整空快照失效(``snapshot_complete`` 必填,无默认值) +- 遗漏 ``snapshot_complete`` 立即失败(TypeError) +- 事务在部分写入后失败并回滚 +- 返回 inserted/updated/unchanged/removed +- sync Action 不调用 CSV/Excel 文件导出器 + +复制后把内存仓库与 stub 外源换成真实 repository / reader;禁止访问真实网络或用户数据目录。 +""" +from __future__ import annotations + +import unittest +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class SyncStats: + total: int = 0 + inserted: int = 0 + updated: int = 0 + unchanged: int = 0 + removed: int = 0 + + def as_dict(self) -> dict[str, int]: + return { + "total": self.total, + "inserted": self.inserted, + "updated": self.updated, + "unchanged": self.unchanged, + "removed": self.removed, + } + + +@dataclass +class MemoryRecordStore: + """内存库:演示幂等 upsert + 全量失效(is_active 标记)。""" + + rows: dict[str, dict[str, Any]] = field(default_factory=dict) + in_transaction: bool = False + _tx_snapshot: dict[str, dict[str, Any]] | None = None + + def begin(self) -> None: + self._tx_snapshot = {k: dict(v) for k, v in self.rows.items()} + self.in_transaction = True + + def commit(self) -> None: + self._tx_snapshot = None + self.in_transaction = False + + def rollback(self) -> None: + if self._tx_snapshot is not None: + self.rows = {k: dict(v) for k, v in self._tx_snapshot.items()} + self._tx_snapshot = None + self.in_transaction = False + + def upsert_snapshot( + self, + records: list[dict[str, Any]], + *, + fail_after_writes: int | None = None, + ) -> SyncStats: + """全量快照:按 business_key 对齐;缺失标 is_active=0。 + + ``fail_after_writes``:在成功完成该次数的 insert/update 之后抛错,用于验证回滚。 + """ + self.begin() + try: + stats = SyncStats(total=len(records)) + seen: set[str] = set() + write_count = 0 + for rec in records: + key = str(rec["business_key"]) + seen.add(key) + existing = self.rows.get(key) + payload = { + "business_key": key, + "title": rec["title"], + "is_active": 1, + } + if existing is None: + self.rows[key] = payload + stats.inserted += 1 + write_count += 1 + elif existing.get("title") == payload["title"] and existing.get("is_active") == 1: + stats.unchanged += 1 + else: + self.rows[key] = payload + stats.updated += 1 + write_count += 1 + if fail_after_writes is not None and write_count >= fail_after_writes: + raise RuntimeError( + f"simulated write failure after {write_count} insert/update write(s)" + ) + for key, existing in list(self.rows.items()): + if key not in seen and existing.get("is_active") == 1: + existing["is_active"] = 0 + stats.removed += 1 + self.commit() + return stats + except Exception: + self.rollback() + raise + + +class ExportSpy: + """若 sync 路径误调导出器,测试应失败。""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def write_csv(self, *_a: Any, **_k: Any) -> None: + self.calls.append("csv") + + def write_excel(self, *_a: Any, **_k: Any) -> None: + self.calls.append("excel") + + +def sync_records( + store: MemoryRecordStore, + external_rows: list[dict[str, Any]], + *, + snapshot_complete: bool, + exporter: ExportSpy | None = None, + fail_after_writes: int | None = None, +) -> dict[str, int]: + """领域同步内核示意:只写库,不导出文件。 + + ``snapshot_complete`` 为 keyword-only **必填**参数(无默认值): + - False:读取失败/分页不完整/不可信结果 → 不得清库或失效旧数据 + - True:外源完整快照(可为空)→ 允许按全量策略 upsert / 标记失效 + 调用方必须显式传入,禁止默认当作完整快照,也禁止靠条数猜测完整性。 + """ + if external_rows is None: + raise ValueError("external_rows required") + if not snapshot_complete: + active = sum(1 for r in store.rows.values() if r.get("is_active") == 1) + return SyncStats(total=0, unchanged=active).as_dict() + stats = store.upsert_snapshot(external_rows, fail_after_writes=fail_after_writes) + if exporter is not None: + # 正确实现不得调用;本示意刻意不调用,供测试断言 + pass + return stats.as_dict() + + +class TestSyncContractSample(unittest.TestCase): + def test_first_sync_inserts(self) -> None: + store = MemoryRecordStore() + stats = sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + self.assertEqual(stats["inserted"], 2) + self.assertEqual(stats["total"], 2) + self.assertEqual(len(store.rows), 2) + + def test_second_sync_is_idempotent(self) -> None: + store = MemoryRecordStore() + rows = [{"business_key": "a", "title": "A"}] + sync_records(store, rows, snapshot_complete=True) + stats = sync_records(store, rows, snapshot_complete=True) + self.assertEqual(stats["unchanged"], 1) + self.assertEqual(stats["inserted"], 0) + self.assertEqual(stats["updated"], 0) + + def test_updates_existing_row(self) -> None: + store = MemoryRecordStore() + sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True) + stats = sync_records( + store, [{"business_key": "a", "title": "A2"}], snapshot_complete=True + ) + self.assertEqual(stats["updated"], 1) + self.assertEqual(store.rows["a"]["title"], "A2") + + def test_marks_removed_inactive_on_full_sync(self) -> None: + store = MemoryRecordStore() + sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + stats = sync_records( + store, [{"business_key": "a", "title": "A"}], snapshot_complete=True + ) + self.assertEqual(stats["removed"], 1) + self.assertEqual(store.rows["b"]["is_active"], 0) + self.assertEqual(store.rows["a"]["is_active"], 1) + + def test_incomplete_empty_snapshot_does_not_wipe_existing(self) -> None: + store = MemoryRecordStore() + sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True) + stats = sync_records(store, [], snapshot_complete=False) + self.assertEqual(store.rows["a"]["is_active"], 1) + self.assertEqual(stats["removed"], 0) + self.assertEqual(stats["unchanged"], 1) + + def test_complete_empty_snapshot_marks_existing_inactive(self) -> None: + store = MemoryRecordStore() + sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + stats = sync_records(store, [], snapshot_complete=True) + self.assertEqual(stats["removed"], 2) + self.assertEqual(store.rows["a"]["is_active"], 0) + self.assertEqual(store.rows["b"]["is_active"], 0) + + def test_omitting_snapshot_complete_raises_type_error(self) -> None: + store = MemoryRecordStore() + with self.assertRaises(TypeError): + sync_records(store, [{"business_key": "a", "title": "A"}]) # type: ignore[call-arg] + + def test_transaction_failure_after_partial_writes_rolls_back(self) -> None: + store = MemoryRecordStore() + sync_records(store, [{"business_key": "a", "title": "A"}], snapshot_complete=True) + with self.assertRaises(RuntimeError) as ctx: + sync_records( + store, + [ + {"business_key": "a", "title": "A2"}, + {"business_key": "b", "title": "B"}, + ], + snapshot_complete=True, + fail_after_writes=1, + ) + self.assertIn("after 1", str(ctx.exception)) + self.assertEqual(store.rows["a"]["title"], "A") + self.assertNotIn("b", store.rows) + self.assertFalse(store.in_transaction) + self.assertIsNone(store._tx_snapshot) + + def test_stats_keys_cover_inserted_updated_unchanged_removed(self) -> None: + store = MemoryRecordStore() + sync_records( + store, + [{"business_key": "a", "title": "A"}, {"business_key": "b", "title": "B"}], + snapshot_complete=True, + ) + stats = sync_records( + store, + [{"business_key": "a", "title": "A2"}], + snapshot_complete=True, + ) + for key in ("total", "inserted", "updated", "unchanged", "removed"): + self.assertIn(key, stats) + self.assertEqual(stats["updated"], 1) + self.assertEqual(stats["removed"], 1) + + def test_sync_does_not_call_file_exporters(self) -> None: + store = MemoryRecordStore() + spy = ExportSpy() + sync_records( + store, + [{"business_key": "a", "title": "A"}], + snapshot_complete=True, + exporter=spy, + ) + self.assertEqual(spy.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/samples/test_table_deprecation_migration.py.sample b/tests/samples/test_table_deprecation_migration.py.sample new file mode 100644 index 0000000..9702626 --- /dev/null +++ b/tests/samples/test_table_deprecation_migration.py.sample @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""旧库废弃表迁移样例(复制到 tests/ 并按技能改写)。 + +验收:给定旧库含废弃物理表 + 元数据 + 历史行 → init_db() 后表与元数据均清除。 +见 references/SCHEMA.md「表增删改与退役」。 +""" +from __future__ import annotations + +import unittest + +from _support import IsolatedDataRoot + + +class TestTableDeprecationMigration(unittest.TestCase): + def test_init_db_drops_legacy_task_logs(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import iter_user_tables + + # 1) 模拟升级前的旧库 + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE task_logs ( + id INTEGER PRIMARY KEY, + task_type TEXT, + status TEXT + ) + """ + ) + cur.execute( + """ + INSERT INTO _jiangchang_tables + (table_name, display_name, visible, readonly) + VALUES ('task_logs', '任务日志', 1, 0) + """ + ) + cur.execute("INSERT INTO task_logs (task_type, status) VALUES ('run', 'ok')") + conn.commit() + finally: + conn.close() + + # 2) 新代码 init_db() 应幂等清除(在 connection.init_db 中调用 drop_deprecated_user_table) + init_db() + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" + ) + self.assertIsNone(cur.fetchone()) + cur.execute( + "SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'" + ) + self.assertEqual(int(cur.fetchone()[0]), 0) + self.assertNotIn("task_logs", list(iter_user_tables(cur))) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py new file mode 100644 index 0000000..c3703ea --- /dev/null +++ b/tests/test_actions_manifest.py @@ -0,0 +1,669 @@ +# -*- coding: utf-8 -*- +"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。""" +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import unittest + +from _support import IsolatedDataRoot, get_skill_root + +from cli.app import build_parser +from util.constants import SKILL_SLUG + +_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"}) +_RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"}) +_ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"}) +_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"}) +_REQUIRED_ACTION_KEYS = frozenset( + {"id", "label", "description", "placements", "entrypoint", "executionProfile"} +) +_TABLE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +_FORBIDDEN_BUSINESS_TERMS = ( + "wechat", + "微信", + "contacts", + "wxid", + "account_wxid", + "contact.db", + "scrape-contacts", + "douyin", + "抖音", + "xiaohongshu", + "小红书", + "公众号", + "logistics", + "物流", +) + +_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") + + +def _load_actions_manifest() -> dict: + path = os.path.join(get_skill_root(), "assets", "actions.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _cli_subcommands() -> set[str]: + parser = build_parser() + subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined] + return set(subs.keys()) + + +def _collect_template_vars(args: list) -> set[str]: + found: set[str] = set() + for item in args: + if not isinstance(item, str): + continue + for match in _TEMPLATE_ARG_PATTERN.finditer(item): + found.add(match.group(1)) + return found + + +def _load_actions_schema() -> dict: + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _validate_bind_tables(action_id: str, bind: object) -> list[str]: + errors: list[str] = [] + if not isinstance(bind, dict): + return [f"{action_id}: bind must be object"] + allowed = {"tables", "inputMapping", "columnMapping", "labelColumn"} + unknown = set(bind.keys()) - allowed + if unknown: + errors.append(f"{action_id}: bind only allows {sorted(allowed)}, got {sorted(bind.keys())}") + tables = bind.get("tables") + if not isinstance(tables, list) or len(tables) < 1: + errors.append(f"{action_id}: bind.tables must be non-empty array") + return errors + seen: set[str] = set() + for table in tables: + if not isinstance(table, str) or not table.strip(): + errors.append(f"{action_id}: bind.tables entries must be non-empty strings") + continue + if table != table.strip() or not _TABLE_NAME_PATTERN.fullmatch(table): + errors.append(f"{action_id}: bind.tables entry {table!r} must be snake_case") + if table in seen: + errors.append(f"{action_id}: bind.tables duplicate {table!r}") + seen.add(table) + label_column = bind.get("labelColumn") + if label_column is not None: + if not isinstance(label_column, str) or not label_column.strip(): + errors.append(f"{action_id}: bind.labelColumn must be non-empty string") + elif not _TABLE_NAME_PATTERN.fullmatch(label_column): + errors.append(f"{action_id}: bind.labelColumn {label_column!r} must be snake_case") + return errors + + +def _load_bindable_business_tables() -> set[str]: + """在隔离临时数据根下 init_db,返回可绑定到 toolbar 的业务表集合。 + + 表必须同时满足: + - SQLite 物理表存在(排除 sqlite_* 与 _jiangchang_* 元数据表) + - ``_jiangchang_tables`` 中有对应行且 ``visible=1`` + + 模板默认无业务表,允许返回空集。失败时抛出 RuntimeError(带诊断)。 + """ + from db.connection import init_db + from db.display_metadata import METADATA_TABLES + from util.runtime_paths import get_db_path + + physical: set[str] = set() + visible_meta: set[str] = set() + try: + with IsolatedDataRoot() as data_root: + init_db() + db_path = get_db_path() + if not os.path.isfile(db_path): + raise RuntimeError( + f"init_db did not create database file under isolated root " + f"{data_root!r}: expected {db_path}" + ) + conn = sqlite3.connect(db_path) + try: + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ) + physical = { + str(row[0]) + for row in cur.fetchall() + if str(row[0]) not in METADATA_TABLES + } + cur.execute( + "SELECT table_name FROM _jiangchang_tables WHERE visible = 1" + ) + visible_meta = {str(row[0]) for row in cur.fetchall()} + finally: + conn.close() + bindable = physical & visible_meta + return bindable + except RuntimeError: + raise + except Exception as exc: + raise RuntimeError( + "failed to load bindable business tables via isolated init_db: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +class TestActionsManifest(unittest.TestCase): + def test_manifest_exists_and_schema(self) -> None: + manifest = _load_actions_manifest() + self.assertEqual(manifest.get("schemaVersion"), 1) + skill = manifest.get("skill") + self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG)) + self.assertIsInstance(manifest.get("actions"), list) + self.assertGreater(len(manifest["actions"]), 0) + + def test_action_ids_unique_and_cli_valid(self) -> None: + manifest = _load_actions_manifest() + subs = _cli_subcommands() + seen: set[str] = set() + for action in manifest["actions"]: + action_id = action["id"] + self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}") + seen.add(action_id) + + self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty") + self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty") + + entry = action["entrypoint"] + self.assertEqual(entry["type"], "cli") + self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI") + + args = entry.get("args") + self.assertIsInstance(args, list, msg=f"{action_id} args must be array") + for item in args: + self.assertIsInstance( + item, + (str, int, float, bool), + msg=f"{action_id} arg item must be scalar", + ) + self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object") + self.assertIsNotNone(item, msg=f"{action_id} arg must not be null") + + placements = action.get("placements") + self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") + replaces = action.get("replaces") + if replaces is not None: + self.assertIn( + replaces, + {"create", "update", "delete", "import"}, + msg=f"{action_id} invalid replaces {replaces!r}", + ) + bind = action.get("bind") or {} + tables = bind.get("tables") or [] + self.assertTrue( + isinstance(tables, list) and len(tables) > 0, + msg=f"{action_id} replaces requires non-empty bind.tables", + ) + else: + self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") + for placement in placements: + self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}") + + def test_every_action_declares_execution_profile(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + profile = action.get("executionProfile") + self.assertIn( + profile, + _ALLOWED_EXECUTION_PROFILES, + msg=f"{action['id']} executionProfile must be sync|async, got {profile!r}", + ) + + def test_schema_requires_execution_profile_and_defines_bind(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + self.assertIn("executionProfile", action_def.get("required") or []) + profile = action_def["properties"]["executionProfile"] + self.assertEqual(set(profile.get("enum") or []), {"sync", "async"}) + self.assertIn("bind", schema["$defs"]) + self.assertIn("bind", action_def["properties"]) + bind_def = schema["$defs"]["bind"] + self.assertEqual(bind_def.get("required"), ["tables"]) + self.assertIs(bind_def.get("additionalProperties"), False) + + def test_schema_defines_interaction_fields(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + props = action_def["properties"] + self.assertIn("interaction", props) + self.assertEqual( + set(props["interaction"].get("enum") or []), + {"auto", "direct", "confirm", "form"}, + ) + self.assertIn("interactionByPlacement", props) + by_placement = props["interactionByPlacement"] + self.assertIs(by_placement.get("additionalProperties"), False) + for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete", "import"): + self.assertIn(key, by_placement.get("properties") or {}) + self.assertEqual( + set((by_placement["properties"][key].get("enum") or [])), + {"direct", "confirm", "form"}, + ) + self.assertIn("replaces", props) + self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind") + self.assertEqual( + set(schema["$defs"]["mutationKind"].get("enum") or []), + {"create", "update", "delete", "import"}, + ) + + def test_schema_defines_hover_hint(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + props = action_def["properties"] + self.assertIn("hoverHint", props) + self.assertNotIn("hoverHint", action_def.get("required") or []) + self.assertEqual(props["hoverHint"].get("type"), "string") + + def test_schema_defines_visible_when_on_input_property(self) -> None: + schema = _load_actions_schema() + prop_def = schema["$defs"]["inputProperty"] + props = prop_def["properties"] + self.assertIn("visibleWhen", props) + self.assertNotIn("visibleWhen", prop_def.get("required") or []) + # Must live on input property, not action top-level (≠ Policy L3). + action_props = schema["$defs"]["action"]["properties"] + self.assertNotIn("visibleWhen", action_props) + + def test_default_manifest_omits_visible_when(self) -> None: + for action in _load_actions_manifest()["actions"]: + properties = ((action.get("inputSchema") or {}).get("properties")) or {} + for name, prop in properties.items(): + self.assertNotIn( + "visibleWhen", + prop if isinstance(prop, dict) else {}, + msg=f"{action['id']}.{name} default template should omit visibleWhen", + ) + + def test_visible_when_example_exists_and_uses_field_rules(self) -> None: + path = os.path.join( + get_skill_root(), "assets", "examples", "visible-when-action.example.json" + ) + self.assertTrue(os.path.isfile(path), msg="visible-when example must exist") + with open(path, encoding="utf-8") as f: + example = json.load(f) + props = example["actions"][0]["inputSchema"]["properties"] + self.assertIn("visibleWhen", props["keyword"]) + self.assertIn("visibleWhen", props["authorUrl"]) + self.assertNotIn("visibleWhen", props["collectMode"]) + + def test_default_manifest_omits_hover_hint(self) -> None: + for action in _load_actions_manifest()["actions"]: + self.assertNotIn( + "hoverHint", + action, + msg=f"{action['id']} default template action should omit hoverHint", + ) + + def test_schema_defines_host_effects_and_steps(self) -> None: + schema = _load_actions_schema() + action_def = schema["$defs"]["action"] + props = action_def["properties"] + self.assertIn("hostEffects", props) + self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects") + self.assertIn("steps", props) + effects = schema["$defs"]["hostEffects"] + self.assertIs(effects.get("additionalProperties"), False) + for key in ("onSuccess", "onFailure"): + self.assertIn(key, effects.get("properties") or {}) + effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or []) + self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"}) + step = schema["$defs"]["actionStep"] + self.assertEqual( + set(step["properties"]["when"].get("enum") or []), + {"before", "afterSuccess", "afterFailure"}, + ) + self.assertEqual( + set(step["properties"]["do"].get("enum") or []), + {"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"}, + ) + self.assertEqual(props["steps"].get("maxItems"), 8) + + def test_manifest_validates_against_json_schema(self) -> None: + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") + schema = _load_actions_schema() + manifest = _load_actions_manifest() + jsonschema.validate(instance=manifest, schema=schema) + + def test_bind_structure_when_present(self) -> None: + manifest = _load_actions_manifest() + errors: list[str] = [] + for action in manifest["actions"]: + if "bind" not in action: + continue + errors.extend(_validate_bind_tables(action["id"], action["bind"])) + self.assertEqual(errors, [], msg="\n".join(errors)) + + def test_toolbar_actions_require_bind_tables(self) -> None: + manifest = _load_actions_manifest() + errors: list[str] = [] + for action in manifest["actions"]: + placements = action.get("placements") or [] + if "toolbar" not in placements: + continue + bind = action.get("bind") + if bind is None: + errors.append(f"{action['id']}: toolbar placement requires bind.tables") + continue + errors.extend(_validate_bind_tables(action["id"], bind)) + self.assertEqual(errors, [], msg="\n".join(errors)) + + def test_bindable_business_tables_helper_loads_template_db(self) -> None: + """helper 本身必须可用:本技能有 3 张业务表,隔离库应可读且不含元数据表。""" + bindable = _load_bindable_business_tables() + self.assertEqual( + bindable, + {"fund_projects", "progress_reports", "alert_rules"}, + ) + self.assertNotIn("_jiangchang_tables", bindable) + self.assertNotIn("_jiangchang_columns", bindable) + + def test_bind_tables_exist_in_physical_and_visible_metadata(self) -> None: + bindable = _load_bindable_business_tables() + manifest = _load_actions_manifest() + missing: list[str] = [] + for action in manifest["actions"]: + bind = action.get("bind") + if not isinstance(bind, dict): + continue + for table in bind.get("tables") or []: + if table not in bindable: + missing.append( + f"{action['id']}: bind.tables entry {table!r} must exist as " + f"physical table AND _jiangchang_tables.visible=1 " + f"(bindable={sorted(bindable)})" + ) + self.assertEqual(missing, [], msg="\n".join(missing)) + + def test_schema_allows_all_placement_execution_profile_combinations(self) -> None: + """POLICY-SKILL-ACTION-004:6 placements × 2 profiles 必须全部通过 Schema。""" + try: + import jsonschema + except ImportError: + self.fail("jsonschema is required to validate placement×executionProfile orthogonality") + schema = _load_actions_schema() + placements = ("toolbar", "row", "batch", "cron", "agent", "skill-detail") + profiles = ("sync", "async") + for placement in placements: + for profile in profiles: + action: dict = { + "id": "probe-action", + "label": "正交探测", + "description": "验证 placements 与 executionProfile 无条件耦合", + "placements": [placement], + "executionProfile": profile, + "entrypoint": {"type": "cli", "command": "health", "args": []}, + } + if placement == "toolbar": + action["bind"] = {"tables": ["demo_items"]} + manifest = { + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [action], + } + with self.subTest(placement=placement, executionProfile=profile): + jsonschema.validate(instance=manifest, schema=schema) + if placement != "toolbar": + self.assertNotIn("bind", action) + + def test_schema_rejects_toolbar_without_bind(self) -> None: + try: + import jsonschema + from jsonschema import ValidationError + except ImportError: + self.fail("jsonschema is required") + schema = _load_actions_schema() + manifest = { + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "probe-toolbar", + "label": "缺 bind 探测", + "description": "toolbar 无 bind 应被 Schema 拒绝", + "placements": ["toolbar"], + "executionProfile": "sync", + "entrypoint": {"type": "cli", "command": "health", "args": []}, + } + ], + } + with self.assertRaises(ValidationError): + jsonschema.validate(instance=manifest, schema=schema) + + def test_schema_allows_bind_label_column(self) -> None: + try: + import jsonschema + except ImportError: + self.fail("jsonschema is required") + schema = _load_actions_schema() + bind_props = schema["$defs"]["bind"]["properties"] + self.assertIn("labelColumn", bind_props) + manifest = { + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "probe-label-column", + "label": "处理勾选", + "description": "验证 bind.labelColumn 可通过 Schema", + "placements": ["toolbar", "batch"], + "executionProfile": "sync", + "interaction": "confirm", + "bind": { + "tables": ["demo_items"], + "inputMapping": {"ids": "$selection.ids"}, + "labelColumn": "display_name", + }, + "entrypoint": {"type": "cli", "command": "health", "args": []}, + } + ], + } + jsonschema.validate(instance=manifest, schema=schema) + + def test_no_execution_profile_placement_coupling_in_tests(self) -> None: + """守护:本文件不得引入入口位置与 sync/async 的错误硬耦合断言。""" + path = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py") + with open(path, encoding="utf-8") as f: + text = f.read() + # 拆开拼接,避免本测试源码自身触发字符串扫描。 + forbidden_snippets = ( + "toolbar " + "只能 async", + "toolbar only " + "async", + 'toolbar"' + " and profile != " + '"async"', + 'toolbar")' + " and profile != " + '"async"', + "toolbar must be " + "async", + "executionProfile" + " == " + '"async"' + " and " + '"toolbar"', + ) + for snippet in forbidden_snippets: + self.assertNotIn(snippet, text) + + def test_template_variables_have_input_schema(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + args = action.get("entrypoint", {}).get("args") or [] + template_vars = _collect_template_vars(args) + if not template_vars: + continue + schema = action.get("inputSchema") or {} + properties = schema.get("properties") or {} + for var in template_vars: + self.assertIn( + var, + properties, + msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it", + ) + + def test_required_fields_exist_in_properties(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + schema = action.get("inputSchema") or {} + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for field in required: + self.assertIn( + field, + properties, + msg=f"{action['id']} required field {field!r} missing from properties", + ) + + def test_sensitive_params_not_on_toolbar(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + props = (action.get("inputSchema") or {}).get("properties") or {} + has_sensitive = any(p.get("sensitive") for p in props.values()) + if has_sensitive and "toolbar" in (action.get("placements") or []): + self.fail(f"{action['id']} has sensitive input but is on toolbar") + + def test_confirmation_message_structure(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + confirmation = action.get("confirmation") + if confirmation is None: + continue + self.assertIsInstance(confirmation, dict) + message = confirmation.get("message") + self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty") + + def test_manifest_has_no_specific_business_skill_names(self) -> None: + path = os.path.join(get_skill_root(), "assets", "actions.json") + with open(path, encoding="utf-8") as f: + raw = f.read().lower() + for term in _FORBIDDEN_BUSINESS_TERMS: + self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}") + + def test_actions_md_exists_and_links_manifest(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("actions.json", text) + self.assertIn("schemaVersion", text) + for term in _FORBIDDEN_BUSINESS_TERMS: + self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}") + + def test_skill_actions_schema_file_exists(self) -> None: + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + schema = json.load(f) + self.assertEqual(schema.get("title"), "SkillActionManifest") + self.assertIn("actions", schema.get("properties", {})) + + def test_skill_actions_schema_json_parses(self) -> None: + schema = _load_actions_schema() + self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1) + self.assertTrue(schema.get("additionalProperties") is False) + self.assertIn("action", schema.get("$defs", {})) + + def test_template_manifest_matches_schema_required_keys(self) -> None: + manifest = _load_actions_manifest() + schema = _load_actions_schema() + for key in _REQUIRED_MANIFEST_KEYS: + self.assertIn(key, manifest) + self.assertIn(key, schema.get("properties", {})) + for action in manifest["actions"]: + for key in _REQUIRED_ACTION_KEYS: + self.assertIn(key, action) + + def test_template_example_does_not_use_reserved_action_fields(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS + self.assertFalse( + overlap, + msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}", + ) + + def test_default_manifest_does_not_place_health_on_toolbar(self) -> None: + """脚手架默认健康类 Action 不应出现在数据管理 toolbar。""" + manifest = _load_actions_manifest() + for action in manifest["actions"]: + if action["id"] in {"health", "version", "config-path"}: + self.assertNotIn( + "toolbar", + action.get("placements") or [], + msg=f"{action['id']} must not be auto-placed on toolbar", + ) + + def test_actions_md_documents_strict_vs_host_boundary(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + with open(path, encoding="utf-8") as f: + text = f.read() + for marker in ( + "严格规范", + "向后兼容", + "Phase 1", + "bind.tables", + "concurrency", + "locks", + "row", + "batch", + "additionalProperties", + "placements", + "executionProfile", + "interaction", + "confirmation", + "数据管理技能按钮顺序", + "数组相对顺序", + "全部直接展示", + "左右分栏", + "行级入口", + "行右键", + "六职责正交", + "POLICY.md", + "hostEffects", + "steps", + "hoverHint", + "visibleWhen", + "字段条件显隐", + "reloadTable", + "afterSuccess", + "afterFailure", + "cellPresentation", + "CELL_PRESENTATION.md", + "labelColumn", + "已选择", + "revealInForm", + "映射只读", + ): + self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") + self.assertNotIn( + "其余进「更多操作」", + text, + msg="ACTIONS.md must not document removed overflow more-menu truncation", + ) + self.assertIn( + "新增 `row-context-menu` 等 placement", + text, + msg="ACTIONS.md must forbid inventing a separate row-context-menu placement", + ) + placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or [] + self.assertNotIn( + "row-context-menu", + placement_enum, + msg="skill-actions.schema must not add row-context-menu to placements enum", + ) + for forbidden in ( + "数据管理 toolbar " + "只挂", + "toolbar " + "只放 async", + "toolbar)→ " + "只挂", + "只挂 **" + "async**", + ): + self.assertNotIn(forbidden, text, msg=f"ACTIONS.md must not contain deprecated coupling: {forbidden!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_adapter_profile_policy.py b/tests/test_adapter_profile_policy.py new file mode 100644 index 0000000..a7bc799 --- /dev/null +++ b/tests/test_adapter_profile_policy.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""adapter profile / 环境开关策略:默认不触碰真实 API 与真实 RPA。""" +from __future__ import annotations + +import os +import unittest + +from jiangchang_skill_core import config + +from adapter_test_utils import ( + ALLOWED_TEST_TARGETS, + FakeAdapter, + assert_no_real_network_env, + get_test_target, + should_skip_profile, +) + + +def _save_env(keys: tuple[str, ...]) -> dict[str, str | None]: + return {k: os.environ.get(k) for k in keys} + + +def _restore_env(saved: dict[str, str | None]) -> None: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + config.reset_cache() + + +_ENV_KEYS = ("OPENCLAW_TEST_TARGET",) + + +class TestAdapterProfilePolicy(unittest.TestCase): + def tearDown(self) -> None: + config.reset_cache() + + def test_get_test_target_defaults_to_unit(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + for k in _ENV_KEYS: + os.environ.pop(k, None) + config.reset_cache() + self.assertEqual(get_test_target(), "unit") + finally: + _restore_env(saved) + + def test_get_test_target_invalid_raises(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + for k in _ENV_KEYS: + os.environ.pop(k, None) + os.environ["OPENCLAW_TEST_TARGET"] = "not_a_valid_target" + config.reset_cache() + with self.assertRaises(ValueError): + get_test_target() + finally: + _restore_env(saved) + + def test_allowed_targets_contains_expected(self) -> None: + self.assertTrue({"unit", "mock", "simulator_api", "real_api"}.issubset(ALLOWED_TEST_TARGETS)) + + def test_simulator_api_runs_when_target_matches(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ["OPENCLAW_TEST_TARGET"] = "simulator_api" + config.reset_cache() + skip, _ = should_skip_profile("simulator_api") + self.assertFalse(skip) + finally: + _restore_env(saved) + + def test_unit_target_skips_simulator_and_real_profiles(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + for k in _ENV_KEYS: + os.environ.pop(k, None) + config.reset_cache() + self.assertEqual(get_test_target(), "unit") + for prof in ("simulator_api", "simulator_rpa", "real_api", "real_rpa"): + skip, reason = should_skip_profile(prof) + self.assertTrue(skip, msg=f"{prof}: {reason}") + skip_mock, _ = should_skip_profile("mock") + self.assertFalse(skip_mock) + finally: + _restore_env(saved) + + def test_real_api_runs_when_target_matches(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ["OPENCLAW_TEST_TARGET"] = "real_api" + config.reset_cache() + skip, _ = should_skip_profile("real_api", write=True) + self.assertFalse(skip) + finally: + _restore_env(saved) + + def test_real_rpa_runs_when_target_matches(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ["OPENCLAW_TEST_TARGET"] = "real_rpa" + config.reset_cache() + skip, _ = should_skip_profile("real_rpa", write=True) + self.assertFalse(skip) + finally: + _restore_env(saved) + + def test_fake_adapter_modes(self) -> None: + self.assertEqual(FakeAdapter("success").call({"a": 1})["success"], True) + with self.assertRaises(TimeoutError): + FakeAdapter("timeout").call({}) + bad = FakeAdapter("invalid_response").call({}) + self.assertNotIsInstance(bad, dict) + with self.assertRaises(PermissionError): + FakeAdapter("unauthorized").call({}) + + def test_assert_no_real_network_env(self) -> None: + saved = _save_env(_ENV_KEYS) + try: + os.environ.pop("OPENCLAW_TEST_TARGET", None) + config.reset_cache() + assert_no_real_network_env() + finally: + _restore_env(saved) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cell_presentation_contract.py b/tests/test_cell_presentation_contract.py new file mode 100644 index 0000000..e600d81 --- /dev/null +++ b/tests/test_cell_presentation_contract.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""Cell Presentation 契约:schema + 文档 + 示例。""" +from __future__ import annotations + +import json +import os +import unittest + +from util.runtime_paths import get_skill_root + + +def _load_schema() -> dict: + path = os.path.join( + get_skill_root(), + "assets", + "schemas", + "skill-cell-presentation.schema.json", + ) + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _load_example() -> dict: + path = os.path.join( + get_skill_root(), + "assets", + "examples", + "skill-cell-presentation.example.json", + ) + with open(path, encoding="utf-8") as f: + return json.load(f) + + +class TestCellPresentationContract(unittest.TestCase): + def test_schema_file_exists_and_parses(self) -> None: + schema = _load_schema() + self.assertEqual(schema.get("title"), "SkillCellPresentationDocument") + self.assertIs(schema.get("additionalProperties"), False) + self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1) + tones = set(schema["$defs"]["tone"].get("enum") or []) + self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"}) + + def test_example_validates_against_schema(self) -> None: + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") + jsonschema.validate(instance=_load_example(), schema=_load_schema()) + + def test_actions_schema_defines_cell_presentation_enable_block(self) -> None: + path = os.path.join( + get_skill_root(), "assets", "schemas", "skill-actions.schema.json" + ) + with open(path, encoding="utf-8") as f: + actions_schema = json.load(f) + self.assertIn("cellPresentation", actions_schema["properties"]) + self.assertIn("cellPresentationDeclaration", actions_schema["$defs"]) + + def test_docs_and_example_exist(self) -> None: + root = get_skill_root() + self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md"))) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json") + ) + ) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json") + ) + ) + + def test_cell_presentation_md_covers_contract(self) -> None: + path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md") + with open(path, encoding="utf-8") as f: + text = f.read() + for marker in ( + "tone", + "danger", + "warning", + "alert", + "_jiangchang_cell_presentation", + "cellPresentation", + "派生", + "任意 CSS", + "用途不限", + "schemaVersion", + ): + self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 0000000..9890161 --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""通用 CLI 冒烟:用法、health、version、init-db(不触网、不深测 run 占位)。""" +from __future__ import annotations + +import io +import json +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from _support import IsolatedDataRoot, platform_kit_version_patch + +from jiangchang_skill_core import config + +# scripts/ 已由 _support 注入 sys.path +from cli.app import main +from util.constants import SKILL_SLUG, SKILL_VERSION + + +class TestCliSmoke(unittest.TestCase): + def test_main_empty_argv_shows_usage_and_nonzero(self) -> None: + buf = io.StringIO() + err = io.StringIO() + with redirect_stdout(buf), redirect_stderr(err): + rc = main([]) + self.assertEqual(rc, 1) + out = buf.getvalue() + self.assertIn("项目经费进度播报助手", out) + self.assertIn("health", out) + self.assertIn("init-db", out) + + def test_health_zero(self) -> None: + old_record = os.environ.get("OPENCLAW_RECORD_VIDEO") + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + try: + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 0) + out = buf.getvalue() + self.assertIn("health:", out) + self.assertIn("python_executable:", out) + self.assertIn("platform_kit_version:", out) + self.assertIn("jiangchang_skill_core_file:", out) + finally: + if old_record is None: + os.environ.pop("OPENCLAW_RECORD_VIDEO", None) + else: + os.environ["OPENCLAW_RECORD_VIDEO"] = old_record + + def test_version_json_and_matches_constants_slug(self) -> None: + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["version"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertIn("version", payload) + self.assertIn("skill", payload) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertEqual(payload["version"], SKILL_VERSION) + + def test_init_db_creates_database_and_is_idempotent(self) -> None: + with IsolatedDataRoot(user_id="_cli_init_db"): + config.reset_cache() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["init-db"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertTrue(os.path.isfile(payload["db_path"])) + + buf2 = io.StringIO() + with redirect_stdout(buf2), redirect_stderr(io.StringIO()): + rc2 = main(["init-db"]) + self.assertEqual(rc2, 0) + payload2 = json.loads(buf2.getvalue().strip()) + self.assertTrue(payload2["ok"]) + self.assertEqual(payload2["db_path"], payload["db_path"]) + + def test_unknown_logs_subcommand_is_rejected(self) -> None: + buf = io.StringIO() + err = io.StringIO() + with redirect_stdout(buf), redirect_stderr(err): + with self.assertRaises(SystemExit) as caught: + main(["logs"]) + self.assertNotEqual(caught.exception.code, 0) + + def test_run_placeholder_returns_nonzero_without_network(self) -> None: + """占位 run:RpaVideoSession 示范 + 模板提示;OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg。""" + old_auth = os.environ.pop("JIANGCHANG_AUTH_BASE_URL", None) + old_record = os.environ.get("OPENCLAW_RECORD_VIDEO") + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + try: + with IsolatedDataRoot(user_id="_cli_run"): + config.reset_cache() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["run"]) + self.assertNotEqual(rc, 0) + self.assertIn("模板", buf.getvalue()) + finally: + if old_auth is not None: + os.environ["JIANGCHANG_AUTH_BASE_URL"] = old_auth + if old_record is None: + os.environ.pop("OPENCLAW_RECORD_VIDEO", None) + else: + os.environ["OPENCLAW_RECORD_VIDEO"] = old_record + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_bootstrap.py b/tests/test_config_bootstrap.py new file mode 100644 index 0000000..abf7139 --- /dev/null +++ b/tests/test_config_bootstrap.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""配置落盘、合并与读取优先级。""" +from __future__ import annotations + +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch + +from jiangchang_skill_core import config +from cli.app import main +from util.config_bootstrap import bootstrap_skill_config +from util.constants import SKILL_SLUG + +_TEST_TARGET_KEY = "OPENCLAW_" + "TEST_TARGET" + + +def _env_line(key: str, value: str) -> str: + return f"{key}={value}\n" + + +class TestConfigBootstrap(unittest.TestCase): + def test_first_run_creates_user_env(self) -> None: + with IsolatedDataRoot(user_id="_cfg_test"): + config.reset_cache() + path = bootstrap_skill_config() + self.assertTrue(os.path.isfile(path)) + self.assertTrue(path.replace("\\", "/").endswith(f"/{SKILL_SLUG}/.env")) + + def test_existing_env_not_overwritten(self) -> None: + with IsolatedDataRoot(user_id="_cfg_keep"): + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "w", encoding="utf-8") as f: + f.write(_env_line(_TEST_TARGET_KEY, "simulator_rpa")) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".example") as tmp: + tmp.write(_env_line(_TEST_TARGET_KEY, "mock") + "HUMAN_WAIT_TIMEOUT=60\n") + tmp_path = tmp.name + try: + config.merge_missing_env_keys(tmp_path, path, comment_skill="test") + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn(_env_line(_TEST_TARGET_KEY, "simulator_rpa").strip(), content) + self.assertNotIn(_env_line(_TEST_TARGET_KEY, "mock").strip(), content) + finally: + os.unlink(tmp_path) + config.reset_cache() + + def test_merge_appends_missing_keys(self) -> None: + with IsolatedDataRoot(user_id="_cfg_merge"): + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "w", encoding="utf-8") as f: + f.write(_env_line(_TEST_TARGET_KEY, "simulator_rpa")) + example = os.path.join(get_skill_root(), ".env.example") + added = config.merge_missing_env_keys(example, path, comment_skill="test") + self.assertIn("HUMAN_WAIT_TIMEOUT", added) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("HUMAN_WAIT_TIMEOUT=180", content) + + def test_config_priority_process_user_example(self) -> None: + with IsolatedDataRoot(user_id="_cfg_prio"): + config.reset_cache() + example = os.path.join(get_skill_root(), ".env.example") + path = config.ensure_env_file(SKILL_SLUG, example) + with open(path, "w", encoding="utf-8") as f: + f.write("PRIORITY_TEST_KEY=from_user\n") + config.reset_cache() + + os.environ["PRIORITY_TEST_KEY"] = "from_process" + self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_process") + + del os.environ["PRIORITY_TEST_KEY"] + config.reset_cache() + self.assertEqual(config.get("PRIORITY_TEST_KEY"), "from_user") + + with open(path, "w", encoding="utf-8") as f: + f.write("# empty user\n") + config.reset_cache() + with open(example, encoding="utf-8") as f: + example_text = f.read() + self.assertIn(_env_line(_TEST_TARGET_KEY, "real_" + "rpa").strip(), example_text) + self.assertEqual(config.get(_TEST_TARGET_KEY), "real_" + "rpa") + + def test_config_path_outputs_json(self) -> None: + with IsolatedDataRoot(user_id="_cfg_path"): + config.reset_cache() + bootstrap_skill_config() + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["config-path"]) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue().strip()) + self.assertEqual(payload["skill"], SKILL_SLUG) + self.assertTrue(payload["env_path"]) + self.assertTrue(payload["example_path"].endswith(".env.example")) + + def test_health_does_not_print_sensitive_plaintext(self) -> None: + with IsolatedDataRoot(user_id="_cfg_health"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + path = bootstrap_skill_config() + with open(path, "a", encoding="utf-8") as f: + f.write("FAKE_SECRET_TOKEN=supersecret12345\n") + config.reset_cache() + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 0) + out = buf.getvalue() + self.assertNotIn("supersecret12345", out) + self.assertIn("env_path:", out) + self.assertIn("env_exists: True", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_data_management_contract.py b/tests/test_data_management_contract.py new file mode 100644 index 0000000..07d135f --- /dev/null +++ b/tests/test_data_management_contract.py @@ -0,0 +1,479 @@ +# -*- coding: utf-8 -*- +"""通用数据管理契约测试(demo_items fixture,不含具体业务技能内容)。""" +from __future__ import annotations + +import json +import sqlite3 +import unittest + +from _support import IsolatedDataRoot + +DEMO_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + generated_value TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE TRIGGER IF NOT EXISTS demo_items_set_updated_at +AFTER UPDATE ON demo_items +FOR EACH ROW +BEGIN + UPDATE demo_items + SET updated_at = unixepoch() + WHERE id = NEW.id AND updated_at = OLD.updated_at; +END; +""" + +DEMO_ITEMS_TABLE_METADATA = { + "table_name": "demo_items", + "display_name": "示例记录", + "description": "通用数据管理契约演示表", + "visible": 1, + "readonly": 0, +} + +DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + {"column_name": "id", "display_name": "编号", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "title", "display_name": "标题", "visible": 1, "searchable": 1, "editable": 1}, + {"column_name": "description", "display_name": "描述", "visible": 1, "searchable": 1, "editable": 1}, + { + "column_name": "status", + "display_name": "状态", + "visible": 1, + "searchable": 1, + "editable": 1, + "options_json": json.dumps( + [ + {"value": "draft", "label": "草稿"}, + {"value": "published", "label": "已发布"}, + {"value": "archived", "label": "已归档"}, + ], + ensure_ascii=False, + ), + }, + {"column_name": "generated_value", "display_name": "生成值", "visible": 1, "searchable": 0, "editable": 0}, + { + "column_name": "created_at", + "display_name": "创建时间", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, + { + "column_name": "updated_at", + "display_name": "更新时间", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, +) + + +def _init_demo_items_fixture(conn: sqlite3.Connection, *, prune: bool = True) -> None: + from db.display_metadata import ( + init_display_metadata_tables, + list_physical_column_names, + seed_column_metadata_batch, + upsert_table_metadata, + ) + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.executescript(DEMO_ITEMS_DDL) + meta = DEMO_ITEMS_TABLE_METADATA + upsert_table_metadata( + cur, + table_name=str(meta["table_name"]), + display_name=str(meta["display_name"]), + description=str(meta["description"]), + visible=int(meta["visible"]), + readonly=int(meta["readonly"]), + ) + physical = list_physical_column_names(cur, "demo_items") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical if prune else None, + ) + conn.commit() + + +class TestDataManagementContract(unittest.TestCase): + def test_demo_items_metadata_permissions(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import ( + get_column_editable, + get_table_readonly, + list_editable_business_columns, + table_allows_manual_create, + ) + + cur = conn.cursor() + self.assertEqual(get_table_readonly(cur, "demo_items"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "id"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1) + self.assertEqual(get_column_editable(cur, "demo_items", "generated_value"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "created_at"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "updated_at"), 0) + + editable_cols = list_editable_business_columns(cur, "demo_items") + self.assertEqual(editable_cols, ["title", "description", "status"]) + self.assertTrue(table_allows_manual_create(cur, "demo_items")) + finally: + conn.close() + + def test_demo_items_init_idempotent_no_duplicates(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'demo_items'") + self.assertEqual(int(cur.fetchone()[0]), 1) + cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'demo_items'") + self.assertEqual(int(cur.fetchone()[0]), 7) + finally: + conn.close() + + def test_reinit_updates_stale_editable_value(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "UPDATE _jiangchang_columns SET editable = 0 WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + _init_demo_items_fixture(conn) + from db.display_metadata import get_column_editable + + self.assertEqual(get_column_editable(conn.cursor(), "demo_items", "title"), 1) + finally: + conn.close() + + def test_missing_table_metadata_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'") + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items" in err for err in report.errors)) + finally: + conn.close() + + def test_missing_column_metadata_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items.title" in err for err in report.errors)) + finally: + conn.close() + + def test_metadata_reference_to_missing_column_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'not_exists', '不存在', 1, 1, 0) + """ + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("not_exists" in err for err in report.errors)) + finally: + conn.close() + + def test_empty_display_name_detected_for_visible_column(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "UPDATE _jiangchang_columns SET display_name = '' WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("display_name 为空" in err for err in report.errors)) + finally: + conn.close() + + def test_visible_columns_use_chinese_display_name(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT column_name, display_name + FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND visible = 1 + """ + ) + for column_name, display_name in cur.fetchall(): + self.assertTrue( + any("\u4e00" <= ch <= "\u9fff" for ch in str(display_name)), + msg=f"{column_name} display_name should be Chinese: {display_name!r}", + ) + finally: + conn.close() + + def test_timestamp_fields_type_default_and_editable(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + for column_name in ("created_at", "updated_at"): + cur.execute(f"PRAGMA table_info('demo_items')") + info = {row[1]: row for row in cur.fetchall()} + row = info[column_name] + self.assertIn("INT", str(row[2]).upper()) + self.assertIn("unixepoch", str(row[4]).lower()) + cur.execute( + """ + SELECT editable, display_type FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = ? + """, + (column_name,), + ) + editable, display_type = cur.fetchone() + self.assertEqual(int(editable), 0) + self.assertEqual(display_type, "datetime_unix_seconds") + finally: + conn.close() + + def test_physical_order_matches_pragma_cid(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import list_physical_column_names + + cur = conn.cursor() + expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA] + physical = list_physical_column_names(cur, "demo_items") + self.assertEqual(physical, expected) + cur.execute("PRAGMA table_info('demo_items')") + cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))] + self.assertEqual(cid_order, expected) + finally: + conn.close() + + def test_options_json_parses_for_enum_field(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT options_json FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = 'status' + """ + ) + raw = cur.fetchone()[0] + from db.display_metadata_validator import validate_options_json + + errors, _ = validate_options_json(raw) + self.assertEqual(errors, []) + parsed = json.loads(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual(parsed[0]["value"], "draft") + finally: + conn.close() + + def test_system_table_no_editable_fields_not_manual_create(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + seed_column_metadata_batch, + table_allows_manual_create, + upsert_table_metadata, + ) + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.execute( + """ + CREATE TABLE source_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL, + synced_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + """ + ) + upsert_table_metadata( + cur, + table_name="source_records", + display_name="来源记录", + description="只由同步流程写入", + visible=1, + readonly=0, + ) + seed_column_metadata_batch( + cur, + table_name="source_records", + columns=( + {"column_name": "id", "display_name": "编号", "editable": 0}, + {"column_name": "item_id", "display_name": "记录编号", "editable": 0}, + {"column_name": "synced_at", "display_name": "同步时间", "editable": 0, "display_type": "datetime_unix_seconds"}, + ), + ) + conn.commit() + self.assertFalse(table_allows_manual_create(cur, "source_records")) + finally: + conn.close() + + def test_table_readonly_blocks_manual_create_even_with_editable_fields(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute("UPDATE _jiangchang_tables SET readonly = 1 WHERE table_name = 'demo_items'") + conn.commit() + from db.display_metadata import table_allows_manual_create + + self.assertFalse(table_allows_manual_create(conn.cursor(), "demo_items")) + finally: + conn.close() + + def test_prune_stale_column_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0) + """ + ) + conn.commit() + from db.display_metadata import list_physical_column_names, seed_column_metadata_batch + + cur = conn.cursor() + physical = list_physical_column_names(cur, "demo_items") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical, + ) + conn.commit() + cur.execute( + "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" + ) + self.assertIsNone(cur.fetchone()) + finally: + conn.close() + + def test_seed_batch_without_valid_column_names_does_not_prune(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn, prune=False) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0) + """ + ) + conn.commit() + from db.display_metadata import seed_column_metadata_batch + + seed_column_metadata_batch( + conn.cursor(), + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + ) + conn.commit() + cur = conn.cursor() + cur.execute( + "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" + ) + self.assertIsNotNone(cur.fetchone()) + finally: + conn.close() + + def test_seed_batch_accepts_list_columns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn, prune=False) + from db.display_metadata import get_column_editable, seed_column_metadata_batch + + columns = list(DEMO_ITEMS_COLUMN_METADATA) + columns[1] = {**columns[1], "display_name": "标题(列表)"} + seed_column_metadata_batch( + conn.cursor(), + table_name="demo_items", + columns=columns, + valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA], + ) + conn.commit() + cur = conn.cursor() + cur.execute( + "SELECT display_name FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + self.assertEqual(cur.fetchone()[0], "标题(列表)") + self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1) + finally: + conn.close() + + def test_prune_does_not_remove_existing_column_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import get_column_editable, seed_column_metadata_batch + + cur = conn.cursor() + before = get_column_editable(cur, "demo_items", "title") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA], + ) + conn.commit() + self.assertEqual(get_column_editable(cur, "demo_items", "title"), before) + finally: + conn.close() + + def test_demo_items_validation_passes(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_db_smoke.py b/tests/test_db_smoke.py new file mode 100644 index 0000000..a30a34a --- /dev/null +++ b/tests/test_db_smoke.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""init_db:幂等创建元数据表与本技能业务表。""" +from __future__ import annotations + +import unittest + +from _support import IsolatedDataRoot + +_BUSINESS_TABLES = {"fund_projects", "progress_reports", "alert_rules"} + + +class TestDbSmoke(unittest.TestCase): + def test_init_db_idempotent_metadata_only(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import METADATA_TABLES, iter_user_tables + + init_db() + init_db() + + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ) + tables = {str(row[0]) for row in cur.fetchall()} + self.assertIn("_jiangchang_tables", tables) + self.assertIn("_jiangchang_columns", tables) + self.assertNotIn("task_logs", tables) + self.assertEqual( + sorted(iter_user_tables(cur)), + sorted(_BUSINESS_TABLES), + ) + leftover = tables - METADATA_TABLES - _BUSINESS_TABLES + self.assertEqual(leftover, set()) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py new file mode 100644 index 0000000..1e0d4d1 --- /dev/null +++ b/tests/test_development_policy_guard.py @@ -0,0 +1,1598 @@ +# -*- coding: utf-8 -*- +"""开发规范 hard policy 守护(索引:development/POLICY_MATRIX.md)。""" +from __future__ import annotations + +import os +import re +import unittest + +from _support import get_skill_root + +SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".pytest_cache", + "rpa-artifacts", + "videos", + } +) + +SKIP_REL_PREFIXES = ("tests/artifacts", "tests/diagnostics") + +POLICY_IDS = ( + "POLICY-STRUCTURE-001", + "POLICY-RUNTIME-001", + "POLICY-RUNTIME-002", + "POLICY-RUNTIME-003", + "POLICY-INSTALL-001", + "POLICY-CONFIG-001", + "POLICY-CONFIG-002", + "POLICY-CONFIG-003", + "POLICY-CONFIG-004", + "POLICY-TESTING-001", + "POLICY-TESTING-002", + "POLICY-RPA-001", + "POLICY-RPA-002", + "POLICY-RPA-003", + "POLICY-RPA-004", + "POLICY-RPA-005", + "POLICY-RPA-006", + "POLICY-PACKAGING-001", + "POLICY-PACKAGING-002", + "POLICY-RELEASE-001", + "POLICY-DOCS-001", + "POLICY-LOGGING-001", + "POLICY-LOGGING-002", + "POLICY-LOGGING-003", + "POLICY-LOGGING-004", + "POLICY-LOGGING-005", + "POLICY-LOGGING-006", + "POLICY-LOGGING-007", + "POLICY-LOGGING-008", + "POLICY-CONTROL-001", + "POLICY-CONTROL-002", + "POLICY-CONTROL-003", + "POLICY-DATA-PATH-001", + "POLICY-SKILL-ACTION-001", + "POLICY-SKILL-ACTION-002", + "POLICY-SKILL-ACTION-003", + "POLICY-SKILL-ACTION-004", + "POLICY-TUTORIAL-001", + "POLICY-ARCH-CORE-001", + "POLICY-DATA-SYNC-001", +) + +STRUCTURE_PATHS = ( + "scripts/main.py", + "scripts/cli", + "scripts/service", + "scripts/db", + "scripts/util", +) + +CONFIG_ENV_BOUNDARY_FILES = frozenset( + { + "scripts/main.py", + "scripts/util/config_bootstrap.py", + "scripts/util/runtime_paths.py", + "scripts/util/logging_config.py", + "scripts/util/constants.py", + } +) + +INSTALL_FORBIDDEN_PATTERNS = ( + re.compile(r"\bpip\s+install\b", re.IGNORECASE), + re.compile(r"\bpython\s+-m\s+pip\s+install\b", re.IGNORECASE), + re.compile(r"\buv\s+pip\s+install\b", re.IGNORECASE), + re.compile(r"\bplaywright\s+install\b", re.IGNORECASE), +) + +CONFIG_ENV_FORBIDDEN_PATTERNS = ( + re.compile(r"\bos\.environ\b"), + re.compile(r"\bos\.getenv\b"), + re.compile(r"\bos\.putenv\b"), + re.compile(r"\benviron\.get\b"), +) + +CONFIG_004_SOURCE = "development/CONFIG.md §.env.example 注释 = 配置管理用户文案" +CONFIG_004_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +CONFIG_004_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +# 注释中禁止出现的开发向指向 / 黑话(大小写不敏感子串) +CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS = ( + "development/", + "adapter.md", + "rpa.md", + "data_paths.md", + "config.md", + "account-manager", + "见 development", + "adapter 档位", + "adapter档位", +) + +TESTING_REAL_TARGET_ALLOWED_ROOT_TESTS = frozenset({"test_adapter_profile_policy.py"}) + +TESTING_001_FORBIDDEN_SUBSTRINGS = ( + "real_" + "api", + "real_" + "rpa", + "OPENCLAW_" + "TEST_TARGET", +) + +TESTING_001_SKIP_ROOT_TESTS = frozenset({"test_development_policy_guard.py"}) + +RPA_001_FORBIDDEN_PATTERNS = ( + re.compile(r"\brpa_helpers\b"), + re.compile(r"\binject_account_manager_scripts_path\b"), + re.compile(r"\bget_account_credential\b"), +) + +RPA_002_FFMPEG_PATTERNS = ( + re.compile( + r"\bsubprocess\.(run|call|Popen|check_output|check_call)\s*\([^)]*ffmpeg", + re.IGNORECASE | re.DOTALL, + ), + re.compile( + r"\bsubprocess\.(run|call|Popen|check_output|check_call)\(\s*['\"][^'\"]*ffmpeg", + re.IGNORECASE, + ), + re.compile(r"\bos\.system\s*\([^)]*ffmpeg", re.IGNORECASE | re.DOTALL), + re.compile(r"\bos\.popen\s*\([^)]*ffmpeg", re.IGNORECASE | re.DOTALL), + re.compile( + r"\bsubprocess\.(run|call|Popen|check_output|check_call)\([^)]*shell\s*=\s*True[^)]*ffmpeg", + re.IGNORECASE | re.DOTALL, + ), +) + +RPA_002_EXEMPT_REL = "scripts/service/task_run_support.py" + +RPA_003_SOURCE = ( + "development/RPA.md §5.3; development/TESTING.md §12; development/POLICY_MATRIX.md" +) + +RPA_004_SOURCE = ( + "development/RPA.md §0.2; development/ADAPTER.md §兄弟依赖; development/POLICY_MATRIX.md" +) +RPA_004_HEADLESS_KEY_RE = re.compile( + r"(?m)^\s*OPENCLAW_BROWSER_HEADLESS\s*=\s*([^\s#]+)" +) +RPA_004_LAUNCH_MARKERS = ( + "launch_persistent_context", + "launch_persistent_browser", +) +RPA_004_PICK_MARKER = "pick_web_account" + +RPA_005_SOURCE = ( + "development/AI_RPA.md §4–§6; development/RPA.md §0.0/§0.3; " + "development/POLICY_MATRIX.md" +) +RPA_005_REQUIRED_DOC_MARKERS = ( + "AI Agent 是用户入口与业务编排者", + "RPA 是确定性执行器", + "破解", + "逆向", + "私有协议", + "验证码", + "人工处理", + "1~3 秒", + "状态等待", +) +RPA_005_STEP_DELAY_KEY_RE = re.compile( + r"(?m)^\s*(STEP_DELAY_MIN|STEP_DELAY_MAX)\s*=\s*([^\s#]+)" +) + +RPA_006_SOURCE = ( + "development/ACCOUNT_MANAGER.md; development/ADAPTER.md §兄弟依赖; " + "development/RPA.md §0.2; development/POLICY_MATRIX.md" +) +RPA_006_REQUIRED_DOC_MARKERS = ( + "一账号一", + "profile_dir", + "lease_token", + "数据管理页面", + "account-manager.db", + "account list", + "lease list", + "关闭浏览器并释放租约", + "两个以上账号", +) +RPA_006_EXAMPLE_CLIENTS = ( + "examples/real_browser_rpa/scripts/service/account_client.py", + "examples/simulator_browser_rpa/scripts/service/account_client.py", +) + +LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md" +LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md" +LOGGING_003_SOURCE = "development/LOGGING.md" +LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md" +LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md" +LOGGING_006_SOURCE = "development/LOGGING.md §0、§3" +LOGGING_007_SOURCE = "development/LOGGING.md §3.1–§3.2" +LOGGING_008_SOURCE = "development/LOGGING.md §0 铁律 3" + +LOGGING_006_EXEMPT_BASENAMES = frozenset( + { + "__init__.py", + "task_run_support.py", + "entitlement_service.py", + "sibling_bridge.py", + } +) + +LOGGING_RPA_MARKERS = ( + "RpaVideoSession", + "uiautomation", + "playwright", + "@rpa_step", + "rpa_step(", +) + +LOGGING_CALL_PATTERN = re.compile( + r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", + re.IGNORECASE, +) +LOGGING_CALL_GET_LOGGER_PATTERN = re.compile( + r"get_skill_logger\s*\(\s*\)\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(", + re.IGNORECASE, +) +SENSITIVE_LOG_ASSIGNMENT_PATTERN = re.compile( + r"(password|token|cookie|secret|api_key|authorization)\s*=", + re.IGNORECASE, +) + + +def _policy_msg(policy_id: str, source: str, detail: str) -> str: + return f"{policy_id} [{source}]: {detail}" + + +def _strip_env_quotes(value: str) -> str: + trimmed = value.strip() + if ( + (trimmed.startswith('"') and trimmed.endswith('"')) + or (trimmed.startswith("'") and trimmed.endswith("'")) + ): + return trimmed[1:-1] + return trimmed + + +def _iter_env_example_active_entries(path: str): + """Yield (lineno, key, value, header_comments, inline_comment) for active KEY= lines.""" + pending: list[str] = [] + with open(path, encoding="utf-8") as handle: + for lineno, line in enumerate(handle, 1): + stripped = line.strip() + if not stripped: + pending = [] + continue + if stripped.startswith("#"): + pending.append(stripped.lstrip("#").strip()) + continue + eq = stripped.find("=") + if eq <= 0: + pending = [] + continue + key = stripped[:eq].strip() + if not CONFIG_004_ENV_KEY_RE.match(key): + pending = [] + continue + rest = stripped[eq + 1 :] + inline: str | None = None + if not rest.startswith('"') and not rest.startswith("'"): + hash_at = rest.find(" #") + if hash_at >= 0: + inline = rest[hash_at + 2 :].strip() + rest = rest[:hash_at] + value = _strip_env_quotes(rest) + yield lineno, key, value, list(pending), inline + pending = [] + + +def _config_004_comment_offenders(path: str) -> list[str]: + offenders: list[str] = [] + for lineno, key, _value, headers, inline in _iter_env_example_active_entries(path): + if not headers: + offenders.append( + f".env.example:{lineno}: {key}= 缺少行头注释(须用中文说明「是什么」)" + ) + elif not any(CONFIG_004_CJK_RE.search(part) for part in headers): + offenders.append( + f".env.example:{lineno}: {key}= 行头注释须含中文(说明「是什么」)" + ) + if not inline: + offenders.append( + f".env.example:{lineno}: {key}= 缺少行尾注释(须用中文说明「怎么填」)" + ) + elif not CONFIG_004_CJK_RE.search(inline): + offenders.append( + f".env.example:{lineno}: {key}= 行尾注释须含中文(说明「怎么填」)" + ) + combined = "\n".join([*headers, inline or ""]).lower() + for token in CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS: + if token.lower() in combined: + offenders.append( + f".env.example:{lineno}: {key}= 注释禁止含 {token!r}(面向用户,勿写开发文档指向/黑话)" + ) + break + return offenders + + +def _rel(skill_root: str, path: str) -> str: + return os.path.relpath(path, skill_root).replace("\\", "/") + + +def _should_skip_dirname(dirname: str) -> bool: + return dirname in SKIP_DIR_NAMES + + +def _should_skip_rel_path(skill_root: str, abs_path: str) -> bool: + rel = _rel(skill_root, abs_path).replace("\\", "/") + for prefix in SKIP_REL_PREFIXES: + normalized = prefix.rstrip("/") + if rel == normalized or rel.startswith(normalized + "/"): + return True + return False + + +def _walk_files(skill_root: str, rel_root: str, *, suffix: str | None = None) -> list[str]: + base = os.path.join(skill_root, rel_root) + if not os.path.exists(base): + return [] + found: list[str] = [] + for root, dirnames, filenames in os.walk(base): + kept: list[str] = [] + for d in dirnames: + if _should_skip_dirname(d): + continue + sub = os.path.join(root, d) + if _should_skip_rel_path(skill_root, sub): + continue + kept.append(d) + dirnames[:] = kept + for name in filenames: + full = os.path.join(root, name) + if _should_skip_rel_path(skill_root, full): + continue + if suffix and not name.endswith(suffix): + continue + found.append(full) + return sorted(found) + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def _requirement_dependency_lines(req_text: str) -> list[str]: + return [ + line.strip() + for line in req_text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +def _scan_install_delivery_files(skill_root: str) -> list[str]: + offenders: list[str] = [] + scan_specs: list[tuple[str, str | None]] = [ + ("scripts", ".py"), + (".", ".ps1"), + (".", ".sh"), + (".", None), + (".github/workflows", ".yml"), + (".github/workflows", ".yaml"), + ] + seen: set[str] = set() + for rel_root, suffix in scan_specs: + if rel_root == "." and suffix is None: + req = os.path.join(skill_root, "requirements.txt") + if os.path.isfile(req): + seen.add(req) + text = _read_text(req) + for pattern in INSTALL_FORBIDDEN_PATTERNS: + if pattern.search(text): + offenders.append( + _policy_msg( + "POLICY-INSTALL-001", + "development/RUNTIME.md; development/RPA.md §1.4; development/DEVELOPMENT.md §3.1", + f"{_rel(skill_root, req)} matches {pattern.pattern}", + ) + ) + continue + for path in _walk_files(skill_root, rel_root, suffix=suffix): + if path in seen: + continue + seen.add(path) + if suffix == ".ps1" and not path.endswith(".ps1"): + continue + if suffix == ".sh" and not path.endswith(".sh"): + continue + text = _read_text(path) + for pattern in INSTALL_FORBIDDEN_PATTERNS: + if pattern.search(text): + offenders.append( + _policy_msg( + "POLICY-INSTALL-001", + "development/RUNTIME.md; development/RPA.md §1.4; development/DEVELOPMENT.md §3.1", + f"{_rel(skill_root, path)} matches {pattern.pattern}", + ) + ) + return offenders + + +def _gitignore_has_pattern(lines: list[str], pattern: str) -> bool: + normalized = [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")] + return pattern in normalized + + +class TestPolicyStructure001(unittest.TestCase): + def test_standard_scripts_layout_exists(self) -> None: + skill_root = get_skill_root() + missing: list[str] = [] + for rel in STRUCTURE_PATHS: + path = os.path.join(skill_root, *rel.split("/")) + if not os.path.exists(path): + missing.append(rel) + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-STRUCTURE-001", + "development/DEVELOPMENT.md §2; development/RUNTIME.md §目录结构", + "missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyRuntime001(unittest.TestCase): + def test_no_vendored_jiangchang_skill_core(self) -> None: + skill_root = get_skill_root() + vendored = os.path.join(skill_root, "scripts", "jiangchang_skill_core") + self.assertFalse( + os.path.isdir(vendored), + msg=_policy_msg( + "POLICY-RUNTIME-001", + "development/RUNTIME.md §共享 Python Runtime", + _rel(skill_root, vendored), + ), + ) + + +class TestPolicyRuntime002(unittest.TestCase): + def test_requirements_txt_excludes_shared_runtime_deps(self) -> None: + skill_root = get_skill_root() + req_path = os.path.join(skill_root, "requirements.txt") + self.assertTrue(os.path.isfile(req_path), msg="requirements.txt missing") + text = _read_text(req_path) + dep_lines = _requirement_dependency_lines(text) + offenders: list[str] = [] + for line in dep_lines: + lower = line.lower() + if "jiangchang-platform-kit" in lower: + offenders.append(line) + if re.search(r"(?<![\w.-])playwright(?![\w.-])", lower): + offenders.append(line) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-RUNTIME-002", + "development/RUNTIME.md; development/DEVELOPMENT.md §3.1", + "forbidden dependency lines: " + "; ".join(offenders), + ), + ) + + +class TestPolicyInstall001(unittest.TestCase): + def test_delivery_files_have_no_auto_install_commands(self) -> None: + offenders = _scan_install_delivery_files(get_skill_root()) + self.assertEqual(offenders, [], msg="\n".join(offenders)) + + +class TestPolicyConfig001(unittest.TestCase): + def test_env_example_exists(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, ".env.example") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg( + "POLICY-CONFIG-001", + "development/CONFIG.md", + ".env.example missing at repo root", + ), + ) + + +class TestPolicyConfig002(unittest.TestCase): + def test_gitignore_ignores_env_files(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, ".gitignore") + self.assertTrue(os.path.isfile(path), msg=".gitignore missing") + lines = _read_text(path).splitlines() + missing: list[str] = [] + if not _gitignore_has_pattern(lines, ".env"): + missing.append(".env") + if not _gitignore_has_pattern(lines, "*.env.local"): + missing.append("*.env.local") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-CONFIG-002", + "development/CONFIG.md §红线:敏感信息不进 .env", + "missing gitignore entries: " + ", ".join(missing), + ), + ) + + +class TestPolicyConfig003(unittest.TestCase): + def test_business_scripts_do_not_read_os_environ_directly(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + if rel.replace("\\", "/") in CONFIG_ENV_BOUNDARY_FILES: + continue + text = _read_text(path) + for lineno, line in enumerate(text.splitlines(), 1): + if line.strip().startswith("#"): + continue + for pattern in CONFIG_ENV_FORBIDDEN_PATTERNS: + if pattern.search(line): + offenders.append( + f"{rel}:{lineno}: {line.strip()} — use jiangchang_skill_core.config.get*" + ) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-CONFIG-003", + "development/CONFIG.md; development/ADAPTER.md", + "\n".join(offenders), + ), + ) + + +class TestPolicyConfig004(unittest.TestCase): + def test_env_example_comments_are_user_facing(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + self.assertTrue(os.path.isfile(path), msg=".env.example missing") + offenders = _config_004_comment_offenders(path) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-CONFIG-004", + CONFIG_004_SOURCE, + "\n".join(offenders) + or "every active KEY in .env.example needs Chinese header+inline comments", + ), + ) + + +class TestPolicyTesting001(unittest.TestCase): + def test_root_tests_do_not_hardcode_real_targets(self) -> None: + skill_root = get_skill_root() + tests_dir = os.path.join(skill_root, "tests") + offenders: list[str] = [] + for name in sorted(os.listdir(tests_dir)): + if not (name.startswith("test_") and name.endswith(".py")): + continue + if name in TESTING_001_SKIP_ROOT_TESTS: + continue + if name in TESTING_REAL_TARGET_ALLOWED_ROOT_TESTS: + continue + path = os.path.join(tests_dir, name) + text = _read_text(path) + for lineno, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + for token in TESTING_001_FORBIDDEN_SUBSTRINGS: + if token in line: + offenders.append(f"{name}:{lineno}: {stripped}") + break + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-TESTING-001", + "development/TESTING.md §默认必跑测试要做什么", + "\n".join(offenders), + ), + ) + + +class TestPolicyTesting002(unittest.TestCase): + def test_integration_python_tests_are_sample_only(self) -> None: + skill_root = get_skill_root() + integration_dir = os.path.join(skill_root, "tests", "integration") + if not os.path.isdir(integration_dir): + return + offenders: list[str] = [] + for name in sorted(os.listdir(integration_dir)): + if not name.endswith(".py"): + continue + if name.endswith(".sample"): + continue + offenders.append(f"tests/integration/{name}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-TESTING-002", + "development/TESTING.md §7 真实联调测试的安全约束", + "non-sample integration tests: " + ", ".join(offenders), + ), + ) + + +class TestPolicyRpa001(unittest.TestCase): + def test_scripts_have_no_account_manager_internal_imports(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + text = _read_text(path) + for pattern in RPA_001_FORBIDDEN_PATTERNS: + if pattern.search(text): + offenders.append(f"{rel}: {pattern.pattern}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-RPA-001", + "development/ADAPTER.md §兄弟依赖; development/RPA.md §1.7", + "\n".join(offenders), + ), + ) + + +class TestPolicyRpa002(unittest.TestCase): + def test_scripts_do_not_invoke_ffmpeg_directly(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + if rel.replace("\\", "/") == RPA_002_EXEMPT_REL: + continue + text = _read_text(path) + for pattern in RPA_002_FFMPEG_PATTERNS: + if pattern.search(text): + offenders.append(f"{rel}: {pattern.pattern}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-RPA-002", + "development/RPA.md §5.3 录屏成片标准", + "\n".join(offenders), + ), + ) + + +class TestPolicyRpa003(unittest.TestCase): + def test_service_layer_retains_video_session_integration(self) -> None: + skill_root = get_skill_root() + service_files = _walk_files(skill_root, "scripts/service", suffix=".py") + self.assertTrue(service_files, msg="scripts/service/*.py not found") + combined = "\n".join(_read_text(path) for path in service_files) + missing: list[str] = [] + if "RpaVideoSession" not in combined: + missing.append("RpaVideoSession") + if "video.add_step" not in combined and ".add_step(" not in combined: + missing.append("video.add_step or .add_step(") + if "merge_video_into_result_summary" not in combined: + missing.append("merge_video_into_result_summary") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-RPA-003", + RPA_003_SOURCE, + "scripts/service/*.py missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyRpa004(unittest.TestCase): + def test_env_example_browser_headless_defaults_to_headed(self) -> None: + skill_root = get_skill_root() + env_path = os.path.join(skill_root, ".env.example") + self.assertTrue(os.path.isfile(env_path), msg=".env.example missing") + text = _read_text(env_path) + match = RPA_004_HEADLESS_KEY_RE.search(text) + if match is None: + return + value = match.group(1).strip().strip("\"'") + self.assertEqual( + value, + "0", + msg=_policy_msg( + "POLICY-RPA-004", + RPA_004_SOURCE, + f".env.example OPENCLAW_BROWSER_HEADLESS must default to 0 (headed), got {value!r}", + ), + ) + + def test_launch_persistent_requires_pick_web_account(self) -> None: + skill_root = get_skill_root() + script_texts = [ + _read_text(path) for path in _walk_files(skill_root, "scripts", suffix=".py") + ] + combined = "\n".join(script_texts) + uses_launch = any(marker in combined for marker in RPA_004_LAUNCH_MARKERS) + if not uses_launch: + return + self.assertIn( + RPA_004_PICK_MARKER, + combined, + msg=_policy_msg( + "POLICY-RPA-004", + RPA_004_SOURCE, + "scripts/ uses launch_persistent_* but missing pick_web_account " + "(must obtain profile_dir before opening browser)", + ), + ) + + +class TestPolicyRpa005(unittest.TestCase): + def test_ai_rpa_governance_doc_has_required_boundaries(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "development", "AI_RPA.md") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg("POLICY-RPA-005", RPA_005_SOURCE, "development/AI_RPA.md missing"), + ) + text = _read_text(path) + missing = [marker for marker in RPA_005_REQUIRED_DOC_MARKERS if marker not in text] + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-RPA-005", + RPA_005_SOURCE, + "AI_RPA.md missing markers: " + ", ".join(missing), + ), + ) + + def test_real_rpa_default_step_delay_is_one_to_three_seconds(self) -> None: + skill_root = get_skill_root() + env_path = os.path.join(skill_root, ".env.example") + values = { + key: value.strip().strip("\"'") + for key, value in RPA_005_STEP_DELAY_KEY_RE.findall(_read_text(env_path)) + } + self.assertEqual( + values.get("STEP_DELAY_MIN"), + "1.0", + msg=_policy_msg( + "POLICY-RPA-005", + RPA_005_SOURCE, + f"STEP_DELAY_MIN must default to 1.0, got {values.get('STEP_DELAY_MIN')!r}", + ), + ) + self.assertEqual( + values.get("STEP_DELAY_MAX"), + "3.0", + msg=_policy_msg( + "POLICY-RPA-005", + RPA_005_SOURCE, + f"STEP_DELAY_MAX must default to 3.0, got {values.get('STEP_DELAY_MAX')!r}", + ), + ) + + +class TestPolicyRpa006(unittest.TestCase): + def test_account_manager_lifecycle_doc_has_required_markers(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "development", "ACCOUNT_MANAGER.md") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg("POLICY-RPA-006", RPA_006_SOURCE, "development/ACCOUNT_MANAGER.md missing"), + ) + text = _read_text(path) + missing = [marker for marker in RPA_006_REQUIRED_DOC_MARKERS if marker not in text] + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-RPA-006", + RPA_006_SOURCE, + "ACCOUNT_MANAGER.md missing markers: " + ", ".join(missing), + ), + ) + + def test_example_account_id_selection_keeps_lease(self) -> None: + skill_root = get_skill_root() + for rel in RPA_006_EXAMPLE_CLIENTS: + text = _read_text(os.path.join(skill_root, *rel.split("/"))) + self.assertIn("_pick_by_id_with_lease", text, msg=f"{rel} must lease explicit account ids") + self.assertIn('"account",\n "pick-web"', text, msg=f"{rel} must use pick-web for lease") + self.assertIn("lease_token", text, msg=f"{rel} must validate lease_token") + self.assertNotIn( + 'data["lease_token"] = ""', + text, + msg=f"{rel} must not return an explicit account without a lease", + ) + + +def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]: + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts", suffix=".py"): + rel = _rel(skill_root, path) + for lineno, line in enumerate(_read_text(path).splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not LOGGING_CALL_PATTERN.search(line): + continue + if SENSITIVE_LOG_ASSIGNMENT_PATTERN.search(line): + offenders.append(f"{rel}:{lineno}: {stripped}") + return offenders + + +class TestPolicyLogging001(unittest.TestCase): + def test_cli_app_sets_up_unified_logging(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "cli", "app.py") + text = _read_text(path) + rel = _rel(skill_root, path) + self.assertIn( + "setup_skill_logging", + text, + msg=_policy_msg( + "POLICY-LOGGING-001", + LOGGING_001_SOURCE, + f"{rel} missing setup_skill_logging", + ), + ) + has_logger_info = bool( + LOGGING_CALL_GET_LOGGER_PATTERN.search(text) + or re.search(r"\bget_skill_logger\s*\(\s*\)\s*\.\s*info\s*\(", text) + ) + self.assertTrue( + has_logger_info, + msg=_policy_msg( + "POLICY-LOGGING-001", + LOGGING_001_SOURCE, + f"{rel} missing get_skill_logger().info (or equivalent)", + ), + ) + + +class TestPolicyLogging002(unittest.TestCase): + def test_task_service_uses_get_skill_logger(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + self.assertRegex( + text, + r"get_skill_logger", + msg=_policy_msg( + "POLICY-LOGGING-002", + LOGGING_002_SOURCE, + f"{rel} must import/use get_skill_logger", + ), + ) + + +class TestPolicyLogging003(unittest.TestCase): + def test_task_service_logs_start_and_failure(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + missing: list[str] = [] + if not re.search(r"\b(log|logger)\.info\s*\(", text): + missing.append("log.info or logger.info") + if not re.search(r"\b(log|logger)\.exception\s*\(", text): + missing.append("log.exception or logger.exception") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-LOGGING-003", + LOGGING_003_SOURCE, + f"{rel} missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyLogging004(unittest.TestCase): + def test_task_service_emits_progress_for_long_tasks(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + has_progress = bool( + re.search(r"\bemit\s*\(", text) + or re.search(r"\bactivity\.emit\s*\(", text) + or re.search(r"\bvideo\.add_step\s*\(", text) + ) + self.assertTrue( + has_progress, + msg=_policy_msg( + "POLICY-LOGGING-004", + LOGGING_004_SOURCE, + f"{rel} must use emit( / activity.emit / video.add_step", + ), + ) + + +class TestPolicyLogging005(unittest.TestCase): + def test_scripts_do_not_log_sensitive_assignments(self) -> None: + offenders = _scan_sensitive_logging_assignments(get_skill_root()) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-005", + LOGGING_005_SOURCE, + "\n".join(offenders), + ), + ) + + +def _iter_service_py_files(skill_root: str) -> list[str]: + service_dir = os.path.join(skill_root, "scripts", "service") + if not os.path.isdir(service_dir): + return [] + return list(_walk_files(skill_root, os.path.join("scripts", "service"), suffix=".py")) + + +def _service_layer_combined_text(skill_root: str) -> str: + parts: list[str] = [] + for path in _iter_service_py_files(skill_root): + parts.append(_read_text(path)) + return "\n".join(parts) + + +_LOGGING_DOMAIN_CALL_RE = re.compile( + r"\b(log|logger|_logger)\s*\.\s*(info|warning|error|exception)\s*\(", + re.IGNORECASE, +) +_PRINT_ERROR_RE = re.compile(r"\bprint\s*\(.*ERROR:", re.IGNORECASE | re.DOTALL) +_FUNC_DEF_RE = re.compile(r"(?m)^(async\s+)?def\s+\w+\s*\(") + + +def _scan_print_error_without_logger(skill_root: str, path: str, text: str) -> list[str]: + """同一函数体内 print(...ERROR:) 须伴随 warning/error/exception 日志。""" + offenders: list[str] = [] + matches = list(_FUNC_DEF_RE.finditer(text)) + if not matches: + return offenders + for i, m in enumerate(matches): + start = m.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + body = text[start:end] + if not _PRINT_ERROR_RE.search(body): + continue + if re.search( + r"\b(log|logger|_logger)\s*\.\s*(warning|error|exception)\s*\(", + body, + re.IGNORECASE, + ): + continue + lineno = text[:start].count("\n") + 1 + offenders.append( + f"{_rel(skill_root, path)}:{lineno}: print(ERROR:) without log.warning|error|exception in same function" + ) + return offenders + + +class TestPolicyLogging006(unittest.TestCase): + def test_service_domain_modules_have_file_logging(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_service_py_files(skill_root): + base = os.path.basename(path) + if base in LOGGING_006_EXEMPT_BASENAMES: + continue + text = _read_text(path) + if not _LOGGING_DOMAIN_CALL_RE.search(text): + offenders.append(_rel(skill_root, path)) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-006", + LOGGING_006_SOURCE, + "service domain modules missing log/logger/_logger.(info|warning|error|exception):\n" + + "\n".join(offenders), + ), + ) + + +class TestPolicyLogging007(unittest.TestCase): + def test_rpa_service_layer_has_event_tokens(self) -> None: + skill_root = get_skill_root() + combined = _service_layer_combined_text(skill_root) + if not any(marker in combined for marker in LOGGING_RPA_MARKERS): + self.skipTest("no RPA markers in scripts/service; LOGGING-007 N/A") + missing: list[str] = [] + if "task_start" not in combined: + missing.append("task_start") + if "task_failed" not in combined and "rpa_failed" not in combined: + missing.append("task_failed|rpa_failed") + if "stage=" not in combined: + missing.append("stage=") + # entitlement tokens strongly recommended; require at least one + if "entitlement_ok" not in combined and "entitlement_failed" not in combined: + missing.append("entitlement_ok|entitlement_failed") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-LOGGING-007", + LOGGING_007_SOURCE, + "RPA service layer missing event tokens: " + ", ".join(missing), + ), + ) + + +class TestPolicyLogging008(unittest.TestCase): + def test_print_error_paired_with_logger(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_service_py_files(skill_root): + text = _read_text(path) + offenders.extend(_scan_print_error_without_logger(skill_root, path, text)) + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-LOGGING-008", + LOGGING_008_SOURCE, + "\n".join(offenders) or "print(ERROR:) must share function with log.warning|error|exception", + ), + ) + + +class TestPolicyControl001(unittest.TestCase): + def test_no_util_progress_module(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "util", "progress.py") + self.assertFalse( + os.path.isfile(path), + msg=_policy_msg( + "POLICY-CONTROL-001", + "development/LOGGING.md §2.5; development/RPA.md §0.1", + "scripts/util/progress.py must not exist", + ), + ) + + +class TestPolicyControl002(unittest.TestCase): + def test_task_service_uses_job_context_and_finish(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "scripts", "service", "task_service.py") + text = _read_text(path) + rel = _rel(skill_root, path) + missing: list[str] = [] + if "job_context(" not in text: + missing.append("job_context(") + if "finish(" not in text: + missing.append("finish(") + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-CONTROL-002", + "development/LOGGING.md §2.5、§7", + f"{rel} missing: " + ", ".join(missing), + ), + ) + + +class TestPolicyControl003(unittest.TestCase): + def test_service_layer_has_no_bare_asyncio_sleep(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _walk_files(skill_root, "scripts/service", suffix=".py"): + rel = _rel(skill_root, path) + for lineno, line in enumerate(_read_text(path).splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "asyncio.sleep" in stripped: + offenders.append(f"{rel}:{lineno}: {stripped}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-CONTROL-003", + "development/RPA.md §0.1", + "use interruptible_sleep instead:\n" + "\n".join(offenders), + ), + ) + + +class TestPolicyDataPath001(unittest.TestCase): + def test_env_example_has_no_cwd_relative_paths(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + offenders: list[str] = [] + with open(path, encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + active = stripped.split("#", 1)[0].strip() + if re.search(r"=\s*\./", active): + offenders.append(f".env.example:{lineno}: {active}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-DATA-PATH-001", + "development/DATA_PATHS.md", + "\n".join(offenders) or "CWD-relative ./ paths in .env.example", + ), + ) + + def test_runtime_paths_exposes_resolve_data_path(self) -> None: + import util.runtime_paths as rp + + self.assertTrue(callable(getattr(rp, "resolve_data_path", None))) + self.assertTrue(callable(getattr(rp, "list_resolved_data_paths", None))) + + def test_business_scripts_do_not_abspath_config_get(self) -> None: + skill_root = get_skill_root() + pattern = re.compile(r"os\.path\.abspath\s*\(\s*config\.get\b") + offenders: list[str] = [] + for rel in _walk_files(skill_root, "scripts", suffix=".py"): + if rel.replace("\\", "/") == "scripts/util/runtime_paths.py": + continue + text = _read_text(os.path.join(skill_root, rel)) + for lineno, line in enumerate(text.splitlines(), 1): + if line.strip().startswith("#"): + continue + if pattern.search(line): + offenders.append(f"{rel}:{lineno}: {line.strip()}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-DATA-PATH-001", + "development/DATA_PATHS.md", + "\n".join(offenders), + ), + ) + + +_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +_RPA_SESSION_TOKENS = ("RpaVideoSession",) + + +def _service_has_rpa_video_session(skill_root: str) -> bool: + service_dir = os.path.join(skill_root, "scripts", "service") + if not os.path.isdir(service_dir): + return False + for rel in _walk_files(skill_root, "scripts/service", suffix=".py"): + text = _read_text(os.path.join(skill_root, rel)) + if any(token in text for token in _RPA_SESSION_TOKENS): + return True + return False + + +def _load_actions_json(skill_root: str) -> dict | None: + path = os.path.join(skill_root, "assets", "actions.json") + if not os.path.isfile(path): + return None + import json + + with open(path, encoding="utf-8") as f: + return json.load(f) + + +class TestPolicySkillAction001(unittest.TestCase): + def test_skill_action_runtime_doc_exists(self) -> None: + path = os.path.join(get_skill_root(), "development", "SKILL_ACTION_RUNTIME.md") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "development/SKILL_ACTION_RUNTIME.md", + "file missing", + ), + ) + text = _read_text(path) + for marker in ("executionProfile", "async", "任务中心", "run_skill_action"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "development/SKILL_ACTION_RUNTIME.md", + f"missing marker {marker!r}", + ), + ) + + def test_schema_defines_execution_profile(self) -> None: + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + schema = json.load(f) + action_props = schema.get("$defs", {}).get("action", {}).get("properties", {}) + profile = action_props.get("executionProfile") + self.assertIsInstance( + profile, + dict, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/schemas/skill-actions.schema.json", + "action.properties.executionProfile missing", + ), + ) + self.assertEqual(set(profile.get("enum") or []), {"sync", "async"}) + + def test_actions_md_documents_execution_profile(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + text = _read_text(path) + for marker in ("executionProfile", "async", "任务中心", "SKILL_ACTION_RUNTIME"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "references/ACTIONS.md", + f"missing marker {marker!r}", + ), + ) + + def test_actions_json_actions_declare_execution_profile(self) -> None: + skill_root = get_skill_root() + manifest = _load_actions_json(skill_root) + if manifest is None: + return + offenders: list[str] = [] + for action in manifest.get("actions") or []: + action_id = action.get("id", "?") + profile = action.get("executionProfile") + if profile not in ("sync", "async"): + offenders.append(f"{action_id}: executionProfile={profile!r}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/actions.json", + "every action must set executionProfile to sync|async:\n" + "\n".join(offenders), + ), + ) + + def test_business_rpa_skill_has_async_agent_action(self) -> None: + from util.constants import SKILL_SLUG + + skill_root = get_skill_root() + if SKILL_SLUG == _TEMPLATE_PLACEHOLDER_SLUG: + self.skipTest("template placeholder slug skips business RPA action check") + if not _service_has_rpa_video_session(skill_root): + self.skipTest("no RpaVideoSession in scripts/service") + manifest = _load_actions_json(skill_root) + self.assertIsNotNone( + manifest, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/actions.json", + "RPA skill must provide assets/actions.json", + ), + ) + found = False + for action in manifest.get("actions") or []: + placements = set(action.get("placements") or []) + if action.get("executionProfile") == "async" and "agent" in placements: + found = True + break + self.assertTrue( + found, + msg=_policy_msg( + "POLICY-SKILL-ACTION-001", + "assets/actions.json", + "RPA business skill needs at least one action with executionProfile=async and placements containing agent", + ), + ) + + +class TestPolicySkillAction002(unittest.TestCase): + def test_schema_requires_execution_profile(self) -> None: + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + schema = json.load(f) + action = schema["$defs"]["action"] + self.assertIn( + "executionProfile", + action.get("required") or [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-002", + "assets/schemas/skill-actions.schema.json", + "executionProfile must be in action.required", + ), + ) + self.assertEqual(set(action["properties"]["executionProfile"].get("enum") or []), {"sync", "async"}) + + def test_manifest_actions_declare_execution_profile(self) -> None: + manifest = _load_actions_json(get_skill_root()) + if manifest is None: + return + offenders: list[str] = [] + for action in manifest.get("actions") or []: + profile = action.get("executionProfile") + if profile not in ("sync", "async"): + offenders.append(f"{action.get('id', '?')}: {profile!r}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-002", + "assets/actions.json", + "every action must set executionProfile to sync|async:\n" + "\n".join(offenders), + ), + ) + + +class TestPolicySkillAction003(unittest.TestCase): + def test_schema_defines_bind_and_toolbar_requires_it(self) -> None: + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + schema = json.load(f) + self.assertIn( + "bind", + schema.get("$defs", {}), + msg=_policy_msg( + "POLICY-SKILL-ACTION-003", + "assets/schemas/skill-actions.schema.json", + "$defs.bind missing", + ), + ) + action = schema["$defs"]["action"] + self.assertIn("bind", action.get("properties", {})) + then_required = (action.get("then") or {}).get("required") or [] + self.assertIn( + "bind", + then_required, + msg=_policy_msg( + "POLICY-SKILL-ACTION-003", + "assets/schemas/skill-actions.schema.json", + "action if/then must require bind when placements contains toolbar", + ), + ) + + def test_toolbar_actions_have_bind_tables(self) -> None: + import re + + table_re = re.compile(r"^[a-z][a-z0-9_]*$") + manifest = _load_actions_json(get_skill_root()) + if manifest is None: + return + offenders: list[str] = [] + for action in manifest.get("actions") or []: + if "toolbar" not in (action.get("placements") or []): + continue + bind = action.get("bind") + tables = bind.get("tables") if isinstance(bind, dict) else None + if not isinstance(tables, list) or not tables: + offenders.append(f"{action.get('id')}: missing bind.tables") + continue + if len(tables) != len(set(tables)): + offenders.append(f"{action.get('id')}: duplicate bind.tables") + for table in tables: + if not isinstance(table, str) or not table_re.fullmatch(table): + offenders.append(f"{action.get('id')}: invalid table {table!r}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-003", + "assets/actions.json", + "toolbar actions need legal bind.tables:\n" + "\n".join(offenders), + ), + ) + + +class TestPolicySkillAction004(unittest.TestCase): + def _forbidden_coupling_phrases(self) -> tuple[str, ...]: + # 拆开拼接,避免本测试源码或废止说明原文触发误报。 + return ( + "数据管理 toolbar " + "只挂", + "toolbar " + "只放 async", + "只挂 **" + "async** action", + "toolbar " + "只能 async", + "toolbar only " + "async", + ) + + def test_docs_do_not_couple_toolbar_to_async(self) -> None: + skill_root = get_skill_root() + paths = ( + os.path.join(skill_root, "references", "ACTIONS.md"), + os.path.join(skill_root, "development", "SKILL_ACTION_RUNTIME.md"), + os.path.join(skill_root, "SKILL.md"), + ) + hits: list[str] = [] + for path in paths: + text = _read_text(path) + for phrase in self._forbidden_coupling_phrases(): + if phrase in text: + hits.append(f"{os.path.relpath(path, skill_root)}: {phrase!r}") + self.assertEqual( + hits, + [], + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "docs", + "placements must stay orthogonal to executionProfile:\n" + "\n".join(hits), + ), + ) + + def test_runtime_doc_states_orthogonality(self) -> None: + text = _read_text(os.path.join(get_skill_root(), "development", "SKILL_ACTION_RUNTIME.md")) + for marker in ("正交", "sync|async", "toolbar"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "development/SKILL_ACTION_RUNTIME.md", + f"missing marker {marker!r}", + ), + ) + + def test_schema_then_only_requires_bind_not_execution_profile(self) -> None: + """toolbar→bind 的 if/then 不得顺带约束 executionProfile。""" + import json + + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + schema = json.load(f) + action = schema["$defs"]["action"] + then = action.get("then") or {} + self.assertEqual( + then.get("required"), + ["bind"], + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "assets/schemas/skill-actions.schema.json", + "action.then must only require bind", + ), + ) + self.assertNotIn("properties", then) + self.assertNotIn("else", action) + matrix_test = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py") + text = _read_text(matrix_test) + self.assertIn( + "test_schema_allows_all_placement_execution_profile_combinations", + text, + msg=_policy_msg( + "POLICY-SKILL-ACTION-004", + "tests/test_actions_manifest.py", + "missing Schema 6×2 orthogonality matrix test", + ), + ) + + +class TestPolicyArchCore001(unittest.TestCase): + def test_docs_declare_single_core_multi_entry(self) -> None: + skill_root = get_skill_root() + text = ( + _read_text(os.path.join(skill_root, "development", "DEVELOPMENT.md")) + + "\n" + + _read_text(os.path.join(skill_root, "development", "SKILL_ACTION_RUNTIME.md")) + + "\n" + + _read_text(os.path.join(skill_root, "SKILL.md")) + ) + for marker in ("单业务内核", "多入口", "domain service"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-ARCH-CORE-001", + "development docs / SKILL.md", + f"missing architecture marker {marker!r}", + ), + ) + sample = os.path.join(skill_root, "tests", "samples", "test_action_core_contract.py.sample") + self.assertTrue( + os.path.isfile(sample), + msg=_policy_msg( + "POLICY-ARCH-CORE-001", + "tests/samples/test_action_core_contract.py.sample", + "sample contract missing", + ), + ) + + +class TestPolicyDataSync001(unittest.TestCase): + def test_schema_md_declares_sync_semantics(self) -> None: + text = _read_text(os.path.join(get_skill_root(), "references", "SCHEMA.md")) + for marker in ("幂等", "空结果", "事务", "inserted", "导出当前数据表"): + self.assertIn( + marker, + text, + msg=_policy_msg( + "POLICY-DATA-SYNC-001", + "references/SCHEMA.md", + f"missing sync marker {marker!r}", + ), + ) + sample = os.path.join(get_skill_root(), "tests", "samples", "test_sync_contract.py.sample") + self.assertTrue( + os.path.isfile(sample), + msg=_policy_msg( + "POLICY-DATA-SYNC-001", + "tests/samples/test_sync_contract.py.sample", + "sample contract missing", + ), + ) + + +class TestPolicyRelease001(unittest.TestCase): + def test_release_ps1_enforces_version_alignment(self) -> None: + skill_root = get_skill_root() + path = os.path.join(skill_root, "release.ps1") + self.assertTrue( + os.path.isfile(path), + msg=_policy_msg("POLICY-RELEASE-001", "release.ps1", "file missing"), + ) + text = _read_text(path) + self.assertIn( + "Assert-SkillReleaseVersionAlignment", + text, + msg=_policy_msg( + "POLICY-RELEASE-001", + "release.ps1", + "must define Assert-SkillReleaseVersionAlignment", + ), + ) + self.assertIn( + "POLICY-RELEASE-001", + text, + msg=_policy_msg( + "POLICY-RELEASE-001", + "release.ps1", + "must reference POLICY-RELEASE-001", + ), + ) + self.assertRegex( + text, + r"Assert-SkillReleaseVersionAlignment\s+-SkillRoot", + msg=_policy_msg( + "POLICY-RELEASE-001", + "release.ps1", + "must invoke Assert-SkillReleaseVersionAlignment before tagging", + ), + ) + + +class TestPolicyDocs001(unittest.TestCase): + def test_policy_matrix_exists_and_lists_policy_ids(self) -> None: + skill_root = get_skill_root() + matrix_path = os.path.join(skill_root, "development", "POLICY_MATRIX.md") + self.assertTrue( + os.path.isfile(matrix_path), + msg=_policy_msg( + "POLICY-DOCS-001", + "development/POLICY_MATRIX.md", + "file missing", + ), + ) + text = _read_text(matrix_path) + missing = [pid for pid in POLICY_IDS if pid not in text] + self.assertEqual( + missing, + [], + msg=_policy_msg( + "POLICY-DOCS-001", + "development/POLICY_MATRIX.md", + "missing policy_id entries: " + ", ".join(missing), + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py new file mode 100644 index 0000000..30a75b4 --- /dev/null +++ b/tests/test_display_metadata.py @@ -0,0 +1,815 @@ +# -*- coding: utf-8 -*- +"""数据管理展示元数据:幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。""" +from __future__ import annotations + +import os +import sqlite3 +import unittest + +from _support import ( + DEMO_ITEMS_COLUMN_METADATA, + DEMO_ITEMS_TABLE, + IsolatedDataRoot, + get_skill_root, + seed_demo_items, +) + + +class TestDisplayMetadata(unittest.TestCase): + def test_init_db_metadata_idempotent(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + init_db() + + conn = get_conn() + try: + cur = conn.cursor() + # 本技能 init_db 建 3 张业务表(项目台账/播报记录/预警规则) + cur.execute("SELECT COUNT(*) FROM _jiangchang_tables") + self.assertEqual(int(cur.fetchone()[0]), 3) + cur.execute("SELECT COUNT(*) FROM _jiangchang_columns") + self.assertGreaterEqual(int(cur.fetchone()[0]), 35) + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" + ) + self.assertIsNone(cur.fetchone()) + finally: + conn.close() + + def test_demo_items_fixture_has_chinese_names(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + "SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = ?", + (DEMO_ITEMS_TABLE,), + ) + display_name, readonly = cur.fetchone() + self.assertEqual(display_name, "示例记录") + self.assertEqual(int(readonly), 0) + + cur.execute( + """ + SELECT column_name, display_name, editable + FROM _jiangchang_columns + WHERE table_name = ? + ORDER BY column_name + """, + (DEMO_ITEMS_TABLE,), + ) + by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()} + self.assertEqual(by_name["status"][0], "状态") + self.assertEqual(int(by_name["status"][1]), 1) + self.assertEqual(by_name["title"][0], "标题") + self.assertEqual(by_name["created_at"][0], "创建时间") + self.assertEqual(int(by_name["created_at"][1]), 0) + finally: + conn.close() + + def test_readonly_table_not_manual_create_in_host_ui(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import table_allows_manual_create + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur, readonly=1) + conn.commit() + self.assertFalse(table_allows_manual_create(cur, DEMO_ITEMS_TABLE)) + finally: + conn.close() + + def test_demo_items_physical_order_matches_pragma_cid(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import list_physical_column_names + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE) + expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA] + self.assertEqual(physical, expected) + cur.execute("PRAGMA table_info('demo_items')") + cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))] + self.assertEqual(cid_order, expected) + finally: + conn.close() + + def test_seed_sets_display_order_null(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT column_name, display_order + FROM _jiangchang_columns + WHERE table_name = ? + """, + (DEMO_ITEMS_TABLE,), + ) + for column_name, display_order in cur.fetchall(): + self.assertIsNone( + display_order, + msg=f"{column_name} display_order should be NULL", + ) + finally: + conn.close() + + def test_reseed_clears_stale_display_order(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.execute( + """ + UPDATE _jiangchang_columns + SET display_order = 99 + WHERE table_name = ? AND column_name = 'id' + """, + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT display_order FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'id' + """, + (DEMO_ITEMS_TABLE,), + ) + self.assertIsNone(cur.fetchone()[0]) + finally: + conn.close() + + def test_legacy_display_order_does_not_change_pragma_physical_order(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import list_physical_column_names + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + UPDATE _jiangchang_columns + SET display_order = 1 + WHERE table_name = ? AND column_name = 'updated_at' + """, + (DEMO_ITEMS_TABLE,), + ) + cur.execute( + """ + UPDATE _jiangchang_columns + SET display_order = 99 + WHERE table_name = ? AND column_name = 'id' + """, + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE) + self.assertEqual(physical[0], "id") + self.assertEqual(physical[-1], "updated_at") + finally: + conn.close() + + def test_metadata_tables_are_not_user_tables(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import iter_user_tables + + init_db() + conn = get_conn() + try: + tables = list(iter_user_tables(conn.cursor())) + # 本技能有 3 张业务表 + self.assertEqual( + sorted(tables), + ["alert_rules", "fund_projects", "progress_reports"], + ) + finally: + conn.close() + + def test_template_database_validation_passes(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_template_database + + init_db() + conn = get_conn() + try: + report = validate_template_database(conn) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + finally: + conn.close() + + def test_validate_rejects_sqlite_foreign_keys_on_business_tables(self) -> None: + from db.display_metadata import ( + _JIANGCHANG_COLUMNS_DDL, + _JIANGCHANG_TABLES_DDL, + ) + from db.display_metadata_validator import validate_display_metadata + + conn = sqlite3.connect(":memory:") + try: + conn.execute(_JIANGCHANG_TABLES_DDL) + conn.execute(_JIANGCHANG_COLUMNS_DDL) + conn.execute("CREATE TABLE parent_rows (id INTEGER PRIMARY KEY, title TEXT)") + conn.execute( + """ + CREATE TABLE child_rows ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL, + FOREIGN KEY (parent_id) REFERENCES parent_rows(id) + ) + """ + ) + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue( + any("FOREIGN KEY" in err or "REFERENCES" in err for err in report.errors), + msg=f"expected FK error, got: {report.errors}", + ) + self.assertTrue(any("child_rows" in err for err in report.errors)) + finally: + conn.close() + + def test_validate_missing_both_metadata_tables(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)") + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors) + self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors) + finally: + conn.close() + + def test_validate_missing_jiangchang_tables_only(self) -> None: + from db.display_metadata import _JIANGCHANG_COLUMNS_DDL + from db.display_metadata_validator import validate_display_metadata + + conn = sqlite3.connect(":memory:") + try: + conn.execute(_JIANGCHANG_COLUMNS_DDL) + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertIn("缺少数据管理元数据表 _jiangchang_tables", report.errors) + self.assertNotIn("缺少数据管理元数据表 _jiangchang_columns", report.errors) + finally: + conn.close() + + def test_validate_missing_jiangchang_columns_only(self) -> None: + from db.display_metadata import _JIANGCHANG_TABLES_DDL + from db.display_metadata_validator import validate_display_metadata + + conn = sqlite3.connect(":memory:") + try: + conn.execute(_JIANGCHANG_TABLES_DDL) + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertIn("缺少数据管理元数据表 _jiangchang_columns", report.errors) + self.assertNotIn("缺少数据管理元数据表 _jiangchang_tables", report.errors) + finally: + conn.close() + + def test_missing_table_metadata_is_reported(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_display_metadata + + init_db() + conn = get_conn() + try: + conn.execute("CREATE TABLE demo_items (id INTEGER PRIMARY KEY, title TEXT)") + conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'") + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items" in err for err in report.errors)) + finally: + conn.close() + + def test_missing_column_metadata_is_reported(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_display_metadata + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = 'status'", + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items.status" in err for err in report.errors)) + finally: + conn.close() + + def test_metadata_reference_to_missing_column_is_reported(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_display_metadata + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES (?, 'not_exists', '不存在', 1, 1, 1) + """, + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("not_exists" in err for err in report.errors)) + finally: + conn.close() + + def test_ordering_test_table_not_alphabetized(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE ordering_test ( + z_field TEXT, + id INTEGER PRIMARY KEY, + a_field TEXT, + created_at INTEGER, + updated_at INTEGER + ); + """ + ) + from db.display_metadata import list_physical_column_names + + physical = list_physical_column_names(conn.cursor(), "ordering_test") + self.assertEqual(physical, ["z_field", "id", "a_field", "created_at", "updated_at"]) + finally: + conn.close() + + def test_skill_frontmatter_validation_for_template(self) -> None: + from db.display_metadata_validator import validate_skill_frontmatter + + report = validate_skill_frontmatter(get_skill_root(), allow_template_placeholders=True) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + + def test_skill_frontmatter_rejects_english_placeholder_name(self) -> None: + import os + import tempfile + + from db.display_metadata_validator import validate_skill_frontmatter + + with tempfile.TemporaryDirectory() as tmp: + skill_md = """--- +name: My Skill +description: demo +metadata: + openclaw: + slug: query-demo-info +--- +""" + with open(os.path.join(tmp, "SKILL.md"), "w", encoding="utf-8") as f: + f.write(skill_md) + report = validate_skill_frontmatter(tmp, allow_template_placeholders=False) + self.assertFalse(report.ok) + self.assertTrue(any("name" in err for err in report.errors)) + + def test_column_order_warning_for_created_at_in_middle(self) -> None: + from db.display_metadata_validator import column_order_warnings + + warnings = column_order_warnings( + "bad_order", + ["created_at", "id", "title", "updated_at"], + ) + self.assertTrue(any("id" in item for item in warnings)) + + def test_generated_column_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE gen_demo ( + id INTEGER PRIMARY KEY, + base_value INTEGER NOT NULL, + generated_value INTEGER GENERATED ALWAYS AS (base_value * 2) STORED + ); + """ + ) + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="gen_demo", + column_name="generated_value", + display_name="生成值", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "gen_demo") + self.assertTrue(any("generated" in err for err in errors)) + finally: + conn.close() + + def test_blob_column_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute( + "CREATE TABLE blob_demo (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)" + ) + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="blob_demo", + column_name="payload", + display_name="载荷", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "blob_demo") + self.assertTrue(any("BLOB" in err for err in errors)) + finally: + conn.close() + + def test_primary_key_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE pk_demo (id INTEGER PRIMARY KEY, title TEXT)") + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="pk_demo", + column_name="id", + display_name="编号", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "pk_demo") + self.assertTrue(any("主键" in err for err in errors)) + finally: + conn.close() + + def test_readonly_table_with_editable_fields_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE warn_demo (id INTEGER PRIMARY KEY, title TEXT); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="warn_demo", display_name="警告演示", readonly=1) + upsert_column_metadata( + cur, + table_name="warn_demo", + column_name="title", + display_name="标题", + editable=1, + ) + conn.commit() + _, warnings = validate_column_permissions(cur, "warn_demo") + self.assertTrue(any("readonly=1" in item for item in warnings)) + finally: + conn.close() + + def test_writable_table_without_editable_fields_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE warn_demo2 (id INTEGER PRIMARY KEY, title TEXT); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="warn_demo2", display_name="警告演示二", readonly=0) + upsert_column_metadata( + cur, + table_name="warn_demo2", + column_name="title", + display_name="标题", + editable=0, + ) + conn.commit() + _, warnings = validate_column_permissions(cur, "warn_demo2") + self.assertTrue(any("readonly=0" in item and "editable=0" in item for item in warnings)) + finally: + conn.close() + + def test_enum_column_without_searchable_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE filter_demo (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_filter_search_metadata + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="filter_demo", display_name="筛选演示", readonly=0) + upsert_column_metadata( + cur, + table_name="filter_demo", + column_name="enabled", + display_name="是否启用", + visible=1, + searchable=0, + editable=1, + display_type="boolean", + options_json='[{"value":0,"label":"停用"},{"value":1,"label":"启用"}]', + ) + conn.commit() + _, warnings = validate_filter_search_metadata(cur, "filter_demo") + self.assertTrue(any("searchable=1" in item for item in warnings)) + finally: + conn.close() + + def test_schema_md_documents_readonly_and_seed_helpers(self) -> None: + path = os.path.join(get_skill_root(), "references", "SCHEMA.md") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("readonly = 1", text) + self.assertIn("seed_column_metadata_batch", text) + self.assertIn("valid_column_names", text) + + def test_default_visible_columns_json_upsert_and_migrate(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + upsert_table_metadata, + ) + + cur = conn.cursor() + # Simulate pre-1.0.79 table without the new column. + cur.execute( + """ + CREATE TABLE _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0, + default_sort_json TEXT + ) + """ + ) + init_display_metadata_tables(cur) + cur.execute("PRAGMA table_info('_jiangchang_tables')") + cols = {str(row[1]) for row in cur.fetchall()} + self.assertIn("default_visible_columns_json", cols) + + payload = '["stat_type","period","stage"]' + upsert_table_metadata( + cur, + table_name="timeliness_stats", + display_name="时效统计", + readonly=1, + default_visible_columns_json=payload, + ) + conn.commit() + cur.execute( + """ + SELECT default_visible_columns_json + FROM _jiangchang_tables + WHERE table_name = 'timeliness_stats' + """ + ) + self.assertEqual(cur.fetchone()[0], payload) + finally: + conn.close() + + def test_grid_help_upsert_and_migrate_from_legacy_schema(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0, + default_sort_json TEXT, + default_visible_columns_json TEXT + ) + """ + ) + cur.execute( + """ + CREATE TABLE _jiangchang_columns ( + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + display_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + searchable INTEGER NOT NULL DEFAULT 1, + editable INTEGER NOT NULL DEFAULT 1, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) + ) + """ + ) + init_display_metadata_tables(cur) + cur.execute("PRAGMA table_info('_jiangchang_tables')") + self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()}) + cur.execute("PRAGMA table_info('_jiangchang_columns')") + self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()}) + + upsert_table_metadata( + cur, + table_name="demo_items", + display_name="示例记录", + description="表单备注,不应出现在表格横幅", + grid_help="表格横幅说明", + ) + upsert_column_metadata( + cur, + table_name="demo_items", + column_name="title", + display_name="标题", + description="填标题", + grid_help="列头问号说明", + ) + upsert_table_metadata( + cur, + table_name="empty_help", + display_name="无表格说明", + ) + conn.commit() + cur.execute( + """ + SELECT description, grid_help + FROM _jiangchang_tables + WHERE table_name = 'demo_items' + """ + ) + self.assertEqual(cur.fetchone(), ("表单备注,不应出现在表格横幅", "表格横幅说明")) + cur.execute( + """ + SELECT description, grid_help + FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = 'title' + """ + ) + self.assertEqual(cur.fetchone(), ("填标题", "列头问号说明")) + cur.execute( + """ + SELECT grid_help FROM _jiangchang_tables + WHERE table_name = 'empty_help' + """ + ) + self.assertTrue(not cur.fetchone()[0]) + finally: + conn.close() + + def test_drop_deprecated_user_table_removes_physical_and_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT, + description TEXT, + sort_order INTEGER, + visible INTEGER, + readonly INTEGER + ) + """ + ) + cur.execute( + """ + CREATE TABLE _jiangchang_columns ( + table_name TEXT, + column_name TEXT, + display_name TEXT, + description TEXT, + display_order INTEGER, + visible INTEGER, + searchable INTEGER, + editable INTEGER, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) + ) + """ + ) + cur.execute( + """ + CREATE TABLE task_logs ( + id INTEGER PRIMARY KEY, + status TEXT, + created_at INTEGER, + updated_at INTEGER + ) + """ + ) + cur.execute( + "INSERT INTO _jiangchang_tables VALUES ('task_logs', '任务日志', NULL, 1, 1, 0)" + ) + cur.execute( + "INSERT INTO _jiangchang_columns VALUES " + "('task_logs', 'id', '编号', NULL, 1, 1, 1, 0, 'text', NULL)" + ) + cur.execute("INSERT INTO task_logs (status) VALUES ('success')") + + from db.display_metadata import drop_deprecated_user_table, iter_user_tables + + drop_deprecated_user_table(cur, "task_logs") + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" + ) + self.assertIsNone(cur.fetchone()) + cur.execute( + "SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'" + ) + self.assertEqual(int(cur.fetchone()[0]), 0) + self.assertNotIn("task_logs", list(iter_user_tables(cur))) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py new file mode 100644 index 0000000..e8070fe --- /dev/null +++ b/tests/test_docs_standards.py @@ -0,0 +1,737 @@ +# -*- coding: utf-8 -*- +"""文档与 pytest 配置守护:防止模板标准退化。""" +from __future__ import annotations + +import configparser +import os +import re +import unittest + +from _support import get_skill_root + + +FORBIDDEN_README_DESCRIPTION_TERMS_EN = ( + "cli", + "schema", + "references", + "development", + "playwright", + "python", + "dom", + "rpa", +) + +FORBIDDEN_README_DESCRIPTION_TERMS_ZH = ( + "Agent 参考索引", + "契约", + "数据结构", + "开发规范", +) + + +def _description_has_forbidden_term(description: str) -> str | None: + lowered = description.lower() + for term in FORBIDDEN_README_DESCRIPTION_TERMS_EN: + if term in lowered: + return term + for term in FORBIDDEN_README_DESCRIPTION_TERMS_ZH: + if term in description: + return term + return None + + +def _extract_workflow_uses_line(text: str) -> str | None: + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("uses:"): + return stripped + return None + + +class TestDocsStandards(unittest.TestCase): + def _read(self, rel_path: str) -> str: + path = os.path.join(get_skill_root(), rel_path) + with open(path, encoding="utf-8") as f: + return f.read() + + def _parse_frontmatter(self, text: str) -> str: + parts = text.split("---", 2) + if len(parts) < 2: + return "" + return parts[1] + + def test_root_readme_has_yaml_frontmatter(self) -> None: + text = self._read("README.md") + self.assertTrue(text.startswith("---"), "README.md must start with YAML frontmatter") + parts = text.split("---", 2) + self.assertGreaterEqual(len(parts), 3, "README.md frontmatter must be closed") + + def test_root_readme_frontmatter_has_description(self) -> None: + frontmatter = self._parse_frontmatter(self._read("README.md")) + self.assertIn("description:", frontmatter, "README.md frontmatter must include description") + + def test_root_readme_description_avoids_technical_index_terms(self) -> None: + frontmatter = self._parse_frontmatter(self._read("README.md")) + match = re.search(r"description:\s*(.+)", frontmatter) + self.assertIsNotNone(match, "README.md description field not found") + description = match.group(1).strip().strip("\"'") + forbidden = _description_has_forbidden_term(description) + self.assertIsNone( + forbidden, + msg=f"README.md description must not contain technical index term {forbidden!r}", + ) + + def test_description_forbidden_terms_are_case_insensitive_for_english(self) -> None: + fail_cases = ( + "CLI 契约说明", + "cli 工具入口", + "SCHEMA 定义", + "Schema 映射", + "schema 字段", + "Playwright 自动化", + "python 脚本", + "RPA 流程", + ) + pass_cases = ( + "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。", + "帮你自动整理订单并生成处理报告", + "适合每天需要重复核对的业务场景", + ) + for description in fail_cases: + with self.subTest(description=description): + self.assertIsNotNone( + _description_has_forbidden_term(description), + msg=f"expected forbidden term in {description!r}", + ) + for description in pass_cases: + with self.subTest(description=description): + self.assertIsNone( + _description_has_forbidden_term(description), + msg=f"expected no forbidden term in {description!r}", + ) + + def test_references_readme_has_no_yaml_frontmatter(self) -> None: + text = self._read("references/README.md") + self.assertFalse( + text.lstrip().startswith("---"), + "references/README.md must not contain YAML frontmatter", + ) + + def test_references_readme_has_no_description_field(self) -> None: + text = self._read("references/README.md") + self.assertNotRegex( + text, + r"(?m)^description\s*:", + msg="references/README.md must not contain description field", + ) + + def test_skill_md_requires_read_readme_first_rule(self) -> None: + text = self._read("SKILL.md") + self.assertIn("README.md", text) + self.assertTrue( + "优先读取" in text or "优先读" in text, + msg="SKILL.md must require reading README.md first for user-facing questions", + ) + + def test_skill_md_states_readme_is_for_users(self) -> None: + text = self._read("SKILL.md") + self.assertIn("README.md", text) + self.assertTrue( + any(marker in text for marker in ("面向用户", "普通用户", "用户市场")), + msg="SKILL.md must state README.md is user-facing documentation", + ) + + def test_market_tab_docs_exist(self) -> None: + root = get_skill_root() + for name in ("README.md", "TUTORIAL.md", "DEMO.md", "CHANGELOG.md"): + path = os.path.join(root, name) + self.assertTrue(os.path.isfile(path), msg=f"{name} must exist at skill root") + + def test_demo_md_frontmatter_has_demo_video_url(self) -> None: + text = self._read("DEMO.md") + self.assertTrue(text.startswith("---"), "DEMO.md must start with YAML frontmatter") + frontmatter = self._parse_frontmatter(text) + self.assertIn( + "demo_video_url:", + frontmatter, + "DEMO.md frontmatter must include demo_video_url", + ) + + def test_changelog_has_section_for_skill_version(self) -> None: + skill_fm = self._parse_frontmatter(self._read("SKILL.md")) + match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)", skill_fm) + self.assertIsNotNone(match, "SKILL.md must declare semver version") + version = match.group(1) + changelog = self._read("CHANGELOG.md") + self.assertRegex( + changelog, + rf"(?m)^##\s*\[?v?{re.escape(version)}\]?", + msg=f"CHANGELOG.md must include a ## section for version {version}", + ) + + def test_skill_md_documents_market_four_tabs(self) -> None: + text = self._read("SKILL.md") + for name in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md", "readme_md", "tutorial_md"): + self.assertIn(name, text, msg=f"SKILL.md must document market material {name}") + + def test_development_md_documents_market_four_tabs(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertIn("TUTORIAL.md", text) + self.assertIn("DEMO.md", text) + self.assertIn("CHANGELOG.md", text) + self.assertIn("demo_video_url", text) + self.assertIn("tutorial_md", text) + self.assertIn("TUTORIAL_SURFACES.md", text) + + def test_tutorial_surfaces_contract_doc_and_checklist(self) -> None: + surfaces = self._read("development/TUTORIAL_SURFACES.md") + self.assertIn("说明", surfaces) + self.assertIn("教程", surfaces) + self.assertIn("skill-detail", surfaces) + checklist = self._read("development/CHECKLIST.md") + self.assertIn("A2-7", checklist) + self.assertIn("A2-8", checklist) + self.assertIn("A2-12", checklist) + self.assertIn("TUTORIAL_SURFACES.md", checklist) + + def test_naming_md_exists_and_covers_standards(self) -> None: + text = self._read("development/NAMING.md") + self.assertIn("verb-noun-platform", text) + self.assertIn("48", text) + self.assertTrue("LEGACY" in text or "历史" in text) + self.assertTrue("verbs.txt" in text or "动词白名单" in text) + self.assertTrue("platforms.txt" in text or "平台白名单" in text) + self.assertTrue("仅适用于新创建" in text or "新技能" in text) + + def test_release_workflow_uses_main_channel(self) -> None: + text = self._read(".github/workflows/release_skill.yaml") + uses_line = _extract_workflow_uses_line(text) + self.assertIsNotNone(uses_line, "release_skill.yaml must contain a uses: line") + self.assertIn( + "client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml", + uses_line, + ) + self.assertRegex( + uses_line, + r"reusable-release-skill\.yaml@main\b", + msg="workflow uses must reference reusable-release-skill.yaml@main", + ) + + def test_rpa_md_covers_video_and_playwright_standards(self) -> None: + text = self._read("development/RPA.md") + self.assertIn("title", text) + self.assertIn("closing_title", text) + self.assertIn("中文", text) + self.assertIn("--no-sandbox", text) + self.assertIn("--disable-blink-features=AutomationControlled", text) + self.assertIn("RpaVideoSession", text) + self.assertIn("1.2.0", text) + + def test_rpa_md_forbids_rpa_helpers_import(self) -> None: + text = self._read("development/RPA.md") + self.assertIn("rpa_helpers", text) + self.assertTrue( + "不要 import" in text or "不要" in text and "import" in text, + msg="RPA.md must document forbidding rpa_helpers import", + ) + + def test_rpa_md_covers_simulator_playwright_layering(self) -> None: + text = self._read("development/RPA.md") + self.assertTrue( + "simulator_playwright" in text + or ("薄 adapter" in text and "playwright.py" in text), + msg="RPA.md must describe thin adapter + *_playwright.py layering", + ) + + def test_rpa_md_covers_industry_simulator_portal(self) -> None: + text = self._read("development/RPA.md") + self.assertTrue( + any(marker in text for marker in ("jc2009", "行业仿真", "门户")), + msg="RPA.md must mention jc2009 / industry simulator / portal gate", + ) + + def test_rpa_md_covers_web_profile_headed_and_login_split(self) -> None: + text = self._read("development/RPA.md") + self.assertIn("0.2", text) + self.assertIn("profile_dir", text) + self.assertIn("POLICY-RPA-004", text) + self.assertIn("有头", text) + self.assertTrue( + "登录不全局强制" in text or "登录 ≠ Profile" in text or "登录≠Profile" in text, + msg="RPA.md §0.2 must separate site-login from Profile requirement", + ) + + def test_account_manager_standard_is_discoverable(self) -> None: + account_text = self._read("development/ACCOUNT_MANAGER.md") + for marker in ( + "一账号一", + "profile_dir", + "lease_token", + "数据管理", + "account-manager.db", + "关闭浏览器并释放租约", + "两个以上账号", + ): + self.assertIn(marker, account_text) + for rel in ("SKILL.md", "development/README.md", "development/RPA.md"): + self.assertIn("ACCOUNT_MANAGER.md", self._read(rel), msg=f"{rel} must link account standard") + + def test_simulator_example_readme_covers_async_and_account_manager(self) -> None: + text = self._read("examples/simulator_browser_rpa/README.md") + self.assertIn("async", text.lower()) + self.assertTrue( + "account-manager" in text.lower() or "pick-web" in text or "pick_web_account" in text, + msg="simulator_browser_rpa README must mention async and account-manager", + ) + + def test_development_md_documents_scaffold_and_git_cleanup(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertTrue( + "scaffold_skill" in text or "scaffold_skill.ps1" in text, + msg="DEVELOPMENT.md must document scaffold_skill", + ) + self.assertTrue( + ("Remove-Item" in text and ".git" in text) + or ("删除" in text and ".git" in text), + msg="DEVELOPMENT.md must document deleting .git", + ) + + def test_naming_md_copy_flow_documents_scaffold_or_git_cleanup(self) -> None: + text = self._read("development/NAMING.md") + self.assertTrue( + "scaffold" in text.lower() or ("清理" in text and ".git" in text), + msg="NAMING.md §9 must mention scaffold or Git cleanup", + ) + + def test_scaffold_ps1_exists(self) -> None: + path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1") + self.assertTrue(os.path.isfile(path), msg="tools/scaffold_skill.ps1 must exist") + + def test_actions_manifest_and_docs_exist(self) -> None: + root = get_skill_root() + self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "WORK_ITEM_STATUS.md"))) + self.assertTrue( + os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json")) + ) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json") + ) + ) + self.assertTrue( + os.path.isfile( + os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json") + ) + ) + self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py"))) + self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py"))) + + def test_tools_readme_documents_actions_scaffold(self) -> None: + text = self._read("tools/README.md") + self.assertIn("actions.json", text) + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + + def test_development_md_documents_both_slug_placeholders(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + + def test_schema_md_documents_data_management_contract(self) -> None: + text = self._read("references/SCHEMA.md") + for marker in ( + "_jiangchang_tables", + "_jiangchang_columns", + "editable", + "options_json", + "demo_items", + "readonly", + "新增/编辑表单:字段说明与默认值(宿主支持)", + "表单与展示:稳定字段类型(宿主支持,须与 Action 对称)", + "datetime_unix_seconds", + "display_type: \"number\"", + "extra_column", + "{value} · {label}", + "DEFAULT", + "description", + "文件 / 图片上传", + "default_visible_columns_json", + "默认展示列", + "grid_help", + "表格说明 `grid_help`", + "表与表之间:禁止 SQLite 外键", + "禁止 SQLite 外键", + "跨表引用 lookup", + "\"multiple\": true", + "Lookup 多选", + "数据管理:列筛选与全局搜索", + "列筛选与全局搜索", + "searchable: 1", + "筛选栏控件", + "表增删改与退役", + "drop_deprecated_user_table", + "孤儿物理表", + ): + self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}") + + def test_actions_md_documents_form_schema_split(self) -> None: + text = self._read("references/ACTIONS.md") + for marker in ( + "inputSchema 与参数表单", + "表单字段类型对照(须与 CRUD 对称)", + "跨表引用:Lookup", + "display_type: \"lookup\"", + "extra_column", + "multiple", + "数据管理新增/编辑", + "_jiangchang_columns.description", + "datetime_unix_seconds", + "SCHEMA.md", + "字段条件显隐", + "visibleWhen", + "grid_help", + "列筛选", + ): + self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") + + def test_actions_md_disambiguates_visible_when_from_policy(self) -> None: + actions = self._read("references/ACTIONS.md") + policy = self._read("references/POLICY.md") + self.assertIn("字段条件显隐", actions) + self.assertIn("inputSchema.properties", actions) + self.assertTrue( + "POLICY.md" in actions and "L3" in actions, + msg="ACTIONS.md must point readers away from Policy L3 visibleWhen", + ) + self.assertTrue( + "inputSchema" in policy and "ACTIONS.md" in policy, + msg="POLICY.md must disambiguate L3 visibleWhen from Action form visibleWhen", + ) + + def test_visible_when_example_is_listed(self) -> None: + text = self._read("assets/examples/README.md") + self.assertIn("visible-when-action.example.json", text) + checklist = self._read("development/CHECKLIST.md") + self.assertIn("A4-7", checklist) + self.assertIn("visibleWhen", checklist) + self.assertIn("A4-8", checklist) + self.assertIn("revealInForm", checklist) + runtime = self._read("development/SKILL_ACTION_RUNTIME.md") + self.assertIn("visibleWhen", runtime) + self.assertIn("revealInForm", runtime) + + def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None: + forbidden = ( + "download-settlement-report-amazon", + "amazon_sim", + "#amz-email", + ) + guarded_docs = ( + "development/RPA.md", + "development/ADAPTER.md", + "development/DEVELOPMENT.md", + "development/CONFIG.md", + "development/TESTING.md", + "references/SCHEMA.md", + "references/ACTIONS.md", + "tools/README.md", + "examples/README.md", + "examples/simulator_browser_rpa/README.md", + ) + for rel in guarded_docs: + text = self._read(rel) + for fragment in forbidden: + self.assertNotIn( + fragment, + text, + msg=f"{rel} must not reference Amazon implementation {fragment!r}", + ) + + def test_adapter_md_covers_four_tiers_and_config_get(self) -> None: + text = self._read("development/ADAPTER.md") + for marker in ( + "mock", + "simulator_rpa", + "real_" + "api", + "real_" + "rpa", + "config.get", + "sibling_bridge", + ): + self.assertIn(marker, text, msg=f"ADAPTER.md missing {marker!r}") + self.assertNotIn("ALLOW_" + "REAL_" + "API", text) + self.assertNotIn("ALLOW_WRITE_ACTIONS", text) + + def test_cli_md_requires_shared_python_not_uv_run(self) -> None: + text = self._read("references/CLI.md") + self.assertIn("python-runtime", text) + self.assertIn("python {baseDir}/scripts/main.py", text) + self.assertNotIn("JIANGCHANG_PYTHON_EXE", text) + self.assertIn("禁止", text) + self.assertIn("uv run python", text) + self.assertNotRegex( + text, + r"(?m)^(?!\*\*禁止\*\*).*\*\*建议\*\*.*uv run python", + msg="CLI.md must not recommend uv run for skill scripts", + ) + + def test_cli_md_has_agent_routing_tables(self) -> None: + text = self._read("references/CLI.md") + self.assertIn("短 CLI 白名单", text) + self.assertIn("Action 强制黑名单", text) + self.assertIn("run_skill_action", text) + + def test_skill_md_requires_shared_python_not_uv_run(self) -> None: + text = self._read("SKILL.md") + self.assertIn("python {baseDir}/scripts/main.py", text) + self.assertNotIn("JIANGCHANG_PYTHON_EXE", text) + self.assertIn("uv run python", text) + self.assertIn("禁止", text) + self.assertIn("run_skill_action", text) + self.assertIn("executionProfile", text) + self.assertTrue( + "任务中心" in text and "无关" in text, + msg="SKILL.md must state Task Center is independent of entry point", + ) + + def test_skill_md_has_intent_routing_table(self) -> None: + text = self._read("SKILL.md") + self.assertIn("用户意图路由表", text) + self.assertIn("用户意图", text) + self.assertIn("准备 vs 执行", text) + + def test_skill_md_how_to_forbids_cli_paste(self) -> None: + text = self._read("SKILL.md") + self.assertIn("回答「怎么用", text) + self.assertIn("禁止", text) + self.assertTrue( + "粘贴" in text and "python" in text, + msg="SKILL.md must forbid pasting python CLI when answering how-to", + ) + self.assertTrue( + "dir" in text or "列" in text, + msg="SKILL.md must discourage directory probing for how-to answers", + ) + + def test_actions_md_has_async_side_effect_checklist(self) -> None: + text = self._read("references/ACTIONS.md") + self.assertIn("最小组合", text) + self.assertIn("confirmation.message", text) + self.assertIn("hoverHint", text) + self.assertIn("用户意图路由表", text) + + def test_actions_md_states_profile_not_entry_for_task_center(self) -> None: + text = self._read("references/ACTIONS.md") + self.assertIn("executionProfile", text) + self.assertIn("任务中心", text) + self.assertTrue( + "无关" in text or "orthogonal" in text.lower() or "正交" in text, + msg="ACTIONS.md must keep placements orthogonal to Task Center", + ) + + def test_testing_md_mentions_pytest_txt_collection_guard(self) -> None: + text = self._read("development/TESTING.md") + self.assertIn(".txt", text) + self.assertIn("pytest.ini", text) + + def test_docs_do_not_claim_1_0_11_as_current_standard(self) -> None: + for rel in ( + "SKILL.md", + "development/RUNTIME.md", + "development/RPA.md", + "development/CONFIG.md", + "development/TESTING.md", + ): + text = self._read(rel) + self.assertNotIn( + ">=1.0.11", + text.replace(" ", ""), + msg=f"{rel} still claims 1.0.11 as current minimum", + ) + + def test_scripts_has_no_legacy_example_artifacts(self) -> None: + scripts_dir = os.path.join(get_skill_root(), "scripts") + service_dir = os.path.join(scripts_dir, "service") + self.assertFalse( + os.path.isfile(os.path.join(service_dir, "example_browser_rpa.py")), + "scripts/service/example_browser_rpa.py must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(service_dir, "example_adapter")), + "scripts/service/example_adapter/ must not exist", + ) + + def test_gitignore_covers_python_runtime_artifacts(self) -> None: + text = self._read(".gitignore") + self.assertIn("__pycache__", text) + self.assertIn(".pytest_cache", text) + self.assertTrue( + "py[cod]" in text or ".pyc" in text, + msg=".gitignore must ignore Python bytecode (e.g. *.py[cod])", + ) + + def test_scripts_runtime_artifacts_not_tracked_by_git(self) -> None: + import subprocess + + try: + out = subprocess.run( + ["git", "ls-files", "scripts"], + cwd=get_skill_root(), + capture_output=True, + text=True, + check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + self.skipTest("git not available or not a git repo") + tracked = out.stdout.splitlines() + for rel in tracked: + lowered = rel.replace("\\", "/").lower() + self.assertNotIn("__pycache__", lowered, msg=f"tracked cache path: {rel}") + self.assertNotIn(".pytest_cache", lowered, msg=f"tracked cache path: {rel}") + self.assertFalse(lowered.endswith(".pyc"), msg=f"tracked .pyc file: {rel}") + + def test_examples_top_level_modes_present(self) -> None: + examples_dir = os.path.join(get_skill_root(), "examples") + for mode in ( + "real_" + "api", + "real_browser_rpa", + "simulator_api", + "simulator_browser_rpa", + "real_desktop_rpa", + "mobile_rpa", + ): + self.assertTrue( + os.path.isdir(os.path.join(examples_dir, mode)), + msg=f"examples/{mode}/ must exist", + ) + + def test_three_surface_locator_standards_are_present(self) -> None: + required = { + "development/RPA_ELEMENT_LOCATING.md": ( + "元素清单", + "expected_count", + "ERROR:RESULT_UNCONFIRMED", + "AI 编程约束", + ), + "development/RPA_BROWSER.md": ( + "Pick Locator", + "iframe", + "Shadow DOM", + "禁止模式", + ), + "development/RPA_DESKTOP.md": ( + "uiautomation", + "add-friend-wecom", + "AutomationId", + "probe-ui", + ), + "development/RPA_MOBILE.md": ( + "Appium Inspector", + "resourceId", + "Native/WebView", + "尚无团队真实成功案例", + ), + } + for rel, markers in required.items(): + text = self._read(rel) + for marker in markers: + self.assertIn(marker, text, msg=f"{rel} missing {marker!r}") + + def test_desktop_and_mobile_examples_state_validation_level(self) -> None: + desktop = self._read("examples/real_desktop_rpa/README.md") + self.assertIn("技术路线已真实验证", desktop) + self.assertIn("add-friend-wecom", desktop) + self.assertIn("dry-run", desktop) + mobile = self._read("examples/mobile_rpa/README.md") + self.assertIn("不是可复制实现", mobile) + self.assertIn("不得宣称已跑通", mobile) + + def test_examples_are_not_full_skill_repos(self) -> None: + examples_dir = os.path.join(get_skill_root(), "examples") + for mode in os.listdir(examples_dir): + mode_path = os.path.join(examples_dir, mode) + if not os.path.isdir(mode_path): + continue + self.assertFalse( + os.path.isfile(os.path.join(mode_path, "SKILL.md")), + msg=f"examples/{mode}/SKILL.md must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(mode_path, "references")), + msg=f"examples/{mode}/references/ must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(mode_path, "development")), + msg=f"examples/{mode}/development/ must not exist", + ) + self.assertFalse( + os.path.isdir(os.path.join(mode_path, ".github")), + msg=f"examples/{mode}/.github/ must not exist", + ) + + def test_examples_readme_states_copy_boundary(self) -> None: + text = self._read("examples/README.md") + self.assertIn("不是完整 skill 仓库", text) + self.assertIn("核心实现案例", text) + self.assertTrue( + "不要整包照搬" in text or "不要整包" in text, + msg="examples/README.md must warn against copying whole example tree", + ) + self.assertTrue( + "scripts" in text and ("复制" in text or "copy map" in text.lower()), + msg="examples/README.md must describe copying into scripts/", + ) + + def test_placeholder_api_examples_are_not_implementation_references(self) -> None: + for rel in (f"examples/{'real_' + 'api'}/README.md", "examples/simulator_api/README.md"): + text = self._read(rel) + self.assertTrue( + "占位" in text or "规划" in text, + msg=f"{rel} must state placeholder/planned status", + ) + self.assertTrue( + "尚未沉淀" in text or "不可作为实现参考" in text or "不可" in text, + msg=f"{rel} must warn not to use as implementation reference", + ) + + def test_docs_do_not_reference_scripts_legacy_examples(self) -> None: + forbidden_fragments = ( + "scripts/service/example_browser_rpa", + "scripts/service/example_adapter", + "example_browser_rpa.py", + "example_adapter/", + ) + doc_paths = [] + for root, _dirs, files in os.walk(get_skill_root()): + rel_root = os.path.relpath(root, get_skill_root()) + if rel_root.startswith(".git"): + continue + for name in files: + if name.endswith(".md"): + doc_paths.append(os.path.join(rel_root, name).replace("\\", "/")) + for rel in doc_paths: + text = self._read(rel) + for fragment in forbidden_fragments: + self.assertNotIn( + fragment, + text, + msg=f"{rel} must not reference legacy scripts example path {fragment!r}", + ) + + +class TestPytestIniCollection(unittest.TestCase): + def test_pytest_ini_exists_and_limits_python_files(self) -> None: + ini_path = os.path.join(get_skill_root(), "pytest.ini") + self.assertTrue(os.path.isfile(ini_path), "pytest.ini must exist at repo root") + parser = configparser.ConfigParser() + parser.read(ini_path, encoding="utf-8") + python_files = parser.get("pytest", "python_files", fallback="") + self.assertIn("test_*.py", python_files) + self.assertIn("*_test.py", python_files) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_entrypoint_subprocess.py b/tests/test_entrypoint_subprocess.py new file mode 100644 index 0000000..eb54057 --- /dev/null +++ b/tests/test_entrypoint_subprocess.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""通过 subprocess 调用 ``python scripts/main.py``,验证真实入口(非仅 import main)。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import unittest + +from _support import IsolatedDataRoot, get_skill_root + +import importlib.metadata as metadata + +from jiangchang_skill_core import version_ge +from util.constants import PLATFORM_KIT_MIN_VERSION + + +def _platform_kit_meets_template_minimum() -> bool: + try: + installed = metadata.version("jiangchang-platform-kit") + except metadata.PackageNotFoundError: + return False + return version_ge(installed, PLATFORM_KIT_MIN_VERSION) + + +def _run_main(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess[str]: + root = get_skill_root() + main_py = os.path.join(root, "scripts", "main.py") + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + env["PYTHONDONTWRITEBYTECODE"] = "1" + return subprocess.run( + [sys.executable, main_py, *args], + cwd=root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + + +class TestEntrypointSubprocess(unittest.TestCase): + @unittest.skipUnless( + _platform_kit_meets_template_minimum(), + f"installed jiangchang-platform-kit below template minimum {PLATFORM_KIT_MIN_VERSION}", + ) + def test_health_exit_zero(self) -> None: + with IsolatedDataRoot(): + cp = _run_main(["health"]) + self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout) + + def test_version_json_and_skill_slug(self) -> None: + with IsolatedDataRoot(): + cp = _run_main(["version"]) + self.assertEqual(cp.returncode, 0, msg=cp.stderr + cp.stdout) + payload = json.loads(cp.stdout.strip()) + self.assertIn("version", payload) + self.assertIn("skill", payload) + from util.constants import SKILL_SLUG + + self.assertEqual(payload["skill"], SKILL_SLUG) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fund_rules.py b/tests/test_fund_rules.py new file mode 100644 index 0000000..abc3fea --- /dev/null +++ b/tests/test_fund_rules.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +"""经费播报计算内核金标准用例(离线、确定性,as_of 固定日期)。""" +from __future__ import annotations + +import unittest +from datetime import date + +from _support import IsolatedDataRoot + +AS_OF = date(2026, 9, 13) # 基准日:2026-01-01 起止 2026-12-31 的项目序时进度 ≈ 0.7005 + + +def _project(**overrides) -> dict: + base = { + "projectNo": "TEST0001", + "projectName": "测试项目", + "projectType": "纵向", + "piName": "张三", + "department": "化材学院", + "totalApproved": 600000.0, + "totalReceived": 300000.0, + "totalSpent": 60000.0, + "frozenAmount": 0.0, + "startDate": "2026-01-01", + "endDate": "2026-12-31", + "status": "在研", + } + base.update(overrides) + return base + + +def _compute(rules: dict | None = None, **overrides) -> dict: + from service.fund_service import _DEFAULT_RULES, compute_project + + return compute_project(_project(**overrides), dict(_DEFAULT_RULES if rules is None else rules), AS_OF) + + +class TestStep1Calculation(unittest.TestCase): + def test_c2_time_progress_by_days(self) -> None: + result = _compute() + # 2026-01-01 ~ 2026-12-31 共 364 天,至 2026-09-13 过 255 天 + self.assertAlmostEqual(result["time_progress"], 255 / 364, places=4) + + def test_c1_no_fund_marks_unknown(self) -> None: + result = _compute(totalReceived=0, totalSpent=0) + self.assertEqual(result["status_tag"], "未到账") + self.assertIsNone(result["spend_rate"]) + + def test_c3_incomplete_ledger(self) -> None: + result = _compute(totalSpent=None, endDate=None) + self.assertEqual(result["status_tag"], "待补录") + c3 = next(i for i in result["items"] if i["rule_id"] == "C3") + self.assertIn("已报销金额", c3["detail"]) + + def test_spend_rate_rounded(self) -> None: + result = _compute() + self.assertEqual(result["spend_rate"], 0.2) + + +class TestStep2Diagnosis(unittest.TestCase): + def test_d1_lag(self) -> None: + result = _compute() + self.assertEqual(result["status_tag"], "严重滞后") + d1 = next(i for i in result["items"] if i["rule_id"] == "D1") + self.assertIn("低于序时进度", d1["detail"]) + + def test_d3_normal(self) -> None: + result = _compute(totalReceived=400000.0, totalSpent=350000.0) + self.assertEqual(result["status_tag"], "正常") # dev ≈ +0.175 + + def test_d2_overshoot(self) -> None: + result = _compute(totalReceived=400000.0, totalSpent=380000.0) + self.assertEqual(result["status_tag"], "支出异常偏快") # dev ≈ +0.2495 + + def test_threshold_boundary_not_lag_when_equal(self) -> None: + # 偏差恰等于 -0.10 时不判滞后(严格小于) + result = _compute(rules={"lag_threshold": 0.5005, "overshoot_threshold": 5.0}) + self.assertEqual(result["status_tag"], "正常") + + def test_rules_override_changes_verdict(self) -> None: + result = _compute(rules={"lag_threshold": 0.60, "overshoot_threshold": 0.20}) + # dev ≈ -0.5005 未超过 -0.60 → 正常 + self.assertEqual(result["status_tag"], "正常") + + +class TestStep3Reminders(unittest.TestCase): + def test_n1_pending_aging_overdue(self) -> None: + result = _compute(frozenAmount=15000.0, frozenSince="2026-04-10") + n1 = next(i for i in result["items"] if i["rule_id"] == "N1") + self.assertEqual((AS_OF - date(2026, 4, 10)).days, 156) + self.assertIn("156", n1["detail"]) + self.assertIn("冲销", n1["suggestion"]) + + def test_n1_pending_within_window_silent(self) -> None: + result = _compute(frozenAmount=15000.0, frozenSince="2026-08-01") + self.assertFalse(any(i["rule_id"] == "N1" for i in result["items"])) + + def test_n1_pending_since_missing_asks_backfill(self) -> None: + result = _compute(frozenAmount=15000.0) + n1 = next(i for i in result["items"] if i["rule_id"] == "N1") + self.assertIn("补录", n1["suggestion"]) + + def test_n2_closing_soon_warns(self) -> None: + result = _compute(endDate="2026-10-15", totalReceived=100000.0, totalSpent=5000.0) + n2 = next(i for i in result["items"] if i["rule_id"] == "N2") + self.assertIn("32", n2["detail"]) + + def test_n2_period_over_asks_settlement(self) -> None: + result = _compute(endDate="2026-08-31", totalReceived=100000.0, totalSpent=90000.0) + n2 = next(i for i in result["items"] if i["rule_id"] == "N2") + self.assertIn("结题决算", n2["name"]) + + +class TestSummaryText(unittest.TestCase): + def test_three_part_summary_no_double_period(self) -> None: + result = _compute(frozenAmount=15000.0, frozenSince="2026-04-10") + lines = result["summary"].split("\n") + self.assertEqual(len(lines), 3) + self.assertTrue(lines[0].startswith("您负责的《测试项目》")) + self.assertIn("执行状态严重滞后", lines[0]) + self.assertTrue(lines[1].startswith("诊断:")) + self.assertTrue(lines[2].startswith("建议:")) + self.assertNotIn("。。", result["summary"]) + self.assertNotIn(";。", result["summary"]) + + def test_summary_numbers_from_ledger_only(self) -> None: + result = _compute() + self.assertIn("60.00 万元", result["summary"]) + self.assertIn("20.0%", result["summary"]) + + +class TestBroadcastKernel(unittest.TestCase): + def setUp(self) -> None: + self._root = IsolatedDataRoot() + self._root.__enter__() + from db.connection import init_db + + init_db() + + def tearDown(self) -> None: + self._root.__exit__(None, None, None) + + def _run(self, projects): + from service.fund_service import run_broadcast + + return run_broadcast(projects, AS_OF) + + def test_broadcast_writes_reports_and_brief(self) -> None: + result = self._run([ + _project(), + _project(projectNo="TEST0002", totalReceived=400000.0, totalSpent=350000.0), + ]) + self.assertEqual(result["count"], 2) + self.assertEqual(result["status_counts"], {"严重滞后": 1, "正常": 1}) + self.assertTrue(result["brief_file"].endswith(".html")) + for report in result["reports"]: + self.assertTrue(report["report_file"].endswith(".html")) + with open(report["report_file"], encoding="utf-8") as f: + html_text = f.read() + self.assertIn("经费执行简报", html_text) + self.assertIn("以财务系统账面为准", html_text) + + def test_empty_projects_raises(self) -> None: + with self.assertRaises(ValueError): + self._run([]) + + def test_monthly_brief_contains_status_rows(self) -> None: + result = self._run([_project()]) + with open(result["brief_file"], encoding="utf-8") as f: + html_text = f.read() + self.assertIn("月度", html_text) + self.assertIn("bar-fill fill-spend", html_text) + + def test_status_ranking_in_summary(self) -> None: + result = self._run([ + _project(), + _project(projectNo="TEST0002", totalReceived=400000.0, totalSpent=350000.0), + ]) + brief_html = open(result["brief_file"], encoding="utf-8").read() + self.assertLess(brief_html.find("严重滞后"), brief_html.find("正常")) + + +class TestProjectService(unittest.TestCase): + def setUp(self) -> None: + self._root = IsolatedDataRoot() + self._root.__enter__() + from db.connection import init_db + + init_db() + + def tearDown(self) -> None: + self._root.__exit__(None, None, None) + + def _upsert(self, **overrides): + from service.project_service import upsert_project + + fields = {"project_no": "TEST0001", "total_received": 300000.0, "total_spent": 60000.0} + fields.update(overrides) + return upsert_project(fields) + + def test_upsert_create_then_partial_update(self) -> None: + payload = self._upsert() + self.assertEqual(payload["action"], "created") + payload = self._upsert(total_spent=90000.0) + self.assertEqual(payload["action"], "updated") + from service.project_service import list_projects + + row = list_projects()[0] + self.assertEqual(row["total_spent"], 90000.0) + self.assertEqual(row["total_received"], 300000.0) # 未提供字段不覆盖 + self.assertEqual(row["project_name"], None) # 从未提供 + + def test_upsert_validations(self) -> None: + with self.assertRaises(ValueError): + self._upsert(project_no="") + with self.assertRaises(ValueError): + self._upsert(total_received=-1) + with self.assertRaises(ValueError): + self._upsert(start_date="2026/13/40") + with self.assertRaises(ValueError): + self._upsert(start_date="2026-01-01", end_date="2025-01-01") + with self.assertRaises(ValueError): + self._upsert(status="草稿") + + def test_import_projects(self) -> None: + from service.project_service import import_projects + + payload = import_projects([ + _project(), + _project(projectNo="TEST0002"), + ]) + self.assertEqual(payload["created"], 2) + payload = import_projects([_project(totalSpent=80000.0)]) + self.assertEqual(payload["updated"], 1) + + def test_set_rule_and_effect(self) -> None: + from service.project_service import set_rule, view_rules + + payload = set_rule("lag_threshold", 0.60) + self.assertEqual(payload["threshold_value"], 0.60) + rules = {r["rule_key"]: r["threshold_value"] for r in view_rules()} + self.assertEqual(rules["lag_threshold"], 0.60) + with self.assertRaises(ValueError): + set_rule("unknown_key", 0.5) + with self.assertRaises(ValueError): + set_rule("lag_threshold", -1) + + def test_stats(self) -> None: + from service.project_service import import_projects, stats + + import_projects([_project()]) + from service.fund_service import run_broadcast + + run_broadcast([_project()], AS_OF) + payload = stats() + self.assertEqual(payload["project_total"], 1) + self.assertEqual(payload["reports_by_tag"].get("严重滞后"), 1) + self.assertTrue(payload["lag_top"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_health_runtime.py b/tests/test_health_runtime.py new file mode 100644 index 0000000..ceae90b --- /dev/null +++ b/tests/test_health_runtime.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""health / runtime 诊断:验证委托 platform-kit 公共 API。""" +from __future__ import annotations + +import io +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from _support import IsolatedDataRoot, get_skill_root, platform_kit_version_patch + +import jiangchang_skill_core +from cli.app import main +from jiangchang_skill_core import ( + collect_runtime_diagnostics, + format_runtime_health_lines, + is_jiangchang_skill_core_from_skill_tree, + runtime_diagnostics_dict, +) +from util.config_bootstrap import bootstrap_skill_config +from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG +from util.runtime_paths import get_skill_root as skill_root_path + + +def _collect_diag(): + return collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=skill_root_path(), + ) + + +class TestHealthRuntimeDiagnostics(unittest.TestCase): + def test_collect_runtime_diagnostics_fields(self) -> None: + with IsolatedDataRoot(user_id="_health_rt"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + diag = _collect_diag() + payload = runtime_diagnostics_dict(diag) + for key in ( + "skill_slug", + "python_executable", + "platform_kit_version", + "jiangchang_skill_core_file", + "resolved_data_root", + "media_assets_root", + "ffmpeg_available", + "background_music_mp3_count", + "runtime_issues", + ): + self.assertIn(key, payload) + self.assertEqual(diag.skill_slug, SKILL_SLUG) + self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION) + + def test_format_runtime_health_lines_contains_core_fields(self) -> None: + diag = _collect_diag() + text = "\n".join(format_runtime_health_lines(diag)) + self.assertIn("skill_slug:", text) + self.assertIn("python_executable:", text) + self.assertIn("platform_kit_version:", text) + self.assertIn("jiangchang_skill_core_file:", text) + self.assertIn("media_assets_root:", text) + self.assertIn("background_music_mp3_count:", text) + + def test_health_cli_prints_runtime_diagnostics(self) -> None: + with IsolatedDataRoot(user_id="_health_cli"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + bootstrap_skill_config() + buf = io.StringIO() + with platform_kit_version_patch(), redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + out = buf.getvalue() + self.assertEqual(rc, 0, msg=out) + self.assertIn("python_executable:", out) + self.assertIn("platform_kit_version:", out) + self.assertIn("jiangchang_skill_core_file:", out) + self.assertIn("media_assets_root:", out) + self.assertIn("background_music_mp3_count:", out) + self.assertIn("env_path:", out) + self.assertIn("example_path:", out) + + def test_health_fails_when_platform_kit_missing(self) -> None: + with IsolatedDataRoot(user_id="_health_no_pkg"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + bootstrap_skill_config() + with patch( + "jiangchang_skill_core.runtime_diagnostics._platform_kit_version", + return_value=None, + ): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = main(["health"]) + self.assertEqual(rc, 1) + self.assertIn("platform_kit_not_installed", buf.getvalue()) + + def test_background_music_issue_is_warning_not_fatal(self) -> None: + from jiangchang_skill_core.media_assets import MediaAssetsStatus + + with IsolatedDataRoot(user_id="_health_music_warn"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "1" + media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets" + ffmpeg_path = media_root / "tools" / "ffmpeg.exe" + ffmpeg_path.parent.mkdir(parents=True, exist_ok=True) + ffmpeg_path.write_bytes(b"x" * 256) + status = MediaAssetsStatus( + root=media_root, + exists=True, + ready=False, + source="local", + warnings=["music_dir_missing"], + ffmpeg_path=ffmpeg_path, + music_dir=None, + ) + music_probe = { + "music_root": None, + "mp3_count": 0, + "usable_count": 0, + "sample_path": None, + "issue": "music_dir_missing", + } + with patch( + "jiangchang_skill_core.runtime_diagnostics.probe_media_assets", + return_value=status, + ), patch( + "jiangchang_skill_core.runtime_diagnostics.probe_background_music", + return_value=music_probe, + ), platform_kit_version_patch(): + diag = _collect_diag() + music_issues = [i for i in diag.issues if i.code == "background_music_unavailable"] + self.assertTrue(music_issues) + self.assertEqual(music_issues[0].severity, "warning") + self.assertFalse(diag.has_fatal_issues) + + def test_skill_tree_core_load_detected_as_issue(self) -> None: + skill_root = get_skill_root() + fake_core = os.path.join( + skill_root, "scripts", "fake_pkg", "jiangchang_skill_core", "__init__.py" + ) + self.assertTrue( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, core_file=fake_core + ) + ) + self.assertFalse( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, + core_file=jiangchang_skill_core.__file__, + ) + ) + + def test_skill_tree_core_load_issue_in_diagnostics(self) -> None: + skill_root = get_skill_root() + fake_core = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py") + with patch.object(jiangchang_skill_core, "__file__", fake_core): + diag = _collect_diag() + codes = diag.issue_codes() + self.assertIn("jiangchang_skill_core_loaded_from_skill_tree", codes) + messages = [issue.message for issue in diag.issues] + self.assertTrue(any("skill tree" in msg.lower() for msg in messages)) + + +class TestHealthRuntimeWithFakeMedia(unittest.TestCase): + def test_probe_counts_mp3_without_download(self) -> None: + with IsolatedDataRoot(user_id="_health_media"): + media_root = Path(os.environ["JIANGCHANG_DATA_ROOT"]) / "shared" / "media-assets" + music_dir = media_root / "music" + music_dir.mkdir(parents=True) + (music_dir / "demo.mp3").write_bytes(b"\x00" * 1024) + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + diag = _collect_diag() + self.assertGreaterEqual(diag.background_music_mp3_count, 1) + self.assertGreaterEqual(diag.background_music_usable_count, 1) + self.assertIsNone(diag.background_music_issue) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_html_page_service.py b/tests/test_html_page_service.py new file mode 100644 index 0000000..06100d3 --- /dev/null +++ b/tests/test_html_page_service.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""HTML 报告页薄写入 helper。""" + +from __future__ import annotations + +import os +import unittest + +from _support import IsolatedDataRoot + +from service.html_page_service import ( + HOST_HTML_RENDER_ACTION_ID, + load_html_template, + render_html_placeholders, + write_html_page, +) + + +class TestHtmlPageService(unittest.TestCase): + def test_host_action_id_is_stable(self) -> None: + self.assertEqual(HOST_HTML_RENDER_ACTION_ID, "render-html") + + def test_write_html_page_atomic_under_html_dir(self) -> None: + with IsolatedDataRoot() as tmp: + path = write_html_page( + "example-report.html", + "<!DOCTYPE html><html><head><title>示例ok", + ) + self.assertTrue(os.path.isfile(path)) + norm = path.replace("\\", "/") + self.assertIn("/html/example-report.html", norm) + self.assertTrue(os.path.normcase(os.path.normpath(path)).startswith( + os.path.normcase(os.path.normpath(tmp)), + )) + self.assertFalse(os.path.isfile(path + ".tmp")) + with open(path, encoding="utf-8") as f: + self.assertIn("示例", f.read()) + + def test_load_and_fill_example_template(self) -> None: + with IsolatedDataRoot(): + template = load_html_template("assets/html/example-report.html") + page = render_html_placeholders( + template, + { + "TITLE": "示例报告", + "GENERATED_AT": "2026-08-14 15:49:00", + "BODY": "

hello

", + }, + ) + self.assertIn("示例报告", page) + self.assertIn("报告生成于 · 2026-08-14 15:49:00", page) + self.assertIn("

hello

", page) + self.assertNotIn("{{TITLE}}", page) + dest = write_html_page("example-report.html", page) + self.assertTrue(os.path.isfile(dest)) + + def test_rejects_unsafe_filename(self) -> None: + with IsolatedDataRoot(): + with self.assertRaises(ValueError): + write_html_page("../x.html", "") + with self.assertRaises(ValueError): + write_html_page("note.txt", "") + + def test_example_report_shell_embedded_preview_layout(self) -> None: + """守护 example-report.html:数据管理 iframe 预览布局基线不退化。""" + template = load_html_template("assets/html/example-report.html") + self.assertIn("overflow-x: hidden", template) + self.assertIn("min-width: 0", template) + self.assertIn("table-layout: fixed", template) + self.assertIn("overflow-wrap: anywhere", template) + self.assertNotIn("max-width:", template) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_no_rpa_helpers_import.py b/tests/test_no_rpa_helpers_import.py new file mode 100644 index 0000000..3707bf4 --- /dev/null +++ b/tests/test_no_rpa_helpers_import.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""禁止在模板主骨架 scripts/ 中 import account-manager 内部 RPA 辅助模块。""" +from __future__ import annotations + +import os +import re +import unittest + +from _support import get_skill_root + +FORBIDDEN_PATTERNS = ( + re.compile(r"\brpa_helpers\b"), + re.compile(r"\binject_account_manager_scripts_path\b"), + re.compile(r"\bget_account_credential\b"), +) + + +class TestNoRpaHelpersImport(unittest.TestCase): + def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None: + scripts_dir = os.path.join(get_skill_root(), "scripts") + violations: list[str] = [] + for root, _dirs, files in os.walk(scripts_dir): + for name in files: + if not name.endswith(".py"): + continue + rel = os.path.relpath(os.path.join(root, name), scripts_dir) + text = open(os.path.join(root, name), encoding="utf-8").read() + for pattern in FORBIDDEN_PATTERNS: + if pattern.search(text): + violations.append(f"{rel}: {pattern.pattern}") + self.assertEqual( + violations, + [], + msg="Forbidden account-manager internal imports in scripts/:\n" + "\n".join(violations), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_platform_import.py b/tests/test_platform_import.py new file mode 100644 index 0000000..05a3203 --- /dev/null +++ b/tests/test_platform_import.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""防回归:公共能力必须从共享 platform-kit 导入,而非技能 vendored 副本。""" +from __future__ import annotations + +import importlib.metadata as metadata +import os +import re +import sys +import unittest + +from _support import get_scripts_dir, get_skill_root + + +def _parse_platform_kit_min_version(skill_md_text: str) -> str: + parts = skill_md_text.split("---", 2) + if len(parts) < 2: + raise ValueError("missing frontmatter") + m = re.search( + r"platform_kit_min_version:\s*[\"']?([^\"'\s]+)[\"']?", + parts[1], + ) + if not m: + raise ValueError("platform_kit_min_version not found") + return m.group(1).strip() + + +def _requirement_dependency_lines(req_text: str) -> list[str]: + return [ + line.strip() + for line in req_text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +class TestPlatformImportSource(unittest.TestCase): + def test_jiangchang_skill_core_not_from_skill_scripts(self) -> None: + scripts_dir = os.path.abspath(get_scripts_dir()) + skill_root = os.path.abspath(get_skill_root()) + vendored = os.path.join(scripts_dir, "jiangchang_skill_core") + + self.assertFalse( + os.path.isdir(vendored), + f"vendored package must not exist: {vendored}", + ) + + import jiangchang_skill_core + import jiangchang_skill_core.rpa.video_session as video_session + + core_file = os.path.abspath(jiangchang_skill_core.__file__) + video_file = os.path.abspath(video_session.__file__) + + self.assertNotIn( + scripts_dir.lower(), + core_file.lower(), + msg=f"jiangchang_skill_core loaded from skill scripts: {core_file}", + ) + self.assertNotIn( + skill_root.lower(), + core_file.lower(), + msg=f"jiangchang_skill_core loaded from skill tree: {core_file}", + ) + self.assertNotIn( + scripts_dir.lower(), + video_file.lower(), + msg=f"video_session loaded from skill scripts: {video_file}", + ) + + from jiangchang_skill_core.rpa.video_session import RpaVideoSession + + self.assertTrue(callable(RpaVideoSession)) + + def test_skill_tree_load_issue_helper(self) -> None: + from jiangchang_skill_core import is_jiangchang_skill_core_from_skill_tree + + skill_root = os.path.abspath(get_skill_root()) + fake = os.path.join(skill_root, "scripts", "jiangchang_skill_core", "__init__.py") + self.assertTrue( + is_jiangchang_skill_core_from_skill_tree(skill_root=skill_root, core_file=fake) + ) + self.assertFalse( + is_jiangchang_skill_core_from_skill_tree( + skill_root=skill_root, core_file=sys.executable + ) + ) + + def test_platform_kit_min_version_is_at_least_declared(self) -> None: + from jiangchang_skill_core import version_ge + from util.constants import PLATFORM_KIT_MIN_VERSION + + self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.11") + + md_path = os.path.join(get_skill_root(), "SKILL.md") + with open(md_path, encoding="utf-8") as f: + md = f.read() + self.assertEqual(_parse_platform_kit_min_version(md), "1.2.11") + + req_path = os.path.join(get_skill_root(), "requirements.txt") + with open(req_path, encoding="utf-8") as f: + req = f.read() + dep_lines = _requirement_dependency_lines(req) + dep_body = "\n".join(dep_lines).lower() + self.assertNotIn("jiangchang-platform-kit", dep_body) + self.assertNotIn("playwright", dep_body) + + try: + installed = metadata.version("jiangchang-platform-kit") + except metadata.PackageNotFoundError: + self.skipTest("jiangchang-platform-kit not installed in current interpreter") + if not version_ge(installed, PLATFORM_KIT_MIN_VERSION): + self.skipTest( + f"installed jiangchang-platform-kit {installed!r} < required " + f"{PLATFORM_KIT_MIN_VERSION!r}; upgrade shared runtime to enforce this check" + ) + self.assertTrue( + version_ge(installed, PLATFORM_KIT_MIN_VERSION), + msg=f"installed {installed!r} < required {PLATFORM_KIT_MIN_VERSION!r}", + ) + + def test_collect_runtime_diagnostics_importable(self) -> None: + from jiangchang_skill_core import collect_runtime_diagnostics + + self.assertTrue(callable(collect_runtime_diagnostics)) + + def test_main_import_smoke(self) -> None: + scripts_dir = get_scripts_dir() + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + + from cli.app import build_parser + + parser = build_parser() + self.assertIsNotNone(parser) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_packaging_constraints.py b/tests/test_release_packaging_constraints.py new file mode 100644 index 0000000..93c4a4d --- /dev/null +++ b/tests/test_release_packaging_constraints.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。""" +from __future__ import annotations + +import os +import unittest + +from _support import get_skill_root + +SCRIPTS_MAX_LINES = 1000 +SCRIPTS_RECOMMENDED_MAX_LINES = 900 + +TEXT_EXTENSIONS = frozenset( + { + ".py", + ".md", + ".yml", + ".yaml", + ".json", + ".toml", + ".ini", + ".txt", + ".ps1", + ".sh", + } +) + +SPECIAL_TEXT_FILES = frozenset({".env.example", ".gitignore"}) + +SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".pytest_cache", + "rpa-artifacts", + "videos", + } +) + +BINARY_EXTENSIONS = frozenset( + { + ".db", + ".sqlite", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".mp4", + ".zip", + ".tar", + ".gz", + } +) + + +def _is_text_candidate(path: str) -> bool: + basename = os.path.basename(path) + if basename in SPECIAL_TEXT_FILES: + return True + ext = os.path.splitext(basename)[1].lower() + if ext in BINARY_EXTENSIONS: + return False + return ext in TEXT_EXTENSIONS + + +def _iter_text_files(skill_root: str) -> list[str]: + found: list[str] = [] + for root, dirnames, filenames in os.walk(skill_root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + full = os.path.join(root, name) + if _is_text_candidate(full): + found.append(full) + return sorted(found) + + +def _iter_scripts_py_files(skill_root: str) -> list[str]: + scripts_dir = os.path.join(skill_root, "scripts") + found: list[str] = [] + if not os.path.isdir(scripts_dir): + return found + for root, dirnames, filenames in os.walk(scripts_dir): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + if name.endswith(".py"): + found.append(os.path.join(root, name)) + return sorted(found) + + +class TestReleasePackagingConstraints(unittest.TestCase): + def test_scripts_py_files_under_pyarmor_line_limit(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_scripts_py_files(skill_root): + with open(path, encoding="utf-8") as f: + line_count = sum(1 for _ in f) + if line_count >= SCRIPTS_MAX_LINES: + rel = os.path.relpath(path, skill_root).replace("\\", "/") + offenders.append( + f"{rel}: {line_count} lines (limit < {SCRIPTS_MAX_LINES})" + ) + self.assertEqual( + offenders, + [], + msg=( + "PyArmor trial/free mode may fail when a single Python file reaches " + f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. " + f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. " + f"Offenders: {offenders}" + ), + ) + + def test_text_files_are_utf8_without_bom(self) -> None: + skill_root = get_skill_root() + bom_failures: list[str] = [] + decode_failures: list[str] = [] + feff_failures: list[str] = [] + + for path in _iter_text_files(skill_root): + rel = os.path.relpath(path, skill_root).replace("\\", "/") + with open(path, "rb") as f: + raw = f.read() + + if raw.startswith(b"\xef\xbb\xbf"): + bom_failures.append(rel) + continue + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + decode_failures.append(f"{rel}: {exc}") + continue + + if "\ufeff" in text: + feff_failures.append(rel) + + self.assertEqual( + bom_failures, + [], + msg=( + "Files must be saved as UTF-8 without BOM (remove UTF-8 BOM prefix). " + f"Offenders: {bom_failures}" + ), + ) + self.assertEqual( + decode_failures, + [], + msg=( + "Files must be valid UTF-8 without BOM. " + f"Offenders: {decode_failures}" + ), + ) + self.assertEqual( + feff_failures, + [], + msg=( + "Files must not contain U+FEFF; save as UTF-8 without BOM. " + f"Offenders: {feff_failures}" + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_diagnostics_integration.py b/tests/test_runtime_diagnostics_integration.py new file mode 100644 index 0000000..1804760 --- /dev/null +++ b/tests/test_runtime_diagnostics_integration.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""Architecture guard: runtime diagnostics must come from platform-kit.""" +from __future__ import annotations + +import os +import unittest + +from _support import get_scripts_dir, get_skill_root + +_FORBIDDEN_DEFINITIONS = ( + "class RuntimeDiagnostics", + "class RuntimeIssue", + "def collect_runtime_diagnostics", + "def format_runtime_health_lines", + "def runtime_diagnostics_dict", + "def _is_git_lfs_pointer", + "def _is_usable_audio_file", + "def _probe_background_music", +) + + +class TestRuntimeDiagnosticsIntegration(unittest.TestCase): + def test_no_vendored_jiangchang_skill_core(self) -> None: + vendored = os.path.join(get_scripts_dir(), "jiangchang_skill_core") + self.assertFalse(os.path.isdir(vendored), vendored) + + def test_runtime_diagnostics_module_removed(self) -> None: + legacy = os.path.join(get_scripts_dir(), "util", "runtime_diagnostics.py") + self.assertFalse(os.path.isfile(legacy), legacy) + + def test_util_py_files_do_not_reimplement_runtime_diagnostics(self) -> None: + util_dir = os.path.join(get_scripts_dir(), "util") + for name in os.listdir(util_dir): + if not name.endswith(".py"): + continue + path = os.path.join(util_dir, name) + with open(path, encoding="utf-8") as f: + text = f.read() + for forbidden in _FORBIDDEN_DEFINITIONS: + self.assertNotIn( + forbidden, + text, + msg=f"{name} must not define {forbidden}", + ) + + def test_task_service_imports_platform_kit_diagnostics(self) -> None: + path = os.path.join(get_scripts_dir(), "service", "task_service.py") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("from jiangchang_skill_core import", text) + self.assertIn("collect_runtime_diagnostics", text) + self.assertIn("format_runtime_health_lines", text) + self.assertIn("PLATFORM_KIT_MIN_VERSION", text) + self.assertNotIn("from util.runtime_diagnostics import", text) + + def test_collect_runtime_diagnostics_callable_from_platform_kit(self) -> None: + from jiangchang_skill_core import collect_runtime_diagnostics + + from util.constants import PLATFORM_KIT_MIN_VERSION, SKILL_SLUG + from util.runtime_paths import get_skill_root + + diag = collect_runtime_diagnostics( + skill_slug=SKILL_SLUG, + platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, + skill_root=get_skill_root(), + ) + self.assertEqual(diag.skill_slug, SKILL_SLUG) + self.assertEqual(diag.platform_kit_min_version, PLATFORM_KIT_MIN_VERSION) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py new file mode 100644 index 0000000..fdea9b8 --- /dev/null +++ b/tests/test_runtime_paths.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""util.runtime_paths:技能根、DB 路径、数据目录均落在隔离数据根下。""" +from __future__ import annotations + +import os +import unittest + +from _support import IsolatedDataRoot, get_skill_root + +_FAKE_REAL_ROOT = r"D:\jiangchang-data" + + +def _legacy_env_key(suffix: str) -> str: + return "CL" + "AW_" + suffix + + +class TestRuntimePaths(unittest.TestCase): + def test_paths_under_isolated_root(self) -> None: + win_default = r"D:\jiangchang-data" + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + + skill_root = rp.get_skill_root() + self.assertTrue(os.path.isdir(skill_root)) + self.assertTrue(os.path.isfile(os.path.join(skill_root, "SKILL.md"))) + + db_path = rp.get_db_path() + self.assertTrue(os.path.normpath(db_path).startswith(os.path.normpath(tmp))) + self.assertNotIn(win_default, db_path) + + data_dir = rp.get_skill_data_dir() + self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp))) + self.assertIn("_test", data_dir.replace("\\", "/")) + self.assertNotIn(win_default, data_dir) + + self.assertEqual(get_skill_root(), skill_root) + + def test_isolation_overrides_prefixed_real_data_root(self) -> None: + """先设假数据根,再进入隔离;get_db_path 必须落在 tempdir。""" + saved = { + k: os.environ.get(k) + for k in ("JIANGCHANG_DATA_ROOT", "JIANGCHANG_USER_ID") + } + try: + os.environ["JIANGCHANG_DATA_ROOT"] = _FAKE_REAL_ROOT + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + db_path = rp.get_db_path() + norm_db = os.path.normcase(os.path.normpath(db_path)) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(norm_db.startswith(norm_tmp), msg=f"db_path={db_path!r} tmp={tmp!r}") + norm_fake = os.path.normcase(os.path.normpath(_FAKE_REAL_ROOT)) + self.assertFalse( + norm_db.startswith(norm_fake), + msg=f"db_path must not be under fake root: {db_path!r}", + ) + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_legacy_env_vars_do_not_override_canonical(self) -> None: + with IsolatedDataRoot() as tmp: + os.environ[_legacy_env_key("DATA_ROOT")] = _FAKE_REAL_ROOT + os.environ[_legacy_env_key("USER_ID")] = "legacy-user" + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + data_dir = rp.get_skill_data_dir() + self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp))) + self.assertIn("_test", data_dir.replace("\\", "/")) + + def test_resolve_data_path_defaults_under_skill_data_dir(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + path = rp.resolve_data_path(None, "output", create=True) + norm = os.path.normcase(os.path.normpath(path)) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(norm.startswith(norm_tmp)) + self.assertTrue(norm.endswith(os.path.normcase(os.path.join("output")))) + + def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + os.environ["SKILL_OUTPUT_DIR"] = "output/videos" + from jiangchang_skill_core import config + + config.reset_cache() + importlib.reload(rp) + path = rp.resolve_data_path("SKILL_OUTPUT_DIR", "output", create=False) + norm = os.path.normcase(os.path.normpath(path)) + self.assertIn(os.path.normcase("output"), norm) + self.assertIn(os.path.normcase("videos"), norm) + self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) + os.environ.pop("SKILL_OUTPUT_DIR", None) + config.reset_cache() + + def test_resolve_input_path_relative_under_input(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + resolved = rp.resolve_input_path("urls.txt", create_parent=True) + norm = os.path.normcase(os.path.normpath(resolved)) + self.assertIn(os.path.normcase("input"), norm) + self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) + + def test_get_input_and_output_dirs(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + input_dir = rp.get_input_dir(create=True) + output_dir = rp.get_output_dir(create=True) + html_dir = rp.get_html_dir(create=True) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(html_dir)).startswith(norm_tmp)) + self.assertTrue(input_dir.replace("\\", "/").endswith("/input")) + self.assertTrue(output_dir.replace("\\", "/").endswith("/output")) + self.assertTrue(html_dir.replace("\\", "/").endswith("/html")) + + def test_list_resolved_data_paths_keys(self) -> None: + with IsolatedDataRoot(): + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + paths = rp.list_resolved_data_paths() + for key in ( + "skill_data_dir", + "input_dir", + "output_dir", + "html_dir", + ): + self.assertIn(key, paths) + self.assertTrue(paths[key]) + self.assertNotIn("downloads_dir", paths) + self.assertNotIn("imports_dir", paths) + self.assertNotIn("exports_dir", paths) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scaffold_guard.py b/tests/test_scaffold_guard.py new file mode 100644 index 0000000..0722e43 --- /dev/null +++ b/tests/test_scaffold_guard.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""skill-template 脚手架与 Git 防串库守护。""" +from __future__ import annotations + +import json +import os +import tempfile +import unittest + +from _support import get_skill_root + +TEMPLATE_MARKER = ".openclaw-skill-template" +TEMPLATE_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug" +SCAFFOLD_TEST_SLUG = "query-demo-records" +SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records" + + +def _read_skill_slug() -> str: + constants_path = os.path.join(get_skill_root(), "scripts", "util", "constants.py") + with open(constants_path, encoding="utf-8") as f: + for line in f: + if line.strip().startswith("SKILL_SLUG"): + _, _, value = line.partition("=") + return value.strip().strip('"').strip("'") + return "" + + +class TestScaffoldGuard(unittest.TestCase): + def test_template_repo_has_marker(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言") + marker = os.path.join(get_skill_root(), TEMPLATE_MARKER) + self.assertTrue( + os.path.isfile(marker), + msg=f"skill-template 源仓库根目录必须存在 {TEMPLATE_MARKER}", + ) + + def test_business_skill_must_not_keep_template_marker(self) -> None: + slug = _read_skill_slug() + if slug == TEMPLATE_SLUG_KEBAB: + self.skipTest("当前仍为模板占位 slug,由 test_template_repo_has_marker 守护") + marker = os.path.join(get_skill_root(), TEMPLATE_MARKER) + self.assertFalse( + os.path.isfile(marker), + msg=( + "业务技能不得保留 .openclaw-skill-template;" + "请使用 tools/scaffold_skill 或删除标记文件并检查是否误复制了模板 .git" + ), + ) + + def test_development_md_documents_git_cleanup(self) -> None: + path = os.path.join(get_skill_root(), "development", "DEVELOPMENT.md") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn(".git", text) + self.assertTrue( + "scaffold_skill" in text or "scaffold" in text.lower(), + msg="DEVELOPMENT.md must document scaffold_skill", + ) + self.assertTrue( + "remote" in text.lower() or "git remote" in text.lower(), + msg="DEVELOPMENT.md must mention git remote self-check", + ) + + def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试") + + import subprocess + import sys + + repo_root = get_skill_root() + scaffold_py = os.path.join(repo_root, "tools", "scaffold_skill.py") + with tempfile.TemporaryDirectory() as tmp: + dest = os.path.join(tmp, SCAFFOLD_TEST_SLUG) + proc = subprocess.run( + [ + sys.executable, + scaffold_py, + "--slug", + SCAFFOLD_TEST_SLUG, + "--destination", + dest, + ], + cwd=repo_root, + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout) + + actions_path = os.path.join(dest, "assets", "actions.json") + self.assertTrue(os.path.isfile(actions_path), msg="scaffold must copy assets/actions.json") + with open(actions_path, encoding="utf-8") as f: + manifest = json.load(f) + self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG) + manifest_raw = json.dumps(manifest) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, manifest_raw) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, manifest_raw) + + actions_md = os.path.join(dest, "references", "ACTIONS.md") + self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md") + + schema_path = os.path.join(dest, "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(schema_path)) + + marker = os.path.join(dest, TEMPLATE_MARKER) + self.assertFalse(os.path.isfile(marker)) + self.assertFalse(os.path.isdir(os.path.join(dest, ".git"))) + + constants_path = os.path.join(dest, "scripts", "util", "constants.py") + with open(constants_path, encoding="utf-8") as f: + constants_text = f.read() + self.assertNotIn(TEMPLATE_SLUG_KEBAB, constants_text) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, constants_text) + self.assertIn(f'SKILL_SLUG = "{SCAFFOLD_TEST_SLUG}"', constants_text) + self.assertIn( + f'LOG_LOGGER_NAME = "openclaw.skill.{SCAFFOLD_TEST_SLUG_UNDERSCORE}"', + constants_text, + ) + + skill_md_path = os.path.join(dest, "SKILL.md") + with open(skill_md_path, encoding="utf-8") as f: + skill_md = f.read() + self.assertIn(f"slug: {SCAFFOLD_TEST_SLUG}", skill_md) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, skill_md) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, skill_md) + + def test_scaffold_ps1_matches_python_placeholder_rules(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest("仅在 skill-template 源仓库运行 scaffold PS1 文案测试") + + ps1_path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1") + with open(ps1_path, encoding="utf-8") as f: + text = f.read() + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + self.assertIn('.Replace("-", "_")', text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skill_metadata.py b/tests/test_skill_metadata.py new file mode 100644 index 0000000..532b463 --- /dev/null +++ b/tests/test_skill_metadata.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""SKILL.md 与 util.constants.SKILL_SLUG 一致性(防复制后漏改 constants)。""" +from __future__ import annotations + +import os +import re +import unittest + +from _support import get_skill_root + + +def _parse_openclaw_slug(skill_md_text: str) -> str: + """从 YAML frontmatter 中解析 metadata.openclaw.slug(无 PyYAML 依赖)。""" + parts = skill_md_text.split("---", 2) + if len(parts) < 2: + raise ValueError("missing frontmatter") + fm = parts[1] + lines = fm.splitlines() + for i, line in enumerate(lines): + if re.match(r"^\s+openclaw:\s*$", line): + for j in range(i + 1, len(lines)): + inner = lines[j] + if inner.strip() and inner[0] not in (" ", "\t"): + break + m = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner) + if m: + return m.group(2).strip().strip("\"'") + break + raise ValueError("slug not found under openclaw") + + +class TestSkillMetadata(unittest.TestCase): + def test_skill_slug_alignment(self) -> None: + root = get_skill_root() + skill_dir = os.path.basename(root) + md_path = os.path.join(root, "SKILL.md") + with open(md_path, encoding="utf-8") as f: + md = f.read() + slug_md = _parse_openclaw_slug(md) + + from util.constants import SKILL_SLUG + + self.assertEqual(slug_md, SKILL_SLUG, "SKILL.md slug 必须与 constants.SKILL_SLUG 一致") + + if skill_dir == "skill-template": + # 模板仓库本体:允许占位 slug + self.assertEqual(SKILL_SLUG, "your-skill-slug") + else: + self.assertNotEqual(SKILL_SLUG, "your-skill-slug", "复制为真实技能后不得保留占位 slug") + self.assertEqual(SKILL_SLUG, skill_dir) + self.assertEqual(slug_md, skill_dir) + + def test_skill_version_alignment(self) -> None: + """SKILL.md version 必须与 constants.SKILL_VERSION 一致(POLICY-RELEASE-001 日常侧)。""" + root = get_skill_root() + md_path = os.path.join(root, "SKILL.md") + with open(md_path, encoding="utf-8") as f: + md = f.read() + match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)[\"']?\s*$", md) + self.assertIsNotNone(match, "SKILL.md must declare top-level semver version") + assert match is not None + md_version = match.group(1) + + from util.constants import SKILL_VERSION + + self.assertEqual( + md_version, + SKILL_VERSION, + "SKILL.md version 必须与 constants.SKILL_VERSION 一致(发布前再与 git tag 对齐)", + ) + self.assertRegex(SKILL_VERSION, r"^\d+\.\d+\.\d+$") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_slug_naming.py b/tests/test_slug_naming.py new file mode 100644 index 0000000..791d177 --- /dev/null +++ b/tests/test_slug_naming.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""slug 语义校验(verb-noun-platform)。""" +from __future__ import annotations + +import unittest + +from util.slug_naming import ( + LEGACY_EXEMPT_SLUGS, + classify_slug_form, + parse_slug_segments, + validate_slug_semantics, +) + + +class TestSlugNaming(unittest.TestCase): + def test_parse_slug_segments(self) -> None: + self.assertEqual(parse_slug_segments("download-settlement-report-amazon"), [ + "download", + "settlement", + "report", + "amazon", + ]) + + def test_classify_standard(self) -> None: + segments = parse_slug_segments("download-settlement-report-amazon") + self.assertEqual(classify_slug_form(segments, slug="download-settlement-report-amazon"), "standard") + + def test_classify_scope(self) -> None: + segments = parse_slug_segments("generate-article-all") + self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope") + + def test_classify_legacy_exempt(self) -> None: + segments = parse_slug_segments("your-skill-slug") + self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt") + + def test_valid_hyphenated_platform_slug(self) -> None: + slug = "fill-export-declaration-single-window" + segments = parse_slug_segments(slug) + self.assertEqual(classify_slug_form(segments, slug=slug), "standard") + errors, warnings = validate_slug_semantics(slug) + self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}") + + def test_valid_standard_slugs(self) -> None: + for slug in ( + "download-settlement-report-amazon", + "add-product-shopee", + "generate-article-all", + "your-skill-slug", + "account-manager", + ): + with self.subTest(slug=slug): + errors, warnings = validate_slug_semantics(slug) + self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}") + + def test_invalid_slugs(self) -> None: + cases = ( + "amazon-download-settlement-report", + "enter-cross-border-sales-receipt-voucher-kingdee", + "reconcile-finance", + "download-settlement-report-unknownplat", + "foobar-settlement-report-amazon", + ) + for slug in cases: + with self.subTest(slug=slug): + errors, _warnings = validate_slug_semantics(slug) + self.assertTrue(errors, msg=f"expected errors for {slug!r}") + + def test_length_boundary_48_passes(self) -> None: + slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon" + self.assertEqual(len(slug), 48) + errors, _warnings = validate_slug_semantics(slug) + self.assertEqual(errors, []) + + def test_length_boundary_49_fails(self) -> None: + slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon" + self.assertEqual(len(slug), 49) + errors, _warnings = validate_slug_semantics(slug) + self.assertTrue(errors) + self.assertTrue(any("48" in err for err in errors)) + + def test_legacy_exempt_set(self) -> None: + self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS) + self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_template_runtime_standard.py b/tests/test_template_runtime_standard.py new file mode 100644 index 0000000..71fe1a5 --- /dev/null +++ b/tests/test_template_runtime_standard.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""Docs guard: template must promote shared runtime, not vendored jiangchang_skill_core.""" +from __future__ import annotations + +import os +import unittest + +from _support import get_skill_root + +DOC_PATHS = ( + "README.md", + "SKILL.md", + "development/RUNTIME.md", + "references/CLI.md", + "development/DEVELOPMENT.md", + "development/RPA.md", + "development/CONFIG.md", +) + +RUNTIME_DOC_PATHS = tuple(p for p in DOC_PATHS if p != "README.md") + +FORBIDDEN_PHRASES = ( + "scripts/jiangchang_skill_core/:运行时", + "运行时与统一日志副本", + "vendor 源码在 scripts/jiangchang_skill_core", + "模板通过 vendor 拷贝引用", + "vendor 自 platform-kit", + "模板 vendor 拷贝在 scripts/jiangchang_skill_core", + "模板默认声明 `jiangchang-platform-kit", + "模板默认包含 `jiangchang-platform-kit", + "默认已含 `jiangchang-platform-kit", + "保留仅含 `jiangchang-platform-kit", +) + +POSITIVE_MARKERS = ( + "jiangchang-platform-kit", + "1.2.11", + "共享 runtime", +) + + +class TestTemplateRuntimeStandard(unittest.TestCase): + def _read(self, rel_path: str) -> str: + path = os.path.join(get_skill_root(), rel_path) + with open(path, encoding="utf-8") as f: + return f.read() + + def test_guarded_docs_avoid_vendored_core_guidance(self) -> None: + for rel_path in DOC_PATHS: + text = self._read(rel_path) + for phrase in FORBIDDEN_PHRASES: + self.assertNotIn( + phrase, + text, + msg=f"{rel_path} must not contain {phrase!r}", + ) + + def test_guarded_docs_promote_shared_runtime_standard(self) -> None: + combined = "\n".join(self._read(p) for p in RUNTIME_DOC_PATHS) + for marker in POSITIVE_MARKERS: + self.assertIn( + marker, + combined, + msg=f"docs should mention {marker!r}", + ) + self.assertTrue( + "不得 vendored" in combined or "不要 vendor" in combined, + msg="docs should state skills must not vendor jiangchang_skill_core", + ) + + def test_runtime_md_mentions_collect_runtime_diagnostics(self) -> None: + text = self._read("development/RUNTIME.md") + self.assertIn("collect_runtime_diagnostics", text) + + def test_cli_md_mentions_health_runtime_diagnostics(self) -> None: + text = self._read("references/CLI.md") + self.assertIn("python_executable", text) + self.assertIn("platform_kit_version", text) + self.assertIn("jiangchang_skill_core_file", text) + self.assertIn("python-runtime", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_timestamp_columns.py b/tests/test_timestamp_columns.py new file mode 100644 index 0000000..6302a1a --- /dev/null +++ b/tests/test_timestamp_columns.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +"""标准时间字段:Unix 秒级时间戳、默认值、trigger 与元数据。""" +from __future__ import annotations + +import sqlite3 +import time +import unittest + +from _support import DEMO_ITEMS_TABLE, IsolatedDataRoot, seed_demo_items + + +class TestTimestampColumns(unittest.TestCase): + def test_seed_creates_integer_timestamp_columns_with_defaults(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import list_physical_column_names + from db.display_metadata_validator import _table_column_info + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE) + self.assertEqual(physical[-2:], ["created_at", "updated_at"]) + + info = _table_column_info(cur, DEMO_ITEMS_TABLE) + for column_name in ("created_at", "updated_at"): + column = info[column_name] + self.assertIn("INT", str(column["type"]).upper()) + self.assertTrue(str(column["dflt_value"]).lower().find("unixepoch") >= 0) + finally: + conn.close() + + def test_insert_without_timestamps_auto_generates_unix_seconds(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + before = int(time.time()) + cur.execute( + "INSERT INTO demo_items (title, status) VALUES ('auto-ts', 'draft')" + ) + conn.commit() + cur.execute( + "SELECT created_at, updated_at FROM demo_items ORDER BY id DESC LIMIT 1" + ) + created_at, updated_at = cur.fetchone() + after = int(time.time()) + self.assertIsInstance(created_at, int) + self.assertIsInstance(updated_at, int) + self.assertGreaterEqual(created_at, before) + self.assertLessEqual(created_at, after) + self.assertEqual(created_at, updated_at) + finally: + conn.close() + + def test_update_business_field_keeps_created_at_and_refreshes_updated_at(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + cur.execute( + """ + INSERT INTO demo_items (title, status, created_at, updated_at) + VALUES ('update-ts', 'draft', 1000, 1000) + """ + ) + row_id = int(cur.lastrowid) + conn.commit() + + cur.execute("UPDATE demo_items SET status = 'published' WHERE id = ?", (row_id,)) + conn.commit() + + cur.execute( + "SELECT created_at, updated_at FROM demo_items WHERE id = ?", + (row_id,), + ) + created_at, updated_at = cur.fetchone() + self.assertEqual(created_at, 1000) + self.assertGreater(updated_at, 1000) + finally: + conn.close() + + def test_reinit_does_not_rewrite_existing_timestamps(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + cur.execute( + """ + INSERT INTO demo_items (title, status, created_at, updated_at) + VALUES ('stable-ts', 'draft', 1234567890, 1234567891) + """ + ) + conn.commit() + finally: + conn.close() + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT created_at, updated_at FROM demo_items WHERE title = 'stable-ts'" + ) + self.assertEqual(cur.fetchone(), (1234567890, 1234567891)) + finally: + conn.close() + + def test_timestamp_metadata_contract(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import DATETIME_UNIX_SECONDS + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT column_name, display_name, display_type, editable, display_order + FROM _jiangchang_columns + WHERE table_name = ? + AND column_name IN ('created_at', 'updated_at') + """, + (DEMO_ITEMS_TABLE,), + ) + by_name = { + name: (display_name, display_type, editable, display_order) + for name, display_name, display_type, editable, display_order in cur.fetchall() + } + self.assertEqual(by_name["created_at"][0], "创建时间") + self.assertEqual(by_name["updated_at"][0], "更新时间") + self.assertEqual(by_name["created_at"][1], DATETIME_UNIX_SECONDS) + self.assertEqual(by_name["updated_at"][1], DATETIME_UNIX_SECONDS) + self.assertEqual(int(by_name["created_at"][2]), 0) + self.assertEqual(int(by_name["updated_at"][2]), 0) + self.assertIsNone(by_name["created_at"][3]) + self.assertIsNone(by_name["updated_at"][3]) + finally: + conn.close() + + def test_metadata_upsert_keeps_timestamp_display_type(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import DATETIME_UNIX_SECONDS + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + seed_demo_items(cur) + conn.commit() + cur.execute( + """ + SELECT display_type FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'created_at' + """, + (DEMO_ITEMS_TABLE,), + ) + self.assertEqual(cur.fetchone()[0], DATETIME_UNIX_SECONDS) + finally: + conn.close() + + def test_insert_uses_database_timestamps(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + before = int(time.time()) + cur.execute( + "INSERT INTO demo_items (title, status) VALUES ('repo-ts', 'draft')" + ) + row_id = int(cur.lastrowid) + conn.commit() + after = int(time.time()) + cur.execute( + "SELECT created_at, updated_at FROM demo_items WHERE id = ?", + (row_id,), + ) + created_at, updated_at = cur.fetchone() + self.assertGreaterEqual(int(created_at), before) + self.assertLessEqual(int(created_at), after) + self.assertEqual(created_at, updated_at) + finally: + conn.close() + + def test_validator_rejects_missing_display_type(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_template_database + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + "UPDATE _jiangchang_columns SET display_type = NULL " + "WHERE table_name = ? AND column_name = 'created_at'", + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_template_database(conn) + self.assertFalse(report.ok) + self.assertTrue(any("display_type" in err for err in report.errors)) + finally: + conn.close() + + def test_validator_rejects_wrong_timestamp_type(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import init_display_metadata + from db.display_metadata_validator import validate_display_metadata + + conn.executescript( + """ + CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY, + title TEXT, + created_at TEXT + ); + """ + ) + init_display_metadata(conn.cursor()) + conn.execute( + """ + INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly) + VALUES ('demo_items', '演示', 1, 0) + """ + ) + for column_name, display_name in ( + ("id", "编号"), + ("title", "标题"), + ("created_at", "创建时间"), + ): + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable, display_type) + VALUES ('demo_items', ?, ?, 1, 0, 0, 'datetime_unix_seconds') + """, + (column_name, display_name), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("created_at 字段类型应为 INTEGER" in err for err in report.errors)) + finally: + conn.close() + + def test_validator_rejects_editable_timestamp(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata_validator import validate_template_database + + init_db() + conn = get_conn() + try: + seed_demo_items(conn.cursor()) + conn.execute( + "UPDATE _jiangchang_columns SET editable = 1 " + "WHERE table_name = ? AND column_name = 'updated_at'", + (DEMO_ITEMS_TABLE,), + ) + conn.commit() + report = validate_template_database(conn) + self.assertFalse(report.ok) + self.assertTrue(any("editable 应为 0" in err for err in report.errors)) + finally: + conn.close() + + def test_validator_does_not_flag_amount_as_timestamp(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import init_display_metadata + from db.display_metadata_validator import validate_display_metadata + + conn.executescript( + """ + CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY, + amount INTEGER, + count INTEGER + ); + """ + ) + init_display_metadata(conn.cursor()) + conn.execute( + """ + INSERT INTO _jiangchang_tables (table_name, display_name, visible, readonly) + VALUES ('demo_items', '演示', 1, 0) + """ + ) + for column_name, display_name in ( + ("id", "编号"), + ("amount", "数量"), + ("count", "计数"), + ): + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', ?, ?, 1, 1, 1) + """, + (column_name, display_name), + ) + conn.commit() + report = validate_display_metadata(conn) + self.assertFalse(any("amount" in err and "display_type" in err for err in report.errors)) + self.assertFalse(any("count" in err and "display_type" in err for err in report.errors)) + finally: + conn.close() + + def test_trigger_creation_is_idempotent(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.timestamp_columns import has_updated_at_trigger, updated_at_trigger_name + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + seed_demo_items(cur) + seed_demo_items(cur) + conn.commit() + self.assertTrue(has_updated_at_trigger(cur, DEMO_ITEMS_TABLE)) + cur.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = ?", + (updated_at_trigger_name(DEMO_ITEMS_TABLE),), + ) + self.assertEqual(int(cur.fetchone()[0]), 1) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tutorial_surfaces.py b/tests/test_tutorial_surfaces.py new file mode 100644 index 0000000..e8db72c --- /dev/null +++ b/tests/test_tutorial_surfaces.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +"""用户市场文档契约:README≠教程、TUTORIAL 三入口结构、与 actions.json 对齐。 + +契约全文:development/TUTORIAL_SURFACES.md +- 模板占位 slug(your-skill-slug):只检查骨架与契约文档存在 +- 业务技能:按 actions.json placements 硬校验教程章节与 README 反模式 +""" +from __future__ import annotations + +import json +import os +import re +import unittest + +from _support import get_skill_root +from util.constants import SKILL_SLUG + +TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" + +_SECTION_DATA_MGMT = re.compile(r"(?m)^##\s*.*数据管理") +_SECTION_DIALOG = re.compile(r"(?m)^##\s*.*(对话任务|对话)") +_SECTION_CRON = re.compile(r"(?m)^##\s*.*定时任务") +_SECTION_COMPARE = re.compile(r"(?m)^##\s*.*(对照表|主能力)") +_SECTION_STUCK = re.compile(r"(?m)^##\s*.*(常见卡住|卡住怎么办)") +_SECTION_WHAT = re.compile(r"(?m)^##\s*.*(本技能做什么|做什么)") +_NO_CRON_HINT = re.compile(r"不需要定时|未提供定时|不提供定时|不适用.*定时|定时.*不适用") + +# README 反模式:分步操作 / Agent 话术专章(说明 Tab 禁止) +_README_HOWTO_AGENT = re.compile(r"(?m)^##\s*.*告诉\s*Agent") +_README_ENTRY_CHAPTER = re.compile(r"(?m)^##\s*入口[一二三]") +_README_NUMBERED_DM_PATH = re.compile( + r"(?m)^\d+[\.、]\s*.*(数据管理|选品任务|表顶).{0,40}(点击|点\s*「)" +) + + +def _read(rel: str) -> str: + path = os.path.join(get_skill_root(), rel) + with open(path, encoding="utf-8") as f: + return f.read() + + +def _load_actions() -> list[dict]: + path = os.path.join(get_skill_root(), "assets", "actions.json") + if not os.path.isfile(path): + return [] + with open(path, encoding="utf-8") as f: + data = json.load(f) + return list(data.get("actions") or []) + + +def _placement_sets(actions: list[dict]) -> tuple[bool, bool, bool]: + has_dm = False + has_agent = False + has_cron = False + dm_tokens = frozenset({"toolbar", "row", "batch"}) + for action in actions: + placements = set(action.get("placements") or []) + if placements & dm_tokens: + has_dm = True + if "agent" in placements and str(action.get("id") or "") not in ( + "health", + "version", + "config-path", + ): + has_agent = True + if "cron" in placements: + has_cron = True + return has_dm, has_agent, has_cron + + +class TestTutorialSurfacesContract(unittest.TestCase): + def test_tutorial_surfaces_doc_exists(self) -> None: + path = os.path.join(get_skill_root(), "development", "TUTORIAL_SURFACES.md") + self.assertTrue( + os.path.isfile(path), + msg="development/TUTORIAL_SURFACES.md must exist (user market doc contract)", + ) + text = _read("development/TUTORIAL_SURFACES.md") + for marker in ( + "README.md", + "TUTORIAL.md", + "数据管理", + "对话", + "定时", + "skill-detail", + "对照表", + "正文不要用", + "禁止", + ): + self.assertIn(marker, text, msg=f"TUTORIAL_SURFACES.md missing {marker!r}") + + def test_market_docs_forbid_redundant_page_h1(self) -> None: + """宿主已有技能名与 Tab 标题:说明/教程正文禁止再用 # 一级标题。""" + + def strip_fences(src: str) -> str: + return re.sub(r"```.*?```", "", src, flags=re.S) + + for rel in ("README.md", "TUTORIAL.md"): + body = strip_fences(_read(rel)) + # YAML frontmatter 里不应出现 ATX H1;正文亦禁止 # + self.assertIsNone( + re.search(r"(?m)^#\s+\S", body), + msg=( + f"{rel} must not use ATX H1 (# …); host already shows skill name / tab. " + "Use ## for sections (see TUTORIAL_SURFACES.md §0.1)." + ), + ) + self.assertIsNone( + re.search(r"(?m)^#\s+.·\s*使用教程\s*$", body), + msg=f"{rel} must not start sections with '# … · 使用教程'", + ) + + def test_development_md_points_to_tutorial_surfaces(self) -> None: + text = _read("development/DEVELOPMENT.md") + self.assertIn( + "TUTORIAL_SURFACES.md", + text, + msg="DEVELOPMENT.md must reference TUTORIAL_SURFACES.md for market docs", + ) + + def test_template_or_business_tutorial_has_required_skeleton(self) -> None: + tutorial = _read("TUTORIAL.md") + self.assertRegex( + tutorial, + _SECTION_WHAT, + msg="TUTORIAL.md must have a '本技能做什么' (or equivalent) section", + ) + self.assertRegex( + tutorial, + _SECTION_DATA_MGMT, + msg="TUTORIAL.md must have a 数据管理 section (or 不适用 placeholder)", + ) + self.assertRegex( + tutorial, + _SECTION_DIALOG, + msg="TUTORIAL.md must have a 对话 section (or 不适用 placeholder)", + ) + self.assertRegex( + tutorial, + _SECTION_CRON, + msg="TUTORIAL.md must have a 定时任务 section (teach or N/A)", + ) + self.assertRegex( + tutorial, + _SECTION_COMPARE, + msg="TUTORIAL.md must have a 主能力对照表 section", + ) + self.assertRegex( + tutorial, + _SECTION_STUCK, + msg="TUTORIAL.md must have a 常见卡住 section", + ) + # 禁止旧「对话单线」作为唯一主结构标题 + self.assertNotRegex( + tutorial, + r"(?m)^##\s*第一步:打开技能并说清目标\s*$", + msg="TUTORIAL.md must not use legacy dialogue-only first step as sole structure", + ) + + def test_readme_has_no_legacy_agent_howto_chapter(self) -> None: + readme = _read("README.md") + self.assertIsNone( + _README_HOWTO_AGENT.search(readme), + msg="README.md must not have '告诉 Agent' howto chapter (belongs in TUTORIAL)", + ) + self.assertIsNone( + _README_ENTRY_CHAPTER.search(readme), + msg="README.md must not have 入口一/二/三 chapters (belongs in TUTORIAL)", + ) + + def test_business_skill_tutorial_aligns_with_actions(self) -> None: + if SKILL_SLUG == TEMPLATE_PLACEHOLDER_SLUG: + self.skipTest("template placeholder slug: skip actions alignment hard checks") + + actions = _load_actions() + has_dm, has_agent, has_cron = _placement_sets(actions) + tutorial = _read("TUTORIAL.md") + readme = _read("README.md") + + if has_dm: + self.assertRegex( + tutorial, + _SECTION_DATA_MGMT, + msg="business skill with data-management placements must document 数据管理 in TUTORIAL", + ) + # 准备类若挂了 toolbar,教程不应把 skill-detail 当唯一主路径 + self.assertNotRegex( + tutorial, + r"(?m)^\d+[\.、]\s*.*技能详情.{0,30}点击", + msg="TUTORIAL must not use 技能详情点击 as primary numbered path when DM exists", + ) + + if has_agent: + self.assertRegex( + tutorial, + _SECTION_DIALOG, + msg="business skill with agent placements must document 对话 in TUTORIAL", + ) + + if has_cron: + self.assertRegex( + tutorial, + _SECTION_CRON, + msg="with cron placements, TUTORIAL must have 定时任务 section", + ) + else: + self.assertRegex( + tutorial, + _NO_CRON_HINT, + msg="without cron placements, TUTORIAL must declare 不需要/未提供定时", + ) + + # README 不应出现「数据管理 → … → 点击」式编号步骤 + self.assertIsNone( + _README_NUMBERED_DM_PATH.search(readme), + msg="README.md must not contain numbered data-management click steps", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_video_service.py b/tests/test_video_service.py new file mode 100644 index 0000000..1b3e6d3 --- /dev/null +++ b/tests/test_video_service.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +"""RpaVideoSession 模板示范与 finish() video 字段 / CLI 输出测试。""" +from __future__ import annotations + +import io +import os +import re +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import AsyncMock, MagicMock, patch + +from _support import IsolatedDataRoot, get_skill_root + +from jiangchang_skill_core import config + + +def _active_env_value(text: str, key: str) -> str | None: + """Return the active (non-comment) KEY=value line from .env-style text.""" + prefix = f"{key}=" + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + active = stripped.split("#", 1)[0].strip() + if active.startswith(prefix): + return active + return None + + +def _combined_service_source(skill_root: str | None = None) -> str: + """Merge scripts/service/*.py source (excludes __pycache__).""" + root = skill_root or get_skill_root() + service_dir = os.path.join(root, "scripts", "service") + parts: list[str] = [] + for dirpath, dirnames, filenames in os.walk(service_dir): + dirnames[:] = [d for d in dirnames if d != "__pycache__"] + for name in sorted(filenames): + if not name.endswith(".py"): + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8") as f: + parts.append(f.read()) + return "\n".join(parts) + + +def _missing_video_integration_tokens(service_text: str) -> list[str]: + missing: list[str] = [] + if "RpaVideoSession" not in service_text: + missing.append("RpaVideoSession") + if "video.add_step" not in service_text and ".add_step(" not in service_text: + missing.append("video.add_step or .add_step(") + if "merge_video_into_result_summary" not in service_text: + missing.append("merge_video_into_result_summary") + return missing + + +class TestEnvExampleVideoDefaults(unittest.TestCase): + def test_env_example_contains_required_keys(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + with open(path, encoding="utf-8") as f: + text = f.read() + for key in ( + "OPENCLAW_" + "TEST_TARGET=real_" + "rpa", + "OPENCLAW_RECORD_VIDEO=0", + "OPENCLAW_ARTIFACTS_ON_FAILURE=1", + "OPENCLAW_BROWSER_HEADLESS=0", + "OPENCLAW_PLAYWRIGHT_STEALTH=1", + ): + self.assertIn(key, text, msg=f"missing {key!r} in .env.example") + + def test_env_example_default_off_but_template_keeps_video_integration(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertEqual( + _active_env_value(text, "OPENCLAW_RECORD_VIDEO"), + "OPENCLAW_RECORD_VIDEO=0", + ) + + service_text = _combined_service_source() + missing = _missing_video_integration_tokens(service_text) + self.assertEqual( + missing, + [], + msg=( + "default OPENCLAW_RECORD_VIDEO=0 does not allow removing video integration; " + f"scripts/service/*.py missing: {', '.join(missing)}" + ), + ) + + def test_env_example_allows_record_video_one_in_comments_only(self) -> None: + sample = "\n".join( + [ + "# example: OPENCLAW_RECORD_VIDEO=1 for production", + "OPENCLAW_RECORD_VIDEO=0", + ] + ) + self.assertEqual( + _active_env_value(sample, "OPENCLAW_RECORD_VIDEO"), + "OPENCLAW_RECORD_VIDEO=0", + ) + + +class TestPrintVideoSummary(unittest.TestCase): + def test_cli_prints_video_path_when_enabled(self) -> None: + from service.task_run_support import _print_video_summary + + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + _print_video_summary( + { + "enabled": True, + "path": r"D:\data\videos\track-fund-progress-default_demo.mp4", + "record_log_path": r"D:\data\logs\ffmpeg-record.log", + "warnings": [], + } + ) + out = buf.getvalue() + self.assertIn("录屏路径", out) + self.assertIn("track-fund-progress-default_demo.mp4", out) + self.assertIn("录屏日志", out) + + def test_disabled_video_prints_nothing(self) -> None: + from service.task_run_support import _print_video_summary + + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + _print_video_summary({"enabled": False, "path": r"D:\tmp\x.mp4"}) + self.assertEqual(buf.getvalue(), "") + + +class TestMergeVideoIntoResultSummary(unittest.TestCase): + def test_finish_payload_includes_video_fields(self) -> None: + from service.task_run_support import merge_video_into_result_summary + + video_info = { + "enabled": True, + "path": r"D:\data\videos\demo.mp4", + "capture_path": r"D:\data\capture.mp4", + "record_log_path": r"D:\data\logs\ffmpeg-record.log", + "warnings": ["test_warning"], + "voiceover_path": r"D:\data\voiceover.wav", + "music_path": r"D:\data\music.mp3", + "audio_warnings": ["tts_skipped"], + } + payload = merge_video_into_result_summary({"demo": True}, video_info) + self.assertIn("video", payload) + self.assertEqual(payload["video_path"], video_info["path"]) + self.assertEqual(payload["raw_video"], video_info["capture_path"]) + self.assertEqual(payload["video_log"], video_info["record_log_path"]) + self.assertEqual(payload["video_warnings"], ["test_warning"]) + self.assertEqual(payload["voiceover_path"], video_info["voiceover_path"]) + self.assertEqual(payload["music_path"], video_info["music_path"]) + self.assertEqual(payload["audio_warnings"], ["tts_skipped"]) + + +class TestTemplateRunVideoSession(unittest.TestCase): + def _fake_video_session(self, mock_video: MagicMock): + class _FakeVideoSession: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def __aenter__(self): + return mock_video + + async def __aexit__(self, *args): + return None + + return _FakeVideoSession + + def test_cmd_run_creates_video_session_with_chinese_titles(self) -> None: + with IsolatedDataRoot(user_id="_video_run"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + + from service import task_service + + mock_video = MagicMock() + mock_video.add_step = MagicMock() + mock_video.summary.return_value = { + "enabled": False, + "path": None, + "capture_path": None, + "record_log_path": None, + "warnings": [], + "voiceover_path": None, + "music_path": None, + "audio_warnings": [], + } + + created: list[dict] = [] + + class _CapturingFakeSession: + def __init__(self, **kwargs): + created.append(kwargs) + + async def __aenter__(self): + return mock_video + + async def __aexit__(self, *args): + return None + + with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession): + finish_kwargs: list[dict] = [] + original_finish = task_service.finish + + def _capture_finish(*args, **kwargs): + finish_kwargs.append(kwargs) + return original_finish(*args, **kwargs) + + buf = io.StringIO() + with patch.object(task_service, "finish", side_effect=_capture_finish): + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="demo-target", input_id="42") + + self.assertEqual(rc, 1) + self.assertTrue(created, "RpaVideoSession should be constructed") + kwargs = created[0] + self.assertEqual(kwargs.get("title"), "开始执行示例任务") + self.assertEqual(kwargs.get("closing_title"), "示例任务执行完成") + self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("title", ""))) + self.assertTrue(re.search(r"[\u4e00-\u9fff]", kwargs.get("closing_title", ""))) + mock_video.add_step.assert_any_call("准备执行示例任务") + mock_video.add_step.assert_any_call("示例任务执行完成") + mock_video.summary.assert_called() + + out = buf.getvalue() + self.assertIn("模板", out) + + self.assertTrue(finish_kwargs, "finish() should be called") + summary = finish_kwargs[-1] + self.assertIn("video", summary) + self.assertIn("video_path", summary) + + +class TestCmdRunFinishCoverage(unittest.TestCase): + def test_cmd_run_entitlement_failure_calls_finish(self) -> None: + with IsolatedDataRoot(user_id="_entitlement_fail"): + from service import task_service + + finish_kwargs: list[dict] = [] + original_finish = task_service.finish + + def _capture_finish(*args, **kwargs): + finish_kwargs.append(kwargs) + return original_finish(*args, **kwargs) + + with patch.object(task_service, "check_entitlement", return_value=(False, "mock denied")): + with patch.object(task_service, "finish", side_effect=_capture_finish): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="t1", input_id="99") + + self.assertEqual(rc, 1) + self.assertTrue(finish_kwargs) + payload = finish_kwargs[-1] + self.assertEqual(payload.get("status"), "failed") + self.assertEqual(payload.get("error_code"), "ENTITLEMENT_DENIED") + self.assertEqual(payload.get("stage"), "auth") + self.assertIn("mock denied", payload.get("message") or "") + + def test_cmd_run_exception_calls_finish(self) -> None: + with IsolatedDataRoot(user_id="_exception_run"): + os.environ["OPENCLAW_RECORD_VIDEO"] = "0" + config.reset_cache() + + from service import task_service + + async def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + finish_kwargs: list[dict] = [] + original_finish = task_service.finish + + def _capture_finish(*args, **kwargs): + finish_kwargs.append(kwargs) + return original_finish(*args, **kwargs) + + with patch.object(task_service, "check_entitlement", return_value=(True, "")): + with patch.object(task_service, "_run_template_demo", side_effect=_boom): + with patch.object(task_service, "finish", side_effect=_capture_finish): + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + rc = task_service.cmd_run(target="t2", input_id="88") + + self.assertEqual(rc, 1) + self.assertTrue(finish_kwargs) + payload = finish_kwargs[-1] + self.assertEqual(payload.get("status"), "failed") + self.assertEqual(payload.get("stage"), "run") + self.assertIn("任务执行异常", payload.get("message") or "") + + def test_get_task_logger_fallback_when_not_initialized(self) -> None: + from service import task_service + + mock_logger = MagicMock() + responses = [RuntimeError("logging not initialized"), mock_logger] + + def _fake_get_skill_logger(): + item = responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + with patch.object(task_service, "get_skill_logger", side_effect=_fake_get_skill_logger): + with patch.object(task_service, "setup_skill_logging") as mock_setup: + logger = task_service._get_task_logger() + + mock_setup.assert_called_once_with(task_service.SKILL_SLUG, task_service.LOG_LOGGER_NAME) + self.assertIs(logger, mock_logger) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..08cce15 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,87 @@ +# skill-template 工具 + +## UI 拾取(开发期) + +`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓: + +- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder +- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder` +- 启动:`python run.py` + +模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。 + +## 业务仓也可以直接 clone(来源 A) + +若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接: + +```powershell +git clone https://git.jc2009.com//.git +``` + +完整说明见 [`../development/DEVELOPMENT.md`](../development/DEVELOPMENT.md) §4「来源 A」。公开公共仓(含本模板、account-manager、platform-kit)登记见 [`../development/SHARED_REPOS.md`](../development/SHARED_REPOS.md)。下面脚手架用于**本地已有 skill-template、要新建尚未灌仓目录**的场景(来源 B)。 + +## 推荐:脚手架创建新技能 + +**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。 + +Windows 首选: + +```powershell +cd D:\OpenClaw\client-commons\skill-template +.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill +``` + +跨平台(Python 3.10+): + +```bash +python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new-skill +``` + +脚本行为: + +- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致) +- **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc` +- 删除目标中的 `.openclaw-skill-template` 标记 +- 将文本文件中的模板占位 slug 替换为新 slug: + - `your-skill-slug` → 新技能 kebab-case slug(如 `scrape-demo-records`) + - `your_skill_slug` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等) + - 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符 +- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action +- **不**移除 Schema 中的 `bind.tables` 支持,**不**把 `bind` 当模板残留清理 +- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar` +- **不**生成任何具体平台业务 Action +- **不**自动 `git init`;打印后续 Git 与测试命令 + +目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。 + +## 脚手架复制后必须保留 / 修改 / 可删除的文件 + +| 文件 | 说明 | +|------|------| +| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 | +| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 | +| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 | +| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 | +| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 | + +`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。 + +## 手工复制后的补救 + +若已整目录复制,必须先清理再初始化: + +```powershell +cd <新技能目录> +Remove-Item -Recurse -Force .git +Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue +Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue +git init +git remote add origin <新技能仓库 URL> +git remote -v +``` + +确认 `origin` **不是** skill-template 的远端 URL。 + +## 模板标记 + +源仓库根目录有 `.openclaw-skill-template`(仅 template 保留)。业务技能不得保留该文件;`tests/test_scaffold_guard.py` 会守护。 diff --git a/tools/highlight_answers.py b/tools/highlight_answers.py new file mode 100644 index 0000000..f45a814 --- /dev/null +++ b/tools/highlight_answers.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Highlight correct answer options in Excel quiz file.""" + +import re +import sys +from pathlib import Path + +import openpyxl +from openpyxl.cell.rich_text import CellRichText, InlineFont, TextBlock + +# Option letter to index mapping +LETTER_TO_IDX = {"A": 0, "B": 1, "C": 2, "D": 3} +IDX_TO_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"} + +# Regex to find option boundaries: A/B/C/D followed by separator +OPTION_MARKER_RE = re.compile( + r"(?=[A-D](?:[\..、::\))]|\s))" +) + +NORMAL_FONT = InlineFont(b=False, color="000000") +RED_BOLD_FONT = InlineFont(b=True, color="FF0000") + + +def parse_answer(answer_raw) -> set[int]: + """Parse answer column into set of option indices (0=A, 1=B, ...).""" + if answer_raw is None: + return set() + s = str(answer_raw).strip() + if not s: + return set() + + indices = set() + + # Pure digits like "13" -> 1 and 3 (not 13) + if re.fullmatch(r"[1-4]+", s): + for ch in s: + indices.add(int(ch) - 1) + return indices + + # Split by common separators + parts = re.split(r"[,,;;\s|]+", s) + for part in parts: + part = part.strip().upper() + if not part: + continue + if part in LETTER_TO_IDX: + indices.add(LETTER_TO_IDX[part]) + elif re.fullmatch(r"[1-4]", part): + indices.add(int(part) - 1) + + return indices + + +def find_option_spans(text: str) -> list[tuple[int, int, str]]: + """Find spans for each A/B/C/D option in the options text.""" + if not text or not isinstance(text, str): + return [] + + markers = [] + for m in OPTION_MARKER_RE.finditer(text): + letter = text[m.start()] + markers.append((m.start(), letter)) + + if not markers: + return [] + + spans = [] + for i, (start, letter) in enumerate(markers): + end = markers[i + 1][0] if i + 1 < len(markers) else len(text) + spans.append((start, end, letter)) + return spans + + +def build_rich_text(options_text: str, answer_indices: set[int]) -> CellRichText | str: + """Build rich text with correct options in red bold.""" + spans = find_option_spans(options_text) + if not spans: + return options_text + + parts = [] + pos = 0 + for start, end, letter in spans: + if start > pos: + parts.append(TextBlock(NORMAL_FONT, options_text[pos:start])) + idx = LETTER_TO_IDX.get(letter) + font = RED_BOLD_FONT if idx is not None and idx in answer_indices else NORMAL_FONT + parts.append(TextBlock(font, options_text[start:end])) + pos = end + + if pos < len(options_text): + parts.append(TextBlock(NORMAL_FONT, options_text[pos:])) + + return CellRichText(*parts) + + +def load_workbook_from_xls(xls_path: Path) -> openpyxl.Workbook: + """Load .xls file, converting via pandas/xlrd if needed.""" + try: + import pandas as pd + + xls_data = pd.read_excel(xls_path, sheet_name=None, header=None, dtype=str) + wb = openpyxl.Workbook() + # Remove default sheet + wb.remove(wb.active) + + for sheet_name, df in xls_data.items(): + ws = wb.create_sheet(title=str(sheet_name)) + for r_idx, row in enumerate(df.itertuples(index=False), start=1): + for c_idx, val in enumerate(row, start=1): + if val is not None and str(val) != "nan": + ws.cell(row=r_idx, column=c_idx, value=str(val)) + return wb + except Exception as e: + raise RuntimeError(f"Failed to read .xls file: {e}") from e + + +def find_header_columns(ws) -> tuple[int, int, int] | None: + """Find header row and column indices for 题目选项 and 题目答案.""" + for row_idx in range(1, min(ws.max_row + 1, 20)): + options_col = None + answer_col = None + for col_idx in range(1, ws.max_column + 1): + val = ws.cell(row=row_idx, column=col_idx).value + if val is None: + continue + s = str(val).strip() + if s == "题目选项": + options_col = col_idx + elif s == "题目答案": + answer_col = col_idx + if options_col is not None and answer_col is not None: + return row_idx, options_col, answer_col + return None + + +def process_sheet(ws, stats: dict) -> bool: + """Process a single worksheet. Returns True if processed.""" + header_info = find_header_columns(ws) + if header_info is None: + return False + + header_row, options_col, answer_col = header_info + stats["sheets"] += 1 + sheet_rows = 0 + sheet_matches = 0 + + for row_idx in range(header_row + 1, ws.max_row + 1): + options_cell = ws.cell(row=row_idx, column=options_col) + answer_cell = ws.cell(row=row_idx, column=answer_col) + + options_text = options_cell.value + if options_text is None: + continue + options_text = str(options_text).strip() + if not options_text: + continue + + answer_indices = parse_answer(answer_cell.value) + if not answer_indices: + continue + + sheet_rows += 1 + sheet_matches += len(answer_indices) + + rich = build_rich_text(options_text, answer_indices) + options_cell.value = rich + + stats["rows"] += sheet_rows + stats["matches"] += sheet_matches + return True + + +def main(): + src = Path(r"D:\下载\安全知识竞赛题库(1).xls") + dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx") + + if not src.exists(): + print(f"ERROR: Source file not found: {src}") + sys.exit(1) + + print(f"Reading: {src}") + wb = load_workbook_from_xls(src) + + stats = {"sheets": 0, "rows": 0, "matches": 0} + for ws in wb.worksheets: + process_sheet(ws, stats) + + wb.save(dst) + print(f"Saved: {dst}") + print(f"处理结果: {stats['sheets']} 个工作表, {stats['rows']} 行, {stats['matches']} 个匹配选项") + + +if __name__ == "__main__": + main() diff --git a/tools/scaffold_skill.ps1 b/tools/scaffold_skill.ps1 new file mode 100644 index 0000000..dcd9b65 --- /dev/null +++ b/tools/scaffold_skill.ps1 @@ -0,0 +1,140 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Scaffold a new skill directory from skill-template (excludes .git). + +.EXAMPLE + .\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Slug, + + [Parameter(Mandatory = $true)] + [string]$Destination, + + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function Test-KebabSlug([string]$Value) { + return $Value -match '^[a-z0-9]+(-[a-z0-9]+)*$' +} + +function Write-ScaffoldNextSteps([string]$TargetPath) { + Write-Host "" + Write-Host "=== 脚手架复制完成 ===" -ForegroundColor Green + Write-Host ("目标目录: " + $TargetPath) + Write-Host "" + Write-Host "请在本机继续执行(未自动 git init,避免误操作):" -ForegroundColor Yellow + Write-Host (' cd "' + $TargetPath + '"') + Write-Host " Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue" + Write-Host " git init" + Write-Host " git remote add origin <你的新技能仓库 URL>" + Write-Host " git remote -v" + Write-Host " # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件" + Write-Host " python tests/run_tests.py -v" + Write-Host "" +} + +$TemplateRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$TargetPath = $Destination + +if (-not (Test-KebabSlug $Slug)) { + throw "Slug must be kebab-case. Got: $Slug" +} + +$leaf = Split-Path -Leaf ($TargetPath.TrimEnd('\', '/')) +if ($leaf -ne $Slug) { + throw "Destination leaf '$leaf' must match -Slug '$Slug'." +} + +$parent = Split-Path -Parent $TargetPath +if (-not $parent -or -not (Test-Path -LiteralPath $parent)) { + throw "Parent directory does not exist: $parent" +} + +$gitPath = Join-Path $TargetPath ".git" +if (Test-Path -LiteralPath $gitPath) { + throw "Target already has .git at $gitPath . Remove it first or use an empty destination." +} + +$targetExists = Test-Path -LiteralPath $TargetPath +if ($targetExists) { + $entries = @(Get-ChildItem -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue) + if ($entries.Count -gt 0 -and -not $Force) { + throw "Target exists and is not empty: $TargetPath . Use -Force or pick an empty directory." + } +} + +$excludeDirs = @(".git", ".pytest_cache", "__pycache__") +$excludeFiles = @(".env", ".env.local") +$templatePlaceholderSlugKebab = "your-skill-slug" +$templatePlaceholderSlugUnderscore = "your_skill_slug" +$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample") + +function Copy-TemplateTree { + param( + [string]$Source, + [string]$Dest + ) + if (-not (Test-Path -LiteralPath $Dest)) { + New-Item -ItemType Directory -Path $Dest -Force | Out-Null + } + Get-ChildItem -LiteralPath $Source -Force | ForEach-Object { + $name = $_.Name + if ($excludeDirs -contains $name) { return } + if ($excludeFiles -contains $name) { return } + if ($name -like "*.pyc") { return } + + $srcPath = $_.FullName + $dstPath = Join-Path $Dest $name + if ($_.PSIsContainer) { + Copy-TemplateTree -Source $srcPath -Dest $dstPath + } + else { + Copy-Item -LiteralPath $srcPath -Destination $dstPath -Force + } + } +} + +function Replace-TemplatePlaceholders { + param( + [string]$Root, + [string]$Slug + ) + $underscoreSlug = $Slug.Replace("-", "_") + Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object { + $name = $_.Name + if ($excludeFiles -contains $name) { return } + $ext = $_.Extension.ToLowerInvariant() + if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return } + try { + $text = [System.IO.File]::ReadAllText($_.FullName) + } + catch { + return + } + if ( + $text -notlike "*$templatePlaceholderSlugKebab*" -and + $text -notlike "*$templatePlaceholderSlugUnderscore*" + ) { return } + $updated = $text.Replace($templatePlaceholderSlugKebab, $Slug) + $updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug) + [System.IO.File]::WriteAllText($_.FullName, $updated) + } +} + +Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan +Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath + +$marker = Join-Path $TargetPath ".openclaw-skill-template" +if (Test-Path -LiteralPath $marker) { + Remove-Item -LiteralPath $marker -Force +} + +Replace-TemplatePlaceholders -Root $TargetPath -Slug $Slug + +Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path diff --git a/tools/scaffold_skill.py b/tools/scaffold_skill.py new file mode 100644 index 0000000..6af5367 --- /dev/null +++ b/tools/scaffold_skill.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""跨平台 skill-template 脚手架(与 scaffold_skill.ps1 行为一致)。""" +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from pathlib import Path + +EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"} +EXCLUDE_FILE_NAMES = {".env", ".env.local"} +KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "your_skill_slug" +TEXT_FILE_SUFFIXES = { + ".md", + ".py", + ".json", + ".yaml", + ".yml", + ".txt", + ".ini", + ".ps1", + ".sample", + ".env.example", +} + + +def _ignore_copy(_dir: str, names: list[str]) -> set[str]: + ignored: set[str] = set() + for name in names: + if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES: + ignored.add(name) + elif name.endswith(".pyc"): + ignored.add(name) + return ignored + + +def _should_replace_placeholders(path: Path) -> bool: + if path.name in EXCLUDE_FILE_NAMES: + return False + if path.suffix.lower() in TEXT_FILE_SUFFIXES: + return True + if path.name == ".env.example": + return True + return False + + +def _slug_to_underscore(slug: str) -> str: + return slug.replace("-", "_") + + +def _replace_template_placeholders(target: Path, slug: str) -> None: + """将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。""" + underscore_slug = _slug_to_underscore(slug) + for path in target.rglob("*"): + if not path.is_file(): + continue + if path.name in EXCLUDE_FILE_NAMES: + continue + if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example": + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if ( + TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text + and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text + ): + continue + updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug) + updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug) + path.write_text(updated, encoding="utf-8") + + +def _print_next_steps(target: Path) -> None: + print() + print("=== 脚手架复制完成 ===") + print(f"目标目录: {target}") + print() + print("请在本机继续执行(未自动 git init,避免误操作):") + print(f' cd "{target}"') + print(" Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue # 若曾手工复制漏删") + print(" git init") + print(" git remote add origin <你的新技能仓库 URL>") + print(" git remote -v # 确认 remote 不是 skill-template") + print(" # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件") + print(" python tests/run_tests.py -v") + print() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="从 skill-template 脚手架复制新技能目录") + parser.add_argument("--slug", required=True, help="kebab-case slug,须与目标目录名一致") + parser.add_argument("--destination", required=True, help="新技能完整目标路径") + parser.add_argument("--force", action="store_true", help="目标非空时允许覆盖(目标不得含 .git)") + args = parser.parse_args(argv) + + slug = args.slug.strip() + if not KEBAB_SLUG.match(slug): + print(f"ERROR: Slug 必须是 kebab-case,当前: {slug}", file=sys.stderr) + return 1 + + target = Path(args.destination).resolve() + if target.name != slug: + print( + f"ERROR: 目标目录末段 '{target.name}' 与 slug '{slug}' 不一致。", + file=sys.stderr, + ) + return 1 + + parent = target.parent + if not parent.is_dir(): + print(f"ERROR: 父目录不存在: {parent}", file=sys.stderr) + return 1 + + if (target / ".git").exists(): + print( + f"ERROR: 目标已存在 .git,请先删除: {target / '.git'}", + file=sys.stderr, + ) + return 1 + + template_root = Path(__file__).resolve().parent.parent + if target.exists(): + entries = list(target.iterdir()) + if entries and not args.force: + print( + f"ERROR: 目标目录已存在且非空: {target}\n请加 --force 或换空目录。", + file=sys.stderr, + ) + return 1 + + target.mkdir(parents=True, exist_ok=True) + print(f"复制模板: {template_root} -> {target}") + for item in template_root.iterdir(): + name = item.name + if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES: + continue + if name.endswith(".pyc"): + continue + dest = target / name + if item.is_dir(): + shutil.copytree(item, dest, ignore=_ignore_copy, dirs_exist_ok=True) + else: + shutil.copy2(item, dest) + + marker = target / ".openclaw-skill-template" + if marker.is_file(): + marker.unlink() + + _replace_template_placeholders(target, slug) + + _print_next_steps(target) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_highlight.py b/tools/verify_highlight.py new file mode 100644 index 0000000..eee9a84 --- /dev/null +++ b/tools/verify_highlight.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Verify highlighted answer output file.""" + +from pathlib import Path + +import openpyxl + +dst = Path(r"D:\下载\安全知识竞赛题库_答案标红.xlsx") +src = Path(r"D:\下载\安全知识竞赛题库(1).xls") + +print(f"Source exists: {src.exists()}") +print(f"Output exists: {dst.exists()}") + +wb = openpyxl.load_workbook(dst, rich_text=True) +ws = wb.active +print(f"Sheet: {ws.title}, rows={ws.max_row}, cols={ws.max_column}") + +# Find header columns +header_row = None +options_col = None +answer_col = None +for row_idx in range(1, 20): + for col_idx in range(1, ws.max_column + 1): + val = ws.cell(row=row_idx, column=col_idx).value + if val and str(val).strip() == "题目选项": + options_col = col_idx + if val and str(val).strip() == "题目答案": + answer_col = col_idx + if options_col and answer_col: + header_row = row_idx + break + +print(f"Header row={header_row}, options_col={options_col}, answer_col={answer_col}") + +# Sample verification for first 5 rows with answers 1,2,3 +samples = [] +for row_idx in range(header_row + 1, ws.max_row + 1): + answer = ws.cell(row=row_idx, column=answer_col).value + if answer in ("1", "2", "3", 1, 2, 3): + cell = ws.cell(row=row_idx, column=options_col) + val = cell.value + runs = [] + if hasattr(val, "__iter__") and not isinstance(val, str): + for block in val: + font = block.font + bold = font.b if font else None + color = font.color if font else None + runs.append((block.text, bold, color)) + samples.append((row_idx, answer, runs)) + if len(samples) >= 5: + break + +for row_idx, answer, runs in samples: + print(f"\nRow {row_idx}, answer={answer}:") + for text, bold, color in runs: + marker = "RED_BOLD" if bold and color == "FF0000" else "normal" + print(f" [{marker}] {repr(text[:50])}")