271 lines
8.9 KiB
JavaScript
271 lines
8.9 KiB
JavaScript
importScripts("config.js");
|
||
|
||
const CONFIG = self.YFB_EXTENSION_CONFIG || {};
|
||
const CHAT_ENDPOINT = CONFIG.chatEndpoint || "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
|
||
const REQUEST_TIMEOUT_MS = Number(CONFIG.requestTimeoutMs) || 45000;
|
||
|
||
chrome.runtime.onInstalled.addListener(() => {
|
||
console.log("乙方宝招标筛选助手已安装");
|
||
});
|
||
|
||
chrome.action.onClicked.addListener((tab) => {
|
||
if (!tab || !tab.id) {
|
||
return;
|
||
}
|
||
|
||
chrome.tabs.sendMessage(tab.id, { type: "YFB_TOGGLE_PANEL" }, () => {
|
||
void chrome.runtime.lastError;
|
||
});
|
||
});
|
||
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
if (!message || message.type !== "YFB_ANALYZE_CANDIDATE") {
|
||
return false;
|
||
}
|
||
|
||
void analyzeCandidate(message.payload)
|
||
.then((data) => {
|
||
sendResponse({ ok: true, data });
|
||
})
|
||
.catch((error) => {
|
||
sendResponse({
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : "AI 分析失败"
|
||
});
|
||
});
|
||
|
||
return true;
|
||
});
|
||
|
||
async function analyzeCandidate(payload) {
|
||
if (!CONFIG.apiKey) {
|
||
throw new Error("缺少 DASHSCOPE_API_KEY,请先执行 npm.cmd run build。");
|
||
}
|
||
|
||
const response = await fetchWithTimeout(CHAT_ENDPOINT, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${CONFIG.apiKey}`
|
||
},
|
||
body: JSON.stringify({
|
||
model: CONFIG.model || "qwen-plus",
|
||
temperature: 0.1,
|
||
max_tokens: 120,
|
||
messages: buildMessages(payload)
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await safeReadText(response);
|
||
throw new Error(`AI 接口返回 ${response.status}: ${errorText || "未知错误"}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const rawContent = extractMessageContent(data);
|
||
const parsed = parseAiJson(rawContent);
|
||
return normalizeAiResult(parsed, payload);
|
||
}
|
||
|
||
async function fetchWithTimeout(url, options) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||
|
||
try {
|
||
return await fetch(url, {
|
||
...options,
|
||
signal: controller.signal
|
||
});
|
||
} catch (error) {
|
||
if (error && error.name === "AbortError") {
|
||
throw new Error("AI 请求超时");
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
|
||
function buildMessages(payload) {
|
||
const compactInput = buildCompactInput(payload);
|
||
|
||
return [
|
||
{
|
||
role: "system",
|
||
content:
|
||
'判断是否属于金融机构相关招标。仅当服务对象明确是银行/信托/保险/消费金融/金融租赁/汽车金融等金融机构,且业务属于"法律服务"、"催收/不良资产处置"、"调解服务"之一时命中。只返回 JSON:{"category":"法律服务|催收/不良资产处置|调解服务|不命中","confidence":0-100}。'
|
||
},
|
||
{
|
||
role: "user",
|
||
content: JSON.stringify(compactInput)
|
||
}
|
||
];
|
||
}
|
||
|
||
function buildCompactInput(payload) {
|
||
const keywordHints = payload?.keywordHints || {};
|
||
|
||
return {
|
||
title: String(payload?.title || ""),
|
||
type: String(payload?.type || ""),
|
||
region: String(payload?.region || ""),
|
||
publishTime: String(payload?.publishTime || ""),
|
||
detailText: limitLength(String(payload?.detailText || ""), Number(CONFIG.maxAiChars) || 1800),
|
||
attachmentNames: Array.isArray(payload?.attachmentNames) ? payload.attachmentNames.slice(0, 6) : [],
|
||
keywordHints: {
|
||
institutions: Array.isArray(keywordHints.institutions) ? keywordHints.institutions.slice(0, 6) : [],
|
||
legal: Array.isArray(keywordHints.legal) ? keywordHints.legal.slice(0, 6) : [],
|
||
collection: Array.isArray(keywordHints.collection) ? keywordHints.collection.slice(0, 6) : [],
|
||
mediation: Array.isArray(keywordHints.mediation) ? keywordHints.mediation.slice(0, 6) : [],
|
||
all: Array.isArray(keywordHints.all) ? keywordHints.all.slice(0, 12) : []
|
||
}
|
||
};
|
||
}
|
||
|
||
function extractMessageContent(apiResponse) {
|
||
const content = apiResponse?.choices?.[0]?.message?.content;
|
||
if (typeof content === "string") {
|
||
return content;
|
||
}
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((part) => {
|
||
if (typeof part === "string") {
|
||
return part;
|
||
}
|
||
return part?.text || "";
|
||
})
|
||
.join("\n");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function parseAiJson(text) {
|
||
const trimmed = String(text || "").trim();
|
||
if (!trimmed) {
|
||
throw new Error("AI 返回为空");
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch (error) {
|
||
const startIndex = trimmed.indexOf("{");
|
||
const endIndex = trimmed.lastIndexOf("}");
|
||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||
throw new Error("AI 返回不是有效 JSON");
|
||
}
|
||
return JSON.parse(trimmed.slice(startIndex, endIndex + 1));
|
||
}
|
||
}
|
||
|
||
function normalizeAiResult(result, payload) {
|
||
const category = normalizeCategory(result?.category);
|
||
const institutionType = Array.isArray(result?.institutionType)
|
||
? result.institutionType.filter(Boolean).map((item) => String(item).trim())
|
||
: Array.isArray(payload?.keywordHints?.institutions)
|
||
? payload.keywordHints.institutions.slice(0, 3)
|
||
: [];
|
||
const matchedKeywords = Array.isArray(result?.matchedKeywords)
|
||
? result.matchedKeywords.filter(Boolean).map((item) => String(item).trim())
|
||
: Array.isArray(payload?.keywordHints?.all)
|
||
? payload.keywordHints.all
|
||
: [];
|
||
|
||
return {
|
||
isRelevant: (typeof result?.isRelevant === "boolean" ? result.isRelevant : category !== "不命中") && category !== "不命中",
|
||
category,
|
||
institutionType,
|
||
confidence: normalizeConfidence(result?.confidence),
|
||
titleSummary: limitLength(result?.titleSummary || "", 60),
|
||
reason: limitLength(result?.reason || "", 120),
|
||
matchedKeywords: dedupe(matchedKeywords)
|
||
};
|
||
}
|
||
|
||
function normalizeCategory(category) {
|
||
const value = String(category || "").trim();
|
||
if (value === "法律服务" || value === "催收/不良资产处置" || value === "调解服务") {
|
||
return value;
|
||
}
|
||
return "不命中";
|
||
}
|
||
|
||
function normalizeConfidence(confidence) {
|
||
const numeric = Number(confidence);
|
||
if (!Number.isFinite(numeric)) {
|
||
return 0;
|
||
}
|
||
return Math.max(0, Math.min(100, Math.round(numeric)));
|
||
}
|
||
|
||
function limitLength(text, maxLength) {
|
||
const normalized = String(text || "").trim();
|
||
if (normalized.length <= maxLength) {
|
||
return normalized;
|
||
}
|
||
return normalized.slice(0, maxLength - 1) + "…";
|
||
}
|
||
|
||
function dedupe(list) {
|
||
return Array.from(new Set(list.filter(Boolean)));
|
||
}
|
||
|
||
async function safeReadText(response) {
|
||
try {
|
||
return await response.text();
|
||
} catch (error) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function buildMessages(payload) {
|
||
const compactInput = buildCompactInput(payload);
|
||
|
||
return [
|
||
{
|
||
role: "system",
|
||
content: [
|
||
"Analyze whether this procurement belongs to a financial-institution-related bidding case.",
|
||
'Only hit when the service target is clearly a financial institution such as a bank, trust, insurance, consumer finance, financial leasing, or auto finance, and the business belongs to one of these categories: "法律服务", "催收/不良资产处置", "调解服务".',
|
||
'Return JSON only with keys: {"category":"法律服务|催收/不良资产处置|调解服务|不命中","confidence":0-100,"summary":"...","reason":"..."}',
|
||
'The "summary" must come from the announcement body, not the title.',
|
||
'Do not repeat the title or start with the title.',
|
||
'Do not include metadata such as "发布时间", "项目编号", "招标单位", "采购单位", "代理单位", "报名截止时间", or "投标截止时间".',
|
||
'Keep "summary" around 50 Chinese characters, ideally within 30 to 50 Chinese characters.',
|
||
'If the body does not provide a valid summary, return an empty string for "summary".',
|
||
'Keep "reason" brief and concrete.'
|
||
].join(" ")
|
||
},
|
||
{
|
||
role: "user",
|
||
content: JSON.stringify(compactInput)
|
||
}
|
||
];
|
||
}
|
||
|
||
function normalizeAiResult(result, payload) {
|
||
const category = normalizeCategory(result?.category);
|
||
const summary = limitLength(result?.summary || result?.titleSummary || "", 60);
|
||
const institutionType = Array.isArray(result?.institutionType)
|
||
? result.institutionType.filter(Boolean).map((item) => String(item).trim())
|
||
: Array.isArray(payload?.keywordHints?.institutions)
|
||
? payload.keywordHints.institutions.slice(0, 3)
|
||
: [];
|
||
const matchedKeywords = Array.isArray(result?.matchedKeywords)
|
||
? result.matchedKeywords.filter(Boolean).map((item) => String(item).trim())
|
||
: Array.isArray(payload?.keywordHints?.all)
|
||
? payload.keywordHints.all
|
||
: [];
|
||
|
||
return {
|
||
isRelevant: (typeof result?.isRelevant === "boolean" ? result.isRelevant : category !== "不命中") && category !== "不命中",
|
||
category,
|
||
institutionType,
|
||
confidence: normalizeConfidence(result?.confidence),
|
||
summary,
|
||
titleSummary: summary,
|
||
reason: limitLength(result?.reason || "", 120),
|
||
matchedKeywords: dedupe(matchedKeywords)
|
||
};
|
||
}
|