diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 7d5af686..598714a4 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -2690,7 +2690,10 @@ function summarizeCommandFailures(control) { function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = []) { const findings = []; if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) }); - const commandTimes = control.filter((item) => item.phase === "completed" || item.phase === "started").map((item) => Date.parse(item.ts)).filter(Number.isFinite); + const commandTimes = control + .filter((item) => item.phase === "completed" || item.phase === "started" || item.type === "observer-periodic-refresh") + .map((item) => Date.parse(item.ts)) + .filter(Number.isFinite); const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean)); const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean)); if (routeSessions.size > 1) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed during observation", routeSessionCount: routeSessions.size, samples: sampleRefs(samples, (item) => item.routeSessionId) }); @@ -2699,10 +2702,13 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN if (mismatches.length > 0) findings.push({ id: "route-active-session-mismatch", severity: "red", summary: "routeSessionId and activeSessionId diverged", count: mismatches.length, samples: mismatches.slice(0, 10).map(ref) }); const uncommandedChanges = []; const commandedPromptSeqs = new Set((sampleMetrics?.timeline ?? []).filter((item) => Number(item?.promptIndex) > 0).map((item) => item.seq).filter((seq) => seq !== null && seq !== undefined)); - for (let i = 1; i < samples.length; i += 1) { - const prev = digestSample(samples[i - 1]); - const next = digestSample(samples[i]); - if (prev !== next && !commandedPromptSeqs.has(samples[i]?.seq) && !nearCommand(samples[i], commandTimes, alertThresholds.uncommandedStateChangeCommandWindowMs)) uncommandedChanges.push(ref(samples[i])); + const previousDigestByPage = new Map(); + for (const sample of samples) { + const pageKey = samplePageKey(sample); + const previous = previousDigestByPage.get(pageKey); + const next = digestSample(sample); + if (previous && previous.digest !== next && !commandedPromptSeqs.has(sample?.seq) && !nearCommand(sample, commandTimes, alertThresholds.uncommandedStateChangeCommandWindowMs)) uncommandedChanges.push(ref(sample)); + previousDigestByPage.set(pageKey, { digest: next, sample }); } if (uncommandedChanges.length > 0) findings.push({ id: "uncommanded-visible-state-change", severity: "amber", summary: "visible message/trace digest changed without a nearby command", count: uncommandedChanges.length, samples: uncommandedChanges.slice(0, 20) }); const finalFlicker = detectFinalFlicker(samples); @@ -6225,12 +6231,29 @@ function longestSharedSubstringLength(a, b) { } function digestSample(sample) { - const messages = Array.isArray(sample.messages) ? sample.messages.map((item) => item.textHash || item.textPreview || "").join("|") : ""; - const trace = Array.isArray(sample.traceRows) ? sample.traceRows.map((item) => (item.status || "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; - const diagnostics = Array.isArray(sample.diagnostics) ? sample.diagnostics.map((item) => (item.className || "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; + const messages = Array.isArray(sample.messages) ? sample.messages.map((item) => stableDigestItem(item, ["dataRole", "role", "status", "sessionId", "messageId", "traceId", "turnId"])).join("|") : ""; + const trace = Array.isArray(sample.traceRows) ? sample.traceRows.map((item) => stableDigestItem(item, ["status", "sessionId", "messageId", "traceId", "turnId", "projectedSeq", "sourceSeq", "eventSeq", "eventKind"])).join("|") : ""; + const diagnostics = Array.isArray(sample.diagnostics) ? sample.diagnostics.map((item) => stableDigestItem(item, ["className", "status", "sessionId", "messageId", "traceId", "turnId", "diagnosticCode"])).join("|") : ""; return sha256((sample.routeSessionId || "") + "|" + (sample.activeSessionId || "") + "|" + messages + "|" + trace + "|" + diagnostics); } +function samplePageKey(sample) { + return String(sample?.pageRole || "control") + ":" + String(sample?.pageId || "default"); +} + +function stableDigestItem(item, fields) { + if (!item || typeof item !== "object") return ""; + const identity = fields.map((field) => String(item?.[field] ?? "")).join(":"); + return identity + ":" + stableVisibleDigestText(item?.textPreview || item?.text || item?.textHash || ""); +} + +function stableVisibleDigestText(value) { + return normalizedText(value) + .replace(/\btotal=\d{1,2}:\d{2}:\d{2}\b/giu, "total=") + .replace(/(?:总耗时|耗时)\s*[=::]?\s*(?:(?:\d+\s*天)?\s*(?:\d+\s*小时)?\s*(?:\d+\s*(?:分钟|分))?\s*(?:\d+\s*秒)?|\d{1,2}:\d{2}(?::\d{2})?)/giu, "耗时 ") + .replace(/最近\s*(?:(?:\d+\s*天)?\s*(?:\d+\s*小时)?\s*(?:\d+\s*(?:分钟|分))?\s*(?:\d+\s*秒)?)\s*前/giu, "最近 前"); +} + function nearCommand(sample, commandTimes, windowMs) { const ts = Date.parse(sample.ts); return Number.isFinite(ts) && commandTimes.some((item) => Math.abs(ts - item) <= windowMs);