Files
routesapi/app/services/vector/delivery_history_store.py
2026-07-06 15:15:51 +05:30

857 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Delivery History Store
======================
Pattern-first vector rider lookup using recent delivery history.
Works on any platform — no faiss-cpu dependency.
Data source (config `pattern_source`)
--------------------------------------
"csv" (legacy) — built from delivery_details.csv, only refreshed when a
human re-exports it and calls POST /ml/reload-history.
"db" (default going forward) — built from the nearledb mirror the ETA-sync
agent already maintains (delivery_raw), rebuilt
automatically every sync cycle via rebuild_from_records().
No manual step, no extra database load.
How it works
------------
Records (however sourced) are turned into two structures:
1. PATTERN TABLE (primary, O(1) lookup)
City divided into ~1.1 km grid cells (round coords to 2 d.p.).
For every (kitchen, zone_cell) pair we count how many times each
rider delivered there. A "clear pattern" = one rider owns ≥ 60 %
of all deliveries in that cell AND ≥ 3 total deliveries.
From the 3 500-row CSV:
• 177 unique (kitchen, zone) cells
• 121 clear dominant-rider patterns (68 %)
• 42 cells where 1 rider owns 100 %
2. VECTOR INDEX (fallback, ~0.5 ms)
Pure-NumPy brute-force L2 search over 4D vectors:
[pickuplat, pickuplon, deliverylat, deliverylong]
Used ONLY when the pattern table has no clear answer.
If faiss-cpu is installed it is used instead of NumPy (faster
for very large indexes), but NumPy is the default.
Disk persistence
----------------
ml_data/faiss_history/
delivery_history_vectors.npy float32 (N, 4) array
delivery_history_records.pkl list of record dicts
delivery_history.meta JSON: csv_mtime, counts
Priority chain in the assignment endpoint:
1. Pattern table (dominant rider for this kitchen + delivery zone)
2. Vector K-NN (nearest-neighbour vote, fallback for new areas)
3. rider_preferences.py hard lock
4. VRP proximity solver
"""
import csv
import json
import logging
import os
import pickle
from collections import Counter, defaultdict
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
CSV_PATH = os.getenv("DELIVERY_HISTORY_CSV", "delivery_details.csv")
CORRECTIONS_PATH = os.getenv("DELIVERY_CORRECTIONS_CSV", "delivery_corrections.csv")
# Each correction record is already written N times in the corrections CSV
# (currently 5× per unique delivery) so they naturally outweigh noisy history.
# Setting CORRECTION_WEIGHT > 1 here adds a further runtime multiplier on top.
_CORRECTION_WEIGHT = int(os.getenv("CORRECTION_WEIGHT", "1"))
_STORE_DIR = os.getenv("FAISS_HISTORY_DIR", "ml_data/faiss_history")
def _paths_for(source: str) -> Tuple[str, str, str]:
"""
Disk cache paths, namespaced by source ("csv" or "db"). Namespaced so that
flipping `pattern_source` at runtime (e.g. to debug, or to roll back)
never clobbers the other mode's cached snapshot — each mode keeps its own
independent copy on disk.
"""
suffix = "" if source == "csv" else f".{source}"
return (
os.path.join(_STORE_DIR, f"delivery_history_vectors{suffix}.npy"),
os.path.join(_STORE_DIR, f"delivery_history_records{suffix}.pkl"),
os.path.join(_STORE_DIR, f"delivery_history{suffix}.meta"),
)
# ---------------------------------------------------------------------------
# Thresholds
# ---------------------------------------------------------------------------
_K_NEIGHBORS = 15
_MIN_KNN_CONFIDENCE = 0.50 # vector fallback: top rider wins ≥ 50 % of votes
_MIN_PATTERN_DOMINANCE = 0.60 # pattern table: rider owns ≥ 60 % of zone
_MIN_PATTERN_VOLUME = 3 # pattern table: at least 3 deliveries in zone
def _pattern_source() -> str:
"""'csv' (legacy, manual refresh) or 'db' (auto-refreshed from nearledb mirror)."""
try:
from app.config.dynamic_config import get_config
return str(get_config().get("pattern_source", "csv"))
except Exception:
return "csv"
# ---------------------------------------------------------------------------
# Pure-NumPy L2 index — drop-in replacement for faiss.IndexFlatL2
# ---------------------------------------------------------------------------
class _NumpyL2Index:
"""
Brute-force L2 nearest-neighbour search using NumPy.
Same interface as faiss.IndexFlatL2 so both paths share one code.
For 3 0005 000 vectors each query takes < 1 ms — fast enough.
"""
def __init__(self, d: int):
self.d = d
self.ntotal = 0
self._vecs: Optional[np.ndarray] = None # shape (N, d) float32
def add(self, vectors: np.ndarray) -> None:
self._vecs = vectors.astype(np.float32)
self.ntotal = len(self._vecs)
def search(self, query: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
"""Return (distances, indices) shaped (1, k), same as faiss."""
if self._vecs is None or self.ntotal == 0:
return (np.array([[]], dtype=np.float32),
np.array([[-1]], dtype=np.int64))
q = query.astype(np.float32) # (1, d)
diff = self._vecs - q # (N, d) broadcasting
sq_dists = np.einsum("ij,ij->i", diff, diff) # (N,) squared L2
actual_k = min(k, self.ntotal)
if actual_k < self.ntotal:
part = np.argpartition(sq_dists, actual_k)[:actual_k]
top_idxs = part[np.argsort(sq_dists[part])]
else:
top_idxs = np.argsort(sq_dists)
return (sq_dists[top_idxs].reshape(1, -1),
top_idxs.reshape(1, -1).astype(np.int64))
def _build_index(vectors: np.ndarray) -> Any:
"""
Build the best available index for the given float32 vector array.
Tries faiss first; falls back to NumPy silently.
"""
try:
import faiss as _faiss
idx = _faiss.IndexFlatL2(vectors.shape[1])
idx.add(vectors)
logger.info("[DeliveryHistory] Using faiss-cpu index.")
return idx
except Exception:
idx = _NumpyL2Index(vectors.shape[1])
idx.add(vectors)
logger.info("[DeliveryHistory] Using NumPy L2 index (faiss-cpu not available).")
return idx
# ---------------------------------------------------------------------------
# Main store
# ---------------------------------------------------------------------------
class DeliveryHistoryStore:
"""
Pattern-first, vector-fallback rider lookup.
Public API
----------
store.find_rider(kitchen, plat, plon, dlat, dlon) -> dict | None
store.record_count() -> int
store.pattern_count() -> int
store.get_pattern_stats() -> list
store.reload_from_csv() -> int
store.rebuild_from_records(records) -> int
"""
def __init__(self):
self._lock = Lock()
self._records: List[Dict] = []
self._index = None
# Pattern table: (kitchen_lower, zone_lat, zone_lon) → pattern dict
self._patterns: Dict[Tuple, Dict] = {}
# Zone index: (zone_lat, zone_lon) → [pattern keys at that cell]
self._zone_index: Dict[Tuple, List[Tuple]] = defaultdict(list)
self._load()
# ------------------------------------------------------------------
# Startup
# ------------------------------------------------------------------
def _load(self) -> None:
os.makedirs(_STORE_DIR, exist_ok=True)
if _pattern_source() == "db":
# DB-driven: the ETA-sync agent keeps this fresh on its own cadence
# by calling rebuild_from_records() after every sync cycle. At
# startup we just load whatever was last persisted — no CSV
# freshness check applies in this mode.
if self._load_from_disk("db"):
return
logger.info(
"[DeliveryHistory] pattern_source=db, no persisted snapshot yet "
"— will populate on the next ETA-sync cycle."
)
return
if self._saved_files_are_current():
if self._load_from_disk("csv"):
return
logger.warning(
"[DeliveryHistory] Saved files corrupt — rebuilding from CSV."
)
records = self._parse_csv()
if not records:
return
self._build_and_save(records, source="csv")
def _saved_files_are_current(self) -> bool:
vectors_path, records_path, meta_path = _paths_for("csv")
for path in (vectors_path, records_path, meta_path):
if not os.path.isfile(path):
return False
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
if not os.path.isfile(CSV_PATH):
return True
return abs(os.path.getmtime(CSV_PATH) - meta.get("csv_mtime", 0)) < 1.0
except Exception:
return False
def _load_from_disk(self, source: str) -> bool:
try:
vectors_path, records_path, _ = _paths_for(source)
vectors = np.load(vectors_path) # (N, 4) float32
with open(records_path, "rb") as f:
records = pickle.load(f)
if not records or len(vectors) != len(records):
return False
index = _build_index(vectors)
patterns, zone_index = self._compute_patterns(records)
with self._lock:
self._records = records
self._index = index
self._patterns = patterns
self._zone_index = zone_index
clear = sum(
1 for p in patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
logger.info(
f"[DeliveryHistory] Loaded {len(records)} records from disk. "
f"Pattern table: {len(patterns)} zones, {clear} with clear dominant rider."
)
return True
except Exception as e:
logger.warning(f"[DeliveryHistory] Disk load failed: {e}")
return False
def _parse_csv_file(self, path: str, label: str = "CSV") -> List[Dict]:
"""Parse any delivery CSV (main history or corrections) into records list."""
records: List[Dict] = []
for encoding in ("utf-8", "latin-1", "cp1252"):
try:
with open(path, newline="", encoding=encoding) as fh:
for row in csv.DictReader(fh):
try:
plat = float(row.get("pickuplat") or 0)
plon = float(row.get("pickuplon") or 0)
dlat = float(row.get("deliverylat") or 0)
dlon = float(row.get("deliverylong") or 0)
uid = int(float(row.get("userid") or 0))
if not plat or not dlat or uid == 0:
continue
# Skip rows where the delivery destination IS a kitchen
dcust = (row.get("deliverycustomer") or "").strip().lower()
if "kitchen" in dcust or "selvarani" in dcust:
continue
records.append({
"kitchen": (row.get("pickupcustomer") or "").strip().lower(),
"pickuplat": plat, "pickuplon": plon,
"deliverylat": dlat, "deliverylong": dlon,
"userid": uid,
"ridername": (row.get("ridername") or "").strip(),
})
except Exception:
continue
break
except (UnicodeDecodeError, FileNotFoundError) as e:
if isinstance(e, FileNotFoundError):
logger.warning(f"[DeliveryHistory] {label} not found at '{path}'.")
return []
records = []
if records:
logger.info(f"[DeliveryHistory] Parsed {len(records)} rows from {label} '{path}'.")
else:
logger.warning(f"[DeliveryHistory] {label} at '{path}' contained no valid rows.")
return records
def _parse_csv(self) -> List[Dict]:
"""Parse main history CSV, then merge manually-corrected records on top."""
main_records = self._parse_csv_file(CSV_PATH, label="Main CSV")
if not main_records:
return []
# Load manually-corrected assignments and merge with runtime weight multiplier
if os.path.isfile(CORRECTIONS_PATH):
corr = self._parse_csv_file(CORRECTIONS_PATH, label="Corrections CSV")
if corr:
weighted = corr * _CORRECTION_WEIGHT # extra amplification if configured
main_records = main_records + weighted
logger.info(
f"[DeliveryHistory] Merged {len(corr)} correction rows "
f"(×{_CORRECTION_WEIGHT} weight) into {len(main_records)} total records."
)
else:
logger.debug(
f"[DeliveryHistory] No corrections file at '{CORRECTIONS_PATH}'"
"using main CSV only."
)
return main_records
def _compute_patterns(
self, records: List[Dict]
) -> Tuple[Dict, Dict]:
"""
Build pattern table from records.
Pattern dict keys
-----------------
userid, ridername, dominance, total_deliveries,
top_deliveries, pattern_score, all_riders
"""
zone_counts: Dict[Tuple, Dict[int, int]] = defaultdict(lambda: defaultdict(int))
zone_names: Dict[Tuple, Dict[int, str]] = defaultdict(lambda: defaultdict(str))
for rec in records:
key = (rec["kitchen"],
round(rec["deliverylat"], 2),
round(rec["deliverylong"], 2))
zone_counts[key][rec["userid"]] += 1
zone_names[key][rec["userid"]] = rec["ridername"]
patterns: Dict[Tuple, Dict] = {}
zone_index: Dict[Tuple, List[Tuple]] = defaultdict(list)
for key, rider_counts in zone_counts.items():
kitchen, zone_lat, zone_lon = key
total = sum(rider_counts.values())
top_rid = max(rider_counts, key=rider_counts.get)
top_count = rider_counts[top_rid]
dominance = top_count / total
# Penalise thin data: full score only at ≥ 5 deliveries
pattern_score = dominance * min(1.0, total / 5.0)
patterns[key] = {
"userid": top_rid,
"ridername": zone_names[key].get(top_rid, ""),
"dominance": round(dominance, 4),
"total_deliveries": total,
"top_deliveries": top_count,
"pattern_score": round(pattern_score, 4),
"all_riders": dict(rider_counts),
}
zone_index[(zone_lat, zone_lon)].append(key)
return patterns, zone_index
def _build_and_save(self, records: List[Dict], source: str = "csv") -> None:
vectors = np.array(
[[r["pickuplat"], r["pickuplon"], r["deliverylat"], r["deliverylong"]]
for r in records],
dtype=np.float32,
)
index = _build_index(vectors)
patterns, zone_index = self._compute_patterns(records)
with self._lock:
self._records = records
self._index = index
self._patterns = patterns
self._zone_index = zone_index
clear = sum(
1 for p in patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
sole = sum(
1 for p in patterns.values()
if p["dominance"] == 1.0
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
logger.info(
f"[DeliveryHistory] Pattern table: {len(patterns)} zones, "
f"{clear} clear patterns (≥60% dominance), {sole} sole-owner zones."
)
try:
vectors_path, records_path, meta_path = _paths_for(source)
np.save(vectors_path, vectors)
with open(records_path, "wb") as f:
pickle.dump(records, f, protocol=pickle.HIGHEST_PROTOCOL)
# csv_mtime only means something for the CSV path's freshness check
# (_saved_files_are_current). DB-built snapshots are refreshed by the
# ETA-sync agent's own schedule, not a file-mtime comparison.
csv_mtime = (
os.path.getmtime(CSV_PATH)
if source == "csv" and os.path.isfile(CSV_PATH)
else 0
)
with open(meta_path, "w", encoding="utf-8") as f:
json.dump({
"source": source,
"csv_mtime": csv_mtime,
"record_count": len(records),
"pattern_zones": len(patterns),
"clear_patterns": clear,
}, f, indent=2)
logger.info(
f"[DeliveryHistory] Saved to '{_STORE_DIR}' (source={source}). "
"Next startup loads from disk."
)
except Exception as e:
logger.warning(
f"[DeliveryHistory] Could not save to disk (non-fatal): {e}"
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def find_rider(
self,
kitchen_name: str,
pickup_lat: float,
pickup_lon: float,
delivery_lat: float,
delivery_lon: float,
k: int = _K_NEIGHBORS,
min_confidence: float = _MIN_KNN_CONFIDENCE,
) -> Optional[Dict[str, Any]]:
"""
Return the best historical rider, or None.
Step 1 — pattern table (O(1)):
Clear dominant rider for this kitchen + 1.1 km delivery zone.
Step 2 — vector K-NN (fallback):
Brute-force 4D search, kitchen-filtered vote.
Return keys: userid, ridername, confidence, match_count, top_votes, source
"""
if not delivery_lat:
return None
kitchen_lower = (kitchen_name or "").strip().lower()
result = self._pattern_lookup(kitchen_lower, delivery_lat, delivery_lon)
if result:
return result
if self._index is None or not self._records:
return None
if not pickup_lat:
return None
return self._vector_knn(
kitchen_lower, pickup_lat, pickup_lon,
delivery_lat, delivery_lon, k, min_confidence
)
def _pattern_lookup(
self,
kitchen_lower: str,
delivery_lat: float,
delivery_lon: float,
) -> Optional[Dict[str, Any]]:
zone_pos = (round(delivery_lat, 2), round(delivery_lon, 2))
with self._lock:
candidate_keys = self._zone_index.get(zone_pos, [])
best_pattern = None
best_score = -1.0
for key in candidate_keys:
key_kitchen = key[0]
if kitchen_lower and key_kitchen:
if (kitchen_lower not in key_kitchen
and key_kitchen not in kitchen_lower):
continue
with self._lock:
pat = self._patterns.get(key)
if not pat:
continue
if (pat["dominance"] >= _MIN_PATTERN_DOMINANCE
and pat["total_deliveries"] >= _MIN_PATTERN_VOLUME
and pat["pattern_score"] > best_score):
best_pattern = pat
best_score = pat["pattern_score"]
if not best_pattern:
return None
return {
"userid": best_pattern["userid"],
"ridername": best_pattern["ridername"],
"confidence": best_pattern["dominance"],
"match_count": best_pattern["total_deliveries"],
"top_votes": best_pattern["top_deliveries"],
"source": "pattern",
}
def _vector_knn(
self,
kitchen_lower: str,
pickup_lat: float,
pickup_lon: float,
delivery_lat: float,
delivery_lon: float,
k: int,
min_confidence: float,
) -> Optional[Dict[str, Any]]:
with self._lock:
query = np.array(
[[pickup_lat, pickup_lon, delivery_lat, delivery_lon]],
dtype=np.float32,
)
actual_k = min(k, len(self._records))
_, indices = self._index.search(query, actual_k)
matched: List[Dict] = []
for idx in indices[0]:
idx = int(idx)
if idx < 0 or idx >= len(self._records):
continue
rec = self._records[idx]
if kitchen_lower and rec["kitchen"]:
if (kitchen_lower not in rec["kitchen"]
and rec["kitchen"] not in kitchen_lower):
continue
matched.append(rec)
if not matched:
return None
votes = Counter(rec["userid"] for rec in matched)
top_uid, top_count = votes.most_common(1)[0]
confidence = top_count / len(matched)
if confidence < min_confidence:
return None
ridername = next(
(r["ridername"] for r in matched if r["userid"] == top_uid), ""
)
return {
"userid": top_uid,
"ridername": ridername,
"confidence": round(confidence, 3),
"match_count": len(matched),
"top_votes": top_count,
"source": "vector_knn",
}
# ------------------------------------------------------------------
# Analytics
# ------------------------------------------------------------------
def record_count(self) -> int:
return len(self._records)
def pattern_count(self) -> int:
return sum(
1 for p in self._patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
def get_pattern_stats(self) -> List[Dict]:
stats = []
with self._lock:
for (kitchen, zlat, zlon), pat in self._patterns.items():
if (pat["dominance"] < _MIN_PATTERN_DOMINANCE
or pat["total_deliveries"] < _MIN_PATTERN_VOLUME):
continue
stats.append({
"kitchen": kitchen,
"zone_lat": zlat,
"zone_lon": zlon,
"userid": pat["userid"],
"ridername": pat["ridername"],
"dominance": pat["dominance"],
"total_deliveries": pat["total_deliveries"],
"top_deliveries": pat["top_deliveries"],
"pattern_score": pat["pattern_score"],
"all_riders": pat["all_riders"],
})
return sorted(stats, key=lambda x: x["pattern_score"], reverse=True)
# ------------------------------------------------------------------
# Rider Efficiency Scores
# ------------------------------------------------------------------
def get_rider_efficiency_scores(self) -> Dict[int, Dict]:
"""
Compute a per-rider efficiency score from the 30-day CSV history.
Metrics per rider
-----------------
delivery_count — total deliveries in CSV
avg_km — average Haversine distance (pickup → delivery)
unique_zones — number of distinct 1.1 km delivery cells covered
efficiency_score — normalised 0..1 composite score:
more deliveries × lower avg km × more zones = higher score
A rider who completes many deliveries efficiently across many zones
scores highest. Used as a tiebreaker during solo consolidation
and Phase-0 host selection.
"""
from math import radians, cos, sin, asin, sqrt as _sqrt
def _hav(la1, lo1, la2, lo2):
try:
la1, lo1, la2, lo2 = map(radians, [la1, lo1, la2, lo2])
a = sin((la2-la1)/2)**2 + cos(la1)*cos(la2)*sin((lo2-lo1)/2)**2
return 2 * asin(min(1.0, _sqrt(a))) * 6371.0
except Exception:
return 0.0
with self._lock:
records = list(self._records) # snapshot under lock
if not records:
return {}
stats: Dict[int, Dict] = {}
for rec in records:
uid = rec["userid"]
km = _hav(rec["pickuplat"], rec["pickuplon"],
rec["deliverylat"], rec["deliverylong"])
zone = (round(rec["deliverylat"], 2), round(rec["deliverylong"], 2))
if uid not in stats:
stats[uid] = {
"ridername": rec["ridername"],
"delivery_count": 0,
"total_km": 0.0,
"zones": set(),
}
s = stats[uid]
s["delivery_count"] += 1
s["total_km"] += km
s["zones"].add(zone)
# Build scores
result: Dict[int, Dict] = {}
raw_scores: Dict[int, float] = {}
for uid, s in stats.items():
n = s["delivery_count"]
avg_km = s["total_km"] / n if n else 0.0
zone_count = len(s["zones"])
# Raw: many deliveries, low km per delivery, wide zone coverage
raw = n / (1.0 + avg_km) * (1.0 + zone_count ** 0.5)
raw_scores[uid] = raw
result[uid] = {
"ridername": s["ridername"],
"delivery_count": n,
"avg_km": round(avg_km, 2),
"unique_zones": zone_count,
}
# Normalise 0..1
max_raw = max(raw_scores.values()) if raw_scores else 1.0
for uid in result:
result[uid]["efficiency_score"] = round(
raw_scores[uid] / max_raw, 4
)
return result
def get_rider_score(self, rider_id: int) -> float:
"""Convenience: return a single rider's efficiency_score (0..1), or 0.5 if unknown."""
scores = self.get_rider_efficiency_scores()
return scores.get(rider_id, {}).get("efficiency_score", 0.5)
# ------------------------------------------------------------------
# Reload
# ------------------------------------------------------------------
def reload_from_csv(self) -> int:
"""Rebuild index from main CSV + corrections (if present)."""
logger.info("[DeliveryHistory] Force-reload from CSV requested.")
records = self._parse_csv() # already merges corrections internally
if not records:
return 0
self._build_and_save(records, source="csv")
return len(records)
def rebuild_from_records(self, records: List[Dict]) -> int:
"""
Rebuild the pattern table + vector index directly from pre-shaped
records (kitchen/pickuplat/pickuplon/deliverylat/deliverylong/userid/
ridername), bypassing CSV parsing entirely.
Called by the ETA-sync agent (delivery_history_service.py) after each
sync cycle when pattern_source=db, so this store stays as fresh as the
nearledb mirror — no manual CSV re-export/reload needed.
"""
if not records:
logger.warning(
"[DeliveryHistory] rebuild_from_records got 0 records — "
"keeping the existing store as-is."
)
return 0
# Merge manually-verified corrections on top, same as the CSV path,
# so the human-override mechanism still works in DB mode.
if os.path.isfile(CORRECTIONS_PATH):
corr = self._parse_csv_file(CORRECTIONS_PATH, label="Corrections CSV")
if corr:
records = records + (corr * _CORRECTION_WEIGHT)
self._build_and_save(records, source="db")
return len(records)
def inject_corrections(self, corrections_path: str = CORRECTIONS_PATH,
weight: int = 5) -> Dict:
"""
Hot-inject a correction CSV into the live store without a full CSV reload.
Each row in the corrections file is counted `weight` times so manually
verified assignments quickly dominate the pattern table for those zones.
Returns a summary dict with old/new record counts and pattern changes.
"""
if not os.path.isfile(corrections_path):
raise FileNotFoundError(f"Corrections file not found: {corrections_path}")
corr = self._parse_csv_file(corrections_path, label="Corrections")
if not corr:
return {"status": "error", "message": "No valid rows in corrections file."}
weighted = corr * weight
with self._lock:
old_count = len(self._records)
# Remove any existing correction rows for the same zones to avoid
# double-injection (identify by source: corrections have no
# "source" field — we use a tag approach).
# Simplest safe approach: just append (first inject from scratch).
merged = list(self._records) + weighted
patterns, zone_index = self._compute_patterns(merged)
old_clear = sum(
1 for p in self._patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
new_clear = sum(
1 for p in patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
vectors = np.array(
[[r["pickuplat"], r["pickuplon"], r["deliverylat"], r["deliverylong"]]
for r in merged],
dtype=np.float32,
)
index = _build_index(vectors)
with self._lock:
self._records = merged
self._index = index
self._patterns = patterns
self._zone_index = zone_index
# Save to disk (under whichever source is currently active) so next
# restart includes corrections
try:
active_source = _pattern_source()
vectors_path, records_path, meta_path = _paths_for(active_source)
np.save(vectors_path, vectors)
with open(records_path, "wb") as f:
pickle.dump(merged, f)
csv_mtime = (
os.path.getmtime(CSV_PATH)
if active_source == "csv" and os.path.isfile(CSV_PATH)
else 0
)
with open(meta_path, "w", encoding="utf-8") as f:
json.dump({"source": active_source, "csv_mtime": csv_mtime,
"record_count": len(merged),
"pattern_count": len(patterns)}, f)
logger.info(
f"[DeliveryHistory] Corrections injected and saved — "
f"records: {old_count}{len(merged)}, "
f"clear patterns: {old_clear}{new_clear}"
)
except Exception as e:
logger.warning(f"[DeliveryHistory] Save after inject failed: {e}")
return {
"status": "ok",
"unique_corrections": len(corr),
"weight": weight,
"records_before": old_count,
"records_after": len(merged),
"clear_patterns_before": old_clear,
"clear_patterns_after": new_clear,
"zones_total": len(patterns),
}
# ------------------------------------------------------------------
# Module-level singleton
# ------------------------------------------------------------------
_store: Optional[DeliveryHistoryStore] = None
_store_lock: Lock = Lock()
def get_delivery_history_store() -> DeliveryHistoryStore:
global _store
if _store is None:
with _store_lock:
if _store is None:
_store = DeliveryHistoryStore()
return _store