"""Shared data models for Alba Core.
These dataclasses describe the clean internal shapes used by extraction,
matching, and lead summaries. They deliberately exclude raw PropertyMe owner,
tenant, and token details.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import date
from typing import Any
[docs]
def clean_text(value: Any) -> str | None:
"""Return compact display text or None.
Empty strings create subtle bugs in matching, so Alba Core normalises them
to None as early as possible.
"""
if value is None:
return None
text = str(value).strip()
return text or None
[docs]
@dataclass(slots=True)
class RentalRequirements:
"""Structured version of a renter's request.
The extractor fills this from plain English. Fields ending in _min or _max
become hard matching rules when present. features and priority are softer
ranking hints.
"""
city: str | None = None
suburb_preferences: list[str] = field(default_factory=list)
budget_max: float | None = None
budget_min: float | None = None
bedrooms_min: int | None = None
bathrooms_min: int | None = None
parking_min: int | None = None
property_type: str | None = None
move_in_timing: str | None = None
priority: str | None = None
pets_required: bool | None = None
furnished_required: bool | None = None
features: list[str] = field(default_factory=list)
no_preference: list[str] = field(default_factory=list)
[docs]
def merge(self, patch: "RentalRequirements") -> "RentalRequirements":
"""Merge newly extracted values over an existing conversation state."""
data = asdict(self)
for key, value in asdict(patch).items():
if isinstance(value, list):
if value:
existing = data.get(key) or []
merged = [*existing]
for item in value:
if item not in merged:
merged.append(item)
data[key] = merged
elif value is not None:
data[key] = value
return RentalRequirements(**data)
@property
def missing_required_fields(self) -> list[str]:
"""Fields needed before Alba Core can call the search complete."""
missing: list[str] = []
if not self.city and not self.suburb_preferences:
missing.append("location")
if self.budget_max is None:
missing.append("budget")
if self.bedrooms_min is None:
missing.append("bedrooms")
return missing
@property
def is_search_ready(self) -> bool:
"""True when the minimum useful matching inputs are known."""
return not self.missing_required_fields
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-friendly representation for API responses."""
return asdict(self)
[docs]
@dataclass(slots=True)
class PropertyRecord:
"""Privacy-shaped property record used by the matcher.
This model contains only fields Alba Core needs. Raw owner, tenant, and
token details should never be mapped into this object.
"""
property_id: str
address: str
suburb: str | None
city: str | None
region: str | None
postcode: str | None
property_type: str | None
rent_pw: float | None
bedrooms: int | None
bathrooms: int | None
parking_spaces: int | None
availability: str | None
available_from: date | None
furnishing: str | None
pets: str | None
smoker: str | None
pool: str | None
spa: str | None
view_type: str | None
description: str | None
listing_url: str | None = None
[docs]
def to_public_dict(self) -> dict[str, Any]:
"""Serialize for API output, converting date objects to ISO strings."""
data = asdict(self)
if self.available_from is not None:
data["available_from"] = self.available_from.isoformat()
return data
[docs]
@dataclass(slots=True)
class MatchResult:
"""A matched property plus score and explanation notes."""
property: PropertyRecord
score: float
hard_filter_reasons: list[str] = field(default_factory=list)
match_notes: list[str] = field(default_factory=list)
score_breakdown: dict[str, float] = field(default_factory=dict)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return the public response shape used by API and smoke tests."""
return {
"property": self.property.to_public_dict(),
"score": round(self.score, 2),
"match_notes": self.match_notes,
"score_breakdown": {key: round(value, 2) for key, value in self.score_breakdown.items()},
}