feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
vi.mock('electron/main', () => ({
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { __deepseekTestUtils } from './deepseek'
|
||||
import { getMonitorAdapter } from './index'
|
||||
import { __deepseekTestUtils } from "./deepseek";
|
||||
import { getMonitorAdapter } from "./index";
|
||||
|
||||
const {
|
||||
extractQuestionText,
|
||||
@@ -20,275 +14,263 @@ const {
|
||||
isObservationComplete,
|
||||
isDeepSeekToggleSelected,
|
||||
isDeepSeekWebpagesTriggerText,
|
||||
} = __deepseekTestUtils
|
||||
} = __deepseekTestUtils;
|
||||
|
||||
describe('deepseek adapter helpers', () => {
|
||||
it('extracts question text from monitoring payload', () => {
|
||||
expect(extractQuestionText({ question_text: 'DeepSeek 怎么看 GEO?' })).toBe(
|
||||
'DeepSeek 怎么看 GEO?',
|
||||
)
|
||||
})
|
||||
describe("deepseek adapter helpers", () => {
|
||||
it("extracts question text from monitoring payload", () => {
|
||||
expect(extractQuestionText({ question_text: "DeepSeek 怎么看 GEO?" })).toBe("DeepSeek 怎么看 GEO?");
|
||||
});
|
||||
|
||||
it('dedupes sources by normalized url without hash fragments', () => {
|
||||
it("dedupes sources by normalized url without hash fragments", () => {
|
||||
const items = dedupeSourceItems([
|
||||
{
|
||||
url: 'https://example.com/a#top',
|
||||
title: 'A',
|
||||
normalized_url: 'https://example.com/a#top',
|
||||
url: "https://example.com/a#top",
|
||||
title: "A",
|
||||
normalized_url: "https://example.com/a#top",
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/a#bottom',
|
||||
title: 'A later',
|
||||
normalized_url: 'https://example.com/a#bottom',
|
||||
url: "https://example.com/a#bottom",
|
||||
title: "A later",
|
||||
normalized_url: "https://example.com/a#bottom",
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]?.normalized_url).toBe('https://example.com/a')
|
||||
expect(items[0]?.title).toBe('A')
|
||||
})
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.normalized_url).toBe("https://example.com/a");
|
||||
expect(items[0]?.title).toBe("A");
|
||||
});
|
||||
|
||||
it('treats revealed search cards as citation sources and inline links as content citation candidates', () => {
|
||||
it("treats revealed search cards as citation sources and inline links as content citation candidates", () => {
|
||||
const classified = classifyDeepseekSources({
|
||||
inlineLinks: [
|
||||
{
|
||||
url: 'https://example.com/citation',
|
||||
title: 'Citation',
|
||||
normalized_url: 'https://example.com/citation',
|
||||
url: "https://example.com/citation",
|
||||
title: "Citation",
|
||||
normalized_url: "https://example.com/citation",
|
||||
},
|
||||
],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks: [
|
||||
{
|
||||
url: 'https://example.com/search',
|
||||
title: 'Search',
|
||||
normalized_url: 'https://example.com/search',
|
||||
url: "https://example.com/search",
|
||||
title: "Search",
|
||||
normalized_url: "https://example.com/search",
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
expect(classified.citations).toHaveLength(1)
|
||||
expect(classified.inlineCitationCandidates).toHaveLength(1)
|
||||
expect(classified.searchResults).toHaveLength(1)
|
||||
expect(classified.citations[0]?.url).toBe('https://example.com/search')
|
||||
expect(classified.inlineCitationCandidates[0]?.url).toBe('https://example.com/citation')
|
||||
expect(classified.searchResults[0]?.url).toBe('https://example.com/search')
|
||||
})
|
||||
expect(classified.citations).toHaveLength(1);
|
||||
expect(classified.inlineCitationCandidates).toHaveLength(1);
|
||||
expect(classified.searchResults).toHaveLength(1);
|
||||
expect(classified.citations[0]?.url).toBe("https://example.com/search");
|
||||
expect(classified.inlineCitationCandidates[0]?.url).toBe("https://example.com/citation");
|
||||
expect(classified.searchResults[0]?.url).toBe("https://example.com/search");
|
||||
});
|
||||
|
||||
it('enriches numeric inline citations with source metadata from revealed search cards', () => {
|
||||
it("enriches numeric inline citations with source metadata from revealed search cards", () => {
|
||||
const classified = classifyDeepseekSources({
|
||||
inlineLinks: [
|
||||
{
|
||||
url: 'https://example.com/citation',
|
||||
url: "https://example.com/citation",
|
||||
title: null,
|
||||
text: '-1',
|
||||
text: "-1",
|
||||
siteName: null,
|
||||
kind: 'inline',
|
||||
kind: "inline",
|
||||
},
|
||||
],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks: [
|
||||
{
|
||||
url: 'https://example.com/citation',
|
||||
title: 'DeepSeek source article',
|
||||
text: 'Snippet',
|
||||
siteName: 'Example',
|
||||
kind: 'search',
|
||||
url: "https://example.com/citation",
|
||||
title: "DeepSeek source article",
|
||||
text: "Snippet",
|
||||
siteName: "Example",
|
||||
kind: "search",
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
expect(classified.inlineCitationCandidates).toHaveLength(1)
|
||||
expect(classified.inlineCitationCandidates[0]?.title).toBe('DeepSeek source article')
|
||||
expect(classified.inlineCitationCandidates[0]?.site_name).toBe('Example')
|
||||
expect(classified.citations).toHaveLength(1)
|
||||
expect(classified.searchResults).toHaveLength(1)
|
||||
})
|
||||
expect(classified.inlineCitationCandidates).toHaveLength(1);
|
||||
expect(classified.inlineCitationCandidates[0]?.title).toBe("DeepSeek source article");
|
||||
expect(classified.inlineCitationCandidates[0]?.site_name).toBe("Example");
|
||||
expect(classified.citations).toHaveLength(1);
|
||||
expect(classified.searchResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps reasoning search pages as citation sources separate from answer content citations', () => {
|
||||
it("keeps reasoning search pages as citation sources separate from answer content citations", () => {
|
||||
const classified = classifyDeepseekSources({
|
||||
inlineLinks: [
|
||||
{
|
||||
url: 'https://example.com/body-citation',
|
||||
title: 'Answer body citation',
|
||||
text: '[15]',
|
||||
siteName: 'Body Site',
|
||||
kind: 'inline',
|
||||
url: "https://example.com/body-citation",
|
||||
title: "Answer body citation",
|
||||
text: "[15]",
|
||||
siteName: "Body Site",
|
||||
kind: "inline",
|
||||
},
|
||||
],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks: [
|
||||
{
|
||||
url: 'https://example.com/reasoning-search',
|
||||
title: 'Reasoning searched page',
|
||||
text: '浏览页面',
|
||||
siteName: 'Search Site',
|
||||
kind: 'search',
|
||||
url: "https://example.com/reasoning-search",
|
||||
title: "Reasoning searched page",
|
||||
text: "浏览页面",
|
||||
siteName: "Search Site",
|
||||
kind: "search",
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
expect(classified.inlineCitationCandidates.map((item) => item.url)).toEqual([
|
||||
'https://example.com/body-citation',
|
||||
])
|
||||
"https://example.com/body-citation",
|
||||
]);
|
||||
expect(classified.citations.map((item) => item.url)).toEqual([
|
||||
'https://example.com/reasoning-search',
|
||||
])
|
||||
"https://example.com/reasoning-search",
|
||||
]);
|
||||
expect(classified.searchResults.map((item) => item.url)).toEqual([
|
||||
'https://example.com/reasoning-search',
|
||||
])
|
||||
})
|
||||
"https://example.com/reasoning-search",
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains all revealed DeepSeek search results instead of truncating after 32 items', () => {
|
||||
it("retains all revealed DeepSeek search results instead of truncating after 32 items", () => {
|
||||
const searchPanelLinks = Array.from({ length: 78 }, (_, index) => ({
|
||||
url: `https://example.com/search-${index + 1}`,
|
||||
title: `Search ${index + 1}`,
|
||||
text: `Snippet ${index + 1}`,
|
||||
siteName: 'Example',
|
||||
kind: 'search' as const,
|
||||
}))
|
||||
siteName: "Example",
|
||||
kind: "search" as const,
|
||||
}));
|
||||
|
||||
const classified = classifyDeepseekSources({
|
||||
inlineLinks: [],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks,
|
||||
})
|
||||
});
|
||||
|
||||
expect(classified.citations).toHaveLength(78)
|
||||
expect(classified.searchResults).toHaveLength(78)
|
||||
})
|
||||
expect(classified.citations).toHaveLength(78);
|
||||
expect(classified.searchResults).toHaveLength(78);
|
||||
});
|
||||
|
||||
it('merges revealed sidebar search links with the snapshot search links by normalized url', () => {
|
||||
it("merges revealed sidebar search links with the snapshot search links by normalized url", () => {
|
||||
const merged = mergeDeepSeekRawSourceLinks(
|
||||
[
|
||||
{
|
||||
url: 'https://example.com/report#top',
|
||||
url: "https://example.com/report#top",
|
||||
title: null,
|
||||
text: 'Visible title',
|
||||
text: "Visible title",
|
||||
siteName: null,
|
||||
kind: 'search',
|
||||
kind: "search",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
url: 'https://example.com/report#bottom',
|
||||
title: 'Merged title',
|
||||
text: 'Merged snippet',
|
||||
siteName: 'Example',
|
||||
kind: 'search',
|
||||
url: "https://example.com/report#bottom",
|
||||
title: "Merged title",
|
||||
text: "Merged snippet",
|
||||
siteName: "Example",
|
||||
kind: "search",
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/extra',
|
||||
title: 'Extra result',
|
||||
text: 'Extra snippet',
|
||||
siteName: 'Example',
|
||||
kind: 'search',
|
||||
url: "https://example.com/extra",
|
||||
title: "Extra result",
|
||||
text: "Extra snippet",
|
||||
siteName: "Example",
|
||||
kind: "search",
|
||||
},
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
expect(merged).toHaveLength(2)
|
||||
expect(merged[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/report',
|
||||
title: 'Merged title',
|
||||
text: 'Visible title',
|
||||
siteName: 'Example',
|
||||
}),
|
||||
)
|
||||
expect(merged[1]?.url).toBe('https://example.com/extra')
|
||||
})
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[0]).toEqual(expect.objectContaining({
|
||||
url: "https://example.com/report",
|
||||
title: "Merged title",
|
||||
text: "Visible title",
|
||||
siteName: "Example",
|
||||
}));
|
||||
expect(merged[1]?.url).toBe("https://example.com/extra");
|
||||
});
|
||||
|
||||
it('normalizes deepseek raw source links into monitoring source items', () => {
|
||||
it("normalizes deepseek raw source links into monitoring source items", () => {
|
||||
const item = buildSourceItem({
|
||||
url: 'https://example.com/report#cite-1',
|
||||
url: "https://example.com/report#cite-1",
|
||||
title: null,
|
||||
text: 'DeepSeek source title',
|
||||
siteName: 'Example',
|
||||
kind: 'source',
|
||||
})
|
||||
text: "DeepSeek source title",
|
||||
siteName: "Example",
|
||||
kind: "source",
|
||||
});
|
||||
|
||||
expect(item).toEqual(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/report',
|
||||
normalized_url: 'https://example.com/report',
|
||||
title: 'DeepSeek source title',
|
||||
site_name: 'Example',
|
||||
host: 'example.com',
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(item).toEqual(expect.objectContaining({
|
||||
url: "https://example.com/report",
|
||||
normalized_url: "https://example.com/report",
|
||||
title: "DeepSeek source title",
|
||||
site_name: "Example",
|
||||
host: "example.com",
|
||||
}));
|
||||
});
|
||||
|
||||
it('unwraps DeepSeek redirect URLs before storing source items', () => {
|
||||
it("unwraps DeepSeek redirect URLs before storing source items", () => {
|
||||
const item = buildSourceItem({
|
||||
url: 'https://chat.deepseek.com/api/redirect?url=https%3A%2F%2Fexample.com%2Fsource%23ref',
|
||||
title: 'Redirected source',
|
||||
url: "https://chat.deepseek.com/api/redirect?url=https%3A%2F%2Fexample.com%2Fsource%23ref",
|
||||
title: "Redirected source",
|
||||
text: null,
|
||||
siteName: null,
|
||||
kind: 'search',
|
||||
})
|
||||
kind: "search",
|
||||
});
|
||||
|
||||
expect(item).toEqual(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/source',
|
||||
normalized_url: 'https://example.com/source',
|
||||
host: 'example.com',
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(item).toEqual(expect.objectContaining({
|
||||
url: "https://example.com/source",
|
||||
normalized_url: "https://example.com/source",
|
||||
host: "example.com",
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not use numeric citation markers as source titles', () => {
|
||||
it("does not use numeric citation markers as source titles", () => {
|
||||
const item = buildSourceItem({
|
||||
url: 'https://example.com/report#cite-1',
|
||||
url: "https://example.com/report#cite-1",
|
||||
title: null,
|
||||
text: '-1',
|
||||
text: "-1",
|
||||
siteName: null,
|
||||
kind: 'inline',
|
||||
})
|
||||
kind: "inline",
|
||||
});
|
||||
|
||||
expect(item?.title).toBeNull()
|
||||
expect(item?.site_name).toBe('example.com')
|
||||
})
|
||||
expect(item?.title).toBeNull();
|
||||
expect(item?.site_name).toBe("example.com");
|
||||
});
|
||||
|
||||
it('treats a stable answer as complete even without citations', () => {
|
||||
expect(
|
||||
isObservationComplete({
|
||||
answer: '这是最终答案',
|
||||
busy: false,
|
||||
loginRequired: false,
|
||||
challengeRequired: false,
|
||||
signature: 'sig-1',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
it("treats a stable answer as complete even without citations", () => {
|
||||
expect(isObservationComplete({
|
||||
answer: "这是最终答案",
|
||||
busy: false,
|
||||
loginRequired: false,
|
||||
challengeRequired: false,
|
||||
signature: "sig-1",
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores source-logo loading placeholders when computing busy signals', () => {
|
||||
expect(
|
||||
detectDeepSeekBusySignals({
|
||||
bodyText: '已阅读 10 个网页',
|
||||
descriptors: [
|
||||
'site_logo_loading site_logo_fallback',
|
||||
'site_logo_loading site_logo_fallback',
|
||||
],
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
it("ignores source-logo loading placeholders when computing busy signals", () => {
|
||||
expect(detectDeepSeekBusySignals({
|
||||
bodyText: "已阅读 10 个网页",
|
||||
descriptors: [
|
||||
"site_logo_loading site_logo_fallback",
|
||||
"site_logo_loading site_logo_fallback",
|
||||
],
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it('recognizes deepseek toggle selected state from class names', () => {
|
||||
expect(isDeepSeekToggleSelected('ds-toggle-button ds-toggle-button--selected')).toBe(true)
|
||||
expect(isDeepSeekToggleSelected('ds-toggle-button')).toBe(false)
|
||||
})
|
||||
it("recognizes deepseek toggle selected state from class names", () => {
|
||||
expect(isDeepSeekToggleSelected("ds-toggle-button ds-toggle-button--selected")).toBe(true);
|
||||
expect(isDeepSeekToggleSelected("ds-toggle-button")).toBe(false);
|
||||
});
|
||||
|
||||
it('only treats the bottom 网页 pill as the DeepSeek source trigger', () => {
|
||||
expect(isDeepSeekWebpagesTriggerText('57 个网页')).toBe(true)
|
||||
expect(isDeepSeekWebpagesTriggerText('已阅读 57 个网页')).toBe(true)
|
||||
expect(isDeepSeekWebpagesTriggerText('搜索到 39 个网页')).toBe(false)
|
||||
})
|
||||
it("only treats the bottom 网页 pill as the DeepSeek source trigger", () => {
|
||||
expect(isDeepSeekWebpagesTriggerText("57 个网页")).toBe(true);
|
||||
expect(isDeepSeekWebpagesTriggerText("已阅读 57 个网页")).toBe(true);
|
||||
expect(isDeepSeekWebpagesTriggerText("搜索到 39 个网页")).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes DeepSeek model labels without mistaking search toggles for models', () => {
|
||||
it("normalizes DeepSeek model labels without mistaking search toggles for models", () => {
|
||||
const snapshot = {
|
||||
url: 'https://chat.deepseek.com/',
|
||||
title: 'DeepSeek',
|
||||
url: "https://chat.deepseek.com/",
|
||||
title: "DeepSeek",
|
||||
loginRequired: false,
|
||||
loginReason: null,
|
||||
challengeRequired: false,
|
||||
@@ -297,84 +279,74 @@ describe('deepseek adapter helpers', () => {
|
||||
busySignals: [],
|
||||
composerValue: null,
|
||||
sendDisabled: false,
|
||||
answer: 'Final answer',
|
||||
answerText: 'Final answer',
|
||||
answer: "Final answer",
|
||||
answerText: "Final answer",
|
||||
reasoning: null,
|
||||
signature: 'sig-1',
|
||||
currentModelLabel: '深度思考 (R1)',
|
||||
signature: "sig-1",
|
||||
currentModelLabel: "深度思考 (R1)",
|
||||
providerRequestID: null,
|
||||
requestID: null,
|
||||
inlineLinks: [],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks: [],
|
||||
}
|
||||
};
|
||||
const sources = {
|
||||
citations: [],
|
||||
inlineCitationCandidates: [],
|
||||
searchResults: [],
|
||||
}
|
||||
};
|
||||
|
||||
const r1Payload = buildDeepSeekPayload(snapshot, sources, 'Final answer')
|
||||
const searchPayload = buildDeepSeekPayload(
|
||||
{
|
||||
...snapshot,
|
||||
currentModelLabel: '联网搜索',
|
||||
},
|
||||
sources,
|
||||
'Final answer',
|
||||
)
|
||||
const r1Payload = buildDeepSeekPayload(snapshot, sources, "Final answer");
|
||||
const searchPayload = buildDeepSeekPayload({
|
||||
...snapshot,
|
||||
currentModelLabel: "联网搜索",
|
||||
}, sources, "Final answer");
|
||||
|
||||
expect(r1Payload.provider_model).toBe('DeepSeek-R1')
|
||||
expect(searchPayload.provider_model).toBeNull()
|
||||
})
|
||||
expect(r1Payload.provider_model).toBe("DeepSeek");
|
||||
expect(searchPayload.provider_model).toBeNull();
|
||||
});
|
||||
|
||||
it('builds json-safe payloads with null placeholders instead of undefined', () => {
|
||||
const payload = buildDeepSeekPayload(
|
||||
{
|
||||
url: 'https://chat.deepseek.com/',
|
||||
title: 'DeepSeek',
|
||||
loginRequired: false,
|
||||
loginReason: null,
|
||||
challengeRequired: false,
|
||||
challengeReason: null,
|
||||
busy: false,
|
||||
busySignals: [],
|
||||
composerValue: null,
|
||||
sendDisabled: false,
|
||||
answer: 'Final answer',
|
||||
answerText: 'Final answer',
|
||||
reasoning: null,
|
||||
signature: 'sig-1',
|
||||
currentModelLabel: null,
|
||||
providerRequestID: null,
|
||||
requestID: null,
|
||||
inlineLinks: [],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks: [],
|
||||
},
|
||||
{
|
||||
citations: [],
|
||||
inlineCitationCandidates: [],
|
||||
searchResults: [],
|
||||
},
|
||||
'Final answer',
|
||||
)
|
||||
it("builds json-safe payloads with null placeholders instead of undefined", () => {
|
||||
const payload = buildDeepSeekPayload({
|
||||
url: "https://chat.deepseek.com/",
|
||||
title: "DeepSeek",
|
||||
loginRequired: false,
|
||||
loginReason: null,
|
||||
challengeRequired: false,
|
||||
challengeReason: null,
|
||||
busy: false,
|
||||
busySignals: [],
|
||||
composerValue: null,
|
||||
sendDisabled: false,
|
||||
answer: "Final answer",
|
||||
answerText: "Final answer",
|
||||
reasoning: null,
|
||||
signature: "sig-1",
|
||||
currentModelLabel: null,
|
||||
providerRequestID: null,
|
||||
requestID: null,
|
||||
inlineLinks: [],
|
||||
sourcePanelLinks: [],
|
||||
searchPanelLinks: [],
|
||||
}, {
|
||||
citations: [],
|
||||
inlineCitationCandidates: [],
|
||||
searchResults: [],
|
||||
}, "Final answer");
|
||||
|
||||
expect(payload.provider_model).toBeNull()
|
||||
expect(payload.provider_request_id).toBeNull()
|
||||
expect(payload.request_id).toBeNull()
|
||||
expect((payload.raw_response_json as Record<string, unknown>).provider_model).toBeNull()
|
||||
expect((payload.raw_response_json as Record<string, unknown>).provider_request_id).toBeNull()
|
||||
expect((payload.raw_response_json as Record<string, unknown>).request_id).toBeNull()
|
||||
expect((payload.raw_response_json as Record<string, unknown>).signature).toBe('sig-1')
|
||||
expect(
|
||||
(payload.raw_response_json as Record<string, unknown>).inline_citation_candidates,
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
expect(payload.provider_model).toBeNull();
|
||||
expect(payload.provider_request_id).toBeNull();
|
||||
expect(payload.request_id).toBeNull();
|
||||
expect((payload.raw_response_json as Record<string, unknown>).provider_model).toBeNull();
|
||||
expect((payload.raw_response_json as Record<string, unknown>).provider_request_id).toBeNull();
|
||||
expect((payload.raw_response_json as Record<string, unknown>).request_id).toBeNull();
|
||||
expect((payload.raw_response_json as Record<string, unknown>).signature).toBe("sig-1");
|
||||
expect((payload.raw_response_json as Record<string, unknown>).inline_citation_candidates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monitor adapter registry', () => {
|
||||
it('registers deepseek as a monitor adapter', () => {
|
||||
expect(getMonitorAdapter('deepseek')?.provider).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
describe("monitor adapter registry", () => {
|
||||
it("registers deepseek as a monitor adapter", () => {
|
||||
expect(getMonitorAdapter("deepseek")?.provider).toBe("deepseek");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,8 @@ interface PublishTaskItem {
|
||||
publishType: string | null
|
||||
fallbackReason: string | null
|
||||
errorMessage: string | null
|
||||
complianceBlockedRecordId: number | null
|
||||
complianceBlockedAt: number | null
|
||||
}
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -84,7 +86,14 @@ function statusLabel(status: DesktopTaskInfo['status']): string {
|
||||
return map[status]
|
||||
}
|
||||
|
||||
function isComplianceBlockedTask(task: PublishTaskItem): boolean {
|
||||
return task.status === 'aborted' && task.complianceBlockedRecordId !== null
|
||||
}
|
||||
|
||||
function statusLabelForTask(task: PublishTaskItem): string {
|
||||
if (isComplianceBlockedTask(task)) {
|
||||
return '合规阻断'
|
||||
}
|
||||
if (task.status === 'succeeded' && task.publishType === 'draft') {
|
||||
return '已存草稿'
|
||||
}
|
||||
@@ -193,6 +202,67 @@ function normalizeTaskErrorMessage(message: string | null): string | null {
|
||||
return normalizePublisherErrorMessage(normalized) ?? normalized
|
||||
}
|
||||
|
||||
function complianceLevelLabel(level: string | null): string {
|
||||
const normalized = level?.trim().toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'block':
|
||||
return '阻断'
|
||||
case 'high':
|
||||
return '高风险'
|
||||
case 'medium':
|
||||
return '中风险'
|
||||
case 'info':
|
||||
return '提示'
|
||||
default:
|
||||
return '风险'
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeComplianceBlockedReason(raw: string | null): string | null {
|
||||
const normalized = extractString(raw)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return normalized
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
const manualStatus = typeof record.manual_review_status === 'string' ? record.manual_review_status : ''
|
||||
if (manualStatus === 'pending') {
|
||||
return '文章版本正在等待运营人工审阅,旧定时任务已停止派发。'
|
||||
}
|
||||
if (manualStatus === 'rejected') {
|
||||
return '文章版本已被运营驳回,请修改文章后重新发布。'
|
||||
}
|
||||
|
||||
const hitCount = typeof record.hit_count === 'number' ? record.hit_count : null
|
||||
const highestLevel =
|
||||
typeof record.highest_level === 'string' ? complianceLevelLabel(record.highest_level) : '风险'
|
||||
const violations = Array.isArray(record.violations) ? record.violations : []
|
||||
const matchedTerms = violations
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return null
|
||||
}
|
||||
const violation = item as Record<string, unknown>
|
||||
return typeof violation.matched_text === 'string' ? violation.matched_text.trim() : null
|
||||
})
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.slice(0, 3)
|
||||
|
||||
const hitPart =
|
||||
hitCount !== null && hitCount > 0 ? `命中 ${hitCount} 条${highestLevel}规则` : `命中${highestLevel}规则`
|
||||
return matchedTerms.length > 0
|
||||
? `${hitPart}:${matchedTerms.join('、')}`
|
||||
: `${hitPart},请修改文章后重新发布。`
|
||||
} catch {
|
||||
return normalized === 'compliance_blocked' ? null : normalized
|
||||
}
|
||||
}
|
||||
|
||||
function inlineErrorPreview(message: string): string {
|
||||
const compact = message.replace(/\s+/g, ' ').trim()
|
||||
const maxLength = INLINE_ERROR_HEAD_CHARS + INLINE_ERROR_TAIL_CHARS + 3
|
||||
@@ -207,6 +277,9 @@ function summaryForTask(
|
||||
publishType: string | null = null,
|
||||
fallbackReason: string | null = null,
|
||||
): string {
|
||||
if (status === 'aborted' && fallbackReason === 'compliance_blocked') {
|
||||
return '定时发布前的合规重检拦截了本次任务,请修改文章后重新发布。'
|
||||
}
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return '任务已进入队列,待前面的发布任务完成后继续执行。'
|
||||
@@ -435,7 +508,17 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
|
||||
const result = task.result ?? {}
|
||||
const taskError = task.error ?? {}
|
||||
const publishType = extractString(result.publish_type)
|
||||
const fallbackReason = extractString(result.fallback_reason)
|
||||
const complianceBlockedRecordId =
|
||||
task.compliance_blocked_record_id ??
|
||||
extractNumber(taskError.record_id) ??
|
||||
extractNumber(taskError.check_record_id)
|
||||
const complianceBlockedReason = summarizeComplianceBlockedReason(
|
||||
task.compliance_blocked_reason ?? extractString(taskError.detail),
|
||||
)
|
||||
const fallbackReason =
|
||||
task.publish_job_status === 'blocked_by_compliance'
|
||||
? 'compliance_blocked'
|
||||
: extractString(result.fallback_reason)
|
||||
const nestedContentRef =
|
||||
payload.content_ref &&
|
||||
typeof payload.content_ref === 'object' &&
|
||||
@@ -468,10 +551,13 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
|
||||
publishType,
|
||||
fallbackReason,
|
||||
errorMessage: normalizeTaskErrorMessage(
|
||||
extractString(taskError.message) ??
|
||||
complianceBlockedReason ??
|
||||
extractString(taskError.message) ??
|
||||
extractString(taskError.detail) ??
|
||||
extractString(taskError.code),
|
||||
),
|
||||
complianceBlockedRecordId,
|
||||
complianceBlockedAt: task.compliance_blocked_at ? parseTimestamp(task.compliance_blocked_at) : null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -651,6 +737,14 @@ const tableScroll = { x: 1080 } as const
|
||||
<span v-else class="cell-sub strong-text">{{ record.summary }}</span>
|
||||
<span class="cell-sub meta-footer">
|
||||
<span class="attempt-chip">尝试 {{ record.attempts }} 次</span>
|
||||
<template v-if="record.complianceBlockedRecordId">
|
||||
<span class="meta-divider">·</span>
|
||||
<span>合规记录 #{{ record.complianceBlockedRecordId }}</span>
|
||||
</template>
|
||||
<template v-if="record.complianceBlockedAt">
|
||||
<span class="meta-divider">·</span>
|
||||
<span>{{ formatDateTime(record.complianceBlockedAt) }} 拦截</span>
|
||||
</template>
|
||||
<template v-if="record.leaseExpiresAt">
|
||||
<span class="meta-divider">·</span>
|
||||
<span>租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</span>
|
||||
|
||||
@@ -125,6 +125,9 @@ export function normalizeWeixinGzhPublishErrorMessage(
|
||||
}
|
||||
|
||||
export function normalizePublisherErrorMessage(message: string | null | undefined): string | null {
|
||||
if (message === 'compliance_blocked') {
|
||||
return '内容合规检测已阻断本次发布,请修改文章后重新发布。'
|
||||
}
|
||||
const qiehaoMessage = normalizeQiehaoPublishErrorMessage(message)
|
||||
const wangyihaoMessage = normalizeWangyihaoPublishErrorMessage(qiehaoMessage)
|
||||
const loginMessage = normalizePublisherLoginErrorMessage(wangyihaoMessage)
|
||||
|
||||
Reference in New Issue
Block a user