'use strict'; const superagent = require('superagent'); const moment = require('moment'); const JSZip = require('jszip'); const cheerio = require('cheerio'); const qiniu = require('qiniu'); const cnipaAuth = require('./cnipaAuth'); const MODULE_LABEL = { review: '发明专利核心审查', grant: '一次性授权案件查询', compare: '新颖性创造性对比', }; const STATUS_TEXT = { created: '待处理', processing: 'AI分析中', completed: '已完成', failed: '处理失败', }; const MODULE_MODEL_MAP = { review: 'PatentCoreReviewResults', grant: 'PatentGrantQueryResults', compare: 'PatentCompareResults', }; const MATERIAL_CODE_MAP = { '100001': { name: '权利要求书', type: '发明专利' }, '100002': { name: '说明书', type: '发明专利' }, '100003': { name: '说明书附图', type: '发明专利' }, '100004': { name: '说明书摘要', type: '发明专利' }, '100006': { name: '补正书', type: '通用文件' }, '100007': { name: '专利代理委托书', type: '通用文件' }, '110101': { name: '发明专利请求书', type: '发明专利' }, '110401': { name: '实质审查请求书', type: '发明专利' }, '120101': { name: '实用新型专利请求书', type: '实用新型' }, '130001': { name: '外观设计图片或照片', type: '外观设计' }, '130002': { name: '外观设计简要说明', type: '外观设计' }, '130101': { name: '外观设计专利请求书', type: '外观设计' }, 'list': { name: '清单文件', type: '通用文件' }, }; const REVIEW_ARCHIVE_EXTS = new Set(['zip', 'rar', '7z']); const REVIEW_TEXT_EXTS = new Set(['html', 'htm', 'xml', 'txt']); const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024; const MAX_ARCHIVE_FILE_COUNT = 80; const MAX_ARCHIVE_DEPTH = 5; const MAX_MATERIAL_TEXT_LENGTH = 60000; const MAX_REVIEW_TEXT_LENGTH = 220000; const normalizeArray = (value) => { if (!value) return []; return Array.isArray(value) ? value.filter(Boolean) : [value].filter(Boolean); }; const normalizeUserId = (value) => { if (!value) return null; const str = String(value).trim(); const num = Number(str.replace(/[^0-9]/g, '')); return isNaN(num) ? null : num; }; const normalizePatentNumber = (value) => String(value || '') .trim() .replace(/[^0-9]/g, ''); const parsePatentNumbers = (input) => String(input || '') .split(/[\n,,;;\s]+/) .map(normalizePatentNumber) .filter(Boolean); const hasFirstOfficeAction = (notices) => notices.some(item => { const noticeName = String(item?.tongzhismc || item?.name || item || ''); return noticeName.includes('第一次审查意见通知书'); }); // 从缓存中获取专利授权查询结果 const getCachedGrantResult = async (models, patentNumber) => { try { const cache = await models.PatentGrantCache.findOne({ where: { patentNumber } }); if (cache) { return { number: cache.patentNumber, title: '未获取', applicant: '未获取', applicationDate: '-', grantDate: '-', hasOfficeAction: !cache.isOneShot, notices: cache.notices || [], basis: cache.basis || (cache.isOneShot ? '发文记录未发现第一次审查意见通知书,按一次性授权案件处理。' : '发文记录存在第一次审查意见通知书,因此不属于一次性授权案件。'), queryStatus: 'cached' }; } return null; } catch (error) { return null; } }; // 将查询结果保存到缓存 const saveGrantResultToCache = async (models, result) => { try { if (!result.number || result.queryStatus === 'failed') return; const isOneShot = result.hasOfficeAction === false; await models.PatentGrantCache.upsert({ patentNumber: result.number, isOneShot, notices: result.notices || [], basis: result.basis }, { conflictFields: ['patent_number'] }); } catch (error) { // 缓存失败不影响主流程 } }; const normalizeGrantQueryResult = (number, noticeBody) => { const noticeList = noticeBody?.data?.tongzhishufw?.tongzhishufwList || []; const certificateList = noticeBody?.data?.zhuanlizsfw?.zhuanlizsfwList || []; const hasOfficeAction = hasFirstOfficeAction(noticeList); const grantNotice = certificateList[0] || noticeList.find(item => String(item?.tongzhismc || '').includes('办理登记手续通知书')); return { number, title: '未获取', applicant: '未获取', applicationDate: '-', grantDate: grantNotice?.fawenr || grantNotice?.xiazaisj || '-', hasOfficeAction, notices: noticeList.map(item => item?.tongzhismc).filter(Boolean), noticeRecords: noticeList, basis: hasOfficeAction ? '发文记录存在第一次审查意见通知书,因此不属于一次性授权案件。' : '发文记录未发现第一次审查意见通知书,按一次性授权案件处理。', }; }; const getCnipaErrorMessage = (error) => { if (!error) return '未知错误'; if (typeof error === 'string') return error; if (Number(error.status) === 412 || Number(error.response?.status) === 412) { return 'HTTP 412: Precondition Failed。CNIPA 查询前置条件不满足,通常是浏览器登录态失效或查询页面未准备完成'; } const responseBody = error.response?.body; const responseText = error.response?.text; const bodyMessage = responseBody?.msg || responseBody?.message || responseBody?.error; const diagnostics = responseBody?.diagnostics || error.response?.diagnostics; const diagnosticMessage = diagnostics ? [ diagnostics.currentUrl ? `浏览器停留地址:${diagnostics.currentUrl}` : '', diagnostics.title ? `页面标题:${diagnostics.title}` : '', diagnostics.bodyText ? `页面内容:${diagnostics.bodyText}` : '', ].filter(Boolean).join(';') : ''; const statusMessage = error.status ? `HTTP ${error.status}` : ''; return [ statusMessage, bodyMessage || error.message || responseText, diagnosticMessage, ].filter(Boolean).join(': ') || '未知错误'; }; const queryGrantNoticeInfo = async (ctx, number, options = {}) => { const body = await cnipaAuth.queryGrantNoticeByBrowser(ctx, number, options); if (!body) throw 'CNIPA 浏览器查询失败,请先调用 /cnipa-auth/browser/prepare 并在打开的 Chrome 中完成登录'; return body; }; const getFileExtFromUrl = (url = '') => { const cleanUrl = String(url || '').split('?')[0].split('#')[0]; let fileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1); try { fileName = decodeURIComponent(fileName); } catch (error) { fileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1); } const dotIndex = fileName.lastIndexOf('.'); return dotIndex >= 0 ? fileName.substring(dotIndex + 1).toLowerCase() : ''; }; const getBaseName = (fileName = '') => { const normalized = String(fileName || '').replace(/\\/g, '/'); return normalized.substring(normalized.lastIndexOf('/') + 1); }; const getMaterialMeta = (fileName = '') => { const baseName = getBaseName(fileName); const nameWithoutExt = baseName.replace(/\.[^.]+$/, ''); const code = MATERIAL_CODE_MAP[nameWithoutExt] ? nameWithoutExt : nameWithoutExt.toLowerCase(); return { code, fileName: baseName, name: MATERIAL_CODE_MAP[code]?.name || baseName, type: MATERIAL_CODE_MAP[code]?.type || '未知', }; }; const isReviewArchive = (url = '') => REVIEW_ARCHIVE_EXTS.has(getFileExtFromUrl(url)); const normalizeText = (text = '') => String(text || '').replace(/\r/g, '\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); const extractTextByExt = (fileName, content) => { const ext = getFileExtFromUrl(fileName); if (ext === 'html' || ext === 'htm' || ext === 'xml') { const $ = cheerio.load(content, { decodeEntities: false }); $('script,style').remove(); return normalizeText($.text()); } return normalizeText(content); }; const escapeHtml = (text = '') => String(text || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const replaceFileExt = (filePath = '', nextExt = 'html') => { const normalized = String(filePath || ''); return normalized.includes('.') ? normalized.replace(/\.[^.\\/]+$/, `.${nextExt}`) : `${normalized}.${nextExt}`; }; const buildHtmlFromXml = ({ filePath, rawContent, extractedText }) => { const meta = getMaterialMeta(filePath); const title = `${meta.name || meta.fileName} - ${meta.code || ''}`; return [ '', '', '
', '', `文件编号:${escapeHtml(meta.code || '待核实')}
`, `原始文件:${escapeHtml(meta.fileName || filePath)}
`, '${escapeHtml(extractedText || '')}`,
'${escapeHtml(rawContent || '')}`,
'',
'',
].join('\n');
};
const prepareReviewUploadBuffer = ({ filePath, rawContent, fileBuffer, extractedText }) => {
const ext = getFileExtFromUrl(filePath);
if (ext !== 'xml') {
return {
uploadFilePath: filePath,
uploadBuffer: fileBuffer,
convertedFrom: null,
};
}
const htmlContent = buildHtmlFromXml({ filePath, rawContent, extractedText });
return {
uploadFilePath: replaceFileExt(filePath, 'html'),
uploadBuffer: Buffer.from(htmlContent, 'utf8'),
convertedFrom: filePath,
};
};
const binaryParser = (res, callback) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
};
const downloadFileBuffer = async (url) => {
const res = await superagent
.get(url)
.buffer(true)
.parse(binaryParser)
.timeout({ response: 30000, deadline: 120000 });
const buffer = res.body;
if (!Buffer.isBuffer(buffer)) throw '文件下载失败';
if (buffer.length > MAX_ARCHIVE_BYTES) throw '压缩包超过大小限制';
return buffer;
};
const createArchiveExtractState = () => ({
fileCount: 0,
totalLength: 0,
materials: [],
uploadedFiles: [],
});
const shouldStopExtract = (state) =>
state.fileCount >= MAX_ARCHIVE_FILE_COUNT ||
state.totalLength >= MAX_REVIEW_TEXT_LENGTH;
const getQiniuConfig = (ctx) => {
const { dmn, bkt, ak, sk } = ctx.app.fs.config.qiniu || {};
if (!dmn || !bkt || !ak || !sk) throw '七牛云配置缺失,无法上传解压后的材料文件';
return { dmn, bkt, ak, sk };
};
const normalizeQiniuDomain = (domain = '') => String(domain || '').replace(/\/+$/, '');
const toSafeQiniuKey = (value = '') =>
String(value || '')
.replace(/\\/g, '/')
.replace(/\.\.+/g, '.')
.replace(/^\/+/, '')
.replace(/[<>:"|?*]/g, '_')
.split('/')
.filter(Boolean)
.join('/');
const getMimeTypeByExt = (fileName = '') => {
const ext = getFileExtFromUrl(fileName);
if (ext === 'html' || ext === 'htm') return 'text/html';
if (ext === 'xml') return 'application/xml';
if (ext === 'txt') return 'text/plain';
return 'application/octet-stream';
};
const uploadMaterialBufferToQiniu = async (ctx, { buffer, filePath, archivePath }) => {
const { dmn, bkt, ak, sk } = getQiniuConfig(ctx);
const mac = new qiniu.auth.digest.Mac(ak, sk);
const putPolicy = new qiniu.rs.PutPolicy({ scope: bkt });
const uploadToken = putPolicy.uploadToken(mac);
const formUploader = new qiniu.form_up.FormUploader();
const putExtra = new qiniu.form_up.PutExtra();
putExtra.mimeType = getMimeTypeByExt(filePath);
const key = toSafeQiniuKey([
'ai-query/patent-review/extracted',
moment().format('YYYYMMDDHHmmssSSS'),
archivePath,
filePath,
].filter(Boolean).join('/'));
const result = await new Promise((resolve, reject) => {
formUploader.put(uploadToken, key, buffer, putExtra, (respErr, respBody) => {
if (respErr) {
reject(respErr);
return;
}
resolve(respBody);
});
});
const storageKey = result?.key || key;
return {
storageKey,
fileUrl: `${normalizeQiniuDomain(dmn)}/${storageKey}`,
};
};
const pushReviewMaterial = async (ctx, state, filePath, fileBuffer, archivePath) => {
const rawContent = fileBuffer.toString('utf8');
const extractedText = extractTextByExt(filePath, rawContent).slice(0, MAX_MATERIAL_TEXT_LENGTH);
if (!extractedText) return;
const meta = getMaterialMeta(filePath);
const preparedUpload = prepareReviewUploadBuffer({
filePath,
rawContent,
fileBuffer,
extractedText,
});
const uploadedFile = await uploadMaterialBufferToQiniu(ctx, {
buffer: preparedUpload.uploadBuffer,
filePath: preparedUpload.uploadFilePath,
archivePath,
});
state.fileCount += 1;
state.totalLength += extractedText.length;
state.uploadedFiles.push({
...meta,
filePath,
uploadFilePath: preparedUpload.uploadFilePath,
convertedFrom: preparedUpload.convertedFrom,
archivePath,
...uploadedFile,
});
state.materials.push({
...meta,
filePath,
uploadFilePath: preparedUpload.uploadFilePath,
convertedFrom: preparedUpload.convertedFrom,
archivePath,
fileUrl: uploadedFile.fileUrl,
storageKey: uploadedFile.storageKey,
status: 'pending',
text: extractedText,
textLength: extractedText.length,
});
};
const extractZipBufferReviewMaterials = async (ctx, buffer, {
archivePath = '',
depth = 0,
state = createArchiveExtractState(),
} = {}) => {
if (depth > MAX_ARCHIVE_DEPTH) throw '压缩包嵌套层级超过限制';
if (!Buffer.isBuffer(buffer)) throw '压缩包内容无效';
if (buffer.length > MAX_ARCHIVE_BYTES) throw '压缩包超过大小限制';
const zip = await JSZip.loadAsync(buffer);
const entries = Object.values(zip.files)
.filter(entry => !entry.dir)
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (shouldStopExtract(state)) break;
const entryPath = archivePath ? `${archivePath}/${entry.name}` : entry.name;
const ext = getFileExtFromUrl(entry.name);
if (ext === 'zip') {
const nestedBuffer = await entry.async('nodebuffer');
await extractZipBufferReviewMaterials(ctx, nestedBuffer, {
archivePath: entryPath,
depth: depth + 1,
state,
});
continue;
}
if (!REVIEW_TEXT_EXTS.has(ext)) continue;
const fileBuffer = await entry.async('nodebuffer');
await pushReviewMaterial(ctx, state, entry.name, fileBuffer, archivePath);
}
return state;
};
const extractZipReviewMaterials = async (ctx, url) => {
const buffer = await downloadFileBuffer(url);
const state = await extractZipBufferReviewMaterials(ctx, buffer, {
archivePath: getBaseName(String(url || '').split('?')[0].split('#')[0]) || 'uploaded.zip',
});
if (!state.materials.length) throw '压缩包内未识别到可审查的 html/xml/txt 文件';
return state;
};
const prepareCoreReviewInput = async (ctx, uploadUrls = []) => {
const archiveUrls = uploadUrls.filter(isReviewArchive);
if (!archiveUrls.length) {
return {
fileUrls: uploadUrls,
extractedMaterials: [],
uploadedFiles: [],
archiveProcessed: false,
};
}
const unsupportedArchive = archiveUrls.find(url => getFileExtFromUrl(url) !== 'zip');
if (unsupportedArchive) {
throw '当前仅支持 zip 压缩包自动解压,请上传 .zip 格式';
}
const materialGroups = [];
const uploadedFiles = [];
for (const url of archiveUrls) {
const extractState = await extractZipReviewMaterials(ctx, url);
materialGroups.push(...extractState.materials);
uploadedFiles.push(...extractState.uploadedFiles);
}
const passthroughUrls = uploadUrls.filter(url => !isReviewArchive(url));
const extractedFileUrls = uploadedFiles.map(item => item.fileUrl).filter(Boolean);
return {
fileUrls: [...passthroughUrls, ...extractedFileUrls],
extractedMaterials: materialGroups.map(({ text, ...material }) => material),
uploadedFiles,
archiveProcessed: true,
};
};
const parseJsonMaybe = (text) => {
if (!text || typeof text !== 'string') return null;
try {
return JSON.parse(text);
} catch (e) {
const codeBlock = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (!codeBlock?.[1]) return null;
try {
return JSON.parse(codeBlock[1]);
} catch (err) {
return null;
}
}
};
const parseJsonWithRemainder = (text = '') => {
const raw = String(text || '').trim();
if (!raw) return { parsed: null, remainder: '' };
const codeBlock = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (codeBlock?.[1]) {
try {
return {
parsed: JSON.parse(codeBlock[1]),
remainder: raw.replace(codeBlock[0], '').trim(),
};
} catch (error) {
return { parsed: null, remainder: raw };
}
}
const jsonStart = raw.search(/[\[{]/);
if (jsonStart < 0) return { parsed: null, remainder: raw };
for (let end = raw.length; end > jsonStart; end -= 1) {
const candidate = raw.slice(jsonStart, end).trim();
if (!candidate.endsWith('}') && !candidate.endsWith(']')) continue;
try {
return {
parsed: JSON.parse(candidate),
remainder: `${raw.slice(0, jsonStart)}${raw.slice(end)}`.trim(),
};
} catch (error) {
// Keep looking for a shorter valid JSON payload.
}
}
return { parsed: null, remainder: raw };
};
const stripMarkdown = (text = '') => String(text || '')
.replace(/```[\s\S]*?```/g, '')
.replace(/[*_`>#-]/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/\n{3,}/g, '\n\n')
.trim();
const getTextBetween = (text = '', startPattern, endPattern) => {
const start = text.search(startPattern);
if (start < 0) return '';
const sliced = text.slice(start);
const end = sliced.slice(1).search(endPattern);
return end >= 0 ? sliced.slice(0, end + 1) : sliced;
};
const normalizeRiskLevel = (value = '') => {
const text = String(value || '');
if (text.includes('高')) return '高';
if (text.includes('中')) return '中';
if (text.includes('低')) return '低';
return '中';
};
const normalizeIssueType = (value = '') => {
const text = String(value || '').toLowerCase();
if (text.includes('formal') || text.includes('形式')) return 'formal';
if (text.includes('guide') || text.includes('指南') || text.includes('不清楚') || text.includes('不支持') || text.includes('公开')) return 'guide';
return 'system';
};
const normalizeIssue = (issue = {}, index = 0) => ({
type: normalizeIssueType(issue.type || issue.issueType || issue.problemType || issue.title),
level: normalizeRiskLevel(issue.level || issue.riskLevel || issue.risk || ''),
title: stripMarkdown(issue.title || issue.name || `问题${index + 1}`),
position: stripMarkdown(issue.position || issue.location || '待核实'),
basis: stripMarkdown(issue.basis || issue.rule || issue.legalBasis || '待核实'),
description: stripMarkdown(issue.description || issue.problem || issue.content || issue.title || ''),
suggestion: stripMarkdown(issue.suggestion || issue.advice || issue.recommendation || '待核实'),
});
const getReviewMaterialIssues = (material = {}) => {
const candidates = [
material.issues,
material.issueList,
material.problems,
material.problemList,
material.questions,
material.questionList,
];
return candidates.find(Array.isArray) || [];
};
const normalizeReviewMaterials = (materials = [], fallbackMaterials = []) => {
const fallbackMap = new Map((fallbackMaterials || []).map(item => [item.code || item.fileName, item]));
return normalizeArray(materials).map((material, index) => {
const fallback = fallbackMap.get(material?.code) || fallbackMap.get(material?.fileName) || {};
const issues = getReviewMaterialIssues(material);
return {
...fallback,
...material,
code: material?.code || fallback.code || String(index + 1),
fileName: material?.fileName || fallback.fileName || material?.name || `材料${index + 1}`,
name: material?.name || material?.fileName || fallback.name || `材料${index + 1}`,
summary: stripMarkdown(
material?.summary ||
material?.analysis ||
material?.technicalSummary ||
material?.technicalSchemeOverview ||
material?.overview ||
''
),
riskLevel: normalizeRiskLevel(material?.riskLevel || material?.risk_level || material?.risk || ''),
issues: issues.map(normalizeIssue),
};
});
};
const parseMarkdownIssues = (section = '') => {
const issueBlock = getTextBetween(section, /问题清单/, /问题类型|修改建议|风险等级|###\s*文件|##\s+/);
if (!issueBlock || /问题清单[::]\s*无/.test(issueBlock)) return [];
const matches = [...issueBlock.matchAll(/(?:^|\n)\s*\d+[.、]\s*(?:\*\*)?([^::\n*]+)(?:\*\*)?[::]\s*([\s\S]*?)(?=\n\s*\d+[.、]\s*(?:\*\*)?[^::\n*]+(?:\*\*)?[::]|\n\s*[-*]\s*\*\*问题类型|\n\s*[-*]?\s*\*\*修改建议|\n\s*[-*]?\s*\*\*风险等级|$)/g)];
return matches.map((match, index) => normalizeIssue({
title: match[1],
description: match[2],
}, index));
};
const parseMarkdownCoreReview = (answer = '', fallbackMaterials = []) => {
const sections = String(answer || '').split(/(?=^###\s*文件\d+[::])/m).filter(item => /^###\s*文件\d+[::]/m.test(item));
const materials = sections.map((section, index) => {
const title = section.match(/^###\s*文件\d+[::]\s*(.+)$/m)?.[1]?.trim();
const risk = section.match(/风险等级[::]\s*(.+)/)?.[1] || '';
const summary = getTextBetween(section, /技术方案概述/, /问题清单|问题类型|修改建议|风险等级|###\s*文件|##\s+/);
const fallback = fallbackMaterials[index] || {};
return {
...fallback,
code: fallback.code || String(index + 1),
fileName: fallback.fileName || title || `材料${index + 1}`,
name: title || fallback.name || `材料${index + 1}`,
summary: stripMarkdown(summary.replace(/^技术方案概述[::]?/, '')).slice(0, 300),
riskLevel: normalizeRiskLevel(risk),
issues: parseMarkdownIssues(section),
};
});
const summaryText = getTextBetween(
answer,
/##\s*[二三]、整体问题汇总/,
/$^/
);
return {
conclusion: stripMarkdown(answer.match(/(?:整体审查结论|初步审查结论)[::]\s*(.+)/)?.[1] || ''),
analysis: String(summaryText || answer).trim(),
materials,
};
};
const parseCoreReviewAnswer = (answer = '', fallbackMaterials = []) => {
const jsonResult = parseJsonWithRemainder(answer);
const parsed = jsonResult.parsed || parseJsonMaybe(answer);
if (parsed) {
const parsedMaterials = parsed.materials ||
parsed.reviewMaterials ||
parsed.fileAnalyses ||
parsed.fileAnalysis ||
parsed.fileReviews ||
parsed.files ||
[];
const markdownSummary = jsonResult.remainder ||
parsed.summaryMarkdown ||
parsed.markdownSummary ||
parsed.overallMarkdown ||
parsed.overallAnalysis ||
parsed.analysis ||
parsed.summary ||
'';
return {
parsed,
conclusion: stripMarkdown(parsed.conclusion || ''),
analysis: String(markdownSummary || '').trim(),
materials: normalizeReviewMaterials(parsedMaterials, fallbackMaterials),
};
}
const markdownParsed = parseMarkdownCoreReview(answer, fallbackMaterials);
return {
parsed: null,
...markdownParsed,
materials: normalizeReviewMaterials(markdownParsed.materials, fallbackMaterials),
};
};
const getFastGptConfig = (ctx) => {
const { apiUrl, patentAppKey } = ctx.app.fs.config.fastGpt || {};
if (!apiUrl) throw 'FASTGPT_URL 配置缺失';
if (!patentAppKey) throw 'FASTGPT_PATENT_APP_KEY 配置缺失';
return { apiUrl, appKey: patentAppKey };
};
const getFileNameFromUrl = (url = '') => {
const cleanUrl = String(url || '').split('?')[0].split('#')[0];
let fileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
try {
fileName = decodeURIComponent(fileName);
} catch (error) {
fileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
}
return fileName || '文件';
};
const buildFastGptContent = (question, fileUrls = []) => {
const urls = normalizeArray(fileUrls);
if (!urls.length) return question;
const content = [{ type: 'text', text: question }];
urls.forEach(url => {
content.push({
type: 'file_url',
url,
});
});
return content;
};
const callPatentFastGpt = async (ctx, { variables = {}, question, fileUrls = [] }) => {
const { apiUrl, appKey } = getFastGptConfig(ctx);
const content = buildFastGptContent(question, fileUrls);
const payload = {
stream: false,
detail: false,
variables,
messages: [{
role: 'user',
content,
}],
};
try {
const res = await superagent
.post(`${apiUrl}/api/v1/chat/completions`)
.send(payload)
.set({
Authorization: `Bearer ${appKey}`,
'Content-Type': 'application/json',
});
return {
answer: res?.body?.choices?.[0]?.message?.content || '',
response: res?.body || null,
};
} catch (error) {
ctx.logger.log({
message: 'patent fastgpt request failed',
status: error?.status,
response: error?.response?.body || error?.response?.text,
payloadSummary: {
variableKeys: Object.keys(variables || {}),
messageContentTypes: Array.isArray(content) ? content.map(item => item.type) : ['text'],
fileUrlCount: normalizeArray(fileUrls).length,
},
});
throw error;
}
};
const getTaskOrThrow = async (models, taskId, transaction = null) => {
const task = await models.PatentReviewTasks.findOne({
where: { id: Number(taskId), deleted: false },
raw: true,
transaction,
});
if (!task) throw '任务不存在';
return task;
};
const buildTaskDetail = async (models, taskId) => {
const task = await getTaskOrThrow(models, taskId);
const files = await models.PatentReviewFiles.findAll({
where: { taskId: Number(taskId) },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true,
});
const resultModelName = MODULE_MODEL_MAP[task.moduleKey];
const result = resultModelName
? await models[resultModelName].findOne({ where: { taskId: Number(taskId) }, raw: true })
: null;
return { ...task, files, result };
};
const upsertResult = async (Model, where, values, transaction) => {
const exists = await Model.findOne({ where, transaction });
if (exists) {
await exists.update(values, { transaction });
return exists;
}
return await Model.create({ ...where, ...values }, { transaction });
};
const inferReviewCounts = (materials = []) => {
const counts = { formalCount: 0, guideCount: 0, systemCount: 0, passedCount: 0 };
materials.forEach(material => {
const issues = Array.isArray(material?.issues) ? material.issues : [];
if (!issues.length) {
counts.passedCount += 1;
return;
}
issues.forEach(issue => {
if (issue?.type === 'formal') counts.formalCount += 1;
else if (issue?.type === 'guide') counts.guideCount += 1;
else counts.systemCount += 1;
});
});
return counts;
};
module.exports.getTaskList = async (ctx) => {
try {
const { models, ORM: { Op } } = ctx.app.fs.dc;
const { page, pageSize, keyword, moduleKey, creator, userId } = ctx.request.query;
const where = { deleted: false };
if (moduleKey) where.moduleKey = moduleKey;
if (creator) where.creator = creator;
if (userId) where.userId = normalizeUserId(userId);
if (keyword) {
where.name = { [Op.like]: `%${keyword}%` };
}
const options = {
where,
order: [['updateAt', 'DESC'], ['id', 'DESC']],
raw: true,
};
if (page && pageSize) {
options.offset = (Number(page) - 1) * Number(pageSize);
options.limit = Number(pageSize);
}
ctx.body = await models.PatentReviewTasks.findAndCountAll(options);
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '获取专利审查任务列表失败' };
}
};
module.exports.createTask = async (ctx) => {
try {
const { models } = ctx.app.fs.dc;
const { name, moduleKey, creator, userId, extra } = ctx.request.body;
if (!name) throw '缺少参数: name';
if (!MODULE_LABEL[moduleKey]) throw '参数错误: moduleKey';
const normalizedUserId = normalizeUserId(userId);
const existingTask = await models.PatentReviewTasks.findOne({
where: {
name,
userId: normalizedUserId,
deleted: false,
},
});
if (existingTask) {
throw '任务名称已存在,请使用其他名称';
}
const task = await models.PatentReviewTasks.create({
name,
moduleKey,
creator,
userId: normalizedUserId,
status: 'created',
statusText: STATUS_TEXT.created,
extra,
createAt: moment(),
updateAt: moment(),
});
ctx.body = task;
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '创建专利审查任务失败' };
}
};
module.exports.getTaskDetail = async (ctx) => {
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
ctx.body = await buildTaskDetail(models, taskId);
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '获取专利审查任务详情失败' };
}
};
module.exports.updateTask = async (ctx) => {
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
const { name, status, statusText, extra } = ctx.request.body;
await getTaskOrThrow(models, taskId);
const updateData = { updateAt: moment() };
if (name !== undefined) updateData.name = name;
if (status !== undefined) updateData.status = status;
if (statusText !== undefined) updateData.statusText = statusText;
if (extra !== undefined) updateData.extra = extra;
await models.PatentReviewTasks.update(updateData, { where: { id: Number(taskId) } });
ctx.status = 204;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '修改专利审查任务失败' };
}
};
module.exports.deleteTask = async (ctx) => {
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
await getTaskOrThrow(models, taskId);
await models.PatentReviewTasks.update(
{ deleted: true, updateAt: moment() },
{ where: { id: Number(taskId) } }
);
ctx.status = 204;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '删除专利审查任务失败' };
}
};
module.exports.saveFiles = async (ctx) => {
const transaction = await ctx.app.fs.dc.orm.transaction();
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
const { files = [], replace = true } = ctx.request.body;
await getTaskOrThrow(models, taskId, transaction);
if (!Array.isArray(files)) throw '参数错误: files';
if (replace) {
await models.PatentReviewFiles.destroy({ where: { taskId: Number(taskId) }, transaction });
}
const rows = files.map((file, index) => ({
taskId: Number(taskId),
fileRole: file.fileRole,
originalName: file.originalName || file.name,
fileUrl: file.fileUrl || file.url,
storageKey: file.storageKey || file.key,
fileSize: file.fileSize || file.size || null,
fileExt: file.fileExt || file.ext || null,
mimeType: file.mimeType || file.type || null,
sortOrder: file.sortOrder ?? index,
metadata: file.metadata || null,
createAt: moment(),
}));
rows.forEach(row => {
if (!row.fileRole) throw '缺少参数: fileRole';
if (!row.originalName) throw '缺少参数: originalName';
});
const saved = rows.length
? await models.PatentReviewFiles.bulkCreate(rows, { returning: true, transaction })
: [];
await models.PatentReviewTasks.update(
{ updateAt: moment() },
{ where: { id: Number(taskId) }, transaction }
);
await transaction.commit();
ctx.body = saved;
ctx.status = 200;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '保存专利审查文件失败' };
}
};
module.exports.runCoreReview = async (ctx) => {
const transaction = await ctx.app.fs.dc.orm.transaction();
const startedAt = moment();
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
const { question, fileUrls, materials = [] } = ctx.request.body;
const task = await getTaskOrThrow(models, taskId, transaction);
if (task.moduleKey !== 'review') throw '当前任务不是发明专利核心审查任务';
await models.PatentReviewTasks.update({
status: 'processing',
statusText: STATUS_TEXT.processing,
startedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction });
const files = await models.PatentReviewFiles.findAll({
where: { taskId: Number(taskId) },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true,
transaction,
});
const uploadUrls = normalizeArray(fileUrls).length
? normalizeArray(fileUrls)
: files.map(item => item.fileUrl).filter(Boolean);
if (!uploadUrls.length) throw '缺少审查文件';
const preparedInput = await prepareCoreReviewInput(ctx, uploadUrls);
const sendQuestion = question || `请进行发明专利核心审查。任务名称:${task.name}`;
const variables = {
type: 'patent_core_review',
};
const { answer, response } = await callPatentFastGpt(ctx, {
variables,
question: sendQuestion,
fileUrls: preparedInput.fileUrls,
});
const parsedResult = parseCoreReviewAnswer(
answer,
Array.isArray(materials) && materials.length ? materials : preparedInput.extractedMaterials
);
const resultMaterials = parsedResult.materials?.length
? parsedResult.materials
: (Array.isArray(materials) && materials.length ? materials : preparedInput.extractedMaterials) || [];
const counts = inferReviewCounts(resultMaterials);
const finishedAt = moment();
const result = await upsertResult(models.PatentCoreReviewResults, { taskId: Number(taskId) }, {
conclusion: parsedResult.conclusion || null,
...counts,
materials: resultMaterials,
analysis: parsedResult.analysis || answer,
rawResult: {
answer,
parsed: parsedResult.parsed,
response,
preprocess: {
archiveProcessed: preparedInput.archiveProcessed,
extractedMaterials: preparedInput.extractedMaterials,
uploadedFiles: preparedInput.uploadedFiles,
passthroughFileUrls: preparedInput.fileUrls,
sourceFileUrls: uploadUrls,
},
},
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, transaction);
await models.PatentReviewTasks.update({
status: 'completed',
statusText: '已完成核心审查',
finishedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction });
await transaction.commit();
ctx.body = result;
ctx.status = 200;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
try {
const { models } = ctx.app.fs.dc;
await models.PatentReviewTasks.update({
status: 'failed',
statusText: STATUS_TEXT.failed,
finishedAt: moment(),
errorMessage: typeof error === 'string' ? error : error.message,
updateAt: moment(),
}, { where: { id: Number(ctx.params.taskId) } });
} catch (e) {
ctx.logger.log(e);
}
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '发明专利核心审查失败' };
}
};
module.exports.saveGrantQueryResult = async (ctx) => {
const transaction = await ctx.app.fs.dc.orm.transaction();
const startedAt = moment();
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
const { inputText, results = [], rawResult = null } = ctx.request.body;
const task = await getTaskOrThrow(models, taskId, transaction);
if (task.moduleKey !== 'grant') throw '当前任务不是一次性授权案件查询任务';
if (!inputText) throw '缺少参数: inputText';
if (!Array.isArray(results)) throw '参数错误: results';
const oneShotCount = results.filter(item => !item.hasOfficeAction).length;
const finishedAt = moment();
const result = await upsertResult(models.PatentGrantQueryResults, { taskId: Number(taskId) }, {
inputText,
totalCount: results.length,
oneShotCount,
nonOneShotCount: results.length - oneShotCount,
results,
rawResult,
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, transaction);
await models.PatentReviewTasks.update({
status: 'completed',
statusText: `已查询 ${results.length} 个号码`,
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction });
await transaction.commit();
ctx.body = result;
ctx.status = 200;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '保存一次性授权查询结果失败' };
}
};
module.exports.runGrantQuery = async (ctx) => {
const transaction = await ctx.app.fs.dc.orm.transaction();
const startedAt = moment();
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
const { inputText } = ctx.request.body;
const task = await getTaskOrThrow(models, taskId, transaction);
if (task.moduleKey !== 'grant') throw '当前任务不是一次性授权案件查询任务';
if (!inputText) throw '缺少参数: inputText';
const numbers = parsePatentNumbers(inputText);
if (!numbers.length) throw '请输入申请号或专利号';
const forceCaptchaFirst = ctx.request.body?.forceCaptchaFirst === true;
const authState = await (cnipaAuth.getAuthDiagnostics?.(ctx) || cnipaAuth.__getAuthDiagnostics?.() || {});
const cookieSeemsReady = authState.hasSession && authState.hasEnableFlag;
// 快速失败:未就绪时直接返回登录要求,避免先跑慢查询导致用户等待 10~20s。
if (forceCaptchaFirst || !cookieSeemsReady) {
ctx.status = 200;
ctx.body = {
requiresCnipaAuth: true,
message: 'CNIPA 登录态已失效,请完成滑块验证后重试',
authDiagnostics: {
hasSession: !!authState.hasSession,
hasEnableFlag: !!authState.hasEnableFlag,
cookieLength: Number(authState.cookieLength || 0),
updatedAt: Number(authState.updatedAt || 0),
fastRejected: true,
forceCaptchaFirst,
},
};
return;
}
await models.PatentReviewTasks.update({
status: 'processing',
statusText: '一次性授权查询中',
startedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction });
await transaction.commit();
const rawResults = [];
const results = [];
const numbersToQuery = []; // 需要实际查询的号码(不在缓存中)
let needCnipaAuth = false;
// 首先尝试从缓存获取
for (const number of numbers) {
const cachedResult = await getCachedGrantResult(models, number);
if (cachedResult) {
results.push(cachedResult);
} else {
numbersToQuery.push(number);
}
}
// 如果有缓存命中,更新任务状态
if (results.length > 0 && numbersToQuery.length === 0) {
// 全部命中缓存,直接返回结果
const finishedAt = moment();
const oneShotCount = results.filter(item => item.hasOfficeAction === false).length;
const nonOneShotCount = results.filter(item => item.hasOfficeAction === true).length;
const saveTransaction = await ctx.app.fs.dc.orm.transaction();
try {
const result = await upsertResult(models.PatentGrantQueryResults, { taskId: Number(taskId) }, {
inputText,
totalCount: results.length,
oneShotCount,
nonOneShotCount,
results,
rawResult: { source: 'cache', items: results },
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, saveTransaction);
await models.PatentReviewTasks.update({
status: 'completed',
statusText: `已查询 ${results.length} 个号码`,
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction: saveTransaction });
await saveTransaction.commit();
ctx.body = result;
ctx.status = 200;
return;
} catch (error) {
await saveTransaction.rollback();
throw error;
}
}
// 查询未缓存的号码
for (const number of numbersToQuery) {
try {
const noticeBody = await queryGrantNoticeInfo(ctx, number);
rawResults.push({ number, response: noticeBody });
const result = normalizeGrantQueryResult(number, noticeBody);
results.push(result);
// 保存到缓存
await saveGrantResultToCache(models, result);
} catch (error) {
const errorMessage = getCnipaErrorMessage(error);
const isAuthError = errorMessage.includes('登录') ||
errorMessage.includes('授权') ||
errorMessage.includes('认证') ||
errorMessage.includes('412') ||
errorMessage.includes('Cookie') ||
errorMessage.includes('session') ||
errorMessage.includes('浏览器') ||
errorMessage.includes('前置条件');
if (isAuthError) needCnipaAuth = true;
rawResults.push({
number,
error: errorMessage,
status: error?.status || error?.response?.status || null,
response: error?.response?.body || error?.response?.text || null,
});
results.push({
number,
title: '未获取',
applicant: '未获取',
applicationDate: '-',
grantDate: '-',
hasOfficeAction: null,
notices: [],
queryStatus: 'failed',
errorMessage,
basis: `查询失败,无法判断是否为一次性授权案件。失败原因:${errorMessage}`,
});
}
}
if (needCnipaAuth && results.every(item => item.queryStatus === 'failed')) {
const cookieSnapshot = String(
await (cnipaAuth.getDebugCookieHeader?.(ctx) || cnipaAuth.__debugCookieHeader?.() || '')
);
ctx.status = 200;
ctx.body = {
requiresCnipaAuth: true,
message: 'CNIPA 登录态已失效,请完成滑块验证后重试',
authDiagnostics: {
hasSession: /SESSION=/.test(cookieSnapshot),
hasEnableFlag: cnipaAuth.__hasCnipaReadyCookie?.(cookieSnapshot) || false,
cookieLength: cookieSnapshot.length,
cookiePreview: cookieSnapshot.slice(0, 180),
},
};
return;
}
const saveTransaction = await ctx.app.fs.dc.orm.transaction();
try {
const finishedAt = moment();
const oneShotCount = results.filter(item => item.hasOfficeAction === false).length;
const nonOneShotCount = results.filter(item => item.hasOfficeAction === true).length;
const hasCachedResults = results.some(item => item.queryStatus === 'cached');
const source = hasCachedResults ? 'cnipa-browser-page-with-cache' : 'cnipa-browser-page';
const result = await upsertResult(models.PatentGrantQueryResults, { taskId: Number(taskId) }, {
inputText,
totalCount: results.length,
oneShotCount,
nonOneShotCount,
results,
rawResult: { source, items: rawResults },
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, saveTransaction);
const cacheInfo = hasCachedResults ? `( ${results.filter(item => item.queryStatus === 'cached').length} 个)` : '';
await models.PatentReviewTasks.update({
status: 'completed',
statusText: `已查询 ${results.length} 个号码${cacheInfo}`,
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction: saveTransaction });
await saveTransaction.commit();
ctx.body = result;
ctx.status = 200;
} catch (error) {
await saveTransaction.rollback();
throw error;
}
} catch (error) {
if (!transaction.finished) await transaction.rollback();
ctx.logger.log(error);
try {
const { models } = ctx.app.fs.dc;
await models.PatentReviewTasks.update({
status: 'failed',
statusText: STATUS_TEXT.failed,
finishedAt: moment(),
errorMessage: typeof error === 'string' ? error : error.message,
updateAt: moment(),
}, { where: { id: Number(ctx.params.taskId) } });
} catch (e) {
ctx.logger.log(e);
}
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '一次性授权查询失败' };
}
};
module.exports.runCompare = async (ctx) => {
const transaction = await ctx.app.fs.dc.orm.transaction();
const startedAt = moment();
try {
const { models } = ctx.app.fs.dc;
const { taskId } = ctx.params;
const { question, targetFileUrl, referenceFileUrls } = ctx.request.body;
const task = await getTaskOrThrow(models, taskId, transaction);
if (task.moduleKey !== 'compare') throw '当前任务不是新颖性创造性对比任务';
const files = await models.PatentReviewFiles.findAll({
where: { taskId: Number(taskId) },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true,
transaction,
});
const targetFile = targetFileUrl
? { fileUrl: targetFileUrl }
: files.find(item => item.fileRole === 'target_file');
const referenceFiles = normalizeArray(referenceFileUrls).length
? normalizeArray(referenceFileUrls).map(url => ({ fileUrl: url }))
: files.filter(item => item.fileRole === 'reference_file');
if (!targetFile?.fileUrl) throw '缺少待申请文件';
if (!referenceFiles.length) throw '缺少对比文件';
if (referenceFiles.length > 10) throw '对比文件不能超过10份';
await models.PatentReviewTasks.update({
status: 'processing',
statusText: STATUS_TEXT.processing,
startedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction });
const rawCompareUrls = [targetFile.fileUrl, ...referenceFiles.map(item => item.fileUrl).filter(Boolean)];
const preparedInput = await prepareCoreReviewInput(ctx, rawCompareUrls);
const compareUrls = preparedInput.fileUrls;
const sendQuestion = question || `请进行专利新颖性与创造性对比分析。任务名称:${task.name}`;
const variables = {
type: 'patent_compare',
};
const { answer, response } = await callPatentFastGpt(ctx, {
variables,
question: sendQuestion,
fileUrls: compareUrls,
});
const parsed = parseJsonMaybe(answer);
const finishedAt = moment();
const parseScore = (value) => {
const num = parseInt(value, 10);
return Number.isFinite(num) ? num : null;
};
const result = await upsertResult(models.PatentCompareResults, { taskId: Number(taskId) }, {
targetFileName: targetFile.originalName || null,
conclusion: parsed?.conclusion || null,
noveltyScore: parseScore(parsed?.noveltyScore),
inventiveScore: parseScore(parsed?.inventiveScore),
analysis: parsed?.analysis || answer,
referencesResult: parsed?.referencesResult || parsed?.references || [],
rawResult: {
answer,
parsed,
response,
preprocess: {
archiveProcessed: preparedInput.archiveProcessed,
sourceFileUrls: rawCompareUrls,
passthroughFileUrls: compareUrls,
},
},
startedAt,
finishedAt,
errorMessage: null,
updateAt: moment(),
}, transaction);
await models.PatentReviewTasks.update({
status: 'completed',
statusText: '已完成新创对比',
finishedAt,
errorMessage: null,
updateAt: moment(),
}, { where: { id: Number(taskId) }, transaction });
await transaction.commit();
ctx.body = result;
ctx.status = 200;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
try {
const { models } = ctx.app.fs.dc;
await models.PatentReviewTasks.update({
status: 'failed',
statusText: STATUS_TEXT.failed,
finishedAt: moment(),
errorMessage: typeof error === 'string' ? error : error.message,
updateAt: moment(),
}, { where: { id: Number(ctx.params.taskId) } });
} catch (e) {
ctx.logger.log(e);
}
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '新颖性创造性对比失败' };
}
};