feat: 商品记忆库 — 从OCR结果学习,逐步替代OCR识别

- 扩展 product_db.py: schema迁移(specification/source/confidence/usage_count/last_seen)
  + 学习逻辑(learn_from_product)、置信度系统、批量查询、导入导出、云端同步
- 注入处理管线: processor.py 在提取产品后调用 _apply_memory() 用记忆补全OCR
  + _is_spec_suspicious() 检测OCR规格质量,处理完后自动学习
- order_service.py 创建共享 ProductDatabase 实例
- dialog_utils.py 新增商品记忆库云端同步条目
- 新建 memory_editor.py: Treeview查看/编辑/搜索/删除/重新导入
- main_window.py 系统设置区新增"商品记忆库"按钮
- build_exe.py 添加 memory_editor 到 hidden_imports
@
This commit is contained in:
2026-05-05 02:40:48 +08:00
parent 5cf9a98d9a
commit d267a1d1fa
8 changed files with 656 additions and 44 deletions
+2
View File
@@ -28,6 +28,7 @@ from .action_handlers import (
merge_orders_with_status, process_excel_file_with_status,
process_dropped_file,
)
from .memory_editor import show_memory_editor
from .config_dialog import show_config_dialog
from .barcode_editor import edit_barcode_mappings
from .shortcuts import bind_keyboard_shortcuts
@@ -256,6 +257,7 @@ def _create_right_panel(content_frame, theme, log_text, root):
create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, ConfigManager()), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "条码映射", lambda: edit_barcode_mappings(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "云端同步", lambda: show_cloud_sync_dialog(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "商品记忆库", lambda: show_memory_editor(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
def _setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar):
+198
View File
@@ -0,0 +1,198 @@
"""商品记忆库查看/编辑对话框"""
import os
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
from app.config.settings import ConfigManager
from app.core.db.product_db import ProductDatabase
from .ui_widgets import center_window
def _get_product_db():
cfg = ConfigManager()
db_path = cfg.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(cfg, 'get_path') else 'data/product_cache.db'
tpl_folder = cfg.get('Paths', 'template_folder', fallback='templates')
item_data = cfg.get('Templates', 'item_data', fallback='商品资料.xlsx')
tpl_path = os.path.join(tpl_folder, item_data)
return ProductDatabase(db_path, tpl_path)
def show_memory_editor(root):
"""显示商品记忆库编辑器"""
db = _get_product_db()
dlg = tk.Toplevel(root)
dlg.title("商品记忆库")
dlg.geometry("950x520")
center_window(dlg)
# ── 顶部搜索栏 ──
top = ttk.Frame(dlg)
top.pack(fill=tk.X, padx=8, pady=(8, 4))
ttk.Label(top, text="搜索:").pack(side=tk.LEFT)
search_var = tk.StringVar()
search_entry = ttk.Entry(top, textvariable=search_var, width=30)
search_entry.pack(side=tk.LEFT, padx=4)
# ── 统计标签 ──
stats_label = ttk.Label(top, text="")
stats_label.pack(side=tk.RIGHT)
# ── Treeview ──
columns = ("barcode", "name", "specification", "unit", "price", "source", "confidence", "usage_count", "last_seen")
tree = ttk.Treeview(dlg, columns=columns, show="headings", height=18)
headers = {
"barcode": ("条码", 120),
"name": ("名称", 180),
"specification": ("规格", 80),
"unit": ("单位", 50),
"price": ("单价", 70),
"source": ("来源", 80),
"confidence": ("置信度", 60),
"usage_count": ("使用次数", 70),
"last_seen": ("最后使用", 140),
}
for col, (text, width) in headers.items():
tree.heading(col, text=text)
tree.column(col, width=width, anchor="center")
# 置信度颜色标签
tree.tag_configure("high", foreground="#28a745") # >= 80 绿
tree.tag_configure("medium", foreground="#ffc107") # 50-79 黄
tree.tag_configure("low", foreground="#dc3545") # < 50 红
scrollbar = ttk.Scrollbar(dlg, orient=tk.VERTICAL, command=tree.yview)
tree.configure(yscrollcommand=scrollbar.set)
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0), pady=4)
scrollbar.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 8), pady=4)
# ── 数据加载 ──
all_records = []
def load_data(filter_text=""):
nonlocal all_records
all_records = db.get_all_memories()
tree.delete(*tree.get_children())
filtered = all_records
if filter_text:
ft = filter_text.lower()
filtered = [r for r in all_records
if ft in str(r.get('barcode', '')).lower()
or ft in str(r.get('name', '')).lower()]
for r in filtered:
conf = r.get('confidence', 0) or 0
tag = "high" if conf >= 80 else ("medium" if conf >= 50 else "low")
last_seen = r.get('last_seen', '') or ''
if last_seen and len(last_seen) > 16:
last_seen = last_seen[:16]
source_display = {
'template': '模板',
'ocr': 'OCR',
'user_confirmed': '手动',
}.get(r.get('source', ''), r.get('source', ''))
tree.insert("", tk.END, values=(
r.get('barcode', ''),
r.get('name', ''),
r.get('specification', ''),
r.get('unit', ''),
f"{r.get('price', 0):.2f}" if r.get('price') else '',
source_display,
conf,
r.get('usage_count', 0) or 0,
last_seen,
), tags=(tag,))
stats_label.config(text=f"{len(filtered)} / {len(all_records)}")
def on_search(*_):
load_data(search_var.get())
search_var.trace_add("write", on_search)
# ── 按钮区 ──
btn_frame = ttk.Frame(dlg)
btn_frame.pack(fill=tk.X, padx=8, pady=(0, 8))
def edit_selected():
sel = tree.selection()
if not sel:
messagebox.showwarning("提示", "请先选择一条记录")
return
item = tree.item(sel[0])
vals = item['values']
barcode = vals[0]
# 弹出编辑对话框
edit_dlg = tk.Toplevel(dlg)
edit_dlg.title(f"编辑: {barcode}")
edit_dlg.geometry("380x260")
center_window(edit_dlg)
fields = [
("名称", "name", vals[1]),
("规格", "specification", vals[2]),
("单位", "unit", vals[3]),
("单价", "price", vals[4]),
]
entries = {}
for i, (label, key, val) in enumerate(fields):
ttk.Label(edit_dlg, text=label).grid(row=i, column=0, sticky='w', padx=8, pady=4)
var = tk.StringVar(value=str(val) if val else '')
ttk.Entry(edit_dlg, textvariable=var, width=30).grid(row=i, column=1, padx=8, pady=4)
entries[key] = var
def save_edit():
updates = {}
for key, var in entries.items():
v = var.get().strip()
if key == 'price':
try:
updates[key] = float(v) if v else 0
except ValueError:
updates[key] = 0
else:
updates[key] = v
db.update_memory(barcode, updates)
edit_dlg.destroy()
load_data(search_var.get())
ttk.Button(edit_dlg, text="保存", command=save_edit).grid(row=len(fields), column=0, columnspan=2, pady=12)
def delete_selected():
sel = tree.selection()
if not sel:
messagebox.showwarning("提示", "请先选择一条记录")
return
item = tree.item(sel[0])
barcode = item['values'][0]
if messagebox.askyesno("确认删除", f"确定要删除条码 {barcode} 的记忆记录吗?"):
db.delete_memory(barcode)
load_data(search_var.get())
def reimport_template():
if messagebox.askyesno("确认", "重新从商品资料导入将重置所有模板商品的置信度为100,确定继续吗?"):
count = db.reimport()
messagebox.showinfo("完成", f"已重新导入 {count} 条记录")
load_data(search_var.get())
ttk.Button(btn_frame, text="编辑", command=edit_selected).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="删除", command=delete_selected).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="重新导入模板", command=reimport_template).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="刷新", command=lambda: load_data(search_var.get())).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="关闭", command=dlg.destroy).pack(side=tk.RIGHT, padx=4)
# 双击编辑
tree.bind("<Double-1>", lambda e: edit_selected())
# 初始加载
load_data()