receipt-ocr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cosplit.now
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # ReceiptOCR
2
+
3
+ 一个可复用的 TypeScript 库,用于借助多模态大语言模型从购物小票图片中提取结构化商品数据。
4
+
5
+ ## 特性
6
+
7
+ - 🚀 **函数式 API**:无状态、异步、可组合
8
+ - 🎯 **类型安全**:完整的 TypeScript 类型定义
9
+ - 🔌 **依赖注入**:验证逻辑由调用方提供
10
+ - 📦 **双模块支持**:同时支持 ESM 和 CommonJS
11
+ - 🤖 **Gemini 驱动**:使用 Google Gemini 多模态模型
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ npm install receipt-ocr
17
+ # 或
18
+ pnpm add receipt-ocr
19
+ ```
20
+
21
+ ## 环境配置
22
+
23
+ 在使用前,需要设置环境变量:
24
+
25
+ ```bash
26
+ # 必需
27
+ export GEMINI_API_KEY=your-gemini-api-key
28
+
29
+ # 可选(默认:gemini-2.0-flash)
30
+ export GEMINI_MODEL=gemini-2.0-flash
31
+ ```
32
+
33
+ ## 基础用法
34
+
35
+ ```typescript
36
+ import { extractReceiptItems } from 'receipt-ocr';
37
+ import fs from 'fs';
38
+
39
+ // 从文件读取图片
40
+ const imageBuffer = fs.readFileSync('receipt.jpg');
41
+
42
+ // 提取商品信息
43
+ const items = await extractReceiptItems(imageBuffer);
44
+
45
+ console.log(items);
46
+ // [
47
+ // {
48
+ // name: "有机牛奶 1L",
49
+ // price: 12.5,
50
+ // quantity: 1,
51
+ // hasTax: false
52
+ // },
53
+ // {
54
+ // name: "可口可乐瓶装",
55
+ // price: 3.5,
56
+ // quantity: 2,
57
+ // hasTax: true,
58
+ // taxAmount: 0.35,
59
+ // deposit: 0.5, // 押金已自动合并
60
+ // discount: -0.5 // 折扣已自动合并
61
+ // },
62
+ // ...
63
+ // ]
64
+ ```
65
+
66
+ ## 商品数据结构
67
+
68
+ 每个商品包含以下字段:
69
+
70
+ ```typescript
71
+ interface ReceiptItem {
72
+ name: string; // 商品名称
73
+ price: number; // 单价
74
+ quantity: number; // 数量(默认 1)
75
+ hasTax: boolean; // 是否含税
76
+ taxAmount?: number; // 税额(可选)
77
+ deposit?: number; // 押金(可选,自动合并)
78
+ discount?: number; // 折扣(可选,自动合并)
79
+ }
80
+ ```
81
+
82
+ ### 附加费用自动合并
83
+
84
+ 库会自动识别并合并押金(Deposit)和折扣(TPD)到对应的商品中,而不是作为独立的商品项返回:
85
+
86
+ - **押金(deposit)**:如 "Deposit VL",会被合并到对应的瓶装商品中
87
+ - **折扣(discount)**:如 "TPD",会被合并到对应的商品中(通常为负数)
88
+
89
+ 这意味着您不需要手动处理这些附加费用,它们会自动关联到正确的商品上。
90
+
91
+ ## 高级用法
92
+
93
+ ### 1. 自动验证(推荐)
94
+
95
+ 使用 Google Search grounding 自动批量验证不确定的商品名称:
96
+
97
+ ```typescript
98
+ import { extractReceiptItems } from 'receipt-ocr';
99
+
100
+ const items = await extractReceiptItems(imageBuffer, {
101
+ autoVerify: true, // 启用自动验证
102
+ });
103
+
104
+ // 库会自动验证并补全模糊的商品名称
105
+ // 如果验证失败,会保持原始名称
106
+ ```
107
+
108
+ **优势**:
109
+ - ✅ 批量处理,只需 1 次额外 API 调用
110
+ - ✅ 使用 Google Search,覆盖面广
111
+ - ✅ 自动处理,无需额外代码
112
+ - ✅ 验证失败时自动保持原始数据
113
+
114
+ 详细文档:[自动验证功能](./docs/AUTO_VERIFICATION.md)
115
+
116
+ ### 2. 自定义验证回调
117
+
118
+ 当需要连接特定产品库时,可以使用自定义验证回调:
119
+
120
+ ```typescript
121
+ import { extractReceiptItems } from 'receipt-ocr';
122
+
123
+ const items = await extractReceiptItems(imageBuffer, {
124
+ verifyCallback: async (name, context) => {
125
+ // 调用外部搜索服务验证/补全商品名称
126
+ const result = await myProductDatabase.search(name);
127
+
128
+ if (result) {
129
+ return { verifiedName: result.fullName };
130
+ }
131
+
132
+ // 返回 null 保持原样
133
+ return null;
134
+ }
135
+ });
136
+ ```
137
+
138
+ ### 3. 组合使用
139
+
140
+ 两种验证方式可以同时使用:
141
+
142
+ ```typescript
143
+ const items = await extractReceiptItems(imageBuffer, {
144
+ autoVerify: true, // 先用 Google Search 批量验证
145
+ verifyCallback: async (name, context) => {
146
+ // 如果自动验证失败,再用自定义逻辑
147
+ const result = await myProductDatabase.search(name);
148
+ return result ? { verifiedName: result.name } : null;
149
+ },
150
+ });
151
+ ```
152
+
153
+ 验证回调接口:
154
+
155
+ ```typescript
156
+ type VerificationCallback = (
157
+ name: string,
158
+ context: {
159
+ rawText: string; // OCR 原始文本
160
+ allItems: ReceiptItem[]; // 所有已解析商品
161
+ }
162
+ ) => Promise<{ verifiedName: string } | null>;
163
+ ```
164
+
165
+ ## 图片输入格式
166
+
167
+ 支持以下三种格式:
168
+
169
+ ```typescript
170
+ // 1. Buffer
171
+ const buffer = fs.readFileSync('receipt.jpg');
172
+ await extractReceiptItems(buffer);
173
+
174
+ // 2. Base64 字符串
175
+ const base64 = 'iVBORw0KGgoAAAANSUhEUgAA...';
176
+ await extractReceiptItems(base64);
177
+
178
+ // 3. 图片 URL
179
+ const url = 'https://example.com/receipt.jpg';
180
+ await extractReceiptItems(url);
181
+ ```
182
+
183
+ ## 策略接口(供扩展)
184
+
185
+ 库预留了完整的策略接口,方便未来扩展:
186
+
187
+ ```typescript
188
+ import { VerificationStrategy } from 'receipt-ocr';
189
+
190
+ const myStrategy: VerificationStrategy = {
191
+ verify: async (name, context) => {
192
+ const verified = await searchProductDB(name);
193
+ return { verifiedName: verified };
194
+ }
195
+ };
196
+ ```
197
+
198
+ ## 开发
199
+
200
+ ```bash
201
+ # 安装依赖
202
+ npm install
203
+
204
+ # 类型检查
205
+ npm run type-check
206
+
207
+ # 构建
208
+ npm run build
209
+
210
+ # 开发模式(监听变化)
211
+ npm run dev
212
+ ```
213
+
214
+ ## 设计原则
215
+
216
+ 1. **无状态**:每次调用独立,无副作用
217
+ 2. **确定性**:不猜测不确定的数据,通过验证机制确保准确性
218
+ 3. **可组合性**:验证逻辑通过依赖注入提供
219
+ 4. **正确性优先**:内部处理不确定性,对外只返回可靠数据
220
+
221
+ ## License
222
+
223
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,393 @@
1
+ 'use strict';
2
+
3
+ var generativeAi = require('@google/generative-ai');
4
+
5
+ // src/adapters/gemini.ts
6
+
7
+ // src/processors/image.ts
8
+ function isUrl(input) {
9
+ return input.startsWith("http://") || input.startsWith("https://");
10
+ }
11
+ function isBase64(input) {
12
+ if (input.startsWith("data:")) {
13
+ return true;
14
+ }
15
+ return input.length > 100 && /^[A-Za-z0-9+/=]+$/.test(input);
16
+ }
17
+ function parseDataUri(dataUri) {
18
+ const match = dataUri.match(/^data:([^;,]+);base64,(.+)$/);
19
+ if (match) {
20
+ return {
21
+ mimeType: match[1],
22
+ data: match[2]
23
+ };
24
+ }
25
+ return {
26
+ mimeType: "image/jpeg",
27
+ data: dataUri
28
+ };
29
+ }
30
+ function getMimeTypeFromUrl(url) {
31
+ const lower = url.toLowerCase();
32
+ if (lower.includes(".png")) return "image/png";
33
+ if (lower.includes(".jpg") || lower.includes(".jpeg")) return "image/jpeg";
34
+ if (lower.includes(".webp")) return "image/webp";
35
+ if (lower.includes(".gif")) return "image/gif";
36
+ return "image/jpeg";
37
+ }
38
+ function processImage(image) {
39
+ if (Buffer.isBuffer(image)) {
40
+ return {
41
+ mimeType: "image/jpeg",
42
+ // 默认,实际中可能需要更精确的检测
43
+ data: image.toString("base64")
44
+ };
45
+ }
46
+ if (typeof image === "string") {
47
+ if (isUrl(image)) {
48
+ return {
49
+ mimeType: getMimeTypeFromUrl(image),
50
+ url: image
51
+ };
52
+ }
53
+ if (image.startsWith("data:")) {
54
+ const { mimeType, data } = parseDataUri(image);
55
+ return { mimeType, data };
56
+ }
57
+ if (isBase64(image)) {
58
+ return {
59
+ mimeType: "image/jpeg",
60
+ data: image
61
+ };
62
+ }
63
+ throw new Error("Unsupported image format: string is not a valid URL or base64");
64
+ }
65
+ throw new Error("Unsupported image input type");
66
+ }
67
+
68
+ // src/adapters/gemini.ts
69
+ function getGeminiConfig() {
70
+ const apiKey = process.env.GEMINI_API_KEY;
71
+ if (!apiKey) {
72
+ throw new Error(
73
+ "GEMINI_API_KEY environment variable is not set. Please set it to use the Gemini adapter."
74
+ );
75
+ }
76
+ const model = process.env.GEMINI_MODEL || "gemini-2.0-flash";
77
+ return { apiKey, model };
78
+ }
79
+ async function callGemini(image, prompt, useGrounding) {
80
+ const { apiKey, model } = getGeminiConfig();
81
+ const genAI = new generativeAi.GoogleGenerativeAI(apiKey);
82
+ const geminiModel = genAI.getGenerativeModel({ model });
83
+ const processedImage = processImage(image);
84
+ const contents = [];
85
+ if (processedImage.url) {
86
+ contents.push({
87
+ fileData: {
88
+ mimeType: processedImage.mimeType,
89
+ fileUri: processedImage.url
90
+ }
91
+ });
92
+ } else if (processedImage.data) {
93
+ contents.push({
94
+ inlineData: {
95
+ mimeType: processedImage.mimeType,
96
+ data: processedImage.data
97
+ }
98
+ });
99
+ }
100
+ contents.push({
101
+ text: prompt
102
+ });
103
+ try {
104
+ const requestParams = {
105
+ contents: [{ role: "user", parts: contents }]
106
+ };
107
+ if (useGrounding) ;
108
+ const result = await geminiModel.generateContent(requestParams);
109
+ const response = result.response;
110
+ const text = response.text();
111
+ if (!text) {
112
+ throw new Error("Gemini API returned empty response");
113
+ }
114
+ return text;
115
+ } catch (error) {
116
+ if (error instanceof Error) {
117
+ throw new Error(`Gemini API call failed: ${error.message}`);
118
+ }
119
+ throw new Error("Gemini API call failed with unknown error");
120
+ }
121
+ }
122
+ function getGeminiConfig2() {
123
+ const apiKey = process.env.GEMINI_API_KEY;
124
+ if (!apiKey) {
125
+ throw new Error(
126
+ "GEMINI_API_KEY environment variable is not set. Please set it to use the Gemini adapter."
127
+ );
128
+ }
129
+ const model = process.env.GEMINI_MODEL || "gemini-2.0-flash";
130
+ return { apiKey, model };
131
+ }
132
+ function buildVerificationPrompt(items) {
133
+ const itemList = items.map((item, index) => `${index + 1}. "${item.name}"`).join("\n");
134
+ return `\u8FD9\u662F\u4ECE Costco \u8D85\u5E02\u5C0F\u7968\u4E2D\u8BC6\u522B\u51FA\u7684\u5546\u54C1\u540D\u79F0\uFF0C\u90E8\u5206\u540D\u79F0\u53EF\u80FD\u4E0D\u5B8C\u6574\u6216\u6709\u7F29\u5199\u3002
135
+ \u8BF7\u4F7F\u7528 Google Search \u67E5\u627E\u5E76\u8865\u5168\u8FD9\u4E9B\u5546\u54C1\u7684\u5B8C\u6574\u6B63\u786E\u540D\u79F0\u3002
136
+
137
+ \u9700\u8981\u9A8C\u8BC1\u7684\u5546\u54C1\uFF1A
138
+ ${itemList}
139
+
140
+ \u9A8C\u8BC1\u65B9\u6CD5\u5EFA\u8BAE\uFF1A
141
+ - \u5728\u641C\u7D22\u5F15\u64CE\u4E2D\u8F93\u5165\uFF1A\u5546\u54C1\u539F\u540D + "Costco"\uFF08\u4F8B\u5982\uFF1A"CEMOI 6X Costco"\uFF09
142
+ - \u8FD9\u6837\u80FD\u66F4\u51C6\u786E\u5730\u627E\u5230 Costco \u9500\u552E\u7684\u5BF9\u5E94\u5546\u54C1
143
+ - \u6CE8\u610F\u786E\u8BA4\u5546\u54C1\u7684\u5305\u88C5\u89C4\u683C\uFF08\u5982\u6570\u91CF\u3001\u5BB9\u91CF\u7B49\uFF09
144
+
145
+ \u8BF7\u8FD4\u56DE JSON \u6570\u7EC4\u683C\u5F0F\uFF0C\u6BCF\u4E2A\u5546\u54C1\u5305\u542B\uFF1A
146
+ - index: \u5E8F\u53F7\uFF081-based\uFF09
147
+ - originalName: \u539F\u59CB\u540D\u79F0
148
+ - verifiedName: \u9A8C\u8BC1\u540E\u7684\u5B8C\u6574\u540D\u79F0\uFF08\u5982\u679C\u627E\u5230\uFF09
149
+ - found: \u662F\u5426\u627E\u5230\u5339\u914D\uFF08\u5E03\u5C14\u503C\uFF09
150
+
151
+ \u793A\u4F8B\u8F93\u51FA\uFF1A
152
+ [
153
+ {"index": 1, "originalName": "ORG MLK", "verifiedName": "Kirkland Signature Organic 2% Milk 1L", "found": true},
154
+ {"index": 2, "originalName": "CEM\u039F\u0399 6\u03A7", "verifiedName": "CEMOI 82% Dark Chocolate Bars, 6 \xD7 100 g", "found": true}
155
+ ]
156
+
157
+ \u53EA\u8FD4\u56DE JSON \u6570\u7EC4\uFF0C\u4E0D\u8981\u5176\u4ED6\u6587\u5B57\u3002`;
158
+ }
159
+ function parseVerificationResponse(responseText) {
160
+ try {
161
+ let cleaned = responseText.trim();
162
+ const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
163
+ if (codeBlockMatch) {
164
+ cleaned = codeBlockMatch[1].trim();
165
+ }
166
+ const parsed = JSON.parse(cleaned);
167
+ if (!Array.isArray(parsed)) {
168
+ throw new Error("Response is not an array");
169
+ }
170
+ return parsed;
171
+ } catch (error) {
172
+ console.error("Failed to parse verification response:", error);
173
+ return [];
174
+ }
175
+ }
176
+ async function batchVerifyItems(items) {
177
+ if (items.length === 0) {
178
+ return /* @__PURE__ */ new Map();
179
+ }
180
+ const { apiKey, model } = getGeminiConfig2();
181
+ const genAI = new generativeAi.GoogleGenerativeAI(apiKey);
182
+ const geminiModel = genAI.getGenerativeModel({ model });
183
+ const prompt = buildVerificationPrompt(items);
184
+ try {
185
+ const result = await geminiModel.generateContent({
186
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
187
+ tools: [{ googleSearch: {} }]
188
+ });
189
+ const response = result.response;
190
+ const text = response.text();
191
+ if (!text) {
192
+ console.warn("Verification returned empty response");
193
+ return /* @__PURE__ */ new Map();
194
+ }
195
+ const verificationResults = parseVerificationResponse(text);
196
+ const resultMap = /* @__PURE__ */ new Map();
197
+ verificationResults.forEach((result2) => {
198
+ if (result2.found && result2.verifiedName) {
199
+ resultMap.set(result2.originalName, result2.verifiedName);
200
+ }
201
+ });
202
+ return resultMap;
203
+ } catch (error) {
204
+ console.error("Batch verification failed:", error);
205
+ return /* @__PURE__ */ new Map();
206
+ }
207
+ }
208
+
209
+ // src/utils/prompt.ts
210
+ var EXTRACTION_PROMPT = `\u5206\u6790\u8FD9\u5F20\u8D2D\u7269\u5C0F\u7968\u56FE\u7247\uFF0C\u63D0\u53D6\u6240\u6709\u5546\u54C1\u4FE1\u606F\u3002
211
+
212
+ \u8F93\u51FA\u683C\u5F0F\u4E3A JSON \u6570\u7EC4\uFF0C\u6BCF\u4E2A\u5546\u54C1\u5305\u542B\uFF1A
213
+ - name: \u5546\u54C1\u540D\u79F0\uFF08\u5B57\u7B26\u4E32\uFF09
214
+ - price: \u5355\u4EF7\uFF08\u6570\u5B57\uFF09
215
+ - quantity: \u6570\u91CF\uFF08\u6570\u5B57\uFF0C\u9ED8\u8BA4 1\uFF09
216
+ - needsVerification: \u662F\u5426\u9700\u8981\u9A8C\u8BC1\uFF08\u5E03\u5C14\u503C\uFF09
217
+ - hasTax: \u662F\u5426\u542B\u7A0E\uFF08\u5E03\u5C14\u503C\uFF09
218
+ - taxAmount: \u7A0E\u989D\uFF08\u6570\u5B57\uFF0C\u53EF\u9009\uFF09
219
+
220
+ \u5173\u4E8E needsVerification \u7684\u5224\u65AD\u89C4\u5219\uFF1A
221
+ - \u5982\u679C\u5546\u54C1\u540D\u79F0\u662F\u7F29\u5199\u3001\u4E0D\u5B8C\u6574\u3001\u88AB\u622A\u65AD\u6216\u5B58\u5728\u6B67\u4E49\uFF0C\u8BBE\u4E3A true
222
+ - \u5982\u679C\u5546\u54C1\u540D\u79F0\u6E05\u6670\u5B8C\u6574\uFF0C\u8BBE\u4E3A false
223
+ - \u4E0D\u8981\u731C\u6D4B\u4E0D\u786E\u5B9A\u7684\u540D\u79F0\uFF0C\u800C\u662F\u4FDD\u7559\u539F\u6837\u5E76\u8BBE needsVerification \u4E3A true
224
+
225
+ **\u91CD\u8981\uFF1A\u9644\u52A0\u8D39\u7528\u5904\u7406\u89C4\u5219**
226
+ \u5BF9\u4E8E\u62BC\u91D1\uFF08Deposit\u3001deposit\u3001\u62BC\u91D1\u7B49\uFF09\u548C\u6298\u6263\uFF08TPD\u3001discount\u3001\u6298\u6263\u7B49\uFF09\u8FD9\u7C7B\u9644\u52A0\u8D39\u7528\uFF1A
227
+ - \u6DFB\u52A0\u989D\u5916\u5B57\u6BB5 isAttachment: true
228
+ - \u6DFB\u52A0 attachmentType: "deposit" \u6216 "discount"
229
+ - **\u91CD\u8981**\uFF1A\u5C06\u9644\u52A0\u8D39\u7528\u7D27\u8DDF\u5728\u5B83\u6240\u5C5E\u7684\u5546\u54C1\u540E\u9762\u6392\u5217
230
+ - \u7CFB\u7EDF\u4F1A\u81EA\u52A8\u5C06\u9644\u52A0\u8D39\u7528\u5408\u5E76\u5230\u5B83\u524D\u9762\u7684\u5546\u54C1\u4E2D
231
+ - \u8FD9\u4E9B\u9644\u52A0\u8D39\u7528\u4E0D\u4F1A\u4F5C\u4E3A\u72EC\u7ACB\u5546\u54C1\u8FD4\u56DE
232
+
233
+ \u5F52\u5C5E\u89C4\u5219\uFF08\u6309\u7167\u8FD9\u4E2A\u987A\u5E8F\u6392\u5217\uFF09\uFF1A
234
+ - \u5546\u54C1A
235
+ - \u5546\u54C1A\u7684\u62BC\u91D1\uFF08\u5982\u679C\u6709\uFF09
236
+ - \u5546\u54C1A\u7684\u6298\u6263\uFF08\u5982\u679C\u6709\uFF09
237
+ - \u5546\u54C1B
238
+ - \u5546\u54C1B\u7684\u62BC\u91D1\uFF08\u5982\u679C\u6709\uFF09
239
+ - ...
240
+
241
+ \u53EA\u8FD4\u56DE JSON \u6570\u7EC4\uFF0C\u4E0D\u8981\u5176\u4ED6\u6587\u5B57\u3002
242
+
243
+ \u793A\u4F8B\u8F93\u51FA\uFF1A
244
+ [
245
+ {"name": "\u6709\u673A\u725B\u5976 1L", "price": 12.5, "quantity": 1, "needsVerification": false, "hasTax": false},
246
+ {"name": "\u53EF\u53E3\u53EF\u4E50\u74F6\u88C5", "price": 3.5, "quantity": 2, "needsVerification": false, "hasTax": true, "taxAmount": 0.35},
247
+ {"name": "Deposit VL", "price": 0.5, "quantity": 2, "needsVerification": false, "hasTax": false, "isAttachment": true, "attachmentType": "deposit"},
248
+ {"name": "TPD", "price": -0.5, "quantity": 1, "needsVerification": false, "hasTax": false, "isAttachment": true, "attachmentType": "discount"},
249
+ {"name": "ORG BRD", "price": 8.0, "quantity": 1, "needsVerification": true, "hasTax": true, "taxAmount": 0.8}
250
+ ]`;
251
+
252
+ // src/processors/parser.ts
253
+ function extractJson(text) {
254
+ let cleaned = text.trim();
255
+ const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
256
+ if (codeBlockMatch) {
257
+ cleaned = codeBlockMatch[1].trim();
258
+ }
259
+ return cleaned;
260
+ }
261
+ function normalizeRawItem(raw) {
262
+ if (!raw || typeof raw !== "object") {
263
+ throw new Error("Invalid item: not an object");
264
+ }
265
+ if (typeof raw.name !== "string" || !raw.name) {
266
+ throw new Error("Invalid item: missing or invalid name field");
267
+ }
268
+ if (typeof raw.price !== "number" || raw.price < 0) {
269
+ throw new Error("Invalid item: missing or invalid price field");
270
+ }
271
+ return {
272
+ name: raw.name,
273
+ price: raw.price,
274
+ quantity: typeof raw.quantity === "number" ? raw.quantity : 1,
275
+ needsVerification: Boolean(raw.needsVerification),
276
+ hasTax: Boolean(raw.hasTax),
277
+ taxAmount: typeof raw.taxAmount === "number" ? raw.taxAmount : void 0,
278
+ deposit: typeof raw.deposit === "number" ? raw.deposit : void 0,
279
+ discount: typeof raw.discount === "number" ? raw.discount : void 0,
280
+ // 保留附加费用标记用于后续处理
281
+ isAttachment: raw.isAttachment === true ? true : void 0,
282
+ attachmentType: raw.attachmentType,
283
+ attachedTo: typeof raw.attachedTo === "number" ? raw.attachedTo : void 0
284
+ };
285
+ }
286
+ function mergeAttachments(items) {
287
+ const result = [];
288
+ let currentItem = null;
289
+ for (let i = 0; i < items.length; i++) {
290
+ const item = items[i];
291
+ if (!item.isAttachment) {
292
+ if (currentItem) {
293
+ result.push(currentItem);
294
+ }
295
+ currentItem = { ...item };
296
+ } else {
297
+ if (currentItem) {
298
+ if (item.attachmentType === "deposit") {
299
+ currentItem.deposit = (currentItem.deposit || 0) + item.price * item.quantity;
300
+ } else if (item.attachmentType === "discount") {
301
+ currentItem.discount = (currentItem.discount || 0) + item.price;
302
+ }
303
+ }
304
+ }
305
+ }
306
+ if (currentItem) {
307
+ result.push(currentItem);
308
+ }
309
+ return result.map((item) => {
310
+ const { isAttachment, attachmentType, attachedTo, ...cleanItem } = item;
311
+ return cleanItem;
312
+ });
313
+ }
314
+ function parseResponse(responseText) {
315
+ try {
316
+ const jsonText = extractJson(responseText);
317
+ const parsed = JSON.parse(jsonText);
318
+ if (!Array.isArray(parsed)) {
319
+ throw new Error("Response is not an array");
320
+ }
321
+ const items = parsed.map((raw, index) => {
322
+ try {
323
+ return normalizeRawItem(raw);
324
+ } catch (error) {
325
+ const message = error instanceof Error ? error.message : "Unknown error";
326
+ throw new Error(`Invalid item at index ${index}: ${message}`);
327
+ }
328
+ });
329
+ const mergedItems = mergeAttachments(items);
330
+ return mergedItems;
331
+ } catch (error) {
332
+ if (error instanceof Error) {
333
+ throw new Error(`Failed to parse LLM response: ${error.message}
334
+
335
+ Response:
336
+ ${responseText}`);
337
+ }
338
+ throw new Error(`Failed to parse LLM response with unknown error
339
+
340
+ Response:
341
+ ${responseText}`);
342
+ }
343
+ }
344
+
345
+ // src/extract.ts
346
+ async function extractReceiptItems(image, options) {
347
+ const responseText = await callGemini(image, EXTRACTION_PROMPT);
348
+ const parsedItems = parseResponse(responseText);
349
+ const itemsNeedingVerification = parsedItems.filter((item) => item.needsVerification);
350
+ if (options?.autoVerify && itemsNeedingVerification.length > 0) {
351
+ try {
352
+ const verificationMap = await batchVerifyItems(itemsNeedingVerification);
353
+ for (const item of parsedItems) {
354
+ if (item.needsVerification) {
355
+ const verifiedName = verificationMap.get(item.name);
356
+ if (verifiedName) {
357
+ item.name = verifiedName;
358
+ item.needsVerification = false;
359
+ }
360
+ }
361
+ }
362
+ } catch (error) {
363
+ console.error("Auto verification failed:", error);
364
+ }
365
+ }
366
+ if (options?.verifyCallback) {
367
+ const publicItems = parsedItems.map(({ needsVerification, ...item }) => item);
368
+ const verificationContext = {
369
+ rawText: responseText,
370
+ allItems: publicItems
371
+ };
372
+ for (const item of parsedItems) {
373
+ if (item.needsVerification) {
374
+ try {
375
+ const result = await options.verifyCallback(item.name, verificationContext);
376
+ if (result && result.verifiedName) {
377
+ item.name = result.verifiedName;
378
+ item.needsVerification = false;
379
+ }
380
+ } catch (error) {
381
+ console.error(`Verification failed for item "${item.name}":`, error);
382
+ }
383
+ }
384
+ }
385
+ }
386
+ const finalItems = parsedItems.map(({ needsVerification, ...item }) => item);
387
+ return finalItems;
388
+ }
389
+
390
+ exports.batchVerifyItems = batchVerifyItems;
391
+ exports.extractReceiptItems = extractReceiptItems;
392
+ //# sourceMappingURL=index.cjs.map
393
+ //# sourceMappingURL=index.cjs.map