fix(kimi): keep monitor robust against K2.6 redesign noise
Desktop Client Build / Resolve Build Metadata (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Frontend CI / Frontend (push) Successful in 6m6s
Desktop Client Build / Resolve Build Metadata (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Frontend CI / Frontend (push) Successful in 6m6s
K2.6 introduced new UI surfaces that polluted the answer extraction and
the busy-signal probe, breaking monitor runs even though Kimi rendered a
correct answer:
- collectCandidates now drops elements that contain a chat composer child
(<textarea>, non-hidden <input>, [contenteditable]). This filters
wrapper containers like .chat-detail-content that the wide
[class*="content"] fallback would otherwise pick, pulling editor
placeholder text ("可快捷使用技能", "Kimi K2.6 已就位") into the answer.
- isAuxiliaryPanelElement skips elements with role="dialog" /
"alertdialog", aria-modal="true", or the <dialog> tag, so the K2.6
launch-announcement popover ("Kimi K2.6, 已就位 / 一键部署 OpenClaw")
no longer counts as answer content.
- Drop the class-based loading-indicator probe entirely. The previous
global [class*="loading"]/"stream"/"typing"/"spinner" sweep matched
K2.6's always-on text-stream wrapper and held busy=true forever, which
caused kimi_query_timeout even after the assistant turn had fully
rendered. The textual busySignalsPattern (思考中/搜索中/生成中…) is
the actual semantic signal and survives any UI rename.
- Surface a capacityNotice from the assistant turn when Kimi shows its
short capacity-exhausted message ("算力不够 / 请稍后再试"). The top
level falls back to it when answerText is empty so the SaaS pipeline
records the user-visible message instead of an unknown row, with
capacity_limited / capacity_notice flagged in raw_response_json.
All filters lean on HTML form / ARIA semantics and visible text rather
than Kimi-specific BEM classes, so they survive future redesigns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import { __kimiTestUtils } from './kimi'
|
||||
const {
|
||||
buildSourceItem,
|
||||
classifyKimiSources,
|
||||
isKimiAnswerComplete,
|
||||
isKimiPromotionalAnswerNoise,
|
||||
mergeKimiSourceLinksIntoContentSnapshot,
|
||||
normalizeUrl,
|
||||
} = __kimiTestUtils
|
||||
@@ -25,6 +27,7 @@ function buildSnapshot(
|
||||
answerBlocks: ['答案'],
|
||||
reasoningText: null,
|
||||
reasoningBlocks: [],
|
||||
capacityNotice: null,
|
||||
questionMatched: true,
|
||||
candidateAnswerCount: 1,
|
||||
candidateReasoningCount: 0,
|
||||
@@ -143,4 +146,25 @@ describe('kimi adapter helpers', () => {
|
||||
expect(merged.searchResultLinks).toHaveLength(1)
|
||||
expect(merged.searchResultLinks[0]?.url).toBe('https://example.com/a')
|
||||
})
|
||||
|
||||
it('rejects Kimi K2.6 marketing copy as an answer', () => {
|
||||
const promotionalText = [
|
||||
'Kimi K2.6,已就位!',
|
||||
'Coding、Agent 集群、Kimi Claw 同步升级',
|
||||
'Agent 集群,升级了',
|
||||
'多技能调用,多产物类型一次性交付',
|
||||
'Kimi Claw 已就位!',
|
||||
'一键部署 OpenClaw,Kimi 24小时为你干活',
|
||||
].join('\n')
|
||||
|
||||
expect(isKimiPromotionalAnswerNoise(promotionalText)).toBe(true)
|
||||
expect(
|
||||
isKimiAnswerComplete(
|
||||
buildSnapshot({
|
||||
answerText: promotionalText,
|
||||
answerBlocks: [promotionalText],
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ const KIMI_BOOTSTRAP_URL = 'https://www.kimi.com/'
|
||||
const KIMI_PAGE_READY_TIMEOUT_MS = 20_000
|
||||
const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000
|
||||
// kimi 现在算力不够需要更多时间来生成回答了,尤其是思考模式,所以把默认的查询超时从 90s 提高到 250s。
|
||||
const KIMI_QUERY_TIMEOUT_MS = 250_000
|
||||
const KIMI_QUERY_TIMEOUT_MS = 180_000
|
||||
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200
|
||||
const KIMI_STABLE_POLLS_REQUIRED = 5
|
||||
const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000
|
||||
@@ -36,6 +36,17 @@ const KIMI_REDIRECT_QUERY_KEYS = [
|
||||
'jump_url',
|
||||
'to',
|
||||
]
|
||||
const KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERNS = [
|
||||
/Kimi\s*K2\.?6[,,]?\s*已就位/i,
|
||||
/Coding[、,\s]+Agent\s*集群[、,\s]+Kimi\s*Claw/i,
|
||||
/Agent\s*集群[,,]?\s*升级了/i,
|
||||
/多技能调用[,,、\s]*多产物类型一次性交付/i,
|
||||
/Kimi\s*Claw\s*已就位/i,
|
||||
/OpenClaw/i,
|
||||
/24\s*小时为你干活/i,
|
||||
]
|
||||
const KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERN_SOURCES =
|
||||
KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERNS.map((pattern) => pattern.source)
|
||||
|
||||
type KimiDomLink = {
|
||||
url: string
|
||||
@@ -57,6 +68,12 @@ type KimiPageSnapshot = {
|
||||
answerBlocks: string[]
|
||||
reasoningText: string | null
|
||||
reasoningBlocks: string[]
|
||||
// Short capacity-error notice ("算力不够,请稍后再试" or similar) shown
|
||||
// inside the assistant turn when Kimi's inference quota is exhausted.
|
||||
// The candidate scorer filters these because they are too short to look
|
||||
// like a real answer, so we surface them through a dedicated field and
|
||||
// fall back to it in the top-level query path.
|
||||
capacityNotice: string | null
|
||||
questionMatched: boolean
|
||||
candidateAnswerCount: number
|
||||
candidateReasoningCount: number
|
||||
@@ -872,6 +889,20 @@ function countMatches(input: string, pattern: RegExp): number {
|
||||
return input.match(new RegExp(pattern.source, flags))?.length ?? 0
|
||||
}
|
||||
|
||||
function isKimiPromotionalAnswerNoise(text: string): boolean {
|
||||
const normalized = normalizeText(text)?.replace(/\s+/g, ' ') ?? ''
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
|
||||
const matchedCount = KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERNS.reduce(
|
||||
(count, pattern) => count + (pattern.test(normalized) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return matchedCount >= 2
|
||||
}
|
||||
|
||||
function hasStructuredAnswerMarkers(text: string): boolean {
|
||||
return (
|
||||
/(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text) ||
|
||||
@@ -940,6 +971,9 @@ function isKimiAnswerComplete(snapshot: KimiPageSnapshot): boolean {
|
||||
if (!answer) {
|
||||
return false
|
||||
}
|
||||
if (isKimiPromotionalAnswerNoise(answer)) {
|
||||
return false
|
||||
}
|
||||
if (isKimiKeywordBlob(answer)) {
|
||||
return false
|
||||
}
|
||||
@@ -973,7 +1007,12 @@ function shouldReturnStableKimiAnswer(
|
||||
}
|
||||
|
||||
const answer = normalizeOptionalString(snapshot.answerText)
|
||||
if (!answer || isKimiKeywordBlob(answer) || firstChangedContentAt === null) {
|
||||
if (
|
||||
!answer ||
|
||||
isKimiKeywordBlob(answer) ||
|
||||
isKimiPromotionalAnswerNoise(answer) ||
|
||||
firstChangedContentAt === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -996,11 +1035,18 @@ function hasKimiObservedContent(snapshot: KimiPageSnapshot | null): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const answer = normalizeOptionalString(snapshot.answerText)
|
||||
const reasoning = normalizeOptionalString(snapshot.reasoningText)
|
||||
const answerBlocks = snapshot.answerBlocks.filter((block) => !isKimiPromotionalAnswerNoise(block))
|
||||
const reasoningBlocks = snapshot.reasoningBlocks.filter(
|
||||
(block) => !isKimiPromotionalAnswerNoise(block),
|
||||
)
|
||||
|
||||
return Boolean(
|
||||
normalizeOptionalString(snapshot.answerText) ||
|
||||
normalizeOptionalString(snapshot.reasoningText) ||
|
||||
snapshot.answerBlocks.length > 0 ||
|
||||
snapshot.reasoningBlocks.length > 0 ||
|
||||
(answer && !isKimiPromotionalAnswerNoise(answer)) ||
|
||||
(reasoning && !isKimiPromotionalAnswerNoise(reasoning)) ||
|
||||
answerBlocks.length > 0 ||
|
||||
reasoningBlocks.length > 0 ||
|
||||
snapshot.citationLinks.length > 0 ||
|
||||
snapshot.searchResultLinks.length > 0,
|
||||
)
|
||||
@@ -1036,6 +1082,9 @@ function answerQualityScore(snapshot: KimiPageSnapshot | null): number {
|
||||
if (answer && isKimiKeywordBlob(answer)) {
|
||||
score -= 1_000
|
||||
}
|
||||
if (answer && isKimiPromotionalAnswerNoise(answer)) {
|
||||
score -= 8_000
|
||||
}
|
||||
if (isKimiAnswerComplete(snapshot)) {
|
||||
score += 1_500
|
||||
}
|
||||
@@ -1468,7 +1517,7 @@ async function readKimiPageSnapshot(
|
||||
questionText: string,
|
||||
): Promise<KimiPageSnapshot> {
|
||||
return page.evaluate(
|
||||
({ question, redirectQueryKeys }) => {
|
||||
({ question, redirectQueryKeys, promotionalAnswerNoisePatternSources }) => {
|
||||
type CandidateKind = 'answer' | 'reasoning'
|
||||
type Candidate = {
|
||||
element: HTMLElement
|
||||
@@ -1562,9 +1611,11 @@ async function readKimiPageSnapshot(
|
||||
? (normalizeInline(current.innerText)?.slice(0, 160) ?? '')
|
||||
: ''
|
||||
if (
|
||||
/(side-console|console|drawer|modal|popover|popup|tooltip|floating|hover-card|dropdown|search-result|search-panel|reference-panel|ref-panel|citation-panel)/i.test(
|
||||
/(side-console|console|drawer|modal|popover|popup|tooltip|floating|hover-card|dropdown|search-result|search-panel|reference-panel|ref-panel|citation-panel|dialog|alertdialog)/i.test(
|
||||
descriptor,
|
||||
) ||
|
||||
current.tagName === 'DIALOG' ||
|
||||
current.getAttribute('aria-modal') === 'true' ||
|
||||
/^(引用来源|搜索网页)\s*\d*/.test(text)
|
||||
) {
|
||||
return true
|
||||
@@ -1636,6 +1687,9 @@ async function readKimiPageSnapshot(
|
||||
'登录后',
|
||||
'有问题,免费聊',
|
||||
]
|
||||
const promotionalAnswerNoisePatterns = promotionalAnswerNoisePatternSources.map(
|
||||
(source) => new RegExp(source, 'i'),
|
||||
)
|
||||
const elementOrders = new Map<Element, number>()
|
||||
let nextOrder = 1
|
||||
const normalizedQuestion = normalizeBlock(question)
|
||||
@@ -2329,7 +2383,21 @@ async function readKimiPageSnapshot(
|
||||
return !partialNoiseSnippets.some((snippet) => line.includes(snippet))
|
||||
})
|
||||
|
||||
return filtered.length > 0 ? filtered.join('\n') : null
|
||||
if (!filtered.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = filtered.join('\n')
|
||||
const normalizedText = text.replace(/\s+/g, ' ').trim()
|
||||
const promotionalHitCount = promotionalAnswerNoisePatterns.reduce(
|
||||
(count, pattern) => count + (pattern.test(normalizedText) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
if (promotionalHitCount >= 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
const findQuestionAnchor = (): HTMLElement | null => {
|
||||
@@ -2468,6 +2536,22 @@ async function readKimiPageSnapshot(
|
||||
if (isAuxiliaryPanelElement(element)) {
|
||||
continue
|
||||
}
|
||||
// Skip wrapper candidates that span both the answer body and the
|
||||
// chat composer (e.g. layout-level wrappers Kimi matches via the
|
||||
// wide [class*="content"] fallback). When that happens, innerText
|
||||
// pulls placeholder/skill text from the editor ("K2.6 思考",
|
||||
// "Kimi K2.6 已就位") into the answer. Filter purely by HTML form
|
||||
// semantics — <textarea>, non-hidden <input>, and contenteditable
|
||||
// are standard signals for any chat composer, so this works for
|
||||
// every platform/account without coupling to Kimi-specific class
|
||||
// names that may be renamed in future redesigns.
|
||||
if (
|
||||
element.querySelector(
|
||||
'textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable=""]',
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
seen.add(element)
|
||||
const order = getElementOrder(element)
|
||||
|
||||
@@ -2760,14 +2844,15 @@ async function readKimiPageSnapshot(
|
||||
if (visibleBusyTextExists) {
|
||||
busySignals.push('visible_busy_text')
|
||||
}
|
||||
const loadingIndicators = Array.from(
|
||||
document.querySelectorAll(
|
||||
'[class*="loading"], [class*="typing"], [class*="spinner"], [class*="stream"]',
|
||||
),
|
||||
).filter((node) => isVisible(node))
|
||||
if (loadingIndicators.length > 0) {
|
||||
busySignals.push('loading_indicator')
|
||||
}
|
||||
// Intentionally NOT scanning for [class*="loading"]/"stream"/"typing"
|
||||
// class names. Class-based loading detection is fragile across Kimi's
|
||||
// UI redesigns (e.g. K2.6 ships always-on `text-stream` wrappers that
|
||||
// never go away even after generation finishes, which forced
|
||||
// busy=true forever and produced spurious kimi_query_timeout errors).
|
||||
// The textual `busySignalsPattern` probe above (bodyText + visible
|
||||
// status/loading elements whose innerText matches "思考中/搜索中/
|
||||
// 生成中…") is the true semantic signal — it stays valid no matter
|
||||
// how Kimi renames its containers.
|
||||
const sendButton = document.querySelector('.send-button-container')
|
||||
const sendDisabled =
|
||||
sendButton instanceof HTMLElement
|
||||
@@ -2781,6 +2866,24 @@ async function readKimiPageSnapshot(
|
||||
document.querySelector('.current-model')?.textContent,
|
||||
)
|
||||
|
||||
// Detect Kimi's capacity-exhausted notice ("算力不够 / 服务繁忙 /
|
||||
// 请稍后再试" etc). The pattern matches phrases only — no class names —
|
||||
// so it stays stable across UI redesigns.
|
||||
const capacityLimitPattern =
|
||||
/(算力不够|算力不足|服务繁忙|当前用户量较大|请稍后再试|请稍候再试|请求过多|系统繁忙|too\s*many\s*requests|service\s*(?:busy|unavailable)|capacity\s*(?:limit|exhaust))/i
|
||||
const capacityNotice = (() => {
|
||||
const assistantNodes = Array.from(
|
||||
document.querySelectorAll('.chat-content-item-assistant, .segment-assistant'),
|
||||
).filter((node) => node instanceof HTMLElement && isVisible(node)) as HTMLElement[]
|
||||
if (!assistantNodes.length) return null
|
||||
const lastNode = assistantNodes[assistantNodes.length - 1]
|
||||
const text = normalizeBlock(lastNode.innerText)
|
||||
// Capacity messages are short; if there's already a longer answer in
|
||||
// this turn, don't fall back here — let the candidate scorer take it.
|
||||
if (!text || text.length > 240) return null
|
||||
return capacityLimitPattern.test(text) ? text : null
|
||||
})()
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: normalizeInline(document.title),
|
||||
@@ -2794,6 +2897,7 @@ async function readKimiPageSnapshot(
|
||||
answerBlocks,
|
||||
reasoningText,
|
||||
reasoningBlocks,
|
||||
capacityNotice,
|
||||
questionMatched: Boolean(questionAnchor),
|
||||
candidateAnswerCount: answerCandidates.length,
|
||||
candidateReasoningCount: reasoningCandidates.length,
|
||||
@@ -2819,6 +2923,7 @@ async function readKimiPageSnapshot(
|
||||
{
|
||||
question: questionText,
|
||||
redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS,
|
||||
promotionalAnswerNoisePatternSources: KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERN_SOURCES,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -3033,7 +3138,17 @@ export const kimiAdapter: MonitorAdapter = {
|
||||
const searchResults = classifiedSources.searchResults
|
||||
const inlineCitationCandidates = classifiedSources.inlineCitationCandidates
|
||||
const panelCitations = classifiedSources.panelCitations
|
||||
const answer = normalizeText(finalSnapshot.answerText)
|
||||
// When Kimi exhausts inference capacity it shows a brief "算力不够 /
|
||||
// 服务繁忙 / 请稍后再试" notice in place of an answer; the candidate
|
||||
// scorer filters that text out for being too short. Fall back to the
|
||||
// dedicated capacityNotice field so the SaaS pipeline still records the
|
||||
// attempt with the user-visible message instead of an unknown row.
|
||||
const capacityNotice = normalizeText(finalSnapshot.capacityNotice)
|
||||
const rawAnswer = normalizeText(finalSnapshot.answerText)
|
||||
const promotionalAnswerNoiseDetected = rawAnswer
|
||||
? isKimiPromotionalAnswerNoise(rawAnswer)
|
||||
: false
|
||||
const answer = promotionalAnswerNoiseDetected ? capacityNotice : (rawAnswer ?? capacityNotice)
|
||||
const reasoning = normalizeText(finalSnapshot.reasoningText)
|
||||
const answerComplete = isKimiAnswerComplete(finalSnapshot)
|
||||
const keywordBlobDetected = answer ? isKimiKeywordBlob(answer) : false
|
||||
@@ -3094,11 +3209,14 @@ export const kimiAdapter: MonitorAdapter = {
|
||||
}
|
||||
}
|
||||
|
||||
const capacityLimited = !rawAnswer && Boolean(capacityNotice)
|
||||
context.reportProgress('kimi.parse_result')
|
||||
return {
|
||||
status: 'succeeded',
|
||||
summary: queryResult.ok
|
||||
? 'Kimi 监控任务执行成功。'
|
||||
? capacityLimited
|
||||
? 'Kimi 算力不足,已回采提示信息。'
|
||||
: 'Kimi 监控任务执行成功。'
|
||||
: 'Kimi 监控任务执行成功(超时前已收集到页面答案)。',
|
||||
payload: {
|
||||
platform: 'kimi',
|
||||
@@ -3123,6 +3241,9 @@ export const kimiAdapter: MonitorAdapter = {
|
||||
busy: finalSnapshot.busy,
|
||||
busy_signals: finalSnapshot.busySignals,
|
||||
send_disabled: finalSnapshot.sendDisabled,
|
||||
capacity_limited: capacityLimited,
|
||||
capacity_notice: finalSnapshot.capacityNotice ?? null,
|
||||
answer_promotional_noise_detected: promotionalAnswerNoiseDetected,
|
||||
answer: answer ?? null,
|
||||
answer_complete: answerComplete,
|
||||
answer_keyword_blob_detected: keywordBlobDetected,
|
||||
@@ -3166,6 +3287,8 @@ export const __kimiTestUtils = {
|
||||
buildSourceItem,
|
||||
classifyKimiSources,
|
||||
dedupeSourceItems,
|
||||
isKimiAnswerComplete,
|
||||
isKimiPromotionalAnswerNoise,
|
||||
mergeKimiSourceLinksIntoContentSnapshot,
|
||||
normalizeUrl,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user