You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1455 lines
52 KiB
1455 lines
52 KiB
'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, '"')
|
|
.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 [
|
|
'<!doctype html>',
|
|
'<html lang="zh-CN">',
|
|
'<head>',
|
|
'<meta charset="utf-8" />',
|
|
`<title>${escapeHtml(title)}</title>`,
|
|
'<style>',
|
|
'body{font-family:Arial,"Microsoft YaHei",sans-serif;line-height:1.7;color:#1f2937;padding:24px;}',
|
|
'h1{font-size:22px;}h2{font-size:18px;margin-top:24px;}',
|
|
'pre{white-space:pre-wrap;word-break:break-word;background:#f6f8fa;padding:16px;border-radius:6px;}',
|
|
'</style>',
|
|
'</head>',
|
|
'<body>',
|
|
`<h1>${escapeHtml(meta.name || meta.fileName)}</h1>`,
|
|
`<p>文件编号:${escapeHtml(meta.code || '待核实')}</p>`,
|
|
`<p>原始文件:${escapeHtml(meta.fileName || filePath)}</p>`,
|
|
'<h2>提取文本</h2>',
|
|
`<pre>${escapeHtml(extractedText || '')}</pre>`,
|
|
'<h2>原始 XML</h2>',
|
|
`<pre>${escapeHtml(rawContent || '')}</pre>`,
|
|
'</body>',
|
|
'</html>',
|
|
].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 : '新颖性创造性对比失败' };
|
|
}
|
|
};
|
|
|