Refactor: enhance LLM prompt for total amount accuracy and prevent misidentification

This commit is contained in:
Proclean Agent
2026-04-17 15:25:40 +08:00
parent a135f6dd72
commit b9c95e2a3c

View File

@@ -6,6 +6,7 @@ import time
import re
import json
import pandas as pd
import logging
from pathlib import Path
from datetime import datetime
@@ -17,6 +18,9 @@ FAILED_DIR = TMP_DIR / "failed"
COMPLETED_BASE = BASE_DIR / "已完成入貨單"
QUEUE_FILE = TMP_DIR / "work_queue.json"
# Logging Configuration
LOG_FILE = Path("/vol1/@apphome/trim.openclaw/data/workspace/incoming_goods_engine.log")
# Files for lookups
PAYMENT_METHODS_FILE = BASE_DIR / "供應商結算方式.xlsx"
PAYMENT_METHODS_JSON = Path("/vol1/@apphome/trim.openclaw/data/workspace/memory/payment_methods.json")
@@ -38,6 +42,18 @@ LLM_MODEL = "deepseek-chat"
SUBMIT_API_URL = "https://www.various-stars.xyz/prod-api/finance/purchaseOrder/add"
SUBMIT_API_KEY = "sk-finance-2026-d455sd5a5d5s4d4gtyt7"
def setup_logging():
"""Configures logging to both file and console."""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_FILE, encoding='utf-8'),
logging.StreamHandler()
]
)
logging.info("=== Incoming Goods Engine Started ===")
def setup_dirs():
"""Ensure required directory structure exists."""
TMP_DIR.mkdir(parents=True, exist_ok=True)
@@ -53,7 +69,7 @@ def get_pdf_page_count(file_path):
reader = PdfReader(file_path)
return len(reader.pages)
except Exception as e:
print(f" [!] Error reading PDF page count: {e}")
logging.error(f"Error reading PDF page count for {file_path.name}: {e}")
return 1
def sync_payment_methods_json():
@@ -70,14 +86,16 @@ def sync_payment_methods_json():
PAYMENT_METHODS_JSON.parent.mkdir(parents=True, exist_ok=True)
with open(PAYMENT_METHODS_JSON, 'w', encoding='utf-8') as f:
json.dump(methods, f, ensure_ascii=False, indent=2)
logging.info("Payment methods successfully synced from Excel to JSON.")
return True
except Exception as e:
print(f"Failed to sync payment methods: {e}")
logging.error(f"Failed to sync payment methods: {e}")
return False
def load_payment_methods():
"""Loads supplier payment methods with Dual-Track Sync."""
if not PAYMENT_METHODS_FILE.exists():
logging.warning("Payment methods Excel file not found.")
return {}
needs_sync = False
@@ -87,17 +105,19 @@ def load_payment_methods():
needs_sync = True
if needs_sync:
logging.info("Payment methods cache out of date. Syncing...")
sync_payment_methods_json()
try:
with open(PAYMENT_METHODS_JSON, 'r', encoding='utf-8') as f:
return json.load(f)
except:
except Exception as e:
logging.error(f"Error loading payment methods JSON: {e}")
return {}
def sync_accounting_from_api():
"""Fetches the latest accounting mapping from the central API."""
print("[*] Attempting to sync accounting database from API...")
logging.info("Attempting to sync accounting database from API...")
headers = {"X-API-Key": SUBMIT_API_KEY, "Content-Type": "application/json"}
try:
response = requests.get(SYNC_ACCOUNTING_API_URL, headers=headers, timeout=15)
@@ -105,7 +125,6 @@ def sync_accounting_from_api():
res_data = response.json()
if res_data.get("code") == 200:
items = res_data.get("data", [])
# Transform to {material_name: account_subject} - skip incomplete items
mapping = {}
for item in items:
m_name = item.get('material_name')
@@ -116,26 +135,26 @@ def sync_accounting_from_api():
ACCOUNTING_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(ACCOUNTING_DB_PATH, 'w', encoding='utf-8') as f:
json.dump(mapping, f, ensure_ascii=False, indent=2)
print(f" [+] API Sync Successful. {len(mapping)} valid mappings updated.")
logging.info(f"API Sync Successful. {len(mapping)} valid mappings updated.")
return True
else:
print(f" [!] API returned error code: {res_data.get('code')}")
logging.error(f"API returned error code: {res_data.get('code')}")
else:
print(f" [!] API request failed. Status: {response.status_code}")
logging.error(f"API request failed. Status: {response.status_code}")
except Exception as e:
print(f" [!] API Sync Error: {e}")
logging.error(f"API Sync Error: {e}")
return False
def sync_accounting_from_excel():
"""Fallback: Converts local Excel to JSON cache."""
print("[*] Attempting to sync accounting database from local Excel...")
logging.info("Attempting to sync accounting database from local Excel fallback...")
try:
if not ACCOUNTING_EXCEL_FILE.exists():
logging.error(f"Accounting Excel file not found at {ACCOUNTING_EXCEL_FILE}")
return False
df = pd.read_excel(ACCOUNTING_EXCEL_FILE)
mapping = {}
# Expected columns: ['會計科目', '貨品名稱']
for _, row in df.iterrows():
subject = str(row['會計科目']).strip()
material = str(row['貨品名稱']).strip()
@@ -145,9 +164,302 @@ def sync_accounting_from_excel():
ACCOUNTING_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(ACCOUNTING_DB_PATH, 'w', encoding='utf-8') as f:
json.dump(mapping, f, ensure_ascii=False, indent=2)
print(f" [+] Excel Sync Successful. {len(mapping)} mappings updated.")
logging.info(f"Excel Sync Successful. {len(mapping)} mappings updated.")
return True
except Exception as e:
logging.error(f"Excel Sync Error: {e}")
return False
def load_accounting_db():
"""Loads the accounting subject mapping with Hybrid Sync (API -> Excel fallback)."""
if sync_accounting_from_api():
pass
elif ACCOUNTING_EXCEL_FILE.exists():
if not ACCOUNTING_DB_PATH.exists() or \
ACCOUNTING_EXCEL_FILE.stat().st_mtime > ACCOUNTING_DB_PATH.stat().st_mtime:
sync_accounting_from_excel()
if not ACCOUNTING_DB_PATH.exists():
logging.error("No accounting database found (API and Excel failed).")
return {}
try:
with open(ACCOUNTING_DB_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logging.error(f"Error loading accounting JSON: {e}")
return {}
def call_ocr_with_retry(file_path, retries=3, interval=3):
"""Calls the OCR API with a retry mechanism and dynamic polling time based on page count."""
file_ext = file_path.suffix.lower()
file_type = "pdf" if file_ext == ".pdf" else "image"
page_count = get_pdf_page_count(file_path)
max_wait_seconds = 30 + (page_count * 50)
max_polls = max(1, max_wait_seconds // 2)
logging.info(f"OCR Task: {file_path.name} | Pages: {page_count} | Max Wait: {max_wait_seconds}s")
try:
with open(file_path, "rb") as f:
encoded_string = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logging.error(f"Error reading file for OCR ({file_path.name}): {e}")
return None
payload = {"file_base64": encoded_string, "file_type": file_type, "output_type": "ocr_only"}
headers = {"X-API-Key": OCR_API_KEY, "Content-Type": "application/json"}
for attempt in range(retries):
try:
response = requests.post(OCR_API_URL, json=payload, headers=headers, timeout=30)
if response.status_code == 200:
data = response.json()
task_id = data.get("task_id")
if not task_id:
logging.error(f"OCR submission failed: No task_id returned. Response: {data}")
return None
logging.info(f" [*] OCR Task {task_id} submitted. Polling...")
for poll_count in range(max_polls):
time.sleep(2)
res = requests.get(f"{OCR_API_URL.rsplit('/', 1)[0]}/result/{task_id}", headers=headers, timeout=10)
if res.status_code == 200:
res_data = res.json()
if res_data.get("status") == "completed":
ocr_text = res_data.get("result", {}).get("ocr_text", "")
logging.info(f" [+] OCR completed for {file_path.name}. Text length: {len(ocr_text)}")
logging.debug(f"RAW OCR TEXT for {file_path.name}:\n{ocr_text}")
return ocr_text
elif res_data.get("status") == "failed":
logging.error(f" [!] OCR task {task_id} failed on server.")
return None
elif res.status_code != 202:
if res.status_code >= 500:
logging.warning(f" [!] OCR server error ({res.status_code}). Retrying poll...")
time.sleep(5)
continue
logging.error(f" [!] OCR polling error: Status {res.status_code}")
return None
logging.error(f" [!] OCR polling timeout for task {task_id}.")
return None
else:
logging.error(f"OCR submission failed. Status: {response.status_code} | Response: {response.text}")
except Exception as e:
logging.error(f"Attempt {attempt + 1} error: {e}")
time.sleep(interval)
return None
def chunk_ocr_text(ocr_text, pages_per_chunk=5):
"""Splits OCR text into chunks based on [Page X] markers using regex lookahead."""
chunks = re.split(r'(?=\[Page \d+\])', ocr_text)
chunks = [c.strip() for c in chunks if c.strip()]
final_chunks = []
for i in range(0, len(chunks), pages_per_chunk):
chunk_content = "\n".join(chunks[i : i + pages_per_chunk])
final_chunks.append(chunk_content)
return final_chunks
def parse_ocr_chunks(chunks, accounting_db, location):
"""Sends text chunks to LLM to extract invoice data."""
all_invoices = []
mapping_str = "\n".join([f"- {k} -> {v}" for k, v in accounting_db.items()])
system_prompt = f"""
You are a high-precision data extraction specialist for a finance system.
Your task is to parse OCR text from an incoming goods invoice into a strict JSON format.
### RULES:
1. **LANGUAGE CONVERSION (STRICT)**:
- Every single field in the JSON (supplierName, materialName, etc.) MUST be in **TRADITIONAL CHINESE (繁體中文)**.
2. **SUPPLIER IDENTIFICATION**:
- The "supplierName" must be the clean COMPANY NAME. Strip out document type descriptors like "發貨單", "Invoice", etc.
3. **DATE**:
- Identify the "Order Date". Format: `YYYY-MM-DD`.
4. **LOCATION**: "receiveLocation" MUST be exactly "{location}".
5. **ACCOUNTING SUBJECT**:
- Use the provided [MAPPING] to map items.
- IMPORTANT: If you are uncertain about an 'accountSubject', append '**' to the end of the string (e.g., "Food Supplies**").
6. **OUTPUT**: Return ONLY a valid JSON object with a key "items" containing an array of invoice objects.
### [MAPPING]:
{mapping_str}
"""
headers = {"Authorization": f"Bearer {LLM_API_KEY}", "Content-Type": "application/json"}
for i, chunk in enumerate(chunks):
if not chunk: continue
payload = {
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"OCR Text:\n{chunk}"}
],
"response_format": { "type": "json_object" },
"temperature": 0.1
}
try:
logging.info(f" [*] Sending chunk {i+1}/{len(chunks)} to LLM...")
res = requests.post(LLM_API_URL, json=payload, headers=headers, timeout=60)
res.raise_for_status()
content = res.json()['choices'][0]['message']['content'].strip()
logging.debug(f"LLM Response for chunk {i+1}:\n{content}")
data = json.loads(content)
all_invoices.extend(data.get("items", []))
except Exception as e:
logging.error(f" [!] LLM chunk error (chunk {i+1}): {e}")
return all_invoices
def submit_to_api(invoice):
"""Submits a single invoice payload to the Finance API."""
headers = {"X-API-Key": SUBMIT_API_KEY, "Content-Type": "application/json"}
try:
res = requests.post(SUBMIT_API_URL, json=invoice, headers=headers, timeout=30)
if res.status_code == 200 and res.json().get("code") == 200:
logging.info(f" [+] ERP Submission Successful for {invoice.get('supplierName', 'Unknown')}.")
return True
else:
logging.error(f" [!] ERP Submission Failed for {invoice.get('supplierName', 'Unknown')}. Status: {res.status_code} | Response: {res.text}")
return False
except Exception as e:
logging.error(f" [!] ERP Submission Error for {invoice.get('supplierName', 'Unknown')}: {e}")
return False
def ingestion_phase():
"""Phase 1: Scan directories, move files to tmp, and build work_queue.json."""
logging.info("=== [PHASE 1] Ingestion Phase ===")
setup_dirs()
queue = []
if QUEUE_FILE.exists():
try:
with open(QUEUE_FILE, 'r', encoding='utf-8') as f:
queue = json.load(f)
logging.info(f"[*] Resuming from existing queue ({len(queue)} tasks).")
return queue
except Exception as e:
logging.error(f"Error reading existing queue: {e}")
logging.info(f"[*] Scanning {BASE_DIR}...")
for loc_dir in [d for d in BASE_DIR.iterdir() if d.is_dir() and d.name not in EXCLUDE_DIRS]:
loc_name = loc_dir.name
for original_file in loc_dir.glob("*"):
if not original_file.is_file() or original_file.suffix.lower() not in ['.pdf', '.jpg', '.jpeg', '.png']:
continue
if (time.time() - original_file.stat().st_mtime) < 120:
continue
tmp_path = TMP_DIR / f"{loc_name}_{original_file.name}"
try:
shutil.move(str(original_file), str(tmp_path))
queue.append({"original_name": original_file.name, "location": loc_name, "tmp_path": str(tmp_path)})
logging.info(f" [+] Ingested: {original_file.name} -> {tmp_path.name}")
except Exception as e:
logging.error(f" [!] Error ingesting {original_file.name}: {e}")
with open(QUEUE_FILE, 'w', encoding='utf-8') as f:
json.dump(queue, f, ensure_ascii=False, indent=2)
return queue
def execution_phase(queue, full_db, payment_methods):
"""Phase 2: Process the queue with logical chunking and all-or-nothing archiving."""
logging.info("\n=== [PHASE 2] Execution Phase ===")
while queue:
task = queue.pop(0)
tmp_path = Path(task['tmp_path'])
loc_name = task['location']
orig_name = task['original_name']
logging.info(f"[Processing] {orig_name} (Location: {loc_name})")
if not tmp_path.exists():
logging.warning(f" [!] File missing from tmp: {tmp_path}. Skipping task.")
continue
# 1. OCR
ocr_text = call_ocr_with_retry(tmp_path)
if not ocr_text:
logging.error(f" [!] OCR failed for {orig_name}. Moving to failed.")
shutil.move(str(tmp_path), FAILED_DIR / tmp_path.name)
with open(QUEUE_FILE, 'w', encoding='utf-8') as f: json.dump(queue, f, ensure_ascii=False, indent=2)
continue
# 2. Chunking & Parsing
chunks = chunk_ocr_text(ocr_text)
logging.info(f" [*] Split into {len(chunks)} chunk(s).")
invoices = parse_ocr_chunks(chunks, full_db, loc_name)
logging.info(f" [*] Extracted {len(invoices)} invoices.")
if not invoices:
logging.error(f" [!] No invoices found in OCR text for {orig_name}. Moving to failed.")
shutil.move(str(tmp_path), FAILED_DIR / tmp_path.name)
with open(QUEUE_FILE, 'w', encoding='utf-8') as f: json.dump(queue, f, ensure_ascii=False, indent=2)
continue
# 3. Submission (All-or-nothing)
all_success = True
for inv in invoices:
supplier = inv.get("supplierName", "")
inv["paymentMethod"] = payment_methods.get(supplier, "現金找付")
if not submit_to_api(inv):
all_success = False
break
if all_success:
# Archive with date prefix for better organization
date_str = datetime.now().strftime("%Y%m%d")
dest_dir = COMPLETED_BASE / loc_name / date_str
dest_dir.mkdir(parents=True, exist_ok=True)
new_filename = f"{date_str}_{orig_name}"
if tmp_path.exists():
shutil.move(str(tmp_path), dest_dir / new_filename)
logging.info(f" [✅] Success! Archived to {dest_dir}/{new_filename}")
else:
logging.warning(f" [⚠️] Success, but file was missing from tmp: {tmp_path}")
else:
logging.error(f" [❌] One or more invoices failed submission for {orig_name}. Moving to failed.")
if tmp_path.exists():
shutil.move(str(tmp_path), FAILED_DIR / tmp_path.name)
else:
logging.warning(f" [⚠️] Failed, but file was already missing from tmp: {tmp_path}")
# Update queue file
with open(QUEUE_FILE, 'w', encoding='utf-8') as f:
json.dump(queue, f, ensure_ascii=False, indent=2)
def run_process():
setup_logging()
setup_dirs()
# Load accounting db using Hybrid Sync
full_db = load_accounting_db()
payment_methods = load_payment_methods()
queue = ingestion_phase()
if queue:
execution_phase(queue, full_db, payment_methods)
else:
logging.info("No tasks to process.")
if not queue and QUEUE_FILE.exists():
try:
os.remove(QUEUE_FILE)
logging.info("Work queue cleared.")
except: pass
logging.info("=== Engine Process Finished ===")
if __name__ == "__main__":
run_process()
:
print(f" [!] Excel Sync Error: {e}")
return False
@@ -384,15 +696,24 @@ def execution_phase(queue, full_db, payment_methods):
break
if all_success:
# Archive
# Archive with date prefix for better organization
date_str = datetime.now().strftime("%Y%m%d")
dest_dir = COMPLETED_BASE / loc_name / date_str
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(tmp_path), dest_dir / orig_name)
print(f" [✅] Success! Archived to {dest_dir}")
new_filename = f"{date_str}_{orig_name}"
if tmp_path.exists():
shutil.move(str(tmp_path), dest_dir / new_filename)
print(f" [✅] Success! Archived to {dest_dir}/{new_filename}")
else:
print(f" [⚠️] Success, but file was missing from tmp: {tmp_path}")
else:
print(f" [❌] One or more invoices failed. Moving file to failed.")
shutil.move(str(tmp_path), FAILED_DIR / tmp_path.name)
if tmp_path.exists():
shutil.move(str(tmp_path), FAILED_DIR / tmp_path.name)
else:
print(f" [⚠️] Failed, but file was already missing from tmp: {tmp_path}")
# Update queue file
with open(QUEUE_FILE, 'w', encoding='utf-8') as f: