fix(memhub): clear pre-existing dev lint errors and flaky SDK dedup tests

Three things blocking CI that came in with dev and are unrelated to the
sedimentation switch itself:

- biome errors in DebugLogPanel.tsx (import order) and ModelUsageView.tsx
  (refreshToken is an intentional refresh trigger, annotated like the
  existing modelVersion trigger in R6Settings).
- test_extractor_prompt_override.py's fake LLM now accepts keyword args the
  real pipeline passes since the usage audit landed (audit_stage).
- the fake embeddings in the intention dedup tests used the process-random
  hash(), so two different texts collided roughly one run in 32 and were
  misread as duplicates; switched to a deterministic md5 index.
This commit is contained in:
kristinhe committed 2026-10-17 23:45:55 +08:00
1 parent 0822e7137e
commit 887583b858
6 files changed
+8891 -132

No files matched your search

+124 -115
View File
@@ -26,11 +26,11 @@ import type {
DebugLogLevel,
DebugLogSnapshot,
} from "./debug-log-types";
import { ModelUsageView } from "./ModelUsageView";
import {
rendererDebugLogs,
subscribeRendererDebugLogs,
} from "./renderer-debug-log";
import { ModelUsageView } from "./ModelUsageView";
import "./debug-log-panel.css";
const LEVELS: Array<{ value: DebugLogLevel | "all"; label: string }> = [
@@ -201,7 +201,11 @@ export function DebugLogPanel() {
</button>
</div>
</header>
<div className="debug-panel-tabs" role="tablist" aria-label="诊断页面">
<div
className="debug-panel-tabs"
role="tablist"
aria-label="诊断页面"
>
<button
type="button"
role="tab"
@@ -222,120 +226,125 @@ export function DebugLogPanel() {
{tab === "logs" ? (
<div className="debug-log-content">
<div className="debug-log-toolbar">
<label className="debug-search">
<Search size={15} aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索事件、组件、错误码或内容"
aria-label="搜索日志"
/>
</label>
<select
aria-label="日志来源"
value={service}
onChange={(event) =>
setService(event.target.value as typeof service)
}
>
<option value="all">全部来源</option>
{Object.entries(SERVICE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<label className="debug-capture-level">
<span>采集级别</span>
<select
aria-label="采集级别"
value={captureLevel}
disabled={capturePending}
onChange={(event) => {
const next = event.target.value as DiagnosticLogLevel;
setCapturePending(true);
void setDiagnosticLogLevel(next)
.then(setCaptureLevel)
.then(() => refresh())
.catch(() => setReadState("error"))
.finally(() => setCapturePending(false));
}}
>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</label>
</div>
<fieldset className="debug-levels">
<legend>显示级别</legend>
{LEVELS.map((option) => (
<button
type="button"
key={option.value}
aria-pressed={level === option.value}
onClick={() => setLevel(option.value)}
>
{option.label}
</button>
))}
</fieldset>
<div className="debug-log-status" aria-live="polite">
<span>{filtered.length} 条匹配</span>
<span>{live ? "实时刷新" : "已暂停"}</span>
{snapshot?.truncated ? <span>日志过多,已截断</span> : null}
{readState === "error" ? (
<span className="debug-read-error">读取失败</span>
) : null}
</div>
<div
className="debug-log-list"
ref={listRef}
onScroll={handleListScroll}
>
{filtered.length ? (
filtered.map((entry) => {
const isExpanded = expanded.has(entry.id);
return (
<article
className={`debug-log-row level-${entry.level}`}
key={entry.id}
<label className="debug-search">
<Search size={15} aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索事件、组件、错误码或内容"
aria-label="搜索日志"
/>
</label>
<select
aria-label="日志来源"
value={service}
onChange={(event) =>
setService(event.target.value as typeof service)
}
>
<option value="all">全部来源</option>
{Object.entries(SERVICE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<label className="debug-capture-level">
<span>采集级别</span>
<select
aria-label="采集级别"
value={captureLevel}
disabled={capturePending}
onChange={(event) => {
const next = event.target.value as DiagnosticLogLevel;
setCapturePending(true);
void setDiagnosticLogLevel(next)
.then(setCaptureLevel)
.then(() => refresh())
.catch(() => setReadState("error"))
.finally(() => setCapturePending(false));
}}
>
<button
type="button"
className="debug-log-summary"
onClick={() => toggleExpanded(entry.id)}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<time>{logTime(entry.timestamp)}</time>
<span className="debug-log-level">{entry.level}</span>
<span className="debug-log-service">
{SERVICE_LABELS[entry.service]}
</span>
<span className="debug-log-event">
{entry.message || `${entry.component} · ${entry.event}`}
</span>
<span className="debug-log-outcome">{entry.outcome}</span>
</button>
{isExpanded ? (
<pre>{JSON.stringify(entry, null, 2)}</pre>
) : null}
</article>
);
})
) : (
<p className="debug-log-empty">
{readState === "loading" ? "正在读取日志…" : "没有匹配的日志"}
</p>
)}
</div>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</label>
</div>
<fieldset className="debug-levels">
<legend>显示级别</legend>
{LEVELS.map((option) => (
<button
type="button"
key={option.value}
aria-pressed={level === option.value}
onClick={() => setLevel(option.value)}
>
{option.label}
</button>
))}
</fieldset>
<div className="debug-log-status" aria-live="polite">
<span>{filtered.length} 条匹配</span>
<span>{live ? "实时刷新" : "已暂停"}</span>
{snapshot?.truncated ? <span>日志过多,已截断</span> : null}
{readState === "error" ? (
<span className="debug-read-error">读取失败</span>
) : null}
</div>
<div
className="debug-log-list"
ref={listRef}
onScroll={handleListScroll}
>
{filtered.length ? (
filtered.map((entry) => {
const isExpanded = expanded.has(entry.id);
return (
<article
className={`debug-log-row level-${entry.level}`}
key={entry.id}
>
<button
type="button"
className="debug-log-summary"
onClick={() => toggleExpanded(entry.id)}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<time>{logTime(entry.timestamp)}</time>
<span className="debug-log-level">{entry.level}</span>
<span className="debug-log-service">
{SERVICE_LABELS[entry.service]}
</span>
<span className="debug-log-event">
{entry.message ||
`${entry.component} · ${entry.event}`}
</span>
<span className="debug-log-outcome">
{entry.outcome}
</span>
</button>
{isExpanded ? (
<pre>{JSON.stringify(entry, null, 2)}</pre>
) : null}
</article>
);
})
) : (
<p className="debug-log-empty">
{readState === "loading"
? "正在读取日志…"
: "没有匹配的日志"}
</p>
)}
</div>
</div>
) : (
<ModelUsageView live={live} refreshToken={usageRefreshToken} />
+40 -14
View File
@@ -40,10 +40,7 @@ interface ModelUsageViewProps {
refreshToken: number;
}
export function ModelUsageView({
live,
refreshToken,
}: ModelUsageViewProps) {
export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
const [rangeMinutes, setRangeMinutes] = useState(60);
const [report, setReport] = useState<ModelUsageReport | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
@@ -59,6 +56,7 @@ export function ModelUsageView({
}
}, [rangeMinutes]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshToken is the explicit refresh trigger.
useEffect(() => {
void load();
}, [load, refreshToken]);
@@ -69,7 +67,10 @@ export function ModelUsageView({
return () => window.clearInterval(timer);
}, [live, load]);
const stages = useMemo(() => summarizeStages(report?.by_minute ?? []), [report]);
const stages = useMemo(
() => summarizeStages(report?.by_minute ?? []),
[report],
);
return (
<div className="model-usage-view">
@@ -100,7 +101,10 @@ export function ModelUsageView({
</div>
<div className="model-usage-metrics">
<Metric label="总 Tokens" value={formatTokens(report?.summary.total_tokens)} />
<Metric
label="总 Tokens"
value={formatTokens(report?.summary.total_tokens)}
/>
<Metric
label="Avg Prompt"
value={formatTokens(report?.summary.avg_prompt_tokens)}
@@ -109,10 +113,16 @@ export function ModelUsageView({
label="Avg Completion"
value={formatTokens(report?.summary.avg_completion_tokens)}
/>
<Metric label="请求数" value={String(report?.summary.request_count ?? 0)} />
<Metric
label="请求数"
value={String(report?.summary.request_count ?? 0)}
/>
</div>
<section className="model-usage-chart-card" aria-label="各 Prompt 每分钟 Token 曲线">
<section
className="model-usage-chart-card"
aria-label="各 Prompt 每分钟 Token 曲线"
>
<div className="model-usage-section-title">
<strong>各 Prompt 花费曲线</strong>
<span>每分钟 · Total Tokens</span>
@@ -164,12 +174,19 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
const plotWidth = width - pad.left - pad.right;
const plotHeight = height - pad.top - pad.bottom;
const lookup = new Map(
rows.map((row) => [`${row.minute}\u0000${row.prompt_stage}`, row.total_tokens]),
rows.map((row) => [
`${row.minute}\u0000${row.prompt_stage}`,
row.total_tokens,
]),
);
const max = Math.max(1, ...rows.map((row) => row.total_tokens));
const x = (index: number) =>
pad.left + (minutes.length <= 1 ? plotWidth / 2 : (index / (minutes.length - 1)) * plotWidth);
const y = (value: number) => pad.top + plotHeight - (value / max) * plotHeight;
pad.left +
(minutes.length <= 1
? plotWidth / 2
: (index / (minutes.length - 1)) * plotWidth);
const y = (value: number) =>
pad.top + plotHeight - (value / max) * plotHeight;
if (!rows.length)
return <div className="model-usage-chart-empty">暂无曲线数据</div>;
@@ -204,7 +221,9 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
return (
<g key={stage}>
<polyline
points={points.map((point) => `${point.x},${y(point.value)}`).join(" ")}
points={points
.map((point) => `${point.x},${y(point.value)}`)
.join(" ")}
stroke={stageColor(stage)}
/>
{points.map((point) => (
@@ -243,7 +262,13 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
function summarizeStages(rows: ModelUsageMinute[]) {
const stages = new Map<
string,
{ name: string; requests: number; prompt: number; completion: number; total: number }
{
name: string;
requests: number;
prompt: number;
completion: number;
total: number;
}
>();
for (const row of rows) {
const stage = stages.get(row.prompt_stage) ?? {
@@ -270,7 +295,8 @@ function stageColor(stage: string): string {
const known = Object.keys(STAGE_LABELS).indexOf(stage);
if (known >= 0) return COLORS[known % COLORS.length];
let hash = 0;
for (const character of stage) hash = (hash * 31 + character.charCodeAt(0)) | 0;
for (const character of stage)
hash = (hash * 31 + character.charCodeAt(0)) | 0;
return COLORS[Math.abs(hash) % COLORS.length];
}
+1 -1
View File
@@ -35,7 +35,7 @@ class _CapturingLLM:
self.last_messages = None
async def complete_messages(self, *, messages, max_tokens, temperature=None,
tools=None, tool_choice=None):
tools=None, tool_choice=None, **kwargs):
self.last_messages = messages
return LLMResponse(
content='{"memory": [], "intentions": [], "basic_info": {}}',
+4 -1
View File
@@ -13,6 +13,7 @@ L7_INTENTION 层去重(0805 事故修复 · v2)单测。
from __future__ import annotations
import asyncio
import hashlib
import uuid
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional
@@ -54,7 +55,9 @@ class _FakeEmbedByHash:
def vec_for(self, text: str) -> List[float]:
v = [0.0] * self.dims
v[abs(hash(text)) % self.dims] = 1.0
# 确定性哈希:内建 hash() 每个进程随机化,会让不同文案偶发撞格、
# 被误判成复本,测试随 PYTHONHASHSEED 变 flaky。
v[int(hashlib.md5(text.encode()).hexdigest(), 16) % self.dims] = 1.0
return v
async def embed_batch(self, texts):
+4 -1
View File
@@ -16,6 +16,7 @@
"""
import asyncio
import hashlib
import shutil
import tempfile
import uuid
@@ -179,8 +180,10 @@ async def test_writer_batch_dedup_e2e():
self.dims = dims
def _vec(self, text):
# 确定性哈希:内建 hash() 每个进程随机化,两条不同文案约有 1/dims
# 概率撞进同一格,被误判成复本,整个测试随 PYTHONHASHSEED 变 flaky。
v = [0.0] * self.dims
v[abs(hash(text)) % self.dims] = 1.0
v[int(hashlib.md5(text.encode()).hexdigest(), 16) % self.dims] = 1.0
return v
async def embed_batch(self, texts):
Generated
+8718
View File
File diff suppressed because it is too large. Load diff