dsh-engineering-ui 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +772 -3
- package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
- package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
- package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
- package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
- package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
- package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
- package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
- package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
- package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
- package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
- package/engineering/skills/cad-workflow/SKILL.md +132 -0
- package/engineering/skills/mode-selection/SKILL.md +660 -0
- package/engineering/skills/physics-in-loop/SKILL.md +238 -0
- package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
- package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
- package/engineering/skills/sw-design/SKILL.md +112 -0
- package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
- package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
- package/engineering/skills/sw-to-cad/SKILL.md +121 -0
- package/engineering/skills/webots-simulation/SKILL.md +58 -0
- package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
- package/engineering/tools/_plugin_paths.py +93 -0
- package/engineering/tools/_root.py +330 -0
- package/engineering/tools/_store.py +446 -0
- package/engineering/tools/ac_bridge.py +459 -0
- package/engineering/tools/ac_rules.json +21 -0
- package/engineering/tools/ac_validate.py +699 -0
- package/engineering/tools/ask_user.py +887 -0
- package/engineering/tools/build_arm1.py +46 -0
- package/engineering/tools/build_assembly.py +94 -0
- package/engineering/tools/build_base.py +57 -0
- package/engineering/tools/build_housing.py +70 -0
- package/engineering/tools/build_structural.py +43 -0
- package/engineering/tools/build_transmission.py +69 -0
- package/engineering/tools/cadx.lsp +128 -0
- package/engineering/tools/choice_contract.py +61 -0
- package/engineering/tools/conn_state.py +1671 -0
- package/engineering/tools/defense_gate.py +3374 -0
- package/engineering/tools/drawing_input.py +314 -0
- package/engineering/tools/env_check.py +467 -0
- package/engineering/tools/examples/build_axis.py +142 -0
- package/engineering/tools/examples/build_cup.py +56 -0
- package/engineering/tools/examples/build_plate.py +58 -0
- package/engineering/tools/examples/build_sphere.py +30 -0
- package/engineering/tools/examples/build_transition_duct.py +381 -0
- package/engineering/tools/gate_integrity.py +401 -0
- package/engineering/tools/mode2_flow.py +3589 -0
- package/engineering/tools/mode_checker.py +77 -0
- package/engineering/tools/mode_gate.py +8013 -0
- package/engineering/tools/part_registry.py +4266 -0
- package/engineering/tools/physics/__init__.py +50 -0
- package/engineering/tools/physics/_compat.py +12 -0
- package/engineering/tools/physics/design_state.py +142 -0
- package/engineering/tools/physics/domain_validator.py +751 -0
- package/engineering/tools/physics/fatigue.py +489 -0
- package/engineering/tools/physics/fea_solver.py +1163 -0
- package/engineering/tools/physics/feapy_solver.py +314 -0
- package/engineering/tools/physics/gb_materials.json +1228 -0
- package/engineering/tools/physics/geometry_gate.py +337 -0
- package/engineering/tools/physics/load_case.py +951 -0
- package/engineering/tools/physics/material_db.py +874 -0
- package/engineering/tools/physics/mesh_adapter.py +321 -0
- package/engineering/tools/physics/refine_rules.py +272 -0
- package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
- package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
- package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
- package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
- package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
- package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
- package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
- package/engineering/tools/physics/rules/housing_rules.json +48 -0
- package/engineering/tools/physics/rules/manifest.json +54 -0
- package/engineering/tools/physics/rules/mold_rules.json +48 -0
- package/engineering/tools/physics/rules/structural_rules.json +56 -0
- package/engineering/tools/physics/rules/transmission_rules.json +48 -0
- package/engineering/tools/physics/simulation_report.py +829 -0
- package/engineering/tools/physics_bridge.py +2685 -0
- package/engineering/tools/profile-manifest.ps1 +72 -0
- package/engineering/tools/run_regression.py +109 -0
- package/engineering/tools/settings.py +419 -0
- package/engineering/tools/solidworks-modeling.md +519 -0
- package/engineering/tools/sw_bridge.py +8215 -0
- package/engineering/tools/sw_guard.py +1066 -0
- package/engineering/tools/sw_surface.py +87 -0
- package/engineering/tools/swapi.py +14766 -0
- package/engineering/tools/verify_sync.py +218 -0
- package/engineering/tools/visual_check.py +2589 -0
- package/engineering/tools/webots_env.py +121 -0
- package/engineering/tools/webots_mcp/__init__.py +3 -0
- package/engineering/tools/webots_mcp/__main__.py +3 -0
- package/engineering/tools/webots_mcp/assembly.py +391 -0
- package/engineering/tools/webots_mcp/bootstrap.py +55 -0
- package/engineering/tools/webots_mcp/controller_worker.py +124 -0
- package/engineering/tools/webots_mcp/evidence.py +288 -0
- package/engineering/tools/webots_mcp/fake_worker.py +69 -0
- package/engineering/tools/webots_mcp/fault_resources.py +121 -0
- package/engineering/tools/webots_mcp/host.py +45 -0
- package/engineering/tools/webots_mcp/ledger.py +73 -0
- package/engineering/tools/webots_mcp/owned_job.py +70 -0
- package/engineering/tools/webots_mcp/physical_gate.py +187 -0
- package/engineering/tools/webots_mcp/physics.py +152 -0
- package/engineering/tools/webots_mcp/pipeline.py +108 -0
- package/engineering/tools/webots_mcp/recipes.py +129 -0
- package/engineering/tools/webots_mcp/repair.py +132 -0
- package/engineering/tools/webots_mcp/repair_trial.py +375 -0
- package/engineering/tools/webots_mcp/runtime.py +311 -0
- package/engineering/tools/webots_mcp/server.py +217 -0
- package/engineering/tools/webots_mcp/sw_export.py +509 -0
- package/engineering/tools/webots_mcp/transport.py +156 -0
- package/engineering/tools/workflow_gate.py +6134 -0
- package/package.json +64 -4
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
ask_user.py —— 子代理专用的阻塞式提问 CLI
|
|
5
|
+
==========================================
|
|
6
|
+
用途:让【子代理】直接在【右侧子代理面板】向用户提问并阻塞等待答案。
|
|
7
|
+
|
|
8
|
+
背景(已核实 DSH 源码):
|
|
9
|
+
· dsh-user-questions 的 ask() 会校验 agents.roots().includes(agent),
|
|
10
|
+
子代理不在 roots 中 → 必然抛 DELEGATED_CALLER,无法自己调 ask_user_question。
|
|
11
|
+
· host-apiproxy 的 provider 用 sessionId = request.agent?.id 决定帧路由,
|
|
12
|
+
应答校验 matchesQuestions() 也要求 sessionId 与 pending 一致。
|
|
13
|
+
· 因此提问必须由"能通过校验的 root"承载 —— 由本插件 host 半代为发起。
|
|
14
|
+
|
|
15
|
+
本 CLI 的工作方式(阻塞等待,不结束回合):
|
|
16
|
+
1) POST /dsh-engineering-ui/ask-child,带上本子代理的 sessionId + 问题/选项
|
|
17
|
+
2) host 代表本子代理发起原生 ask(),返回 pendingId
|
|
18
|
+
3) 本脚本【原地轮询】等待用户在子代理面板作答
|
|
19
|
+
4) 拿到答案后打印 JSON 并退出 —— 调用方从 stdout 读到答案,继续原逻辑
|
|
20
|
+
|
|
21
|
+
这与已废弃的 followup 方案的本质区别:
|
|
22
|
+
followup 把答案变成"下一轮新消息",子代理会当成新任务;
|
|
23
|
+
本方案答案回到【发起提问的那次调用栈】,子代理直接从工具返回值继续。
|
|
24
|
+
|
|
25
|
+
用法:
|
|
26
|
+
python ask_user.py --room <房间名> --child <子代理sessionId> \
|
|
27
|
+
--question "以下是零件参数,要用吗?" \
|
|
28
|
+
--option "确认,开始建模" --option "需要修改"
|
|
29
|
+
|
|
30
|
+
或从 JSON 文件读取问题(推荐,避免命令行转义问题):
|
|
31
|
+
python ask_user.py --room <房间名> --child <sessionId> --spec questions.json
|
|
32
|
+
|
|
33
|
+
输出(stdout,单行 JSON):
|
|
34
|
+
{"ok": true, "answers": [{"id":"q1","selected":["确认,开始建模"],"custom":""}]}
|
|
35
|
+
{"ok": false, "error": "..."}
|
|
36
|
+
"""
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
import urllib.request
|
|
43
|
+
import urllib.error
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
47
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NL_S = chr(10) # 换行(避免源码里出现字面量换行导致的转义问题)
|
|
53
|
+
NL_B = b"\n"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def emit(obj):
|
|
57
|
+
r"""【Bug2 修复】稳健 JSON 输出,杜绝中文 GBK 乱码。
|
|
58
|
+
|
|
59
|
+
本脚本是【小屋唯一的提问通道】,输出里的房间名/问题/用户选项全是中文。
|
|
60
|
+
原实现直接 print(json.dumps(..., ensure_ascii=False)):一旦 stdout 被
|
|
61
|
+
pwsh 以 GBK 代码页捕获,答案就会以乱码回传给小屋 —— 小屋据此判断
|
|
62
|
+
"用户选了什么"会出错,是本轮"报告/提问频道疑似未完全生效"的根因之一。
|
|
63
|
+
|
|
64
|
+
修复:优先以 UTF-8 字节直写(绕开控制台代码页);失败则退回
|
|
65
|
+
ensure_ascii=True 的纯 ASCII 转义(任何代码页都能无损还原)。
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
|
69
|
+
except Exception:
|
|
70
|
+
text = json.dumps({"ok": False, "error": "serialize failed"}, ensure_ascii=True)
|
|
71
|
+
try:
|
|
72
|
+
_buf = getattr(sys.stdout, "buffer", None)
|
|
73
|
+
if _buf is not None:
|
|
74
|
+
_buf.write(text.encode("utf-8", "replace"))
|
|
75
|
+
_buf.write(NL_B.encode("utf-8"))
|
|
76
|
+
_buf.flush()
|
|
77
|
+
return
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
try:
|
|
81
|
+
sys.stdout.write(text + NL_S)
|
|
82
|
+
sys.stdout.flush()
|
|
83
|
+
except UnicodeEncodeError:
|
|
84
|
+
try:
|
|
85
|
+
sys.stdout.write(json.dumps(obj, ensure_ascii=True, indent=2) + NL_S)
|
|
86
|
+
sys.stdout.flush()
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── 【2026-10-29 G03 修复】默认 host 不能写死 3080 ────────────────────────
|
|
92
|
+
# 实测:DSH Web GUI 实际监听 64488 / 63211 / 55660…(每次启动端口都变),
|
|
93
|
+
# 而本脚本默认 POST 到 127.0.0.1:3080 → WinError 10061(无法连接),
|
|
94
|
+
# 子代理提问全部失败,只能父代理代问(还要多一轮往返)。
|
|
95
|
+
# 现在按下面的顺序解析 host:
|
|
96
|
+
# ① --host 显式参数(调用方最清楚)
|
|
97
|
+
# ② 环境变量 DSH_HOST
|
|
98
|
+
# ③ DSH_WEB_URL(DSH 宿主注入的真实 GUI 地址,最可靠)
|
|
99
|
+
# ④ 兼容旧口径:3080 / 19387(老版本曾用固定端口)
|
|
100
|
+
# 并在连接失败时【快速失败 + 给出降级指引】,不再傻等到 540s 超时。
|
|
101
|
+
def _resolve_default_host():
|
|
102
|
+
for _env in ("DSH_HOST", "DSH_WEB_URL", "DSH_WEB_URL_ALT"):
|
|
103
|
+
_v = (os.environ.get(_env) or "").strip()
|
|
104
|
+
if _v.startswith("http://") or _v.startswith("https://"):
|
|
105
|
+
return _v.rstrip("/")
|
|
106
|
+
return "http://127.0.0.1:3080"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
DEFAULT_HOST = _resolve_default_host()
|
|
110
|
+
PREFIX = "/dsh-engineering-ui"
|
|
111
|
+
|
|
112
|
+
#: 连接失败时最多尝试的候选 host(快速探测,每个 ≤2s,总 ≤6s)
|
|
113
|
+
FAST_FAIL_HOSTS = ("DSH_HOST", "DSH_WEB_URL")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _candidate_hosts(explicit=None):
|
|
117
|
+
"""按优先级列出候选 host(去重,保序)。"""
|
|
118
|
+
vals = []
|
|
119
|
+
if explicit:
|
|
120
|
+
vals.append(str(explicit).strip().rstrip("/"))
|
|
121
|
+
for _env in FAST_FAIL_HOSTS:
|
|
122
|
+
_v = (os.environ.get(_env) or "").strip()
|
|
123
|
+
if _v.startswith("http://") or _v.startswith("https://"):
|
|
124
|
+
vals.append(_v.rstrip("/"))
|
|
125
|
+
for _v in ("http://127.0.0.1:3080", "http://127.0.0.1:19387"):
|
|
126
|
+
vals.append(_v)
|
|
127
|
+
out = []
|
|
128
|
+
for v in vals:
|
|
129
|
+
if v and v not in out:
|
|
130
|
+
out.append(v)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_BRIDGE_DOWN_HINT = (
|
|
135
|
+
"宿主代问桥不可用(/dsh-engineering-ui/ask-child 连不上)。"
|
|
136
|
+
"这不是你的错,是插件 host 半没在对应端口上跑。降级做法(必须照做):\n"
|
|
137
|
+
" ① 把问题列表用 send_message 发给父代理(主对话),请它用 ask_user_question 代问用户;\n"
|
|
138
|
+
" ② 父代理把用户答复回传给你后,你再用 mode2_flow.py answer-question <seq> \"<答复>\" 逐条落账;\n"
|
|
139
|
+
" ③ 在上述两步完成前,不要把问题当成'已问过',也不要编造用户回答。"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _post(url, payload, timeout=30):
|
|
143
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
144
|
+
req = urllib.request.Request(
|
|
145
|
+
url, data=data,
|
|
146
|
+
headers={"content-type": "application/json; charset=utf-8"},
|
|
147
|
+
method="POST",
|
|
148
|
+
)
|
|
149
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
150
|
+
body = resp.read().decode("utf-8", "replace")
|
|
151
|
+
return json.loads(body)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _get(url, timeout=30):
|
|
155
|
+
req = urllib.request.Request(url, method="GET")
|
|
156
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
157
|
+
body = resp.read().decode("utf-8", "replace")
|
|
158
|
+
return json.loads(body)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _writeback_answer(room, questions, ans, pending_id, child):
|
|
162
|
+
"""【Bug#8 修复】把面板作答结果回写进 pending.json 的 answers 字段。
|
|
163
|
+
|
|
164
|
+
目的:让 mode_gate.py confirm-part 能校验"用户确实在面板上点过",
|
|
165
|
+
使 C 模式逐件确认具备完整可追溯性(而非仅靠主对话的独立授权)。
|
|
166
|
+
|
|
167
|
+
写入结构:
|
|
168
|
+
{"answers": {"<零件名/题目摘要>": {
|
|
169
|
+
"selected": [...], "custom": "...",
|
|
170
|
+
"pendingId": "...", "child": "...",
|
|
171
|
+
"ts": 1234567890.0, "time": "2026-09-15 11:20:00"}}}
|
|
172
|
+
|
|
173
|
+
容错:任何异常都不影响主流程(作答结果照常返回给调用方)。
|
|
174
|
+
"""
|
|
175
|
+
if not room:
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
import time as _t
|
|
179
|
+
path = os.path.join(_TOOLS_DIR, "reports",
|
|
180
|
+
_safe_room(room) + ".pending.json")
|
|
181
|
+
data = {}
|
|
182
|
+
if os.path.exists(path):
|
|
183
|
+
try:
|
|
184
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
185
|
+
data = json.load(f) or {}
|
|
186
|
+
except Exception:
|
|
187
|
+
data = {}
|
|
188
|
+
answers = data.get("answers") or {}
|
|
189
|
+
# 从作答结果里提取选中的标签
|
|
190
|
+
sel = []
|
|
191
|
+
custom = ""
|
|
192
|
+
try:
|
|
193
|
+
for a in (ans.get("answers") or []):
|
|
194
|
+
sel.extend(a.get("selected") or [])
|
|
195
|
+
if a.get("custom"):
|
|
196
|
+
custom = str(a.get("custom"))
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
# ── 【Bug#2 修复】键必须包含【零件名】,否则 confirm-part 永远匹配不上 ──
|
|
200
|
+
# 现象(测试部实测):
|
|
201
|
+
# confirm-part "结构件" "立柱" → answer_evidence=null、
|
|
202
|
+
# audit="未读到面板作答痕迹",但 pending.json 里明明有作答记录。
|
|
203
|
+
# 根因:原实现优先用 `header` 作键,而小屋提问时写的是
|
|
204
|
+
# --header "零件确认"(通用标题!),零件名只在 --question 里。
|
|
205
|
+
# → answers 的键变成 "零件确认"/"确认,开始建模" 这类泛化标签,
|
|
206
|
+
# 完全不含零件名 → confirm-part 按零件名(立柱/大臂)去查必然 miss。
|
|
207
|
+
# 修复:键的优先级改为
|
|
208
|
+
# ① 从 question 文本中提取的【零件名】(最可靠)
|
|
209
|
+
# ② header
|
|
210
|
+
# ③ id / 摘要兜底
|
|
211
|
+
# 并且【同时】写入多个别名键,让各种匹配策略都能命中。
|
|
212
|
+
key = ""
|
|
213
|
+
part_guess = ""
|
|
214
|
+
try:
|
|
215
|
+
q0 = (questions or [{}])[0]
|
|
216
|
+
_qtext = str(q0.get("question") or "")
|
|
217
|
+
# 提取零件名:优先取【】/()内的名称(小屋常用写法)
|
|
218
|
+
import re as _re2
|
|
219
|
+
for _pat in (r"[【\[]([^】\]]+)[】\]]", r"[((]([^))]+)[))]"):
|
|
220
|
+
_m = _re2.search(_pat, _qtext)
|
|
221
|
+
if _m:
|
|
222
|
+
_cand = _m.group(1).strip()
|
|
223
|
+
# 排除明显不是零件名的内容(纯数字/规格/材质)
|
|
224
|
+
if _cand and not _re2.fullmatch(r"[\d\.\sA-Za-z\-]+", _cand):
|
|
225
|
+
part_guess = _cand
|
|
226
|
+
break
|
|
227
|
+
# 若括号里没找到,尝试"建模<名>" / "用于<名>" 句式
|
|
228
|
+
if not part_guess:
|
|
229
|
+
_m2 = _re2.search(r"(?:建模|用于|确认)\s*([\u4e00-\u9fa5A-Za-z0-9_\-]{2,20})",
|
|
230
|
+
_qtext)
|
|
231
|
+
if _m2:
|
|
232
|
+
part_guess = _m2.group(1).strip()
|
|
233
|
+
except Exception:
|
|
234
|
+
part_guess = ""
|
|
235
|
+
try:
|
|
236
|
+
q0 = (questions or [{}])[0]
|
|
237
|
+
key = part_guess or str(q0.get("header") or "").strip() \
|
|
238
|
+
or str(q0.get("id") or "").strip()
|
|
239
|
+
if not key:
|
|
240
|
+
key = str(q0.get("question") or "")[:40]
|
|
241
|
+
except Exception:
|
|
242
|
+
key = part_guess or ""
|
|
243
|
+
if not key:
|
|
244
|
+
key = "answer_%d" % int(_t.time())
|
|
245
|
+
rec = {
|
|
246
|
+
"selected": sel,
|
|
247
|
+
"custom": custom,
|
|
248
|
+
"pendingId": pending_id,
|
|
249
|
+
"child": child,
|
|
250
|
+
"ts": _t.time(),
|
|
251
|
+
# 【Bug#8 修复】原写成 _t.time.strftime(...) —— _t 是 time 模块,
|
|
252
|
+
# 而 _t.time 是【函数】不是结构体,调 .strftime 必抛 AttributeError,
|
|
253
|
+
# 又被外层 except 静默吞掉 → 回写永远失败且无任何报错。
|
|
254
|
+
# 正确写法:_t.strftime(...) 或 time.localtime() 转换。
|
|
255
|
+
"time": _t.strftime("%Y-%m-%d %H:%M:%S", _t.localtime()),
|
|
256
|
+
}
|
|
257
|
+
answers[key] = rec
|
|
258
|
+
# ── 【Bug#2 修复】多别名登记 ──────────────────────────────────────
|
|
259
|
+
# 让 confirm-part 无论用【零件名 / header / 问题摘要 / 选中标签】
|
|
260
|
+
# 哪种键去查都能命中,避免"证据明明在却读不到"。
|
|
261
|
+
_aliases = []
|
|
262
|
+
if part_guess:
|
|
263
|
+
_aliases.append(part_guess)
|
|
264
|
+
try:
|
|
265
|
+
_q0 = (questions or [{}])[0]
|
|
266
|
+
_h = str(_q0.get("header") or "").strip()
|
|
267
|
+
if _h:
|
|
268
|
+
_aliases.append(_h)
|
|
269
|
+
_qtext_full = str(_q0.get("question") or "").strip()
|
|
270
|
+
if _qtext_full:
|
|
271
|
+
_aliases.append(_qtext_full[:40])
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
for _al in _aliases:
|
|
275
|
+
if _al and _al not in answers:
|
|
276
|
+
answers[_al] = dict(rec)
|
|
277
|
+
# 同时按"选中标签"登记一份,供按零件名精确匹配
|
|
278
|
+
for s in sel:
|
|
279
|
+
answers[str(s)] = dict(rec)
|
|
280
|
+
data["answers"] = answers
|
|
281
|
+
data["last_answer_at"] = rec["time"]
|
|
282
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
283
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
284
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
# ── 【Bug#8 修复】回写失败必须留痕,不能再静默吞掉 ──────────────
|
|
287
|
+
# 原实现 except: pass,导致"回写永远失败"却毫无痕迹,
|
|
288
|
+
# 排查时只能看到 confirm-part 的 audit 说"未读到作答痕迹"。
|
|
289
|
+
# 现在把失败原因写到 tools/reports/_ask_user_writeback_error.log,
|
|
290
|
+
# 便于定位;仍不影响主流程(答案照常返回)。
|
|
291
|
+
try:
|
|
292
|
+
_log = os.path.join(_TOOLS_DIR, "reports", "_ask_user_writeback_error.log")
|
|
293
|
+
os.makedirs(os.path.dirname(_log), exist_ok=True)
|
|
294
|
+
with open(_log, "a", encoding="utf-8") as _lf:
|
|
295
|
+
_lf.write("%s room=%s error=%r\n"
|
|
296
|
+
% (__import__("time").strftime("%Y-%m-%d %H:%M:%S"),
|
|
297
|
+
room, e))
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ══ 【2026-10-29 修复】跨会话去重:同一道题只允许存在一张卡 ══════════════
|
|
305
|
+
# 现场事故(会话 ceac3fff):三个总计子代理同时存活(各自 sessionId 不同),
|
|
306
|
+
# 它们各自调 ask_user.py 问同一批问题;而旧去重只查
|
|
307
|
+
# /pending-child?childSessionId=<自己>
|
|
308
|
+
# 只能看到"自己的"卡,看不到另外两个子代理刚建的同一张卡 ——
|
|
309
|
+
# 于是同一题被登记 3 次,用户看到已经答过的选项反复弹出来。
|
|
310
|
+
#
|
|
311
|
+
# 修复(三重):
|
|
312
|
+
# ① 先按【全局】查 pending(rootSessionId=*),跨会话比对题目语义;
|
|
313
|
+
# ② 命中 → 直接复用那张卡(不新建);
|
|
314
|
+
# ③ 若命中的是【别人的】卡(childSessionId 不同),不阻塞等待自己那张,
|
|
315
|
+
# 而是立刻返回 gate=ASK_DUPLICATE_OWNED_BY_PEER + 已答结果/指引,
|
|
316
|
+
# 让调用方明白"这题别人已经在问了,别再重复问用户"。
|
|
317
|
+
def _global_pending(host):
|
|
318
|
+
"""跨会话取全部未结提问(插件支持 rootSessionId=*)。"""
|
|
319
|
+
try:
|
|
320
|
+
return _get("%s/pending-child?rootSessionId=*" % (host + PREFIX), timeout=5)
|
|
321
|
+
except Exception:
|
|
322
|
+
return {}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _dedup_across_sessions(host, questions, my_child):
|
|
326
|
+
"""在【所有子代理】的未结提问里找同一道题。
|
|
327
|
+
|
|
328
|
+
Returns: {"hit": bool, "owned_by_me": bool, "pendingId": str|None,
|
|
329
|
+
"peer_child": str|None}
|
|
330
|
+
"""
|
|
331
|
+
out = {"hit": False, "owned_by_me": False, "pendingId": None,
|
|
332
|
+
"peer_child": None}
|
|
333
|
+
data = _global_pending(host)
|
|
334
|
+
for p in (data.get("pending") or []):
|
|
335
|
+
pid = p.get("pendingId")
|
|
336
|
+
if not pid:
|
|
337
|
+
continue
|
|
338
|
+
peer_qs = p.get("questions") or []
|
|
339
|
+
if not peer_qs or len(peer_qs) != len(questions):
|
|
340
|
+
continue
|
|
341
|
+
if not _same_question({"questions": peer_qs}):
|
|
342
|
+
continue
|
|
343
|
+
out["hit"] = True
|
|
344
|
+
out["pendingId"] = pid
|
|
345
|
+
out["peer_child"] = p.get("childSessionId")
|
|
346
|
+
out["owned_by_me"] = (str(p.get("childSessionId") or "") == str(my_child))
|
|
347
|
+
return out
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ══ 【2026-10-29 修复】已答台账:同一道题答过就不再弹 ═══════════════════
|
|
353
|
+
# 上面那层只能拦住"同时未结"的重复卡;但实测里还有另一种情况:
|
|
354
|
+
# 卡已结算(用户答过了),另一个子代理稍后才问同一道题 → 又弹一次。
|
|
355
|
+
# 用户原话:"会突然跳出来已经选择过的选项"。
|
|
356
|
+
#
|
|
357
|
+
# 因此再加一层【持久化已答台账】:
|
|
358
|
+
# · 每次成功拿到答案 → 按"题目语义指纹"落盘(含答案与时间);
|
|
359
|
+
# · 每次提问前先查台账,命中且未过期 → 直接返回旧答案,不再打扰用户;
|
|
360
|
+
# · TTL 默认 2 小时(够一次完整设计任务),过期自动忽略。
|
|
361
|
+
# 台账位置:与 mode_state 同目录(_store.state_dir()/ask_answered.json)。
|
|
362
|
+
# ══ 题目文本归一化 / 选项指纹(模块级)═══════════════════════════════════
|
|
363
|
+
# 【2026-10-29 修复】这两个函数原先定义在 main() 内部的闭包里,导致
|
|
364
|
+
# 【模块级】的已答台账 _ledger_key() 调不到它们 → 指纹恒为 None →
|
|
365
|
+
# 台账永远写不进去 → "同一题反复弹出"根本拦不住。
|
|
366
|
+
# 现在提到模块级,闭包内直接引用同名模块函数(行为完全一致)。
|
|
367
|
+
import re as _re_mod
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _norm_text(s):
|
|
371
|
+
"""问题文本归一化:去空白/标点/常见套话,只留实义字符。"""
|
|
372
|
+
t = str(s or "")
|
|
373
|
+
t = _re_mod.sub(r"[\s,。、;:!?,.;:!?~~…\-—_()()【】\[\]\"'“”‘’]", "", t)
|
|
374
|
+
for w in ("以下参数将用于建模", "以下参数用于", "参数如下", "请确认",
|
|
375
|
+
"要用吗", "可以用吗", "是否确认", "确认开始建模", "开始建模",
|
|
376
|
+
"参数确认", "请选择", "请核对", "核对后", "将用于"):
|
|
377
|
+
t = t.replace(w, "")
|
|
378
|
+
return t
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _opts_of(qs):
|
|
382
|
+
"""选项标签集合指纹(每题的选项排序后成元组)。"""
|
|
383
|
+
out = []
|
|
384
|
+
for q in (qs or []):
|
|
385
|
+
labels = []
|
|
386
|
+
for o in (q.get("options") or []):
|
|
387
|
+
if isinstance(o, dict):
|
|
388
|
+
labels.append(str(o.get("label", "")))
|
|
389
|
+
else:
|
|
390
|
+
labels.append(str(o))
|
|
391
|
+
out.append(tuple(sorted(labels)))
|
|
392
|
+
return tuple(out)
|
|
393
|
+
|
|
394
|
+
ASK_LEDGER_NAME = "ask_answered.json"
|
|
395
|
+
ASK_LEDGER_TTL_SEC = 2 * 3600
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _ledger_path():
|
|
399
|
+
"""台账路径(优先与门禁状态同目录,取不到则退到本脚本目录)。"""
|
|
400
|
+
try:
|
|
401
|
+
import _store
|
|
402
|
+
_d = _store.state_dir()
|
|
403
|
+
except Exception:
|
|
404
|
+
_d = None
|
|
405
|
+
if not _d:
|
|
406
|
+
_d = os.path.dirname(os.path.abspath(__file__))
|
|
407
|
+
try:
|
|
408
|
+
os.makedirs(_d, exist_ok=True)
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
return os.path.join(_d, ASK_LEDGER_NAME)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _ledger_load():
|
|
415
|
+
try:
|
|
416
|
+
p = _ledger_path()
|
|
417
|
+
if not os.path.exists(p):
|
|
418
|
+
return {}
|
|
419
|
+
with open(p, "r", encoding="utf-8-sig") as f:
|
|
420
|
+
d = json.load(f)
|
|
421
|
+
return d if isinstance(d, dict) else {}
|
|
422
|
+
except Exception:
|
|
423
|
+
return {}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _ledger_save(data):
|
|
427
|
+
try:
|
|
428
|
+
p = _ledger_path()
|
|
429
|
+
tmp = p + ".tmp"
|
|
430
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
431
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
432
|
+
os.replace(tmp, p)
|
|
433
|
+
return True
|
|
434
|
+
except Exception:
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _ledger_key(questions):
|
|
439
|
+
"""题目语义指纹(与 _same_question 同源的归一化口径)。
|
|
440
|
+
|
|
441
|
+
⚠️ _opts_of() 返回【元组的元组】(每题一个 label 元组),
|
|
442
|
+
不能直接 "|".join —— 之前这里抛 TypeError 被 except 吞掉,
|
|
443
|
+
指纹恒为 None,台账永远写不进去("同一题反复弹"因此拦不住)。
|
|
444
|
+
现在显式展平。
|
|
445
|
+
"""
|
|
446
|
+
try:
|
|
447
|
+
import hashlib
|
|
448
|
+
parts = []
|
|
449
|
+
for q in questions:
|
|
450
|
+
parts.append(_norm_text(q.get("question", "")))
|
|
451
|
+
_o = _opts_of([q])
|
|
452
|
+
_labels = list(_o[0]) if (_o and isinstance(_o[0], (tuple, list))) else []
|
|
453
|
+
parts.append("|".join(_labels))
|
|
454
|
+
raw = "\u0001".join(parts)
|
|
455
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
456
|
+
except Exception:
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _ledger_get(questions):
|
|
461
|
+
"""查已答台账;命中且未过期返回答案,否则 None。"""
|
|
462
|
+
key = _ledger_key(questions)
|
|
463
|
+
if not key:
|
|
464
|
+
return None
|
|
465
|
+
data = _ledger_load()
|
|
466
|
+
rec = data.get(key)
|
|
467
|
+
if not isinstance(rec, dict):
|
|
468
|
+
return None
|
|
469
|
+
try:
|
|
470
|
+
age = time.time() - float(rec.get("ts") or 0)
|
|
471
|
+
except Exception:
|
|
472
|
+
return None
|
|
473
|
+
if age > ASK_LEDGER_TTL_SEC:
|
|
474
|
+
return None
|
|
475
|
+
return {"answers": rec.get("answers"), "answered_at": rec.get("at"),
|
|
476
|
+
"age_sec": round(age, 1), "key": key}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _ledger_put(questions, ans):
|
|
480
|
+
"""把答案写进台账(同一指纹覆盖为最新)。"""
|
|
481
|
+
key = _ledger_key(questions)
|
|
482
|
+
if not key:
|
|
483
|
+
return False
|
|
484
|
+
data = _ledger_load()
|
|
485
|
+
# 顺手清过期项,避免无限增长
|
|
486
|
+
now = time.time()
|
|
487
|
+
for k in list(data.keys()):
|
|
488
|
+
try:
|
|
489
|
+
if now - float((data[k] or {}).get("ts") or 0) > ASK_LEDGER_TTL_SEC:
|
|
490
|
+
data.pop(k, None)
|
|
491
|
+
except Exception:
|
|
492
|
+
data.pop(k, None)
|
|
493
|
+
data[key] = {"answers": (ans or {}).get("answers"),
|
|
494
|
+
"ts": now,
|
|
495
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
496
|
+
"questions": [q.get("question") for q in questions]}
|
|
497
|
+
return _ledger_save(data)
|
|
498
|
+
|
|
499
|
+
def _safe_room(room):
|
|
500
|
+
return "".join(c for c in str(room) if c.isalnum() or c in " _-").strip()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _resolve_self_session_id():
|
|
507
|
+
r"""解析"我自己的 sessionId"(本子代理的身份)。
|
|
508
|
+
|
|
509
|
+
── 【BUG-01 修复】新增 --child auto ────────────────────────────────────
|
|
510
|
+
缺陷(2026-10-29 竞速小车任务实测):
|
|
511
|
+
建模小屋需要确认参数时必须走本桥,但 --child 要求填【自己的 sessionId】,
|
|
512
|
+
而 DSH 只把身份放在环境变量 DSH_SESSION_ID 里;提示词又写着
|
|
513
|
+
"--child <自己的 sessionId>"(占位符),子代理无从得知该填什么 ——
|
|
514
|
+
于是"小屋一旦需要确认任何参数就彻底无法继续"。
|
|
515
|
+
|
|
516
|
+
修复:--child 接受 auto(或 self/me/current),按下列顺序自动解析:
|
|
517
|
+
① 环境变量 DSH_SESSION_ID(DSH 注入给子代理的会话 id,最权威)
|
|
518
|
+
② 环境变量 DSH_AGENT_ID / DSH_CHILD_SESSION_ID(兼容别名)
|
|
519
|
+
③ mode_state.json 里属于"本进程/本房间"的绑定(whoami 同源口径)
|
|
520
|
+
全部失败返回空串,由调用方给出可执行的报错(而不是静默用错身份)。
|
|
521
|
+
|
|
522
|
+
注意:绝不能猜一个"看起来像"的 id —— 猜错会把提问投递给别的会话,
|
|
523
|
+
表现为"提问发出去了但用户看不到/答完不回传",比直接报错更难排查。
|
|
524
|
+
"""
|
|
525
|
+
for _k in ("DSH_SESSION_ID", "DSH_AGENT_ID", "DSH_CHILD_SESSION_ID"):
|
|
526
|
+
_v = str(os.environ.get(_k) or "").strip()
|
|
527
|
+
if _v:
|
|
528
|
+
return _v, _k
|
|
529
|
+
return "", None
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def main():
|
|
533
|
+
ap = argparse.ArgumentParser(description="子代理阻塞式提问")
|
|
534
|
+
ap.add_argument("--child", required=True,
|
|
535
|
+
help=("本子代理的 sessionId;"
|
|
536
|
+
"填 auto 则自动取 DSH_SESSION_ID(【BUG-01】推荐用法)"))
|
|
537
|
+
ap.add_argument("--room", default="", help="房间名(用于进度上报,可选)")
|
|
538
|
+
ap.add_argument("--question", default="", help="问题文本")
|
|
539
|
+
ap.add_argument("--option", action="append", default=[], help="选项(可多次)")
|
|
540
|
+
ap.add_argument("--spec", default="", help="问题 JSON 文件路径(含 questions 数组)")
|
|
541
|
+
ap.add_argument("--header", default="", help="问题分组标题")
|
|
542
|
+
ap.add_argument("--multi", action="store_true", help="是否多选")
|
|
543
|
+
ap.add_argument("--host", default=DEFAULT_HOST, help="DSH host 地址")
|
|
544
|
+
# ── 【C5 修复】默认超时必须低于 DSH shell 调用上限(600s)──────────────
|
|
545
|
+
# 原默认 1800s(30 分钟)远超外壳上限 → 首次提问必被掐断,
|
|
546
|
+
# 而 host 侧 pending 仍然存活 → 面板堆积多张待答卡,重试又重复登记。
|
|
547
|
+
# 现默认 540s(留 60s 余量),并允许 DSH_ASK_TIMEOUT 环境变量覆盖。
|
|
548
|
+
#
|
|
549
|
+
# ── 【长等待修复 2026-10-31】为什么还要一个"分段上限" ──────────────────
|
|
550
|
+
# 事故(session-6019cd4b):ask_user.py 以 540s 一次性阻塞在 pwsh 里,
|
|
551
|
+
# 若再套上 "Start-Sleep + job_output wait:true" 的轮询,GUI 会连续
|
|
552
|
+
# 好几分钟没有任何反馈 —— 用户以为 DSH 卡死而强制退出,退出瞬间
|
|
553
|
+
# 所有 fiber 失活,等待中的 job_output 抛 inactive context 并结束整轮。
|
|
554
|
+
# 本脚本自身【绝不会】调用 job_output,但调用方会包装它,所以这里给出
|
|
555
|
+
# 单段上限 SEG_MAX,并在超时返回体里【明确要求分段等待】而不是盲目重发。
|
|
556
|
+
_def_to = int(os.environ.get("DSH_ASK_TIMEOUT", "540"))
|
|
557
|
+
_SEG_MAX = 90 # 单段最长阻塞(秒):超过就返回"仍在等待,请分段续等"
|
|
558
|
+
ap.add_argument("--timeout", type=int, default=_def_to,
|
|
559
|
+
help="等待用户作答的最长秒数(默认 540,须 < DSH shell 上限 600)")
|
|
560
|
+
ap.add_argument("--seg-max", type=int, default=_SEG_MAX,
|
|
561
|
+
help="单段最长阻塞秒数(默认 90;到点返回『仍在等待』,"
|
|
562
|
+
"由调用方分段续等,避免长时间静默)")
|
|
563
|
+
ap.add_argument("--interval", type=float, default=2.0, help="轮询间隔秒数")
|
|
564
|
+
args = ap.parse_args()
|
|
565
|
+
|
|
566
|
+
# ── 【BUG-01 修复】--child auto → 自动取自己的 DSH_SESSION_ID ──────────
|
|
567
|
+
# 只认这几个明确的占位写法;其他值一律原样使用(保持向后兼容)。
|
|
568
|
+
if str(args.child).strip().lower() in ("auto", "self", "me", "current", "this"):
|
|
569
|
+
_sid, _src = _resolve_self_session_id()
|
|
570
|
+
if not _sid:
|
|
571
|
+
emit({
|
|
572
|
+
"ok": False,
|
|
573
|
+
"gate": "CHILD_SESSION_UNKNOWN",
|
|
574
|
+
"error": ("--child auto 无法自动确定本子代理的 sessionId:"
|
|
575
|
+
"环境变量 DSH_SESSION_ID 未设置。"),
|
|
576
|
+
"why": ("DSH 只在【子代理进程内】注入 DSH_SESSION_ID;"
|
|
577
|
+
"在普通 shell 里手动跑本脚本时它是空的。"),
|
|
578
|
+
"how": [
|
|
579
|
+
"① 在子代理里调用时优先用 auto:--child auto",
|
|
580
|
+
"② 若确实拿不到,请把父代理分配的房间 id 显式传入:",
|
|
581
|
+
" --child <你的 sessionId>",
|
|
582
|
+
"③ 查当前已知会话:python mode_gate.py whoami <房间名>",
|
|
583
|
+
],
|
|
584
|
+
})
|
|
585
|
+
return 1
|
|
586
|
+
args.child = _sid
|
|
587
|
+
sys.stderr.write("[ask_user] --child auto 解析为 %s (来源: %s)%s"
|
|
588
|
+
% (_sid, _src, NL_S))
|
|
589
|
+
|
|
590
|
+
# 组装问题
|
|
591
|
+
if args.spec:
|
|
592
|
+
try:
|
|
593
|
+
with open(args.spec, "r", encoding="utf-8") as f:
|
|
594
|
+
spec = json.load(f)
|
|
595
|
+
questions = spec.get("questions") if isinstance(spec, dict) else spec
|
|
596
|
+
if not isinstance(questions, list) or not questions:
|
|
597
|
+
raise ValueError("spec 中没有有效的 questions 数组")
|
|
598
|
+
except Exception as e:
|
|
599
|
+
emit({"ok": False, "error": "读取 spec 失败: %s" % e})
|
|
600
|
+
return 1
|
|
601
|
+
else:
|
|
602
|
+
if not args.question:
|
|
603
|
+
emit({"ok": False, "error": "缺少 --question 或 --spec"})
|
|
604
|
+
return 1
|
|
605
|
+
questions = [{
|
|
606
|
+
"id": "q1",
|
|
607
|
+
"question": args.question,
|
|
608
|
+
"header": args.header or "确认",
|
|
609
|
+
"options": [{"label": o} for o in (args.option or ["确认"])],
|
|
610
|
+
"multiSelect": bool(args.multi),
|
|
611
|
+
}]
|
|
612
|
+
|
|
613
|
+
# ── 【C5 修复 → Bug#3 强化】登记前先查该子代理是否已有【同题】pending ──
|
|
614
|
+
# 场景:首次提问被外壳 600s 掐断,但 host 侧 pending 仍存活。
|
|
615
|
+
# 重试时若直接再登记,面板会堆出第 2、3 张同样的卡,用户体验极差,
|
|
616
|
+
# 而且用户答了旧卡、脚本却在等新卡 → 看起来"卡死"。
|
|
617
|
+
#
|
|
618
|
+
# ── 【Bug#3 修复】旧实现要求问题文本【逐字完全一致】,实测必漏 ────────
|
|
619
|
+
# 测试反馈:壳体机架"底座安装板"一度堆了 5 张卡。
|
|
620
|
+
# 根因:小屋每次重试时措辞略有差异 ——
|
|
621
|
+
# "以下参数将用于建模底座安装板,要用吗?"
|
|
622
|
+
# "确认开始建模底座安装板?"
|
|
623
|
+
# "底座安装板参数确认"
|
|
624
|
+
# 三者语义相同但字符串不同,逐字比对必然返回 False
|
|
625
|
+
# → 每个都当成新问题登记 → 卡片堆积。
|
|
626
|
+
#
|
|
627
|
+
# 修复策略(两层,从严到宽):
|
|
628
|
+
# ① 语义指纹匹配(主路径):把问题文本归一化后提取
|
|
629
|
+
# 【归一化文本 + 选项集合 + 关键数字】三者全等即视为同一张卡;
|
|
630
|
+
# ② 归一化后互相包含 / 数字集合一致(措辞微调但主体与尺寸相同)。
|
|
631
|
+
# 硬约束:选项集合必须完全一致才可合并 ——
|
|
632
|
+
# 选项是"用户能选什么"的契约,放宽会把 A 零件的选项套到 B 零件上。
|
|
633
|
+
import re as _re
|
|
634
|
+
# _norm_text / _opts_of 已提到模块级(台账复用同一口径),这里不再重复定义
|
|
635
|
+
|
|
636
|
+
# ── 【Bug#3 修复·防误合并】"修改类"问句不参与合并 ──────────────────
|
|
637
|
+
# 反例:'底座安装板 厚度改为 12mm 确认' 与 '底座安装板 参数确认'
|
|
638
|
+
# 归一化后都含"底座安装板",会被第②层"数字一致/互相包含"误判为同题。
|
|
639
|
+
# 但前者是【用户已要求修改后的再次确认】,语义上是新的一轮提问,
|
|
640
|
+
# 若复用旧卡片,用户点的是"旧参数"的选项 → 确认内容与实际建模不符。
|
|
641
|
+
# 这类必须登记【新卡】,绝不能与旧卡合并。
|
|
642
|
+
_MODIFY_KW = ("改为", "改成", "修改", "调整", "改为为", "变更",
|
|
643
|
+
"重做", "重新", "换成")
|
|
644
|
+
|
|
645
|
+
def _is_modify_question(s):
|
|
646
|
+
t = str(s or "")
|
|
647
|
+
return any(k in t for k in _MODIFY_KW)
|
|
648
|
+
|
|
649
|
+
def _digits_of(s):
|
|
650
|
+
"""提取文本里的关键数字(尺寸),用于判定是否为同一零件。"""
|
|
651
|
+
return sorted(_re.findall(r"\d+(?:\.\d+)?", str(s or "")))
|
|
652
|
+
|
|
653
|
+
# _opts_of 已提到模块级
|
|
654
|
+
|
|
655
|
+
def _semantic_key(qs):
|
|
656
|
+
"""语义指纹:(归一化问题文本元组, 选项集合, 数字集合)。三者全等=同题。"""
|
|
657
|
+
norm = tuple(_norm_text(q.get("question", "")) for q in (qs or []))
|
|
658
|
+
digits = tuple(_digits_of(" ".join(str(q.get("question", "")) for q in (qs or []))))
|
|
659
|
+
return (norm, _opts_of(qs), digits)
|
|
660
|
+
|
|
661
|
+
def _same_question(p):
|
|
662
|
+
"""判断 host 侧 pending 与本脚本要问的题是否【语义相同】(Bug#3)。"""
|
|
663
|
+
try:
|
|
664
|
+
qs = p.get("questions") or []
|
|
665
|
+
if len(qs) != len(questions):
|
|
666
|
+
return False
|
|
667
|
+
# ── 硬约束 0:修改类问句不合并(否则会复用旧参数的卡)──
|
|
668
|
+
_new_is_mod = any(_is_modify_question(q.get("question", ""))
|
|
669
|
+
for q in questions)
|
|
670
|
+
_old_is_mod = any(_is_modify_question(q.get("question", ""))
|
|
671
|
+
for q in qs)
|
|
672
|
+
if _new_is_mod != _old_is_mod:
|
|
673
|
+
return False
|
|
674
|
+
# ── 硬约束:选项集合必须一致(用户可选项是契约,不可放宽)──
|
|
675
|
+
if _opts_of(qs) != _opts_of(questions):
|
|
676
|
+
return False
|
|
677
|
+
# 第①层:语义指纹全等
|
|
678
|
+
try:
|
|
679
|
+
if _semantic_key(qs) == _semantic_key(questions):
|
|
680
|
+
return True
|
|
681
|
+
except Exception:
|
|
682
|
+
pass
|
|
683
|
+
# 第②层:归一化后互相包含 / 关键数字一致
|
|
684
|
+
for a, b in zip(qs, questions):
|
|
685
|
+
na = _norm_text(a.get("question", ""))
|
|
686
|
+
nb = _norm_text(b.get("question", ""))
|
|
687
|
+
if na == nb:
|
|
688
|
+
continue
|
|
689
|
+
if not na or not nb:
|
|
690
|
+
return False
|
|
691
|
+
if (na in nb or nb in na) and min(len(na), len(nb)) >= 2:
|
|
692
|
+
continue
|
|
693
|
+
da = _digits_of(a.get("question", ""))
|
|
694
|
+
db = _digits_of(b.get("question", ""))
|
|
695
|
+
if da and da == db:
|
|
696
|
+
continue
|
|
697
|
+
return False
|
|
698
|
+
return True
|
|
699
|
+
except Exception:
|
|
700
|
+
return False
|
|
701
|
+
|
|
702
|
+
# ── 【2026-10-29 G03 修复】先选一个真正可达的 host(快速失败,不等 540s)──
|
|
703
|
+
# 旧实现直接拿 args.host 去 POST;3080 没监听时只会在最后报一句
|
|
704
|
+
# "无法连接 host",既没有降级指引,也没有尝试 DSH_WEB_URL 里那个真实端口。
|
|
705
|
+
_hosts = _candidate_hosts(args.host if args.host != DEFAULT_HOST else None)
|
|
706
|
+
_host = None
|
|
707
|
+
for _h in _hosts:
|
|
708
|
+
try:
|
|
709
|
+
_probe = _get("%s/pending-child?childSessionId=%s" % (_h + PREFIX, args.child),
|
|
710
|
+
timeout=3)
|
|
711
|
+
if isinstance(_probe, dict):
|
|
712
|
+
_host = _h
|
|
713
|
+
break
|
|
714
|
+
except Exception:
|
|
715
|
+
continue
|
|
716
|
+
if not _host:
|
|
717
|
+
emit({
|
|
718
|
+
"ok": False,
|
|
719
|
+
"gate": "ASK_BRIDGE_UNREACHABLE",
|
|
720
|
+
"error": "无法连接宿主代问桥(已尝试:%s)" % " / ".join(_hosts),
|
|
721
|
+
"tried_hosts": _hosts,
|
|
722
|
+
"hint": _BRIDGE_DOWN_HINT,
|
|
723
|
+
"fallback": "parent_relay",
|
|
724
|
+
})
|
|
725
|
+
return 1
|
|
726
|
+
args.host = _host
|
|
727
|
+
|
|
728
|
+
pending_id = None
|
|
729
|
+
reused = False
|
|
730
|
+
# ── ① 先查【跨会话】去重(2026-10-29 修复)──────────────────────────
|
|
731
|
+
# 旧实现只查自己的 childSessionId,看不到别的子代理刚建的同一张卡,
|
|
732
|
+
# 于是同一题被登记多次 → 用户看到已答过的选项反复弹出。
|
|
733
|
+
_dup = _dedup_across_sessions(args.host, questions, args.child)
|
|
734
|
+
if _dup.get("hit"):
|
|
735
|
+
if _dup.get("owned_by_me"):
|
|
736
|
+
pending_id = _dup.get("pendingId")
|
|
737
|
+
reused = True
|
|
738
|
+
else:
|
|
739
|
+
# 别的子代理已经在问同一题 → 不要重复问用户
|
|
740
|
+
emit({
|
|
741
|
+
"ok": False,
|
|
742
|
+
"gate": "ASK_DUPLICATE_OWNED_BY_PEER",
|
|
743
|
+
"pendingId": _dup.get("pendingId"),
|
|
744
|
+
"peer_child": _dup.get("peer_child"),
|
|
745
|
+
"my_child": args.child,
|
|
746
|
+
"questions": questions,
|
|
747
|
+
"error": ("这道题【另一个子代理正在问用户】,不要重复提问。"),
|
|
748
|
+
"why": ("2026-10-29 实测:多个总计子代理同时存活时,各自提问导致",
|
|
749
|
+
"用户看到已答过的选项反复弹出。"),
|
|
750
|
+
"next": ("① 不要重问;② 等父代理把用户答复回传给你(send_message),",
|
|
751
|
+
"然后直接 mode2_flow.py answer-question <seq> \"<答复>\" 落账;",
|
|
752
|
+
"③ 若你确认自己是被误开的重复子代理 → 立刻结束回合。"),
|
|
753
|
+
})
|
|
754
|
+
return 1
|
|
755
|
+
try:
|
|
756
|
+
_pend = _get("%s/pending-child?childSessionId=%s" % (args.host + PREFIX, args.child))
|
|
757
|
+
for _p in (_pend.get("pending") or []):
|
|
758
|
+
if _same_question(_p) and _p.get("pendingId"):
|
|
759
|
+
pending_id = _p.get("pendingId")
|
|
760
|
+
reused = True
|
|
761
|
+
break
|
|
762
|
+
except Exception:
|
|
763
|
+
pending_id = None
|
|
764
|
+
|
|
765
|
+
if pending_id:
|
|
766
|
+
# 复用已有卡片:不重复登记,避免面板堆积
|
|
767
|
+
pass
|
|
768
|
+
else:
|
|
769
|
+
# 1) 登记新提问
|
|
770
|
+
try:
|
|
771
|
+
reg = _post(args.host + PREFIX + "/ask-child", {
|
|
772
|
+
"childSessionId": args.child,
|
|
773
|
+
"questions": questions,
|
|
774
|
+
})
|
|
775
|
+
except Exception as e:
|
|
776
|
+
emit({"ok": False, "gate": "ASK_BRIDGE_UNREACHABLE",
|
|
777
|
+
"error": "无法连接 host: %s" % e,
|
|
778
|
+
"host": args.host,
|
|
779
|
+
"hint": _BRIDGE_DOWN_HINT,
|
|
780
|
+
"fallback": "parent_relay"})
|
|
781
|
+
return 1
|
|
782
|
+
|
|
783
|
+
if not reg.get("ok") or not reg.get("pendingId"):
|
|
784
|
+
emit({"ok": False, "error": reg.get("error", "登记失败")})
|
|
785
|
+
return 1
|
|
786
|
+
|
|
787
|
+
pending_id = reg["pendingId"]
|
|
788
|
+
room = args.room
|
|
789
|
+
|
|
790
|
+
# 2) 阻塞轮询,直到用户作答(期间保持心跳,避免被判 stale)
|
|
791
|
+
# ── 【Bug#7 修复】必须区分【已作答】与【卡片已过期消失】──────────
|
|
792
|
+
# 测试反馈:卡片在宿主端 10 分钟 TTL 过期后 pending 消失,但小屋
|
|
793
|
+
# 报告仍停在 params_shown —— 因为它以为"pending 还在等",
|
|
794
|
+
# 既不敢重发(怕加剧 Bug#3 堆卡),又不会退出 → 静默卡死。
|
|
795
|
+
# 根因:原实现只要 pending 消失就去取 result,
|
|
796
|
+
# 取不到 result 时【静默 pass 继续轮询】,直到 timeout 才报错。
|
|
797
|
+
# 修复:一旦"pending 消失 + result 取不到(连续 N 次确认)",
|
|
798
|
+
# 立即以明确错误退出,让小屋据此上报 failed,进入主对话介入流程。
|
|
799
|
+
deadline = time.time() + int(args.timeout)
|
|
800
|
+
# ── 【长等待修复】把整段等待再切成若干"短段",每段到点就返回可续等的结果 ──
|
|
801
|
+
# 目的:任何一次 pwsh 调用都不会静默阻塞超过 seg-max 秒,
|
|
802
|
+
# 用户随时能在 GUI 看到进度、随时能打断(2026-10-31 卡死事故的直接成因)。
|
|
803
|
+
_seg_cap = max(5, min(int(getattr(args, "seg_max", 90) or 90), int(args.timeout)))
|
|
804
|
+
_seg_deadline = min(deadline, time.time() + _seg_cap)
|
|
805
|
+
base = args.host + PREFIX
|
|
806
|
+
_gone_streak = 0 # pending 消失但取不到 result 的连续次数
|
|
807
|
+
_GONE_LIMIT = 3 # 连续 3 次(约 6 秒)即确认为"过期消失"
|
|
808
|
+
while time.time() < _seg_deadline:
|
|
809
|
+
try:
|
|
810
|
+
pending = _get("%s/pending-child?childSessionId=%s" % (base, args.child))
|
|
811
|
+
still = [p for p in (pending.get("pending") or []) if p.get("pendingId") == pending_id]
|
|
812
|
+
if not still:
|
|
813
|
+
# 该 pending 已从 host 侧消失:两种可能 ——
|
|
814
|
+
# (a) 用户已作答(答案由 host 侧 childResults 保存,可取出)
|
|
815
|
+
# (b) 卡片 TTL 过期被清理(取不到 result)
|
|
816
|
+
ans = _get("%s/result-child?pendingId=%s" % (base, pending_id))
|
|
817
|
+
if ans and ans.get("ok") and ans.get("answers") is not None:
|
|
818
|
+
# ── (a) 已作答 ──
|
|
819
|
+
# 【Bug#8 修复】把作答结果回写 pending.json,供门禁校验
|
|
820
|
+
_writeback_answer(args.room, questions, ans, pending_id, args.child)
|
|
821
|
+
emit(ans)
|
|
822
|
+
return 0
|
|
823
|
+
# ── (b) 取不到 result → 可能是过期,也可能刚被删除的竞态 ──
|
|
824
|
+
_gone_streak += 1
|
|
825
|
+
if _gone_streak >= _GONE_LIMIT:
|
|
826
|
+
emit({"ok": False,
|
|
827
|
+
"error": "pending expired before answer"
|
|
828
|
+
"(提问卡片在宿主端已过期/被清理,且无作答结果)",
|
|
829
|
+
"pendingId": pending_id,
|
|
830
|
+
"pending_alive": False,
|
|
831
|
+
"expired": True,
|
|
832
|
+
"hint": ("卡片已消失且无答案。请【不要】把它当成『用户未回复』"
|
|
833
|
+
"继续等待:应上报 room-report <房间> failed "
|
|
834
|
+
"'提问卡片过期未作答',由主对话决定重发或介入。"
|
|
835
|
+
"若确需重问,请重新调用本脚本(会登记新卡片)。"),
|
|
836
|
+
"room": args.room or None,
|
|
837
|
+
"child": args.child})
|
|
838
|
+
return 2 # 专门的状态码:过期(区别于超时 1)
|
|
839
|
+
else:
|
|
840
|
+
_gone_streak = 0 # 卡片还在 → 计数清零
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
time.sleep(args.interval)
|
|
844
|
+
|
|
845
|
+
# ── 【C5 修复 + 长等待修复】超时输出必须可自救,且区分两种情况 ────────
|
|
846
|
+
# · 分段窗口到点、但总预算还没用完 → return 1 + "仍在等待,请续等",
|
|
847
|
+
# 调用方应当【立刻再调一次】(同一张卡会被复用,不会重复登记)。
|
|
848
|
+
# · 总预算(--timeout)用尽 → return 1 + "彻底超时",
|
|
849
|
+
# 调用方应当上报 room-report failed 并交给主对话介入。
|
|
850
|
+
# 禁止的做法(2026-10-31 事故):把本脚本套进 "Start-Sleep + job_output wait:true"
|
|
851
|
+
# 的长阻塞轮询 —— 静默数分钟会让用户以为 DSH 卡死,强退后整轮 turn 作废。
|
|
852
|
+
_left = deadline - time.time()
|
|
853
|
+
if _left > 0:
|
|
854
|
+
emit({"ok": False,
|
|
855
|
+
"error": "本段等待窗口已到(%d 秒),用户尚未作答" % _seg_cap,
|
|
856
|
+
"pendingId": pending_id,
|
|
857
|
+
"pending_alive": True,
|
|
858
|
+
"expired": False,
|
|
859
|
+
"segmented": True,
|
|
860
|
+
"remaining_sec": int(_left),
|
|
861
|
+
"hint": ("提问卡片仍在面板上,答案还没来 —— 这是【正常的分段返回】,不是失败。"
|
|
862
|
+
"请【立刻再调一次本脚本】继续等(同题会复用同一张卡,不会重复登记,"
|
|
863
|
+
"也不会产生第二张卡);每次调用最多阻塞 %d 秒,"
|
|
864
|
+
"总预算还剩 %d 秒。"
|
|
865
|
+
"🚫不要用 Start-Sleep + job_output wait:true 包住本脚本:"
|
|
866
|
+
"静默长阻塞会让用户以为 DSH 卡死(2026-10-31 事故根因)。")
|
|
867
|
+
% (_seg_cap, int(_left)),
|
|
868
|
+
"room": args.room or None,
|
|
869
|
+
"child": args.child})
|
|
870
|
+
return 1
|
|
871
|
+
|
|
872
|
+
emit({"ok": False,
|
|
873
|
+
"error": "等待用户作答超时(总预算 %d 秒已用尽)" % args.timeout,
|
|
874
|
+
"pendingId": pending_id,
|
|
875
|
+
"pending_alive": True,
|
|
876
|
+
"expired": False,
|
|
877
|
+
"segmented": False,
|
|
878
|
+
"hint": ("提问卡片仍在面板上,但总等待预算已用尽。请【不要盲目重发同一题】,"
|
|
879
|
+
"先看 /pending-child 确认卡片是否还在:"
|
|
880
|
+
"还在 → 上报 room-report <房间> failed '等待用户作答超时',由主对话介入;"
|
|
881
|
+
"不在 → 按卡片过期处理(重问会登记新卡)。"
|
|
882
|
+
"需要更长等待可用 --timeout(须低于外壳上限 600s)配合分段续等。")})
|
|
883
|
+
return 1
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
if __name__ == "__main__":
|
|
887
|
+
sys.exit(main())
|