Keep view mode across tabs and speed up recommendation pipeline

This commit is contained in:
b17301
2026-04-27 17:29:24 +09:00
parent 354d149245
commit b2a3802dff
7 changed files with 504 additions and 68 deletions
+219 -63
View File
@@ -145,7 +145,9 @@ _DASHBOARD_CACHE_TTL_SEC = 20
_SUGGEST_CACHE: dict[str, dict[str, Any]] = {}
_SUGGEST_CACHE_TTL_SEC = 20
_STATUS_ROWS_CACHE: dict[str, dict[str, Any]] = {}
_STATUS_ROWS_CACHE_TTL_SEC = 20
_STATUS_ROWS_CACHE_TTL_SEC = 300
_PAIR_RECOMMEND_CACHE: dict[str, dict[str, Any]] = {}
_PAIR_RECOMMEND_CACHE_TTL_SEC = 120
_STATUS_CACHE_WARMING: set[str] = set()
_STATUS_CACHE_WARMING_LOCK = threading.Lock()
@@ -1000,6 +1002,7 @@ def refresh_wehago_compare_data(engine: Any, source_root: Path | None = None) ->
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
return {
"scanned_files": summary.scanned_files,
"imported_files": summary.imported_files,
@@ -2189,6 +2192,7 @@ def save_recheck_review_rows(engine: Any, rows: list[dict[str, Any]]) -> int:
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
return len(normalized_rows)
@@ -2308,6 +2312,7 @@ def save_manual_pair_matches(
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
return len(rows_to_save)
@@ -2384,6 +2389,7 @@ def undo_last_action(engine: Any) -> dict[str, Any]:
_DASHBOARD_CACHE.clear()
_SUGGEST_CACHE.clear()
_STATUS_ROWS_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
return {"undone": True, "action_type": action_type, "affected": affected}
@@ -2416,6 +2422,14 @@ def _jaccard_similarity(left: Any, right: Any) -> float:
return (inter / union) if union else 0.0
def _jaccard_similarity_tokens(left_tokens: set[str], right_tokens: set[str]) -> float:
if not left_tokens or not right_tokens:
return 0.0
inter = len(left_tokens & right_tokens)
union = len(left_tokens | right_tokens)
return (inter / union) if union else 0.0
def _numeric_amount_for_side(row: dict[str, Any], side: str) -> float:
if side == "debit":
return parse_amount(row.get("ledger_debit") if "ledger_debit" in row else row.get("voucher_debit"))
@@ -2561,62 +2575,41 @@ def _collect_status_rows_for_workbench(
) -> dict[str, list[dict[str, Any]]]:
if start_year is None or end_year is None:
return {"ledger_only": [], "voucher_only": []}
if engine is not None:
init_wehago_compare_db(engine)
reviewed_keys: set[str] = set()
manual_pair_matches: list[dict[str, Any]] = []
if engine is not None:
with engine.begin() as conn:
reviewed_keys = get_saved_recheck_review_keys(conn, start_year, end_year)
manual_pair_matches = get_saved_manual_pair_matches(conn, start_year, end_year)
result: dict[str, list[dict[str, Any]]] = {"ledger_only": [], "voucher_only": []}
ledger_voucher_filter = normalize_text(ledger_voucher_no)
ledger_reason_filter = normalize_text(ledger_review_reason)
voucher_voucher_filter = normalize_text(voucher_voucher_no)
voucher_reason_filter = normalize_text(voucher_review_reason)
for year in range(start_year, end_year + 1):
bundle = discover_compare_result_bundle(year)
if not bundle:
continue
parsed = parse_compare_result_bundle(
str(bundle["ledger_result"]),
bundle["ledger_result"].stat().st_mtime,
str(bundle["voucher_result"]),
bundle["voucher_result"].stat().st_mtime,
year,
)
parsed = apply_saved_recheck_reviews(parsed, reviewed_keys)
parsed = apply_saved_manual_pair_matches(parsed, manual_pair_matches)
for row in parsed["ledger_only"]["rows"]:
if _filter_status_row(
row,
ledger_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
ledger_reason_filter,
):
result["ledger_only"].append(row)
for row in parsed["voucher_only"]["rows"]:
if _filter_status_row(
row,
voucher_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
voucher_reason_filter,
):
result["voucher_only"].append(row)
rows_by_status = _get_cached_status_rows_by_range(engine, start_year, end_year)
for row in rows_by_status["ledger_only"]:
if _filter_status_row(
row,
ledger_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
ledger_reason_filter,
):
result["ledger_only"].append(row)
for row in rows_by_status["voucher_only"]:
if _filter_status_row(
row,
voucher_voucher_filter,
"",
"",
"",
"",
"",
"",
"",
voucher_reason_filter,
):
result["voucher_only"].append(row)
return result
@@ -2630,6 +2623,23 @@ def recommend_pair_matches(
voucher_review_reason: str = "",
limit: int = 300,
) -> dict[str, Any]:
safe_limit = max(min(int(limit or 300), 1000), 1)
cache_key = "|".join(
[
str(start_year),
str(end_year),
normalize_text(ledger_voucher_no),
normalize_text(ledger_review_reason),
normalize_text(voucher_voucher_no),
normalize_text(voucher_review_reason),
str(safe_limit),
]
)
now = time.time()
cached = _PAIR_RECOMMEND_CACHE.get(cache_key)
if cached and (now - float(cached.get("ts", 0))) <= _PAIR_RECOMMEND_CACHE_TTL_SEC:
return cached["payload"]
dataset = _collect_status_rows_for_workbench(
engine,
start_year,
@@ -2642,7 +2652,10 @@ def recommend_pair_matches(
ledger_rows = dataset["ledger_only"]
voucher_rows = dataset["voucher_only"]
if not ledger_rows or not voucher_rows:
return {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}}
payload = {"pairs": [], "stats": {"ledger_rows": len(ledger_rows), "voucher_rows": len(voucher_rows), "recommended": 0, "auto_eligible": 0}}
_PAIR_RECOMMEND_CACHE.clear()
_PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload}
return payload
amount_index: dict[float, list[dict[str, Any]]] = {}
for voucher_row in voucher_rows:
@@ -2655,6 +2668,19 @@ def recommend_pair_matches(
continue
amount_index.setdefault(amount, []).append(voucher_row)
voucher_tokens_by_key: dict[str, tuple[set[str], set[str], set[str], str, str]] = {}
for voucher_row in voucher_rows:
voucher_key = clean(voucher_row.get("voucher_row_key"))
if not voucher_key:
continue
voucher_tokens_by_key[voucher_key] = (
_tokenize_for_similarity(voucher_row.get("voucher_account_name")),
_tokenize_for_similarity(voucher_row.get("voucher_vendor")),
_tokenize_for_similarity(voucher_row.get("voucher_desc")),
clean(voucher_row.get("voucher_account_code")),
clean(voucher_row.get("voucher_account_name")),
)
edge_candidates: list[dict[str, Any]] = []
for ledger_row in ledger_rows:
candidate_amounts = {
@@ -2673,23 +2699,149 @@ def recommend_pair_matches(
voucher_candidates.append(voucher_row)
if not voucher_candidates:
continue
scored_candidates: list[tuple[dict[str, Any], dict[str, Any]]] = []
ledger_key = clean(ledger_row.get("ledger_row_key"))
ledger_account_code = clean(ledger_row.get("ledger_account_code"))
ledger_account_tokens = _tokenize_for_similarity(ledger_row.get("ledger_account_name"))
ledger_vendor_tokens = _tokenize_for_similarity(ledger_row.get("ledger_vendor"))
ledger_desc_tokens = _tokenize_for_similarity(ledger_row.get("ledger_desc"))
side = _determine_primary_side(ledger_row)
if side == "debit":
ledger_amount = parse_amount(ledger_row.get("ledger_debit"))
elif side == "credit":
ledger_amount = parse_amount(ledger_row.get("ledger_credit"))
else:
ledger_amount = max(parse_amount(ledger_row.get("ledger_debit")), parse_amount(ledger_row.get("ledger_credit")))
top_row: dict[str, Any] | None = None
top_score: dict[str, Any] | None = None
second_best_score = -999.0
for voucher_row in voucher_candidates:
score_result = _score_pair_match(ledger_row, voucher_row)
voucher_key = clean(voucher_row.get("voucher_row_key"))
token_payload = voucher_tokens_by_key.get(voucher_key)
if not token_payload:
continue
voucher_account_tokens, voucher_vendor_tokens, voucher_desc_tokens, voucher_code, voucher_account_name = token_payload
score = 0.0
reasons: list[str] = []
hard_pass = True
if side == "debit":
voucher_amount = parse_amount(voucher_row.get("voucher_debit"))
elif side == "credit":
voucher_amount = parse_amount(voucher_row.get("voucher_credit"))
else:
voucher_amount = max(parse_amount(voucher_row.get("voucher_debit")), parse_amount(voucher_row.get("voucher_credit")))
amount_gap = abs(ledger_amount - voucher_amount)
if amount_gap < 0.5 and ledger_amount > 0:
score += 50
reasons.append("금액 일치")
elif amount_gap < 5 and ledger_amount > 0:
score += 35
reasons.append("금액 근접")
elif amount_gap < 100 and ledger_amount > 0:
score += 10
reasons.append("금액 유사")
else:
hard_pass = False
account_sim = _jaccard_similarity_tokens(ledger_account_tokens, voucher_account_tokens)
if ledger_account_code and voucher_code and ledger_account_code == voucher_code:
score += 22
reasons.append("계정코드 일치")
elif ledger_account_code and voucher_code and ledger_account_code[:4] == voucher_code[:4]:
score += 10
reasons.append("계정코드 대분류 일치")
else:
if account_sim >= 0.8:
score += 16
reasons.append("계정명 유사도 높음")
elif account_sim >= 0.55:
score += 8
reasons.append("계정명 유사")
else:
hard_pass = False
vendor_sim = _jaccard_similarity_tokens(ledger_vendor_tokens, voucher_vendor_tokens)
if vendor_sim >= 0.9:
score += 16
reasons.append("거래처 일치")
elif vendor_sim >= 0.65:
score += 10
reasons.append("거래처 유사")
elif vendor_sim >= 0.4:
score += 4
reasons.append("거래처 일부 유사")
else:
score -= 8
desc_sim = _jaccard_similarity_tokens(ledger_desc_tokens, voucher_desc_tokens)
if desc_sim >= 0.85:
score += 10
reasons.append("적요 매우 유사")
elif desc_sim >= 0.6:
score += 6
reasons.append("적요 유사")
elif desc_sim >= 0.35:
score += 2
ledger_date = _parse_iso_date(ledger_row.get("ledger_date"))
proof_date = _parse_iso_date(voucher_row.get("proof_date"))
if ledger_date and proof_date:
day_gap = abs((ledger_date - proof_date).days)
if day_gap <= 3:
score += 8
reasons.append("일자 근접")
elif day_gap <= 10:
score += 4
elif day_gap <= 45:
score += 1
else:
score -= 6
confidence = "low"
if score >= 88:
confidence = "high"
elif score >= 72:
confidence = "medium"
score_result = {
"score": round(score, 2),
"confidence_level": confidence,
"reason": ", ".join(reasons[:4]),
"auto_eligible": bool(
hard_pass
and score >= 88
and amount_gap < 0.5
and (
(ledger_account_code and voucher_code and ledger_account_code == voucher_code)
or account_sim >= 0.8
)
and vendor_sim >= 0.65
),
"hard_pass": hard_pass,
"vendor_similarity": round(vendor_sim, 4),
"account_similarity": round(account_sim, 4),
"amount_gap": round(amount_gap, 2),
}
if not score_result["hard_pass"] or score_result["score"] < 72:
continue
scored_candidates.append((voucher_row, score_result))
if not scored_candidates:
if top_score is None or score_result["score"] > float(top_score["score"]):
second_best_score = float(top_score["score"]) if top_score else second_best_score
top_row = voucher_row
top_score = score_result
elif score_result["score"] > second_best_score:
second_best_score = score_result["score"]
if top_row is None or top_score is None:
continue
scored_candidates.sort(key=lambda item: item[1]["score"], reverse=True)
top_candidate, top_score = scored_candidates[0]
second_score = scored_candidates[1][1]["score"] if len(scored_candidates) > 1 else -999
if top_score["score"] - second_score < 6:
if top_score["score"] - second_best_score < 6:
continue
edge_candidates.append(
{
"ledger_row": ledger_row,
"voucher_row": top_candidate,
"voucher_row": top_row,
"score": top_score["score"],
"confidence_level": top_score["confidence_level"],
"reason": top_score["reason"],
@@ -2730,10 +2882,10 @@ def recommend_pair_matches(
"voucher_row": edge["voucher_row"],
}
)
if len(picked) >= max(min(int(limit or 300), 1000), 1):
if len(picked) >= safe_limit:
break
return {
payload = {
"pairs": picked,
"stats": {
"ledger_rows": len(ledger_rows),
@@ -2743,6 +2895,9 @@ def recommend_pair_matches(
"high_confidence": sum(1 for row in picked if row["confidence_level"] == "high"),
},
}
_PAIR_RECOMMEND_CACHE.clear()
_PAIR_RECOMMEND_CACHE[cache_key] = {"ts": now, "payload": payload}
return payload
def save_recommended_pair_matches(
@@ -3260,6 +3415,7 @@ def get_status_field_suggestions(
}
)
_SUGGEST_CACHE.clear()
_PAIR_RECOMMEND_CACHE.clear()
_SUGGEST_CACHE[cache_key] = {"ts": now, "rows": all_rows}
safe_offset = max(int(offset or 0), 0)