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 +21 -0
- package/README.md +223 -0
- package/dist/index.cjs +393 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +123 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +390 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
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
|