fix(monitoring): reject succeeded results missing answers or citation sources

Server callback now rejects succeeded submissions without a non-empty answer,
or whose answer contains [citation:N] markers without any sources. Desktop
adapters mirror this client-side: kimi returns unknown when sources arrive
without a final answer, and yuanbao returns unknown when the answer has
unresolved citation markers. Yuanbao also unwraps redirect URLs, harvests
citations from more DOM attributes and document URL fields, drops links
pointing back at yuanbao itself, and dedupes overlapping streaming
fragments. Runtime controller forwards only an explicit "succeeded" status
as success; anything else becomes failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:07:21 +08:00
parent 453a374a49
commit a6b17bae62
6 changed files with 312 additions and 22 deletions
+190 -15
View File
@@ -365,6 +365,25 @@ function resolveCitationsFromMarkers(
.filter((item): item is MonitoringSourceItem => item !== null);
}
function sharedBoundaryLength(left: string, right: string): number {
const maxLength = Math.min(left.length, right.length);
for (let length = maxLength; length >= 8; length -= 1) {
if (left.slice(left.length - length) === right.slice(0, length)) {
return length;
}
}
return 0;
}
function sharedPrefixLength(left: string, right: string): number {
const maxLength = Math.min(left.length, right.length);
let length = 0;
while (length < maxLength && left[length] === right[length]) {
length += 1;
}
return length;
}
function mergeText(current: string | null, nextFragment: string | null): string | null {
if (!nextFragment) {
return current;
@@ -378,6 +397,23 @@ function mergeText(current: string | null, nextFragment: string | null): string
if (nextFragment.startsWith(current)) {
return nextFragment;
}
const forwardOverlap = sharedBoundaryLength(current, nextFragment);
if (forwardOverlap > 0) {
return `${current}${nextFragment.slice(forwardOverlap)}`;
}
const reverseOverlap = sharedBoundaryLength(nextFragment, current);
if (reverseOverlap > 0) {
return `${nextFragment}${current.slice(reverseOverlap)}`;
}
const commonPrefixLength = sharedPrefixLength(current, nextFragment);
const duplicatePrefixThreshold = Math.max(16, Math.min(80, Math.floor(Math.min(current.length, nextFragment.length) * 0.45)));
if (commonPrefixLength >= duplicatePrefixThreshold) {
return answerQualityScore(nextFragment) > answerQualityScore(current) ? nextFragment : current;
}
return `${current}${nextFragment}`;
}
@@ -399,6 +435,28 @@ function normalizeSourceUrl(value: string | null): string | null {
try {
const url = new URL(input);
for (const key of [
"url",
"u",
"target",
"target_url",
"targetUrl",
"redirect",
"redirect_url",
"redirectUrl",
"jump_url",
"jumpUrl",
"link",
]) {
const nested = normalizeText(url.searchParams.get(key));
if (!nested || nested === input || !/^https?:\/\//i.test(nested)) {
continue;
}
const unwrapped = normalizeSourceUrl(nested);
if (unwrapped) {
return unwrapped;
}
}
url.hash = "";
return url.toString();
} catch {
@@ -406,6 +464,15 @@ function normalizeSourceUrl(value: string | null): string | null {
}
}
function isYuanbaoInternalUrl(value: string): boolean {
try {
const host = new URL(value).hostname.toLowerCase();
return host === "yuanbao.tencent.com" || host.endsWith(".yuanbao.tencent.com");
} catch {
return false;
}
}
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
if (typeof input === "string") {
const url = normalizeSourceUrl(input);
@@ -430,9 +497,32 @@ function buildSourceItem(input: unknown): MonitoringSourceItem | null {
"jumpUrl",
"target_url",
"targetUrl",
"target",
"doc_url",
"docUrl",
"document_url",
"documentUrl",
"article_url",
"articleUrl",
"web_url",
"webUrl",
"webpage_url",
"webpageUrl",
"url_pc",
"urlPc",
"pc_url",
"pcUrl",
"mobile_url",
"mobileUrl",
"link_url",
"linkUrl",
"ori_url",
"oriUrl",
"original_url",
"originalUrl",
]),
);
if (!url) {
if (!url || isYuanbaoInternalUrl(url)) {
return null;
}
@@ -1373,25 +1463,79 @@ const yuanbaoQueryInPage = async (
const links: YuanbaoDomLink[] = [];
const seen = new Set<string>();
for (const node of Array.from(scope.querySelectorAll("a[href]"))) {
if (!(node instanceof HTMLAnchorElement) || !isVisible(node)) {
const urlAttributeNames = [
"href",
"data-href",
"data-url",
"data-link",
"data-source-url",
"data-target-url",
"data-jump-url",
"data-doc-url",
"data-article-url",
];
const extractUrls = (value: string | null): string[] => {
const normalized = normalize(value);
if (!normalized) {
return [];
}
const urls = new Set<string>();
const pushCandidate = (candidate: string) => {
try {
const decoded = decodeURIComponent(candidate);
if (/^https?:\/\//i.test(decoded)) {
urls.add(decoded);
}
} catch {
if (/^https?:\/\//i.test(candidate)) {
urls.add(candidate);
}
}
};
if (/^https?:\/\//i.test(normalized)) {
pushCandidate(normalized);
}
for (const matched of normalized.matchAll(/https?:\/\/[^\s"'<>\\]+/gi)) {
if (matched[0]) {
pushCandidate(matched[0]);
}
}
return Array.from(urls);
};
for (const node of [scope, ...Array.from(scope.querySelectorAll("*"))]) {
if (!(node instanceof HTMLElement) || !isVisible(node)) {
continue;
}
const href = normalize(node.href);
if (!href || !/^https?:\/\//i.test(href) || seen.has(href)) {
continue;
const urls = new Set<string>();
if (node instanceof HTMLAnchorElement) {
for (const url of extractUrls(node.href)) {
urls.add(url);
}
}
for (const attributeName of urlAttributeNames) {
for (const url of extractUrls(node.getAttribute(attributeName))) {
urls.add(url);
}
}
seen.add(href);
links.push({
url: href,
title: normalize(node.title) ?? normalize(node.getAttribute("aria-label")),
text:
normalize(node.innerText)
?? normalize(node.textContent)
?? normalize(node.getAttribute("aria-label")),
});
for (const url of urls) {
if (seen.has(url)) {
continue;
}
seen.add(url);
links.push({
url,
title: normalize(node.getAttribute("title")) ?? normalize(node.getAttribute("aria-label")),
text:
normalize(node.innerText)
?? normalize(node.textContent)
?? normalize(node.getAttribute("aria-label")),
});
}
}
return links;
};
@@ -1644,6 +1788,7 @@ export const yuanbaoAdapter: MonitorAdapter = {
const providerModel = resolveProviderModel(pageResult, parsed);
const conversationID = parsed.conversationID ?? extractConversationID(pageResult.url);
const requestID = parsed.requestID ?? conversationID;
const maxCitationMarkerIndex = citationMarkerIndexes.length ? Math.max(...citationMarkerIndexes) : 0;
if (pageResult.ok === false) {
const failedPageResult = pageResult;
@@ -1691,6 +1836,26 @@ export const yuanbaoAdapter: MonitorAdapter = {
};
}
if (answer && maxCitationMarkerIndex > 0 && citations.length < maxCitationMarkerIndex) {
return {
status: "unknown",
summary: "元宝回答含引用标记但未拿到完整引用来源,已回写 unknown 等待后续补采。",
error: buildAdapterError(
"yuanbao_incomplete_citations",
"yuanbao answer contains citation markers but source extraction is incomplete",
{
citation_marker_count: citationMarkerIndexes.length,
max_citation_marker_index: maxCitationMarkerIndex,
citation_count: citations.length,
search_result_count: searchResults.length,
dom_citation_count: domCitations.length,
parsed_citation_count: parsed.citations.length,
parsed_search_result_count: parsed.searchResults.length,
},
),
};
}
context.reportProgress("yuanbao.parse_result");
return {
status: "succeeded",
@@ -1724,3 +1889,13 @@ export const yuanbaoAdapter: MonitorAdapter = {
};
},
};
export const __yuanbaoTestUtils = {
buildSourceItem,
dedupeSourceItems,
extractCitationMarkerIndexes,
mergeText,
normalizeSourceUrl,
resolveCitationsFromMarkers,
selectBestAnswerText,
};