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:
@@ -10,6 +10,7 @@ const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000;
|
|||||||
const KIMI_QUERY_TIMEOUT_MS = 120_000;
|
const KIMI_QUERY_TIMEOUT_MS = 120_000;
|
||||||
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200;
|
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200;
|
||||||
const KIMI_STABLE_POLLS_REQUIRED = 5;
|
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_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_SEND_BUTTON_SELECTOR = ".send-button-container";
|
||||||
const KIMI_THINKING_MODE_LABEL = "K2.6 思考";
|
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 {
|
function answerQualityScore(snapshot: KimiPageSnapshot | null): number {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return Number.NEGATIVE_INFINITY;
|
return Number.NEGATIVE_INFINITY;
|
||||||
@@ -428,6 +568,15 @@ function answerQualityScore(snapshot: KimiPageSnapshot | null): number {
|
|||||||
if (snapshot.questionMatched) {
|
if (snapshot.questionMatched) {
|
||||||
score += 600;
|
score += 600;
|
||||||
}
|
}
|
||||||
|
if (!hasKimiObservedContent(snapshot)) {
|
||||||
|
score -= 2_000;
|
||||||
|
}
|
||||||
|
if (answer && isKimiKeywordBlob(answer)) {
|
||||||
|
score -= 1_000;
|
||||||
|
}
|
||||||
|
if (isKimiAnswerComplete(snapshot)) {
|
||||||
|
score += 1_500;
|
||||||
|
}
|
||||||
if (snapshot.busy) {
|
if (snapshot.busy) {
|
||||||
score -= 120;
|
score -= 120;
|
||||||
}
|
}
|
||||||
@@ -442,6 +591,15 @@ function pickBetterSnapshot(current: KimiPageSnapshot | null, candidate: KimiPag
|
|||||||
return candidate;
|
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;
|
return answerQualityScore(candidate) >= answerQualityScore(current) ? candidate : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1851,9 +2009,11 @@ async function waitForKimiAnswer(
|
|||||||
await pinKimiConversationToBottom(page);
|
await pinKimiConversationToBottom(page);
|
||||||
let stablePollCount = 0;
|
let stablePollCount = 0;
|
||||||
let bestSnapshot = await readKimiPageSnapshot(page, questionText);
|
let bestSnapshot = await readKimiPageSnapshot(page, questionText);
|
||||||
|
let bestContentSnapshot = hasKimiObservedContent(bestSnapshot) ? bestSnapshot : null;
|
||||||
const initialSignature = bestSnapshot.signature;
|
const initialSignature = bestSnapshot.signature;
|
||||||
let lastSignature: string | null = initialSignature;
|
let lastSignature: string | null = initialSignature;
|
||||||
let sawChange = false;
|
let sawChange = false;
|
||||||
|
let firstChangedContentAt: number | null = null;
|
||||||
|
|
||||||
while (Date.now() - startedAt <= KIMI_QUERY_TIMEOUT_MS) {
|
while (Date.now() - startedAt <= KIMI_QUERY_TIMEOUT_MS) {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
@@ -1863,6 +2023,9 @@ async function waitForKimiAnswer(
|
|||||||
await pinKimiConversationToBottom(page);
|
await pinKimiConversationToBottom(page);
|
||||||
const snapshot = await readKimiPageSnapshot(page, questionText);
|
const snapshot = await readKimiPageSnapshot(page, questionText);
|
||||||
bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot);
|
bestSnapshot = pickBetterSnapshot(bestSnapshot, snapshot);
|
||||||
|
if (hasKimiObservedContent(snapshot)) {
|
||||||
|
bestContentSnapshot = pickBetterSnapshot(bestContentSnapshot, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
if (snapshot.loginRequired) {
|
if (snapshot.loginRequired) {
|
||||||
return {
|
return {
|
||||||
@@ -1882,6 +2045,9 @@ async function waitForKimiAnswer(
|
|||||||
if (snapshot.signature && hasContent) {
|
if (snapshot.signature && hasContent) {
|
||||||
if (snapshot.signature !== initialSignature) {
|
if (snapshot.signature !== initialSignature) {
|
||||||
sawChange = true;
|
sawChange = true;
|
||||||
|
if (firstChangedContentAt === null) {
|
||||||
|
firstChangedContentAt = Date.now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (snapshot.signature === lastSignature) {
|
if (snapshot.signature === lastSignature) {
|
||||||
stablePollCount = snapshot.busy ? 0 : stablePollCount + 1;
|
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);
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
finalSnapshot,
|
finalSnapshot,
|
||||||
@@ -1907,7 +2083,10 @@ async function waitForKimiAnswer(
|
|||||||
|
|
||||||
await pinKimiConversationToBottom(page);
|
await pinKimiConversationToBottom(page);
|
||||||
await ensureKimiCitationPanelOpen(page, signal);
|
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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "kimi_query_timeout",
|
error: "kimi_query_timeout",
|
||||||
@@ -2006,6 +2185,8 @@ export const kimiAdapter: MonitorAdapter = {
|
|||||||
);
|
);
|
||||||
const answer = normalizeText(finalSnapshot.answerText);
|
const answer = normalizeText(finalSnapshot.answerText);
|
||||||
const reasoning = normalizeText(finalSnapshot.reasoningText);
|
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 providerModel = normalizeText(finalSnapshot.currentModelLabel) ?? KIMI_THINKING_MODE_LABEL;
|
||||||
const conversationID = extractConversationID(finalSnapshot.url);
|
const conversationID = extractConversationID(finalSnapshot.url);
|
||||||
|
|
||||||
@@ -2024,6 +2205,28 @@ export const kimiAdapter: MonitorAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!answer && !searchResults.length && !citations.length) {
|
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
|
return queryResult.ok
|
||||||
? {
|
? {
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
@@ -2072,6 +2275,8 @@ export const kimiAdapter: MonitorAdapter = {
|
|||||||
busy_signals: finalSnapshot.busySignals,
|
busy_signals: finalSnapshot.busySignals,
|
||||||
send_disabled: finalSnapshot.sendDisabled,
|
send_disabled: finalSnapshot.sendDisabled,
|
||||||
answer: answer ?? null,
|
answer: answer ?? null,
|
||||||
|
answer_complete: answerComplete,
|
||||||
|
answer_keyword_blob_detected: keywordBlobDetected,
|
||||||
answer_block_count: finalSnapshot.answerBlocks.length,
|
answer_block_count: finalSnapshot.answerBlocks.length,
|
||||||
answer_blocks: finalSnapshot.answerBlocks,
|
answer_blocks: finalSnapshot.answerBlocks,
|
||||||
reasoning: reasoning ?? null,
|
reasoning: reasoning ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user