'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(title)}`, '', '', '', `

${escapeHtml(meta.name || meta.fileName)}

`, `

文件编号:${escapeHtml(meta.code || '待核实')}

`, `

原始文件:${escapeHtml(meta.fileName || filePath)}

`, '

提取文本

', `
${escapeHtml(extractedText || '')}
`, '

原始 XML

', `
${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 : '新颖性创造性对比失败' }; } };