G7 증명과 G8 clean-head 승격 준비

This commit is contained in:
Yun Chan 2026-08-09 18:22:03 +09:00
parent 94c681d450
commit 5221f79e3f
52 changed files with 6876 additions and 506 deletions

View file

@ -153,7 +153,7 @@ class Settings(BaseSettings):
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_URL",
)
local_whisper_stt_model: str = Field(
default="large-v3",
default="small",
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_MODEL",
)
local_whisper_stt_language: str = Field(

View file

@ -206,9 +206,13 @@ async def voice_health() -> JSONResponse:
"stt_model": voice_service.stt_model(),
"stt_batch_fallback_available": voice_service.batch_stt_available(),
"tts_model": (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
voice_svc.MELOTTS_TTS_MODEL
if tts_provider == "melotts"
else (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
)
),
"tts_provider": tts_provider,
"limits": {

View file

@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Callable
from ..store import InProcSession
@ -44,7 +44,7 @@ class LearnerGrowthMetrics:
def iso_datetime(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def session_activity_time(sess: InProcSession) -> float:

View file

@ -998,7 +998,7 @@ class VoiceService:
local_whisper_stt_model
if local_whisper_stt_model is not None
else settings.local_whisper_stt_model
).strip() or "large-v3"
).strip() or "small"
self._local_whisper_stt_language = (
local_whisper_stt_language
if local_whisper_stt_language is not None

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, Optional, cast
from pydantic import BaseModel, Field
@ -31,6 +31,7 @@ WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
KOREA_STANDARD_TIME = timezone(timedelta(hours=9), name="KST")
MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
MISSING_SESSION_EVALUATION_ERROR = (
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
@ -478,7 +479,11 @@ def stage_label(stage: object) -> StageLabel:
def iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def session_calendar_date(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=KOREA_STANDARD_TIME).strftime("%Y-%m-%d")
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
@ -1757,7 +1762,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
initial=client_initial,
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
),
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
date=session_calendar_date(sess.created_at),
durationLabel=_duration_label(duration_seconds),
durationSeconds=duration_seconds,
reachedPhase=reached_phase,

View file

@ -0,0 +1,49 @@
"""회기 읽기 모델의 시간 직렬화 계약 테스트."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from .session_read_model import iso, session_calendar_date
from .services.session_metrics import iso_datetime
class SessionReadModelIsoTest(unittest.TestCase):
def test_none_remains_none(self) -> None:
self.assertIsNone(iso(None))
def test_epoch_is_serialized_as_utc_with_offset(self) -> None:
self.assertEqual(iso(0), "1970-01-01T00:00:00+00:00")
def test_timestamp_round_trips_without_local_timezone_drift(self) -> None:
timestamp = 1_800_000_120.0
rendered = iso(timestamp)
self.assertIsNotNone(rendered)
assert rendered is not None
parsed = datetime.fromisoformat(rendered)
self.assertEqual(parsed.tzinfo, timezone.utc)
self.assertEqual(parsed.timestamp(), timestamp)
def test_serialized_timestamp_has_explicit_utc_suffix(self) -> None:
rendered = iso(1_800_000_120.0)
self.assertIsNotNone(rendered)
assert rendered is not None
self.assertTrue(rendered.endswith("+00:00"))
def test_dashboard_metrics_use_the_same_utc_contract(self) -> None:
timestamp = 1_800_000_120.0
self.assertEqual(iso_datetime(timestamp), iso(timestamp))
def test_review_calendar_date_uses_korea_standard_time(self) -> None:
utc_late_afternoon = datetime(2026, 8, 8, 15, 30, tzinfo=timezone.utc)
self.assertEqual(session_calendar_date(utc_late_afternoon.timestamp()), "2026-08-09")
if __name__ == "__main__":
unittest.main()

View file

@ -693,7 +693,7 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
query = parse_qs(urlsplit(url).query)
self.assertEqual(urlsplit(url).scheme, "ws")
self.assertEqual(urlsplit(url).hostname, "127.0.0.1")
self.assertEqual(query["model"], ["large-v3"])
self.assertEqual(query["model"], ["small"])
self.assertEqual(query["language"], ["ko"])
self.assertEqual(query["sample_rate"], ["16000"])
self.assertEqual(query["channels"], ["1"])
@ -701,7 +701,7 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(query["utterance_end_ms"], ["1200"])
self.assertEqual(result.text, "안녕하세요")
self.assertEqual(result.model, "large-v3")
self.assertEqual(result.model, "small")
self.assertEqual(result.language, "ko")
self.assertEqual([word.word for word in result.words], ["안녕하세요"])
self.assertAlmostEqual(result.words[0].start, 0.05, places=3)
@ -927,7 +927,7 @@ class LocalWhisperProviderSelectionTest(unittest.TestCase):
service = VoiceService(api_key="", stt_provider="local_whisper")
self.assertTrue(service.streaming_stt_enabled())
self.assertEqual(service.stt_provider(), "local_whisper")
self.assertEqual(service.stt_model(), "large-v3")
self.assertEqual(service.stt_model(), "small")
def test_missing_sidecar_url_falls_back_to_batch(self) -> None:
service = VoiceService(

View file

@ -16,6 +16,7 @@ from .persona_repository import PersonaVoiceMap
from .routes import voice as voice_routes
from .services import multimodal_alliance_store
from .services.voice import (
MELOTTS_TTS_MODEL,
StreamingTranscriptEvent,
TranscriptResult,
TranscriptWord,
@ -150,6 +151,27 @@ class _FakeRouteStreamingSession:
self.aborted = True
class VoiceHealthContractTest(unittest.IsolatedAsyncioTestCase):
async def test_melotts_health_reports_the_operated_model(self) -> None:
service = SimpleNamespace(
is_available=lambda: True,
stt_available=lambda: True,
tts_available=lambda: True,
tts_provider=lambda: "melotts",
stt_provider=lambda: "local_whisper",
stt_model=lambda: "small",
batch_stt_available=lambda: False,
)
with patch.object(voice_routes, "voice_service", service):
response = await voice_routes.voice_health()
body = json.loads(bytes(response.body))
self.assertEqual(body["status"], "ok")
self.assertEqual(body["tts_provider"], "melotts")
self.assertEqual(body["tts_model"], MELOTTS_TTS_MODEL)
class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]:
return (

View file

@ -9,6 +9,8 @@ python-multipart==0.0.18
httpx==0.28.1
websockets==14.2
sse-starlette==3.0.3
# Windows G7 topology evidence pins and samples exact host-process provenance.
psutil==6.1.1
# P4(2026-07-13 회의): 자유 양식 엑셀 업로드 → 텍스트 변환 (personas/sources/upload)
openpyxl==3.1.5

View file

@ -590,6 +590,12 @@ function isOnline(seconds: number) {
}
async function openAdminAndReadHealth(page: Page) {
const userSnapshots: AdminUsersResponse[] = [];
const collectUsersResponse = async (response: Response) => {
if (!isAdminUsersResponse(response) || !response.ok()) return;
userSnapshots.push((await response.json()) as AdminUsersResponse);
};
page.on("response", collectUsersResponse);
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
@ -613,10 +619,36 @@ async function openAdminAndReadHealth(page: Page) {
const health = (await healthResponse.json()) as AdminHealthResponse;
const usage = (await usageResponse.json()) as AdminUsageResponse;
const users = (await usersResponse.json()) as AdminUsersResponse;
const initialUsers = (await usersResponse.json()) as AdminUsersResponse;
const uptime = (await uptimeResponse.json()) as AdminUptimeResponse;
const tickets = (await ticketsResponse.json()) as AdminTicketsResponse;
expect(health.services.length).toBeGreaterThan(0);
if (userSnapshots.length === 0) userSnapshots.push(initialUsers);
let users = initialUsers;
await expect
.poll(
async () => {
const rendered = await page.locator(".vgops-kpi b").allTextContents();
const matchingSnapshot = userSnapshots.find((snapshot) => {
const activeSessions = snapshot.users.reduce(
(sum, user) => sum + Math.max(0, user.active_sessions),
0,
);
const onlineUsers = snapshot.users.filter((user) => isOnline(user.last_seen_at)).length;
return (
rendered[0] === countLabel(activeSessions) &&
rendered[1] === countLabel(onlineUsers) &&
rendered[2] === countLabel(snapshot.users.length)
);
});
if (matchingSnapshot) users = matchingSnapshot;
return Boolean(matchingSnapshot);
},
{ message: "관리자 KPI가 실제 /admin/users 응답 중 하나를 반영해야 한다" },
)
.toBe(true);
page.off("response", collectUsersResponse);
return { health, usage, users, uptime, tickets };
}

View file

@ -2,11 +2,12 @@ import http, { type Server } from "node:http";
import { promises as fs } from "node:fs";
import { AddressInfo } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
const repoRoot = path.resolve(process.cwd(), "..", "..");
const specDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(specDir, "..", "..", "..");
const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html");
interface StaticServer {
@ -144,8 +145,8 @@ async function readMetrics(page: Page): Promise<DashboardMetrics> {
function expectDerivedMetrics(metrics: DashboardMetrics) {
expect(metrics.total).toBeGreaterThan(0);
expect(metrics.done).toBe(33);
expect(metrics.doing).toBe(2);
expect(metrics.done).toBe(32);
expect(metrics.doing).toBe(3);
expect(metrics.planned).toBe(0);
expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned);
expect(metrics.ownerBoard).toBe(
@ -336,6 +337,30 @@ test.describe("dev dashboard static command center", () => {
await page.setViewportSize(viewport);
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
await expectNoHorizontalOverflow(page);
const pulseDetails = page.locator(".pulse-details");
await expect(pulseDetails).toHaveJSProperty("open", false);
await pulseDetails.locator("summary").click();
await expect(pulseDetails).toHaveJSProperty("open", true);
await expect(pulseDetails).toContainText("detached clean release와 exact Git commit/tree");
await pulseDetails.locator("summary").click();
await expect(pulseDetails).toHaveJSProperty("open", false);
if (viewport.width <= 760) {
const controlHeights = await page
.locator(".owner-pill, .fbtn, .tab-button, .dg-link, .pulse-details summary")
.evaluateAll((controls) =>
controls.map((control) => Math.ceil(control.getBoundingClientRect().height)),
);
expect(controlHeights.length).toBeGreaterThan(0);
expect(controlHeights.every((height) => height >= 44)).toBe(true);
const firstScreenPositions = await page.locator("#phase-rail, .kpis").evaluateAll((regions) =>
regions.map((region) => Math.floor(region.getBoundingClientRect().top)),
);
expect(firstScreenPositions).toHaveLength(2);
expect(firstScreenPositions.every((top) => top < viewport.height)).toBe(true);
}
}
});
});

View file

@ -862,6 +862,60 @@ async function capture(page: Page, projectName: string, stage: string) {
}
test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fixture", () => {
test("3.5초 경계의 자기주도 경로 전환이 복구되면 부트 진단을 즉시 닫는다", async ({
page,
}) => {
await routeSelfDirectedUiFixture(page);
await page.goto("/learn");
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible();
// index.html의 첫 watchdog(3.5초)과 실제 자기주도 경로 전환을 겹친다.
// route가 잠깐 비어 보이는 동안에는 진단이 뜨되, React 콘텐츠 복구 후에는
// 두 번째 watchdog(8초)을 기다리지 않고 바로 사라져야 한다.
await page.evaluate(async () => {
const remaining = Math.max(0, 3_300 - performance.now());
await new Promise((resolve) => window.setTimeout(resolve, remaining));
const root = document.getElementById("root");
const action = document.querySelector<HTMLElement>(
"[data-learner-primary-action]",
);
if (!root || !action) {
throw new Error("self-directed route transition fixture unavailable");
}
root.style.visibility = "hidden";
action.click();
});
await expect(page).toHaveURL(/\/learn\/practice$/);
await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({
timeout: 1_200,
});
await page.evaluate(() => {
document.getElementById("root")?.style.removeProperty("visibility");
});
await expect(
page.getByRole("heading", { name: "연습할 내담자를 선택합니다." }),
).toBeVisible();
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, {
timeout: 1_000,
});
});
test("React 부트가 실제로 비어 있으면 진단을 유지한다", async ({ page }) => {
await page.route("**/src/main.tsx", (route) => route.abort("failed"));
await page.goto("/learn", { waitUntil: "domcontentloaded" });
const diagnostic = page.locator("#vignette-boot-diagnostic");
await expect(diagnostic).toBeVisible({ timeout: 4_500 });
await expect(diagnostic).toContainText("React 화면이 정상 콘텐츠를 표시하지 못해서");
await page.waitForTimeout(250);
await expect(diagnostic).toBeVisible();
await expect(page.locator("#root")).toBeEmpty();
});
test("홈 추천과 목표 선택부터 회기·복기·처방 재연습 시작까지 한 흐름으로 보존한다", async ({
page,
}, testInfo) => {

View file

@ -432,6 +432,24 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
});
});
await page.route(
`**/api/sessions/${sessionId}/multimodal-alliance/consent`,
async (route) => {
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
submission_id: consentBody.submission_id,
consent_snapshot_id: "00000000-0000-4000-8000-000000000777",
consent_status: "granted",
deletion_request_id: null,
idempotent_replay: false,
}),
});
},
);
return {
sessionStartRequests,
liveCoachRequests,
@ -444,6 +462,15 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
};
}
async function acceptVoiceInputConsent(page: Page) {
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(consentDialog).toBeVisible();
await expect(consentDialog).toContainText("원음은 보존하지 않습니다");
await expect(consentDialog).toContainText("동의하지 않아도 텍스트로 계속할 수 있습니다");
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect(consentDialog).toHaveCount(0);
}
async function installVoicePersistenceFailureFixture(page: Page, transcriptText: string) {
await page.addInitScript((text) => {
const fixture = {
@ -1370,6 +1397,7 @@ test.describe("P1 MVP core loop", () => {
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: failedVoiceText });
@ -1410,6 +1438,7 @@ test.describe("P1 MVP core loop", () => {
await page.getByRole("button", { name: "이어가기" }).click();
await page.getByRole("button", { name: "마이크 켜기" }).click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const transcriptLog = page.getByRole("log", { name: "실시간 상담 축어록" });
@ -1494,6 +1523,7 @@ test.describe("P1 MVP core loop", () => {
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await acceptVoiceInputConsent(page);
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: crisisText });

View file

@ -85,6 +85,8 @@ interface SessionDetailResponse {
session_id: string;
theory_mode: string;
status: "active" | "ended";
started_at: string;
ended_at?: string | null;
turns?: Array<{
speaker: "learner" | "client";
text: string;
@ -548,6 +550,8 @@ async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워
}
test.describe("session persistence", () => {
test.use({ timezoneId: "Asia/Seoul" });
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
page,
}) => {
@ -922,6 +926,21 @@ test.describe("session persistence", () => {
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
timeout: 15_000,
});
const reloadedDetailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(reloadedDetailResponse);
const reloadedDetail = (await reloadedDetailResponse.json()) as SessionDetailResponse;
expect(reloadedDetail.status).toBe("active");
expect(reloadedDetail.ended_at).toBeNull();
expect(reloadedDetail.started_at).toMatch(/(?:Z|[+-]\d{2}:\d{2})$/);
const sessionAgeMs = Date.now() - Date.parse(reloadedDetail.started_at);
expect(Number.isFinite(sessionAgeMs)).toBe(true);
expect(sessionAgeMs).toBeGreaterThanOrEqual(-5_000);
expect(sessionAgeMs).toBeLessThan(10 * 60 * 1_000);
await expect(
page.locator(".sx-session-progress__grid > span").first().locator("b"),
).toHaveText(/^0\d:[0-5]\d$/);
await expect(page.locator(".sx-timebar.is-over")).toHaveCount(0);
await expect(page.getByRole("dialog", { name: "회기 시간이 끝났어요" })).toHaveCount(0);
const coachMark = page.locator(".sx-utt.is-learner .sx-utt__coach-mark");
await expect(coachMark).toBeVisible({
timeout: 15_000,

View file

@ -246,6 +246,13 @@ export async function routeFilledSessionReview(
body: JSON.stringify(filledReviewResponse(sessionId)),
});
});
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
}
export function prepostMeasuresResponse(

View file

@ -156,6 +156,7 @@ async function expectReviewRegionsReachable(page: Page) {
.evaluate((el) => window.getComputedStyle(el).gridTemplateAreas);
expect(transcriptAreas).toContain("transcript");
await page.locator(".sr-overview").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
@ -546,9 +547,12 @@ test.describe("session review", () => {
};
}),
);
expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe(
true,
);
expect(filterMetrics.every((item) => item.whiteSpace === "nowrap")).toBe(true);
if ((page.viewportSize()?.width ?? 0) <= 720) {
expect(filterMetrics.every((item) => item.height >= 44)).toBe(true);
} else {
expect(filterMetrics.every((item) => item.height <= 34)).toBe(true);
}
await expectNoHorizontalOverflow(page);
});
@ -566,6 +570,7 @@ test.describe("session review", () => {
expect(review.session_id).toBe(FILLED_REVIEW_SESSION_ID);
expect(review.reviewReady).toBe(true);
expect(review.turns).toHaveLength(6);
await expect(page.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`));
await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible();

View file

@ -74,6 +74,8 @@
var errors = [];
var loadedAt = Date.now();
var sentDiagnostics = {};
var healthyCheckTimer = null;
var healthyObserver = null;
function short(value) {
return String(value || "").slice(0, 900);
}
@ -116,14 +118,68 @@
style.opacity !== "0"
);
}
function visibleCount() {
function visibleCount(scope) {
var root = scope || document;
var nodes = Array.prototype.slice.call(
document.querySelectorAll(
root.querySelectorAll(
"main,header,h1,h2,p,a,button,input,select,article,section,.vg-shell,.vg-route-error,.vgops-head,.vgops-diagnostic,.lg-root",
),
);
return nodes.filter(isVisiblyRendered).length;
}
function appHealth() {
var root = document.getElementById("root");
var path = window.location.pathname;
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
var text = (root && root.innerText ? root.innerText : "").trim();
var visible = root ? visibleCount(root) : 0;
var adminContent = root
? root.querySelector(
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
)
: null;
var hasAdminContent = !isAdmin || isVisiblyRendered(adminContent);
return {
healthy: Boolean(
root &&
root.childElementCount > 0 &&
text.length > 24 &&
visible > 0 &&
hasAdminContent,
),
visible: visible,
};
}
function dismissDiagnosticIfHealthy() {
if (!appHealth().healthy) return false;
var panel = document.getElementById("vignette-boot-diagnostic");
if (panel) panel.remove();
return true;
}
function scheduleHealthyCheck() {
if (healthyCheckTimer !== null) window.clearTimeout(healthyCheckTimer);
healthyCheckTimer = window.setTimeout(function () {
healthyCheckTimer = null;
dismissDiagnosticIfHealthy();
}, 60);
}
function observeAppHealth() {
if (healthyObserver || !window.MutationObserver) return;
var root = document.getElementById("root");
if (!root) {
window.setTimeout(observeAppHealth, 100);
return;
}
healthyObserver = new MutationObserver(scheduleHealthyCheck);
healthyObserver.observe(root, {
attributes: true,
attributeFilter: ["aria-hidden", "class", "hidden", "style"],
characterData: true,
childList: true,
subtree: true,
});
scheduleHealthyCheck();
}
function diagnosticApiBase() {
var host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
@ -183,19 +239,16 @@
}
function render(reason) {
var path = window.location.pathname;
var isAdmin = path === "/admin" || path.indexOf("/admin/") === 0;
var text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
var visible = visibleCount();
var adminContent = document.querySelector(
"[data-vignette-admin-root],[data-vignette-admin-diagnostic],[data-testid='admin-ai-page']",
);
var hasAdminContent = !isAdmin || isVisiblyRendered(adminContent);
if (reason === "watchdog" && text.length > 24 && visible > 0 && hasAdminContent) {
var old = document.getElementById("vignette-boot-diagnostic");
if (old) old.remove();
var health = appHealth();
if (health.healthy && reason !== "manual") {
if (reason !== "watchdog") {
sendDiagnostic(reason, text.length, health.visible);
}
dismissDiagnosticIfHealthy();
return;
}
sendDiagnostic(reason, text.length, visible);
sendDiagnostic(reason, text.length, health.visible);
var panel = document.getElementById("vignette-boot-diagnostic");
if (!panel) {
@ -229,7 +282,7 @@
text.length +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">visibleNodes</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
visible +
health.visible +
"</dd>",
"<dt style=\"padding:7px 9px;background:rgba(0,0,0,.04);font-weight:700\">elapsed</dt><dd style=\"margin:0;padding:7px 9px;overflow-wrap:anywhere\">" +
(Date.now() - loadedAt) +
@ -260,6 +313,7 @@
render("manual");
},
};
observeAppHealth();
window.setTimeout(function () {
render("watchdog");
}, 3500);