fix(desktop): guard Kimi adapter against incomplete and keyword-blob answers

Add heuristics to distinguish keyword fragments from substantive answers,
enforce a grace period before returning incomplete content, and mark timed-out
or reasoning-only captures as unknown so later passes can re-collect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 09:34:05 +08:00
parent 4f4f063200
commit e04593f4af
+208 -3
View File
@@ -10,6 +10,7 @@ const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000;
const KIMI_QUERY_TIMEOUT_MS = 120_000;
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200;
const KIMI_STABLE_POLLS_REQUIRED = 5;
const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000;
const KIMI_EDITOR_SELECTOR = "div[role=\"textbox\"].chat-input-editor, .chat-input-editor[role=\"textbox\"], [role=\"textbox\"][class*=\"chat-input-editor\"]";
const KIMI_SEND_BUTTON_SELECTOR = ".send-button-container";
const KIMI_THINKING_MODE_LABEL = "K2.6 思考";
@@ -404,6 +405,145 @@ async function ensureKimiCitationPanelOpen(page: PlaywrightPage, signal?: AbortS
}
}
function countMatches(input: string, pattern: RegExp): number {
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
return input.match(new RegExp(pattern.source, flags))?.length ?? 0;
}
function hasStructuredAnswerMarkers(text: string): boolean {
return (
/(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text)
|| /(?:^|\n)\|.+\|(?:\n|$)/m.test(text)
|| /(?:^|\n)\s*[^:\n]{1,24}[:]\s+\S+/m.test(text)
);
}
function isKimiTerminalShortAnswer(text: string): boolean {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized || normalized.length > 48) {
return false;
}
return /(未找到|暂无|没有|无法|不能|抱歉|不确定|未检索到|未发现|无明显结果)/.test(normalized);
}
function isKimiKeywordBlob(text: string): boolean {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) {
return false;
}
const punctuationCount = countMatches(normalized, /[。!?;:,、,.!?;:]/g);
const lineCount = normalized.split(/\n+/).filter(Boolean).length;
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
return (
!isKimiTerminalShortAnswer(normalized)
&& !hasStructuredAnswerMarkers(normalized)
&& lineCount <= 1
&& punctuationCount === 0
&& tokenCount >= 3
&& normalized.length <= 140
);
}
function isKimiSubstantiveAnswer(answer: string, answerBlocks: string[]): boolean {
const normalized = answer.trim();
if (!normalized) {
return false;
}
const punctuationCount = countMatches(normalized, /[。!?;:,、,.!?;:]/g);
const lineCount = normalized.split(/\n+/).filter(Boolean).length;
if (hasStructuredAnswerMarkers(normalized)) {
return true;
}
if (answerBlocks.length >= 2) {
return true;
}
if (normalized.length >= 140) {
return true;
}
if (normalized.length >= 80 && punctuationCount >= 2) {
return true;
}
if (normalized.length >= 60 && lineCount >= 3) {
return true;
}
return false;
}
function isKimiAnswerComplete(snapshot: KimiPageSnapshot): boolean {
const answer = normalizeOptionalString(snapshot.answerText);
if (!answer) {
return false;
}
if (isKimiKeywordBlob(answer)) {
return false;
}
if (isKimiTerminalShortAnswer(answer)) {
return true;
}
if (isKimiSubstantiveAnswer(answer, snapshot.answerBlocks)) {
return true;
}
const punctuationCount = countMatches(answer, /[。!?;:,、,.!?;:]/g);
if (snapshot.citationLinks.length >= 1 && answer.length >= 50 && punctuationCount >= 1) {
return true;
}
if (snapshot.searchResultLinks.length >= 3 && answer.length >= 70 && punctuationCount >= 1) {
return true;
}
if (snapshot.reasoningBlocks.length >= 2 && answer.length >= 70 && punctuationCount >= 1) {
return true;
}
return false;
}
function shouldReturnStableKimiAnswer(
snapshot: KimiPageSnapshot,
firstChangedContentAt: number | null,
now: number,
): boolean {
if (isKimiAnswerComplete(snapshot)) {
return true;
}
const answer = normalizeOptionalString(snapshot.answerText);
if (!answer || isKimiKeywordBlob(answer) || firstChangedContentAt === null) {
return false;
}
const changedContentAge = now - firstChangedContentAt;
if (changedContentAge < KIMI_INCOMPLETE_ANSWER_GRACE_MS) {
return false;
}
const punctuationCount = countMatches(answer, /[。!?;:,、,.!?;:]/g);
return (
answer.length >= 48
|| punctuationCount >= 1
|| snapshot.answerBlocks.length >= 2
|| isKimiTerminalShortAnswer(answer)
);
}
function hasKimiObservedContent(snapshot: KimiPageSnapshot | null): boolean {
if (!snapshot) {
return false;
}
return Boolean(
normalizeOptionalString(snapshot.answerText)
|| normalizeOptionalString(snapshot.reasoningText)
|| snapshot.answerBlocks.length > 0
|| snapshot.reasoningBlocks.length > 0
|| snapshot.citationLinks.length > 0
|| snapshot.searchResultLinks.length > 0,
);
}
function answerQualityScore(snapshot: KimiPageSnapshot | null): number {
if (!snapshot) {
return Number.NEGATIVE_INFINITY;
@@ -428,6 +568,15 @@ function answerQualityScore(snapshot: KimiPageSnapshot | null): number {
if (snapshot.questionMatched) {
score += 600;
}
if (!hasKimiObservedContent(snapshot)) {
score -= 2_000;
}
if (answer && isKimiKeywordBlob(answer)) {
score -= 1_000;
}
if (isKimiAnswerComplete(snapshot)) {
score += 1_500;
}
if (snapshot.busy) {
score -= 120;
}
@@ -442,6 +591,15 @@ function pickBetterSnapshot(current: KimiPageSnapshot | null, candidate: KimiPag
return candidate;
}
const currentHasContent = hasKimiObservedContent(current);
const candidateHasContent = hasKimiObservedContent(candidate);
if (candidateHasContent && !currentHasContent) {
return candidate;
}
if (currentHasContent && !candidateHasContent) {
return current;
}
return answerQualityScore(candidate) >= answerQualityScore(current) ? candidate : current;
}
@@ -1851,9 +2009,11 @@ async function waitForKimiAnswer(
await pinKimiConversationToBottom(page);
let stablePollCount = 0;
let bestSnapshot = await readKimiPageSnapshot(page, questionText);
let bestContentSnapshot = hasKimiObservedContent(bestSnapshot) ? bestSnapshot : null;
const initialSignature = bestSnapshot.signature;
let lastSignature: string | null = initialSignature;
let sawChange = false;
let firstChangedContentAt: number | null = null;
while (Date.now() - startedAt <= KIMI_QUERY_TIMEOUT_MS) {
if (signal.aborted) {
@@ -1863,6 +2023,9 @@ async function waitForKimiAnswer(
await pinKimiConversationToBottom(page);
const snapshot = await readKimiPageSnapshot(page, questionText);
bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot);
if (hasKimiObservedContent(snapshot)) {
bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, snapshot);
}
if (snapshot.loginRequired) {
return {
@@ -1882,6 +2045,9 @@ async function waitForKimiAnswer(
if (snapshot.signature && hasContent) {
if (snapshot.signature !== initialSignature) {
sawChange = true;
if (firstChangedContentAt === null) {
firstChangedContentAt = Date.now();
}
}
if (snapshot.signature === lastSignature) {
stablePollCount = snapshot.busy ? 0 : stablePollCount + 1;
@@ -1891,9 +2057,19 @@ async function waitForKimiAnswer(
}
}
if (sawChange && hasContent && !snapshot.busy && stablePollCount >= KIMI_STABLE_POLLS_REQUIRED) {
const now = Date.now();
if (
sawChange
&& hasContent
&& !snapshot.busy
&& stablePollCount >= KIMI_STABLE_POLLS_REQUIRED
&& shouldReturnStableKimiAnswer(snapshot, firstChangedContentAt, now)
) {
await ensureKimiCitationPanelOpen(page, signal);
const finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText));
let finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText));
if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) {
finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot);
}
return {
ok: true,
finalSnapshot,
@@ -1907,7 +2083,10 @@ async function waitForKimiAnswer(
await pinKimiConversationToBottom(page);
await ensureKimiCitationPanelOpen(page, signal);
const finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText));
let finalSnapshot = pickBetterSnapshot(bestSnapshot, await readKimiPageSnapshot(page, questionText));
if (!hasKimiObservedContent(finalSnapshot) && bestContentSnapshot) {
finalSnapshot = pickBetterSnapshot(finalSnapshot, bestContentSnapshot);
}
return {
ok: false,
error: "kimi_query_timeout",
@@ -2006,6 +2185,8 @@ export const kimiAdapter: MonitorAdapter = {
);
const answer = normalizeText(finalSnapshot.answerText);
const reasoning = normalizeText(finalSnapshot.reasoningText);
const answerComplete = isKimiAnswerComplete(finalSnapshot);
const keywordBlobDetected = answer ? isKimiKeywordBlob(answer) : false;
const providerModel = normalizeText(finalSnapshot.currentModelLabel) ?? KIMI_THINKING_MODE_LABEL;
const conversationID = extractConversationID(finalSnapshot.url);
@@ -2024,6 +2205,28 @@ export const kimiAdapter: MonitorAdapter = {
}
if (!answer && !searchResults.length && !citations.length) {
const timedOut = !queryResult.ok && queryResult.error === "kimi_query_timeout";
const hasReasoningOnly = Boolean(reasoning);
if (timedOut || hasReasoningOnly) {
const incompleteDetail = !queryResult.ok
? queryResult.detail ?? queryResult.error
: "kimi returned no final answer before completion";
return {
status: "unknown",
summary: "Kimi 超时未拿到完整答案,已回写 unknown 等待后续补采。",
error: buildAdapterError(
timedOut ? queryResult.error : "kimi_incomplete_response",
incompleteDetail,
{
page_url: finalSnapshot.url,
current_model_label: finalSnapshot.currentModelLabel,
reasoning_present: Boolean(reasoning),
busy: finalSnapshot.busy,
},
),
};
}
return queryResult.ok
? {
status: "unknown",
@@ -2072,6 +2275,8 @@ export const kimiAdapter: MonitorAdapter = {
busy_signals: finalSnapshot.busySignals,
send_disabled: finalSnapshot.sendDisabled,
answer: answer ?? null,
answer_complete: answerComplete,
answer_keyword_blob_detected: keywordBlobDetected,
answer_block_count: finalSnapshot.answerBlocks.length,
answer_blocks: finalSnapshot.answerBlocks,
reasoning: reasoning ?? null,