#!/usr/bin/env node 'use strict'; const fs = require('fs'); function emit(stage, message, status = 'running', extra = {}) { process.stderr.write(JSON.stringify({ type: 'process_event', stage, message, status, timestamp: new Date().toISOString(), ...extra, }) + '\n'); } function readInput() { const raw = String(process.argv[2] || process.env.SKILL_INPUT || process.env.AIFLOW_SKILL_INPUT || fs.readFileSync(0, 'utf8')).trim(); if (!raw) return {}; return JSON.parse(raw); } function normalizeDate(value) { const text = String(value || '').trim(); if (!text) return ''; const compact = text.replace(/[.年/]/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\s+/g, ''); const match = compact.match(/^(\d{4})-?(\d{1,2})-?(\d{1,2})$/); if (!match) return text; const [, y, m, d] = match; return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; } function cleanAmount(value) { const text = String(value || '').trim(); if (!text) return ''; return text.replace(/[¥¥,\s]/g, ''); } function cleanVin(value) { return String(value || '').trim().replace(/\s+/g, '').toUpperCase(); } function parseJsonText(value) { if (!value) return null; if (typeof value === 'object') return value; const text = String(value).trim(); if (!text) return null; try { return JSON.parse(text); } catch { const match = text.match(/\{[\s\S]*\}/); if (!match) return null; try { return JSON.parse(match[0]); } catch { return null; } } } function firstNonEmpty(...values) { for (const value of values) { if (value !== undefined && value !== null && value !== '') return value; } return ''; } function readField(source, names) { if (!source || typeof source !== 'object') return ''; for (const name of names) { const value = source[name]; if (value === undefined || value === null || value === '') continue; if (typeof value === 'object') { return firstNonEmpty(value.words, value.word, value.value, value.text); } return String(value).trim(); } return ''; } function getCandidate(input) { return parseJsonText(input.modelResult) || parseJsonText(input.ocrResult) || parseJsonText(input.mockResult) || parseJsonText(input.result) || (input.fields ? { fields: input.fields } : null); } function normalizeVehicleInvoice(candidate) { const root = candidate && typeof candidate === 'object' ? candidate : {}; const fields = root.fields && typeof root.fields === 'object' ? root.fields : root; const words = root.words_result && typeof root.words_result === 'object' ? root.words_result : fields; const normalizedFields = { invoiceCode: readField(words, ['invoiceCode', '发票代码']), invoiceNumber: readField(words, ['invoiceNumber', 'invoiceNo', '发票号码', '发票号']), issueDate: normalizeDate(readField(words, ['issueDate', '开票日期', '填发日期'])), buyerName: readField(words, ['buyerName', '购买方名称', '购货单位名称', '购买方', '买方名称']), buyerId: readField(words, ['buyerId', '购买方纳税人识别号', '购买方身份证号码/组织机构代码', '买方纳税人识别号']), buyerAddressPhone: readField(words, ['buyerAddressPhone', '购买方地址、电话', '购买方地址电话']), vehicleType: readField(words, ['vehicleType', '车辆类型']), brandModel: readField(words, ['brandModel', '厂牌型号', '品牌型号', '厂牌型号及配置']), origin: readField(words, ['origin', '产地']), certificateNo: readField(words, ['certificateNo', '合格证号']), importCertificateNo: readField(words, ['importCertificateNo', '进口证明书号']), inspectionNo: readField(words, ['inspectionNo', '商检单号']), engineNo: readField(words, ['engineNo', '发动机号码', '发动机号']), vin: cleanVin(readField(words, ['vin', 'VIN', '车辆识别代号/车架号码', '车辆识别代号', '车架号码', '车架号'])), taxpayerNo: readField(words, ['taxpayerNo', '纳税人识别号', '主管税务机关代码']), sellerName: readField(words, ['sellerName', '销货单位名称', '销售方名称', '销方名称']), sellerTaxpayerNo: readField(words, ['sellerTaxpayerNo', '销货单位纳税人识别号', '销售方纳税人识别号']), sellerAccount: readField(words, ['sellerAccount', '销货单位开户银行及账号', '销售方开户行及账号']), sellerAddressPhone: readField(words, ['sellerAddressPhone', '销货单位地址、电话', '销售方地址电话']), taxAuthorityCode: readField(words, ['taxAuthorityCode', '主管税务机关代码']), taxAuthorityName: readField(words, ['taxAuthorityName', '主管税务机关名称']), totalAmount: cleanAmount(readField(words, ['totalAmount', '价税合计', '价税合计小写', '金额合计'])), taxRate: readField(words, ['taxRate', '增值税税率或征收率', '税率']), taxAmount: cleanAmount(readField(words, ['taxAmount', '增值税税额', '税额'])), totalAmountUpper: readField(words, ['totalAmountUpper', '价税合计大写', '大写']), tonnage: readField(words, ['tonnage', '吨位']), limitedPassengerCount: readField(words, ['limitedPassengerCount', '限乘人数']), }; return { success: true, fields: normalizedFields, raw: root, }; } function buildPrompt(input) { return [ '请使用多模态大模型识别机动车销售统一发票图片或PDF,只输出合法JSON,不输出解释。', '只根据图片/PDF可见内容提取字段,无法确认的字段输出空字符串,不要猜测。', '日期统一 YYYY-MM-DD;金额只保留数字和小数点;VIN 保留大写字符。', '输出schema:{"fields":{"invoiceCode":"","invoiceNumber":"","issueDate":"","buyerName":"","buyerId":"","buyerAddressPhone":"","vehicleType":"","brandModel":"","origin":"","certificateNo":"","importCertificateNo":"","inspectionNo":"","engineNo":"","vin":"","taxpayerNo":"","sellerName":"","sellerTaxpayerNo":"","sellerAccount":"","sellerAddressPhone":"","taxAuthorityCode":"","taxAuthorityName":"","totalAmount":"","taxRate":"","taxAmount":"","totalAmountUpper":"","tonnage":"","limitedPassengerCount":""}}', input.imageUrl ? `图片URL:${input.imageUrl}` : '', input.pdfUrl ? `PDF URL:${input.pdfUrl}` : '', input.imageBase64 ? '已提供 imageBase64,请直接看图识别。' : '', input.pdfBase64 ? '已提供 pdfBase64,请直接识别PDF。' : '', ].filter(Boolean).join('\n'); } async function run(input) { const candidate = getCandidate(input); if (candidate) { emit('normalize', '归一化多模态机动车发票识别结果', 'completed'); return normalizeVehicleInvoice(candidate); } return { success: false, needsModelVision: true, error: '未提供多模态模型识别结果。请先让多模态模型查看 imageUrl/imageBase64/pdfUrl/pdfBase64,并把模型返回JSON作为 modelResult 传入本skill。', prompt: buildPrompt(input), fields: { invoiceCode: '', invoiceNumber: '', issueDate: '', buyerName: '', buyerId: '', vehicleType: '', brandModel: '', engineNo: '', vin: '', totalAmount: '', sellerName: '', }, }; } (async () => { try { const input = readInput(); process.stdout.write(JSON.stringify(await run(input))); } catch (err) { process.stdout.write(JSON.stringify({ success: false, error: err && err.message ? err.message : String(err), })); } })();