Publish voice duplex architecture latency training and evaluation research
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
work
|
||||||
|
.env*
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
work/
|
||||||
|
*.log
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json build.mjs index.html styles.css app.js model.js favicon.svg robots.txt release.json ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.28-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build --chmod=0644 /app/dist/ /usr/share/nginx/html/
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 K1412
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 语音双工系统的架构、时延与评测
|
||||||
|
|
||||||
|
公开研究网页,比较三段式、两段式、原生语音与混合设计,解释时间片、计算耗时与用户交互时延,并整理双工训练和系统评测方法。
|
||||||
|
|
||||||
|
访问 [研究网页](https://voice-duplex.k1412.top/)。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
使用 Node.js 20 或更新版本及 Python 3。运行 `npm run check`、`npm run build`,随后运行 `npm run dev` 并访问终端显示的本地地址。
|
||||||
|
|
||||||
|
网页使用本地静态资源。队列实验的纯计算模型位于 `model.js`,对应回归用例位于 `model.test.mjs`。原始论文与官方资料见页面来源章节。来源报告值、工程推断和说明性模型分别标注。
|
||||||
|
|
||||||
|
## 内容与日期
|
||||||
|
|
||||||
|
资料核对日期为 2026 年 11 月 9 日。评测数据保留原始版本和条件,完整系统评测方法按方案呈现。
|
||||||
|
|
||||||
|
## 容器
|
||||||
|
|
||||||
|
Dockerfile 构建静态资源并使用 Nginx 在 8080 端口提供服务,健康检查路径为 `/healthz`。
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
原创代码、文字和绘图代码采用 MIT 许可。外部链接中的论文、商标与内容遵循各自权利声明。
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { queueSteps, architectures, benchmarks } from "./model.js";
|
||||||
|
|
||||||
|
const chunkSlider = document.getElementById("chunk-slider");
|
||||||
|
const computeSlider = document.getElementById("compute-slider");
|
||||||
|
const queueChart = document.getElementById("queue-chart");
|
||||||
|
|
||||||
|
function renderQueue() {
|
||||||
|
const chunkMs = Number(chunkSlider.value);
|
||||||
|
const computeMs = Number(computeSlider.value);
|
||||||
|
const steps = queueSteps(chunkMs, computeMs);
|
||||||
|
const totalMs = Math.max(steps.at(-1).end, chunkMs * 9);
|
||||||
|
const percentage = (value) => `${(value / totalMs) * 100}%`;
|
||||||
|
document.getElementById("chunk-value").textContent = `${chunkMs} ms`;
|
||||||
|
document.getElementById("compute-value").textContent = `${computeMs} ms`;
|
||||||
|
const ratio = document.getElementById("rtf-value");
|
||||||
|
ratio.textContent = `${(computeMs / chunkMs).toFixed(2)}×`;
|
||||||
|
ratio.classList.toggle("overloaded", computeMs >= chunkMs);
|
||||||
|
document.getElementById("backlog-value").textContent =
|
||||||
|
`${steps.at(-1).wait} ms`;
|
||||||
|
document.getElementById("growth-value").textContent =
|
||||||
|
`${Math.max(0, computeMs - chunkMs)} ms`;
|
||||||
|
queueChart.replaceChildren();
|
||||||
|
const ticks = document.createElement("div");
|
||||||
|
ticks.className = "queue-ticks";
|
||||||
|
for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
|
||||||
|
const tick = document.createElement("span");
|
||||||
|
tick.textContent = `${Math.round(totalMs * fraction)}${fraction === 1 ? " ms" : ""}`;
|
||||||
|
ticks.append(tick);
|
||||||
|
}
|
||||||
|
queueChart.append(ticks);
|
||||||
|
for (const step of steps) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "queue-row";
|
||||||
|
row.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
`第${step.index}块在${step.arrival}毫秒就绪,等待${step.wait}毫秒,在${step.end}毫秒计算完成`,
|
||||||
|
);
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "queue-label";
|
||||||
|
label.textContent = `块 ${step.index}`;
|
||||||
|
const arrival = document.createElement("span");
|
||||||
|
arrival.className = "queue-arrival";
|
||||||
|
arrival.style.left = percentage(step.arrival);
|
||||||
|
const wait = document.createElement("span");
|
||||||
|
wait.className = "queue-wait";
|
||||||
|
wait.style.left = percentage(step.arrival);
|
||||||
|
wait.style.width = percentage(step.wait);
|
||||||
|
const compute = document.createElement("span");
|
||||||
|
compute.className = "queue-compute";
|
||||||
|
compute.style.left = percentage(step.start);
|
||||||
|
compute.style.width = percentage(computeMs);
|
||||||
|
row.append(label, wait, compute, arrival);
|
||||||
|
queueChart.append(row);
|
||||||
|
}
|
||||||
|
const explanation = document.getElementById("queue-explanation");
|
||||||
|
if (computeMs < chunkMs) {
|
||||||
|
explanation.textContent = `每块留有 ${chunkMs - computeMs} ms 计算余量。此理想条件下等待为零,单块 ${computeMs} ms 的计算延迟仍然存在。`;
|
||||||
|
} else if (computeMs === chunkMs) {
|
||||||
|
explanation.textContent =
|
||||||
|
"理想条件下刚好赶上输入节奏。出现额外计算或调度抖动时,队列就会开始积压。";
|
||||||
|
} else {
|
||||||
|
explanation.textContent = `每块增加 ${computeMs - chunkMs} ms 等待,第 8 块已等待 ${steps.at(-1).wait} ms。需要减少计算、增加处理能力或调整处理策略。`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectArchitecture(key) {
|
||||||
|
const selected = architectures[key];
|
||||||
|
const flow = document.getElementById("architecture-flow");
|
||||||
|
flow.replaceChildren();
|
||||||
|
selected.nodes.forEach(([title, subtitle, type], index) => {
|
||||||
|
if (index) {
|
||||||
|
const arrow = document.createElement("span");
|
||||||
|
arrow.className = "flow-arrow";
|
||||||
|
arrow.textContent = "→";
|
||||||
|
arrow.setAttribute("aria-hidden", "true");
|
||||||
|
flow.append(arrow);
|
||||||
|
}
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = `flow-node ${type || ""}`;
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = title;
|
||||||
|
const small = document.createElement("small");
|
||||||
|
small.textContent = subtitle;
|
||||||
|
node.append(strong, small);
|
||||||
|
flow.append(node);
|
||||||
|
});
|
||||||
|
document.getElementById("architecture-detail").innerHTML = selected.detail;
|
||||||
|
document.querySelectorAll("[data-architecture]").forEach((button) => {
|
||||||
|
button.setAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
String(button.dataset.architecture === key),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBenchmarks() {
|
||||||
|
const chart = document.getElementById("benchmark-chart");
|
||||||
|
for (const model of benchmarks) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "benchmark-row";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "benchmark-label";
|
||||||
|
label.textContent = model.name;
|
||||||
|
const tracks = document.createElement("div");
|
||||||
|
tracks.className = "benchmark-tracks";
|
||||||
|
for (const [key, className] of [
|
||||||
|
["first", "first"],
|
||||||
|
["answer", "answer"],
|
||||||
|
]) {
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = `benchmark-bar ${className}`;
|
||||||
|
bar.style.width = `${(model[key] / 12) * 100}%`;
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.className = "benchmark-value";
|
||||||
|
value.textContent = `${model[key].toFixed(2)} s`;
|
||||||
|
bar.append(value);
|
||||||
|
tracks.append(bar);
|
||||||
|
}
|
||||||
|
row.append(label, tracks);
|
||||||
|
chart.append(row);
|
||||||
|
}
|
||||||
|
const axis = document.createElement("div");
|
||||||
|
axis.className = "benchmark-axis";
|
||||||
|
for (const value of ["0", "3", "6", "9", "12 s"]) {
|
||||||
|
const tick = document.createElement("span");
|
||||||
|
tick.textContent = value;
|
||||||
|
axis.append(tick);
|
||||||
|
}
|
||||||
|
chart.append(axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkSlider.addEventListener("input", renderQueue);
|
||||||
|
computeSlider.addEventListener("input", renderQueue);
|
||||||
|
document.querySelectorAll("[data-preset]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const [chunk, compute] = button.dataset.preset.split(",");
|
||||||
|
chunkSlider.value = chunk;
|
||||||
|
computeSlider.value = compute;
|
||||||
|
renderQueue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-architecture]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () =>
|
||||||
|
selectArchitecture(button.dataset.architecture),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeToggle = document.getElementById("theme-toggle");
|
||||||
|
try {
|
||||||
|
const preference = localStorage.getItem("voice-duplex-theme");
|
||||||
|
if (preference === "dark" || preference === "light")
|
||||||
|
document.documentElement.dataset.theme = preference;
|
||||||
|
} catch {
|
||||||
|
/* The page remains usable when storage is unavailable. */
|
||||||
|
}
|
||||||
|
themeToggle.addEventListener("click", () => {
|
||||||
|
const theme =
|
||||||
|
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
themeToggle.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
theme === "dark" ? "切换浅色主题" : "切换深色主题",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("voice-duplex-theme", theme);
|
||||||
|
} catch {
|
||||||
|
/* Theme changes still apply for this visit. */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationLinks = [...document.querySelectorAll(".contents nav a")];
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||||
|
if (!visible.length) return;
|
||||||
|
const currentId = visible[0].target.id;
|
||||||
|
navigationLinks.forEach((link) => {
|
||||||
|
const active = link.hash === `#${currentId}`;
|
||||||
|
link.classList.toggle("active", active);
|
||||||
|
if (active) link.setAttribute("aria-current", "location");
|
||||||
|
else link.removeAttribute("aria-current");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: "-110px 0px -55% 0px", threshold: 0 },
|
||||||
|
);
|
||||||
|
document
|
||||||
|
.querySelectorAll(".article-section")
|
||||||
|
.forEach((section) => observer.observe(section));
|
||||||
|
|
||||||
|
selectArchitecture("cascade");
|
||||||
|
renderQueue();
|
||||||
|
renderBenchmarks();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { mkdirSync, copyFileSync } from "node:fs";
|
||||||
|
const files = [
|
||||||
|
"index.html",
|
||||||
|
"styles.css",
|
||||||
|
"app.js",
|
||||||
|
"model.js",
|
||||||
|
"favicon.svg",
|
||||||
|
"robots.txt",
|
||||||
|
"release.json",
|
||||||
|
];
|
||||||
|
mkdirSync(new URL("./dist/", import.meta.url), { recursive: true });
|
||||||
|
for (const file of files)
|
||||||
|
copyFileSync(
|
||||||
|
new URL(file, import.meta.url),
|
||||||
|
new URL(`dist/${file}`, import.meta.url),
|
||||||
|
);
|
||||||
|
console.log(`Built ${files.length} public assets.`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#287557"/><path d="M17 19v27m0-11 16-16m-13 13 15 14" stroke="#f7f6f2" stroke-width="5" fill="none" stroke-linecap="round"/><circle cx="45" cy="44" r="4" fill="#f7f6f2"/></svg>
|
||||||
|
After Width: | Height: | Size: 280 B |
+1319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
export function queueSteps(chunkMs, computeMs, count = 8) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(chunkMs) ||
|
||||||
|
!Number.isFinite(computeMs) ||
|
||||||
|
chunkMs <= 0 ||
|
||||||
|
computeMs <= 0
|
||||||
|
) {
|
||||||
|
throw new RangeError("Time values must be finite and positive.");
|
||||||
|
}
|
||||||
|
let previousEnd = 0;
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const arrival = (index + 1) * chunkMs;
|
||||||
|
const start = Math.max(arrival, previousEnd);
|
||||||
|
const end = start + computeMs;
|
||||||
|
previousEnd = end;
|
||||||
|
return { index: index + 1, arrival, start, end, wait: start - arrival };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const architectures = {
|
||||||
|
cascade: {
|
||||||
|
nodes: [
|
||||||
|
["用户语音", "声音输入", "input"],
|
||||||
|
["ASR", "识别成文本"],
|
||||||
|
["文本 LLM", "生成回答"],
|
||||||
|
["TTS", "合成声音"],
|
||||||
|
["系统语音", "播放输出", "output"],
|
||||||
|
],
|
||||||
|
detail:
|
||||||
|
"<p><strong>两个独立的文本接口。</strong>ASR 把用户语音交给文本模型,TTS 再把回答读出。模块可以独立替换。仅传文字时,输入声学信息会在接口处被压缩。</p><p>持续双工需要在这些模块之间保持新输入、话轮状态、取消与播放进度的一致。</p>",
|
||||||
|
},
|
||||||
|
two: {
|
||||||
|
nodes: [
|
||||||
|
["用户语音", "声音输入", "input"],
|
||||||
|
["语音理解 LLM", "直接读取音频特征"],
|
||||||
|
["回答文本", "内容接口"],
|
||||||
|
["TTS", "合成声音"],
|
||||||
|
["系统语音", "播放输出", "output"],
|
||||||
|
],
|
||||||
|
detail:
|
||||||
|
"<p><strong>输入端直接理解声音。</strong>模型接收音频特征并生成回答文字,之后由独立 TTS 合成。输入端可以保留语气等线索,输出端仍通过文本接口组织。</p><p>本页以 Ultravox 核心加外接 TTS 作为这一工作定义的例子。</p>",
|
||||||
|
},
|
||||||
|
native: {
|
||||||
|
nodes: [
|
||||||
|
["用户语音", "声音输入", "input"],
|
||||||
|
["语音表示", "编码或特征"],
|
||||||
|
["联合交互模型", "声音与内容建模"],
|
||||||
|
["语音解码", "还原波形"],
|
||||||
|
["系统语音", "播放输出", "output"],
|
||||||
|
],
|
||||||
|
detail:
|
||||||
|
"<p><strong>语音直接参与内容与声学生成。</strong>编码器、音频 token、文字辅助流和波形解码器可以共同存在,具体组织依模型而异。</p><p>持续听说依赖输入更新方式、因果训练与实时服务。原生语音接口与全双工行为分别核验。</p>",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const benchmarks = [
|
||||||
|
{ name: "级联管线", first: 8.78, answer: 10.12 },
|
||||||
|
{ name: "Ultravox 0.7", first: 3.88, answer: 8.4 },
|
||||||
|
{ name: "GPT-Realtime 1.5", first: 6.36, answer: 6.89 },
|
||||||
|
{ name: "Gemini Live 3.1", first: 3.95, answer: 4.25 },
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { queueSteps, benchmarks } from "./model.js";
|
||||||
|
|
||||||
|
test("faster processing leaves no waiting queue", () => {
|
||||||
|
const steps = queueSteps(160, 80);
|
||||||
|
assert.equal(steps[0].arrival, 160);
|
||||||
|
assert.equal(steps[0].end, 240);
|
||||||
|
assert.ok(steps.every((step) => step.wait === 0));
|
||||||
|
});
|
||||||
|
test("equal processing and media intervals leave zero ideal headroom", () => {
|
||||||
|
assert.ok(queueSteps(160, 160).every((step) => step.wait === 0));
|
||||||
|
});
|
||||||
|
test("overload adds sixty milliseconds per subsequent input block", () => {
|
||||||
|
const steps = queueSteps(160, 220);
|
||||||
|
assert.equal(steps[0].wait, 0);
|
||||||
|
assert.equal(steps[7].wait, 420);
|
||||||
|
assert.equal(steps[7].end, 1920);
|
||||||
|
});
|
||||||
|
test("invalid media times fail explicitly", () => {
|
||||||
|
assert.throws(() => queueSteps(0, 80), RangeError);
|
||||||
|
assert.throws(() => queueSteps(160, Number.NaN), RangeError);
|
||||||
|
});
|
||||||
|
test("benchmark ordering and source values are preserved", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
benchmarks.map((model) => model.answer),
|
||||||
|
[10.12, 8.4, 6.89, 4.25],
|
||||||
|
);
|
||||||
|
assert.ok(benchmarks.every((model) => model.first <= model.answer));
|
||||||
|
});
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
charset utf-8;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
||||||
|
location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
|
||||||
|
location / { try_files $uri $uri/ =404; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "voice-duplex-research",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check": "node --check app.js && node --check model.js && node --test model.test.mjs && node validate.mjs",
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "python3 -m http.server 4173 --bind 127.0.0.1 --directory dist"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"release": "voice-duplex-20261109-v1",
|
||||||
|
"researchCutoff": "2026-11-09",
|
||||||
|
"canonicalUrl": "https://voice-duplex.k1412.top/",
|
||||||
|
"evidence": "published primary sources and clearly labeled explanatory simulations"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
+1625
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
|
||||||
|
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
|
||||||
|
assert.equal(new Set(ids).size, ids.length, "HTML IDs must be unique");
|
||||||
|
for (const match of html.matchAll(/href="#([^"]+)"/g)) {
|
||||||
|
assert.ok(ids.includes(match[1]), `Missing anchor ${match[1]}`);
|
||||||
|
}
|
||||||
|
for (const pattern of [
|
||||||
|
/\/Users\//,
|
||||||
|
/git\.n\.xiaomi/,
|
||||||
|
/100\.94\./,
|
||||||
|
/human-authorization/,
|
||||||
|
/Bearer\s+[A-Za-z0-9]/,
|
||||||
|
]) {
|
||||||
|
assert.ok(!pattern.test(html), `Private content matched ${pattern}`);
|
||||||
|
}
|
||||||
|
assert.ok(html.includes("说明性模型"));
|
||||||
|
assert.ok(html.includes("来源报告值"));
|
||||||
|
assert.ok(html.includes("2026 年 11 月 9 日"));
|
||||||
|
console.log(
|
||||||
|
`Validated ${ids.length} unique anchors, public-content boundaries and evidence labels.`,
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user