feat(tradein): этаж/этажность optional + best test presets по deal count

UX: floor и total_floors теперь optional — все 6 мест в estimator.py уже
имеют None-guards (Tier H/IMV/Cian валидируют наличие до вызова).
При None — Tier H/IMV/Cian skip, оценка через Tier W (wide) с понижением
confidence до medium/low. Critical guard: tradein.deals также не имеют
жёстких NOT NULL на этих полях — schema-only change достаточен.

Test presets: заменены на top-6 ЕКБ улиц по deal_count за 12 мес
(rosreestr_deals ДКП квартиры):
  - Академика Ландау (686 сделок/год, Академический новостройки)
  - Малышева 84 (центр классика — оставлен)
  - Краснолесья (487 сделок/год, Академический эконом)
  - Космонавтов 50 (415 сделок/год, Уралмаш бюджет)
  - 8 Марта 80 (305 сделок/год, центр Ленинский)
  - Московская (426 сделок/год, центр премиум)

Разброс: 4 района, размеры 35-75м², 1-3 комнаты, цены 120-185 К ₽/м².
Все адреса дают medium+ confidence из-за объёма ДКП-сделок на улице.
This commit is contained in:
lekss361 2026-05-25 00:20:08 +03:00
parent 8c540ecec6
commit c60d394ff0
4 changed files with 61 additions and 53 deletions

View file

@ -16,8 +16,8 @@ class TradeInEstimateInput(BaseModel):
address: str = Field(min_length=3, max_length=500) address: str = Field(min_length=3, max_length=500)
area_m2: float = Field(gt=10, lt=500) area_m2: float = Field(gt=10, lt=500)
rooms: int = Field(ge=0, le=10) # 0 = студия rooms: int = Field(ge=0, le=10) # 0 = студия
floor: int = Field(ge=1, le=100) floor: int | None = Field(default=None, ge=1, le=100)
total_floors: int = Field(ge=1, le=100) total_floors: int | None = Field(default=None, ge=1, le=100)
year_built: int | None = Field(default=None, ge=1800, le=2100) year_built: int | None = Field(default=None, ge=1800, le=2100)
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None

View file

@ -54,11 +54,18 @@ function validate(f: FormState): string | null {
if (!f.area_m2 || isNaN(a) || a <= 10 || a >= 500) return "Площадь: от 10 до 500 м²"; if (!f.area_m2 || isNaN(a) || a <= 10 || a >= 500) return "Площадь: от 10 до 500 м²";
const r = Number(f.rooms); const r = Number(f.rooms);
if (f.rooms === "" || isNaN(r) || r < 0 || r > 10) return "Комнат: 0 (студия) — 10"; if (f.rooms === "" || isNaN(r) || r < 0 || r > 10) return "Комнат: 0 (студия) — 10";
const fl = Number(f.floor); if (f.floor !== "") {
if (!f.floor || isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100"; const fl = Number(f.floor);
const tf = Number(f.total_floors); if (isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100";
if (!f.total_floors || isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100"; if (f.total_floors !== "") {
if (fl > tf) return "Этаж не может быть больше этажей в доме"; const tf = Number(f.total_floors);
if (!isNaN(tf) && fl > tf) return "Этаж не может быть больше этажей в доме";
}
}
if (f.total_floors !== "") {
const tf = Number(f.total_floors);
if (isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100";
}
if (f.year_built !== "") { if (f.year_built !== "") {
const y = Number(f.year_built); const y = Number(f.year_built);
if (isNaN(y) || y < 1800 || y > 2100) return "Год постройки: 1800—2100"; if (isNaN(y) || y < 1800 || y > 2100) return "Год постройки: 1800—2100";
@ -71,8 +78,8 @@ function toPayload(f: FormState): TradeInEstimateInput {
address: f.address.trim(), address: f.address.trim(),
area_m2: Number(f.area_m2), area_m2: Number(f.area_m2),
rooms: Number(f.rooms), rooms: Number(f.rooms),
floor: Number(f.floor), floor: f.floor !== "" ? Number(f.floor) : null,
total_floors: Number(f.total_floors), total_floors: f.total_floors !== "" ? Number(f.total_floors) : null,
has_balcony: f.has_balcony, has_balcony: f.has_balcony,
}; };
if (f.year_built !== "") out.year_built = Number(f.year_built); if (f.year_built !== "") out.year_built = Number(f.year_built);
@ -212,7 +219,7 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
<div className="field-row"> <div className="field-row">
<div className="field"> <div className="field">
<label className="field-label" htmlFor="floor"> <label className="field-label" htmlFor="floor">
Этаж <span className="req">*</span> Этаж <span className="hint">если знаешь</span>
</label> </label>
<input <input
className="control mono" className="control mono"
@ -223,12 +230,11 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
max={100} max={100}
value={form.floor} value={form.floor}
onChange={field("floor")} onChange={field("floor")}
required
/> />
</div> </div>
<div className="field"> <div className="field">
<label className="field-label" htmlFor="totalfloor"> <label className="field-label" htmlFor="totalfloor">
Всего этажей <span className="req">*</span> Всего этажей <span className="hint">если знаешь</span>
</label> </label>
<input <input
className="control mono" className="control mono"
@ -239,10 +245,12 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
max={100} max={100}
value={form.total_floors} value={form.total_floors}
onChange={field("total_floors")} onChange={field("total_floors")}
required
/> />
</div> </div>
</div> </div>
<small style={{ fontSize: 11, color: "var(--muted)", marginTop: -4, display: "block" }}>
1-й и последний этаж снижают цену на 510% заполни если знаешь
</small>
{/* Year + House type */} {/* Year + House type */}
<div className="field-row"> <div className="field-row">

View file

@ -13,6 +13,19 @@ interface Preset {
} }
const PRESETS: Preset[] = [ const PRESETS: Preset[] = [
{
label: "Академика Ландау · 1к/38м²",
hint: "Академический новостройки · 686 сделок/год",
data: {
address: "Екатеринбург, ул. Академика Ландау, 27",
area_m2: 38,
rooms: 1,
floor: 12,
total_floors: 25,
year_built: 2015,
house_type: "monolith",
},
},
{ {
label: "Малышева 84 · 2к/55м²", label: "Малышева 84 · 2к/55м²",
hint: "Центр · 36 аналогов", hint: "Центр · 36 аналогов",
@ -27,37 +40,37 @@ const PRESETS: Preset[] = [
}, },
}, },
{ {
label: "Куйбышева 48 · 3к/75м²", label: "Краснолесья · 3к/75м²",
hint: "Парковый · 22 аналога", hint: "Академический эконом · 487 сделок/год",
data: { data: {
address: "Екатеринбург, ул. Куйбышева, 48", address: "Екатеринбург, ул. Краснолесья, 30",
area_m2: 75, area_m2: 75,
rooms: 3, rooms: 3,
floor: 7, floor: 7,
total_floors: 12, total_floors: 16,
year_built: 2005, year_built: 2010,
house_type: "monolith", house_type: "panel",
}, },
}, },
{ {
label: "Цвиллинга 58 · 1к/46м²", label: "Космонавтов 50 · 2к/55м²",
hint: "Гольфстрим · 17 аналогов", hint: "Уралмаш бюджет · 415 сделок/год",
data: { data: {
address: "Екатеринбург, улица Цвиллинга, 58", address: "Екатеринбург, ул. Космонавтов, 50",
area_m2: 46, area_m2: 55,
rooms: 1, rooms: 2,
floor: 4, floor: 5,
total_floors: 25, total_floors: 9,
year_built: 2020, year_built: 1975,
house_type: "monolith", house_type: "panel",
}, },
}, },
{ {
label: "8 Марта 50 · 1к/45м²", label: "8 Марта 80 · 1к/35м²",
hint: "Ленинский · 19 аналогов", hint: "Центр Ленинский · 305 сделок/год",
data: { data: {
address: "Екатеринбург, ул. 8 Марта, 50", address: "Екатеринбург, ул. 8 Марта, 80",
area_m2: 45, area_m2: 35,
rooms: 1, rooms: 1,
floor: 3, floor: 3,
total_floors: 9, total_floors: 9,
@ -66,31 +79,18 @@ const PRESETS: Preset[] = [
}, },
}, },
{ {
label: "Малышева 84 · 3к/68м²", label: "Московская · 2к/55м²",
hint: "Центр · 33 аналога", hint: "Центр премиум · 426 сделок/год",
data: { data: {
address: "Екатеринбург, ул. Малышева, 84", address: "Екатеринбург, ул. Московская, 195",
area_m2: 68, area_m2: 55,
rooms: 3, rooms: 2,
floor: 5, floor: 6,
total_floors: 9, total_floors: 9,
year_built: 1980, year_built: 1980,
house_type: "brick", house_type: "brick",
}, },
}, },
{
label: "Белинского 200 · 2к/50м²",
hint: "ЮЗ · 7 аналогов · премиум",
data: {
address: "Екатеринбург, ул. Белинского, 200",
area_m2: 50,
rooms: 2,
floor: 6,
total_floors: 10,
year_built: 1990,
house_type: "panel",
},
},
]; ];
interface Props { interface Props {

View file

@ -16,8 +16,8 @@ export interface TradeInEstimateInput {
address: string; // min 3, max 500 address: string; // min 3, max 500
area_m2: number; // 10 < x < 500 area_m2: number; // 10 < x < 500
rooms: number; // 0-10 (0 = студия) rooms: number; // 0-10 (0 = студия)
floor: number; // 1-100 floor: number | null; // 1-100, optional
total_floors: number; // 1-100 total_floors: number | null; // 1-100, optional
year_built?: number; // 1800-2100 year_built?: number; // 1800-2100
house_type?: HouseType; house_type?: HouseType;
repair_state?: RepairState; repair_state?: RepairState;