Files
canteen-asset-tracker/scripts/extract_asset_location_data.py
T
shawn 13bf61d856 feat: extract floor/building/room/trailer from asset names
Adds scripts/extract_asset_location_data.py which:
- Extracts floor, building_number, room, trailer_number, building_name from names
- Detects and removes duplicated name segments
- Updates both assets (1848) and locations (364) tables
- Dry-run mode by default, --apply to write changes
2026-05-23 19:41:55 -04:00

438 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Extract floor, building_number, room, trailer_number, and building_name
from asset names in assets.db. Also deduplicate duplicated name segments.
Usage:
python3 scripts/extract_asset_location_data.py # dry-run (report only)
python3 scripts/extract_asset_location_data.py --apply # update DB
"""
import re
import sqlite3
import sys
from pathlib import Path
from typing import Optional
DB_PATH = str(Path(__file__).resolve().parent.parent / "assets.db")
# ─── Regex patterns ──────────────────────────────────────────────────────────
# Floor: "1ST FL", "2nd Floor", "3rd Floor", "4TH FLO", "5th floor", "6TH FL",
# "SOG MAIN 3RD FLOOR", "7th floor", "G-REAR 6TH FL", etc.
# We capture the floor number as the first group.
FLOOR_PATTERNS = [
re.compile(r'\b(\d+)(?:ST|ND|RD|TH|st|nd|rd|th)\s+(?:FL(?:OOR)?|Floor|floor|FLO(?:OR)?|Floo|floo)\b', re.IGNORECASE),
# "2ND FLOOR", "3RD FLOOR" etc.
re.compile(r'\b(\d+)(?:TH|ST|ND|RD)\s+FLOOR\b', re.IGNORECASE),
# "3rd FLR" (FLR abbreviation)
re.compile(r'\b(\d+)(?:ST|ND|RD|TH|st|nd|rd|th)\s+FLR\b', re.IGNORECASE),
]
# Floor: "FL02" in "FL02NEWBLD" -> floor "2"
# Must be FL + 1-2 digits + 2+ uppercase letters or end of field (via word boundary).
# Excludes "FL4950", "FL2500", "FL8108", "FL13323" (4+ digits after FL).
# Also matches "FL10W" (floor 10 west), "FL24TWR" (floor 24 tower).
FLOOR_CODE = re.compile(r'\bFL(\d{1,2})(?:(?:[A-Z]{2,})|\b)')
# Building-number patterns
# "BLDG 215", "Bldg 210", "Building 1180", "BLDG 220", "Bldg 215-3rd Floor"
BUILDING_PATTERNS = [
re.compile(r'\b(?:BLDG|Bldg|Building|Bldg)\s+(\d+)\b'),
# "#6328", "#811", "#9842", "#5288", "#1001", "# 6328" (store/chain numbers)
re.compile(r'#\s*(\d{3,})\b'),
]
# Room patterns
# "Room 1290", "Room 1323", "SUITE 100", "SUITE 214", "Ste 400", "Ste 108"
ROOM_PATTERNS = [
re.compile(r'\b(?:Room|room)\s+(\d+)\b'),
re.compile(r'\b(?:SUITE|Suite|suite|Ste)\s+(\d+)\b'),
# "Breakroom suite 700" (suite at end)
re.compile(r'\bsuite\s+(\d+)\b'),
]
# Trailer patterns
# "Trailer 1051", "Trailer 1135", "Trailer 1055", "Trailer 1011", "Trailer 1145", "Trailer 1155"
# Also "Trailer 342" (3-digit trailer)
TRAILER_PATTERNS = [
re.compile(r'\b(?:Trailer|trailer)\s+(\d+)\b'),
]
# ─── Known building/venue names (prefixes to check) ──────────────────────────
# These appear as prefix segments in asset names and are valid building names.
BUILDING_NAME_PREFIXES = [
"Buena Vista Palace",
"Hilton Worldwide",
"Four Seasons Resort",
"Gran Destino",
"Shades of Green",
"Coronado Springs",
"Caribbean Beach",
"All Star Movies",
"Disney Springs",
"Epcot",
"Celebration Suites",
]
# Suffixes that indicate what follows is a function/area, NOT part of building name.
AREA_SUFFIXES = [
"Breakroom", "Break Room", "BRKRM", "Break", "BR",
"Vending", "Vending Area",
"Laundry", "Laundry Room",
"Cafeteria", "Cafe",
"Maintenance", "Warehouse",
"Wardrobe", "Cast",
"Lobby", "Guest",
"Admin", "Office",
]
def extract_floor(name: str) -> Optional[str]:
"""Extract floor number from an asset name."""
for pat in FLOOR_PATTERNS:
m = pat.search(name)
if m:
return str(int(m.group(1))) # Normalise "02" -> "2"
m = FLOOR_CODE.search(name)
if m:
val = str(int(m.group(1))) # Normalise "02" -> "2"
# Ensure it's a reasonable floor number (1-100)
try:
num = int(val)
if 1 <= num <= 100:
return val
except ValueError:
pass
# Edge: "Villas 5 fl by room" etc. — rare but present
m = re.search(r'\b(\d+)\s+fl\b', name, re.IGNORECASE)
if m:
val = str(int(m.group(1)))
try:
if 1 <= int(val) <= 100:
return val
except ValueError:
pass
return None
def extract_building_number(name: str) -> Optional[str]:
"""Extract building number from an asset name."""
for pat in BUILDING_PATTERNS:
m = pat.search(name)
if m:
return m.group(1)
return None
def extract_room(name: str) -> Optional[str]:
"""Extract room/suite number from an asset name."""
for pat in ROOM_PATTERNS:
m = pat.search(name)
if m:
return m.group(1)
return None
def extract_trailer(name: str) -> Optional[str]:
"""Extract trailer number from an asset name."""
for pat in TRAILER_PATTERNS:
m = pat.search(name)
if m:
return m.group(1)
return None
def extract_building_name(name: str) -> Optional[str]:
"""
Attempt to derive a building/venue name from the asset name.
Strategy: look for known building-name prefixes, or extract the
first segment before a hyphen/separator that looks like a location name
(not just a person name, address fragment, or functional descriptor).
"""
name_upper = name.upper()
# Check known building name prefixes
for prefix in BUILDING_NAME_PREFIXES:
if prefix.upper() in name_upper:
return prefix
# Try prefix extraction: the first significant segment
# Split on common separators
parts = re.split(r'\s*-\s*', name)
first = parts[0].strip()
# Skip if the first segment looks like a person name (has name pattern)
# e.g. "Todd J - ...", "Brenda G - ...", "Kelli M - ..."
person_pattern = re.match(r'^[A-Z][a-z]+ [A-Z]\.?$', first)
if person_pattern:
return None
# Skip if it's an address fragment
if re.match(r'^\d+\s+', first):
# Could be a building name like "Building 1180" — but that's already
# captured as building_number. Skip other addresses.
if not re.match(r'^Building\s+\d+', first, re.IGNORECASE):
return None
# Skip if the first segment is clearly a person or Disney cast ID
# "Jeremy B", "Randal W", "Susan G", "Brenda G", etc.
if re.match(r'^[A-Z][a-z]+ [A-Z] -', first):
return None
# If first segment is long enough (>10 chars) and doesn't contain area keywords,
# it's probably the building/venue name
first_no_area = first
for suffix in AREA_SUFFIXES:
# Remove trailing area/function suffixes
pat = re.compile(r'\s+' + re.escape(suffix) + r'$', re.IGNORECASE)
first_no_area = pat.sub('', first_no_area)
if len(first_no_area) > 8 and not re.search(r'\b(?:BREAK|VEND|LAUNDRY|CAFE|MAINT|WAREHOUSE|CAFETERIA|OFFICE|LOBBY|GUEST|ADMIN|SERVICES|BACKSTAGE|WARDROBE|CA$T|DINING|KITCHEN|STORAGE|WORKSHOP|SECURITY|RECEIVING|PARKING|OUTSIDE|OUTLET|HALLWAY)\b', first_no_area.upper()):
return first_no_area.strip()
return None
# ─── Name deduplication ──────────────────────────────────────────────────────
def should_deduplicate(left: str, right: str) -> Optional[str]:
"""
Check if `right` is a duplicate/continuation of `left`.
Returns the merged name if deduplication is warranted, None otherwise.
"""
l = left.strip()
r = right.strip()
if not l or not r:
return None
# Exact match: "X-X" -> keep "X"
if l.lower() == r.lower():
return l
# Right starts with left: "X" + "-" + "X Y" -> "X Y"
if r.lower().startswith(l.lower()):
return r
# Left starts with right: "X Y" + "-" + "X" -> "X Y"
if l.lower().startswith(r.lower()):
return l
# Large common prefix (>= 65 % of the shorter string and >= 10 chars)
min_len = min(len(l), len(r))
if min_len >= 10:
common = 0
for a, b in zip(l.lower(), r.lower()):
if a == b:
common += 1
else:
break
if common >= 0.65 * min_len:
# Keep the longer variant
return l if len(l) >= len(r) else r
return None
def deduplicate_name(name: str) -> str:
"""
Detect duplicated name segments and return a cleaned name.
Strategy: for assets with multiple hyphen-separated segments, check
if splitting at each hyphen position reveals duplication.
"""
# Find all hyphen positions and try each
hyphens = [m.start() for m in re.finditer('-', name)]
best_name = name
# Try splitting at first hyphen
for idx in hyphens:
left = name[:idx].strip()
right = name[idx+1:].strip()
result = should_deduplicate(left, right)
if result is not None:
candidate = result
# If this is shorter or the same length but cleaner, use it
if len(candidate) < len(best_name) or (len(candidate) <= len(best_name) and candidate != name):
best_name = candidate
# Iterative: run again on the result (for triple-nested duplicates)
if best_name != name:
best_name = deduplicate_name(best_name)
return best_name
# ─── Main logic ──────────────────────────────────────────────────────────────
def process_assets(db: sqlite3.Connection, apply: bool = False) -> dict:
"""Process assets table, returning counts of extracted values."""
cursor = db.cursor()
# Fetch all assets with non-empty names
cursor.execute("SELECT id, name, floor, building_number, room, trailer_number, building_name FROM assets WHERE name != '' ORDER BY id")
rows = cursor.fetchall()
counts = {
'floor': 0,
'building_number': 0,
'room': 0,
'trailer_number': 0,
'building_name': 0,
'name_cleaned': 0,
'total': len(rows),
}
updates = []
for row in rows:
asset_id, name, cur_floor, cur_bldg_num, cur_room, cur_trailer, cur_bldg_name = row
# 1. Deduplicate name
cleaned = deduplicate_name(name)
name_changed = (cleaned != name)
# 2. Extract fields (only if currently empty)
floor = cur_floor if cur_floor else (extract_floor(cleaned) or '')
building_number = cur_bldg_num if cur_bldg_num else (extract_building_number(cleaned) or '')
room = cur_room if cur_room else (extract_room(cleaned) or '')
trailer = cur_trailer if cur_trailer else (extract_trailer(cleaned) or '')
building_name = cur_bldg_name if cur_bldg_name else (extract_building_name(cleaned) or '')
# Track counts
if floor and not cur_floor:
counts['floor'] += 1
if building_number and not cur_bldg_num:
counts['building_number'] += 1
if room and not cur_room:
counts['room'] += 1
if trailer and not cur_trailer:
counts['trailer_number'] += 1
if building_name and not cur_bldg_name:
counts['building_name'] += 1
if name_changed:
counts['name_cleaned'] += 1
updates.append((floor, building_number, room, trailer, building_name, cleaned, asset_id))
if apply:
cursor.executemany(
"UPDATE assets SET floor=?, building_number=?, room=?, trailer_number=?, building_name=?, name=? WHERE id=?",
updates
)
db.commit()
return counts
def process_locations(db: sqlite3.Connection, apply: bool = False) -> dict:
"""Process locations table similarly."""
cursor = db.cursor()
cursor.execute("SELECT id, name, floor, building_number, trailer_number FROM locations WHERE name != '' ORDER BY id")
rows = cursor.fetchall()
counts = {
'floor': 0,
'building_number': 0,
'trailer_number': 0,
'name_cleaned': 0,
'total': len(rows),
}
updates = []
for row in rows:
loc_id, name, cur_floor, cur_bldg_num, cur_trailer = row
# Deduplicate (locations seem cleaner but process anyway)
cleaned = deduplicate_name(name)
name_changed = (cleaned != name)
# Extract fields
floor = cur_floor if cur_floor else (extract_floor(cleaned) or '')
building_number = cur_bldg_num if cur_bldg_num else (extract_building_number(cleaned) or '')
trailer = cur_trailer if cur_trailer else (extract_trailer(cleaned) or '')
if floor and not cur_floor:
counts['floor'] += 1
if building_number and not cur_bldg_num:
counts['building_number'] += 1
if trailer and not cur_trailer:
counts['trailer_number'] += 1
if name_changed:
counts['name_cleaned'] += 1
updates.append((floor, building_number, trailer, cleaned, loc_id))
if apply:
cursor.executemany(
"UPDATE locations SET floor=?, building_number=?, trailer_number=?, name=? WHERE id=?",
updates
)
db.commit()
return counts
def print_summary(label: str, counts: dict):
"""Print a human-readable summary of extraction results."""
print(f"\n{'=' * 60}")
print(f" {label}")
print(f"{'=' * 60}")
print(f" Total rows processed: {counts['total']}")
print(f" Floors extracted: {counts['floor']}")
print(f" Building numbers: {counts['building_number']}")
if 'room' in counts:
print(f" Rooms/Suites: {counts['room']}")
if 'trailer_number' in counts:
print(f" Trailer numbers: {counts['trailer_number']}")
if 'building_name' in counts:
print(f" Building names: {counts['building_name']}")
print(f" Names cleaned: {counts['name_cleaned']}")
def main():
apply = '--apply' in sys.argv
print(f"DB: {DB_PATH}")
print(f"Mode: {'APPLY (will write to DB)' if apply else 'DRY RUN (no changes)'}")
db = sqlite3.connect(DB_PATH)
db.execute("PRAGMA journal_mode=WAL")
print("\nProcessing assets...")
asset_counts = process_assets(db, apply=apply)
print_summary("ASSETS", asset_counts)
print("\nProcessing locations...")
loc_counts = process_locations(db, apply=apply)
print_summary("LOCATIONS", loc_counts)
db.close()
if not apply:
print("\n─── DRY RUN ───")
print("Re-run with --apply to write changes to the database.")
# Sample some cleaned names for verification
print("\n─── Sample deduplications (first 20) ───")
db2 = sqlite3.connect(DB_PATH)
cursor = db2.cursor()
cursor.execute("SELECT id, name FROM assets WHERE name LIKE '%-%' ORDER BY id LIMIT 20")
for row in cursor.fetchall():
original = row[1]
cleaned = deduplicate_name(original)
if cleaned != original:
print(f" [{row[0]}] '{original}'")
print(f"'{cleaned}'")
db2.close()
if __name__ == '__main__':
main()