220 lines
6.5 KiB
JavaScript
220 lines
6.5 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 || payload?.title || "", 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 "";
|
|||
|
|
}
|
|||
|
|
}
|