feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain (feasible -> aggregates) + JS-side tier classification, fall-through cooldown filter, heat scoring, and fit ranking. Per-port settings via system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS. Tier ladder (default): A : no interest history B : lost-only history (still recommendable + boosted by heat) C : active interest in early stage (open..eoi_signed) D : active interest at deposit_10pct or beyond (hidden by default) Heat (only for tier B): recency weight 30 full @ <=30 days, decays to 0 @ 365 days furthest stage weight 40 full when prior reached deposit interest count weight 15 saturates at 5+ EOI count weight 15 saturates at 3+ Multi-port isolation enforced (§14.10 critical): the SQL filters by port_id AND the entry-point function rejects cross-port interest lookups with an explicit error. Fall-through policy supports immediate_with_heat (default), cooldown, and never_auto_recommend. 15 unit tests covering tier classification, heat saturation, weight tuning, zero-weight guard. Smoke-tested end-to-end via scripts/dev-recommender-smoke.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
scripts/dev-recommender-smoke.ts
Normal file
52
scripts/dev-recommender-smoke.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Dev-only smoke check for the berth recommender. Resolves the first
|
||||||
|
* port-nimara interest (with desired dims set) and prints the top-N
|
||||||
|
* recommendations.
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/dev-recommender-smoke.ts
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { eq, isNotNull, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [port] = await db
|
||||||
|
.select({ id: ports.id })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.slug, 'port-nimara'))
|
||||||
|
.limit(1);
|
||||||
|
if (!port) throw new Error('port-nimara not found');
|
||||||
|
|
||||||
|
const [interest] = await db
|
||||||
|
.select({ id: interests.id })
|
||||||
|
.from(interests)
|
||||||
|
.where(and(eq(interests.portId, port.id), isNotNull(interests.desiredLengthFt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!interest) throw new Error('No interest with desired dims set');
|
||||||
|
|
||||||
|
console.log(`> Recommending berths for interest ${interest.id} on port ${port.id}…`);
|
||||||
|
const recs = await recommendBerths({
|
||||||
|
interestId: interest.id,
|
||||||
|
portId: port.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`> ${recs.length} recommendations:`);
|
||||||
|
for (const r of recs) {
|
||||||
|
console.log(
|
||||||
|
` ${r.mooringNumber.padEnd(5)} tier=${r.tier} fit=${r.fitScore} ` +
|
||||||
|
`${r.lengthFt}×${r.widthFt}×${r.draftFt} ft buf=${r.sizeBufferPct}% ` +
|
||||||
|
`${r.reasons.dimensional}; ${r.reasons.pipeline}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
631
src/lib/services/berth-recommender.service.ts
Normal file
631
src/lib/services/berth-recommender.service.ts
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
/**
|
||||||
|
* Berth recommender (plan §4.4 + §13).
|
||||||
|
*
|
||||||
|
* Pure SQL ranking - no AI. The single CTE chain:
|
||||||
|
*
|
||||||
|
* 1. interest_input - the desired dimensions + port for this interest
|
||||||
|
* 2. feasible - berths in the port that can fit the yacht
|
||||||
|
* (length/width/draft >= desired) AND don't exceed
|
||||||
|
* the configured max-oversize percentage
|
||||||
|
* 3. tier_inputs - per-berth aggregates over interest_berths +
|
||||||
|
* interests: active count, lost count, max stage
|
||||||
|
* among active interests, latest fall-through
|
||||||
|
* timestamp, total historical interest count,
|
||||||
|
* EOI-signed count
|
||||||
|
* 4. classified - assign tier A/B/C/D
|
||||||
|
*
|
||||||
|
* The feasible berths are returned to JavaScript for fit-score + heat
|
||||||
|
* calculation - keeping that out of SQL lets per-port admins tune the
|
||||||
|
* heat weights without a code deploy.
|
||||||
|
*
|
||||||
|
* Heat scoring (only relevant for tier B; berths surface back into the
|
||||||
|
* recommender pool after a fall-through):
|
||||||
|
* - recency : how recently did the last interest fall through
|
||||||
|
* - furthest stage : how close did the prior interest get to closing
|
||||||
|
* - historical interest : how often does this berth attract interest
|
||||||
|
* - historical EOI count : how often does interest convert to EOI
|
||||||
|
*
|
||||||
|
* Per-port settings (defaults below) drive the cap, fall-through policy,
|
||||||
|
* heat weights, and tier-D visibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
|
||||||
|
// ─── Settings ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RecommenderSettings {
|
||||||
|
maxOversizePct: number;
|
||||||
|
topNDefault: number;
|
||||||
|
fallthroughPolicy: 'immediate_with_heat' | 'cooldown' | 'never_auto_recommend';
|
||||||
|
fallthroughCooldownDays: number;
|
||||||
|
heatWeightRecency: number;
|
||||||
|
heatWeightFurthestStage: number;
|
||||||
|
heatWeightInterestCount: number;
|
||||||
|
heatWeightEoiCount: number;
|
||||||
|
tierLadderHideLateStage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RECOMMENDER_SETTINGS: RecommenderSettings = {
|
||||||
|
maxOversizePct: 30,
|
||||||
|
topNDefault: 8,
|
||||||
|
fallthroughPolicy: 'immediate_with_heat',
|
||||||
|
fallthroughCooldownDays: 30,
|
||||||
|
heatWeightRecency: 30,
|
||||||
|
heatWeightFurthestStage: 40,
|
||||||
|
heatWeightInterestCount: 15,
|
||||||
|
heatWeightEoiCount: 15,
|
||||||
|
tierLadderHideLateStage: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_KEYS = {
|
||||||
|
maxOversizePct: 'recommender_max_oversize_pct',
|
||||||
|
topNDefault: 'recommender_top_n_default',
|
||||||
|
fallthroughPolicy: 'fallthrough_policy',
|
||||||
|
fallthroughCooldownDays: 'fallthrough_cooldown_days',
|
||||||
|
heatWeightRecency: 'heat_weight_recency',
|
||||||
|
heatWeightFurthestStage: 'heat_weight_furthest_stage',
|
||||||
|
heatWeightInterestCount: 'heat_weight_interest_count',
|
||||||
|
heatWeightEoiCount: 'heat_weight_eoi_count',
|
||||||
|
tierLadderHideLateStage: 'tier_ladder_hide_late_stage',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads recommender settings for the port, layered over the defaults.
|
||||||
|
* Per-port row wins; null portId row is the global fallback; defaults
|
||||||
|
* cover anything still missing.
|
||||||
|
*/
|
||||||
|
export async function loadRecommenderSettings(portId: string): Promise<RecommenderSettings> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ key: systemSettings.key, value: systemSettings.value, portId: systemSettings.portId })
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(systemSettings.key, Object.values(SETTINGS_KEYS)),
|
||||||
|
sql`(${systemSettings.portId} = ${portId} OR ${systemSettings.portId} IS NULL)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const portRows = new Map<string, unknown>();
|
||||||
|
const globalRows = new Map<string, unknown>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.portId === portId) portRows.set(r.key, r.value);
|
||||||
|
else if (r.portId === null) globalRows.set(r.key, r.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = <T>(key: string, parse: (v: unknown) => T | null, fallback: T): T => {
|
||||||
|
const portVal = portRows.has(key) ? parse(portRows.get(key)) : null;
|
||||||
|
if (portVal !== null) return portVal;
|
||||||
|
const globalVal = globalRows.has(key) ? parse(globalRows.get(key)) : null;
|
||||||
|
if (globalVal !== null) return globalVal;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asNumber = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const asBool = (v: unknown): boolean | null => (typeof v === 'boolean' ? v : null);
|
||||||
|
const asPolicy = (v: unknown): RecommenderSettings['fallthroughPolicy'] | null => {
|
||||||
|
if (v === 'immediate_with_heat' || v === 'cooldown' || v === 'never_auto_recommend') {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxOversizePct: pick(
|
||||||
|
SETTINGS_KEYS.maxOversizePct,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.maxOversizePct,
|
||||||
|
),
|
||||||
|
topNDefault: pick(
|
||||||
|
SETTINGS_KEYS.topNDefault,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.topNDefault,
|
||||||
|
),
|
||||||
|
fallthroughPolicy: pick(
|
||||||
|
SETTINGS_KEYS.fallthroughPolicy,
|
||||||
|
asPolicy,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.fallthroughPolicy,
|
||||||
|
),
|
||||||
|
fallthroughCooldownDays: pick(
|
||||||
|
SETTINGS_KEYS.fallthroughCooldownDays,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.fallthroughCooldownDays,
|
||||||
|
),
|
||||||
|
heatWeightRecency: pick(
|
||||||
|
SETTINGS_KEYS.heatWeightRecency,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightRecency,
|
||||||
|
),
|
||||||
|
heatWeightFurthestStage: pick(
|
||||||
|
SETTINGS_KEYS.heatWeightFurthestStage,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightFurthestStage,
|
||||||
|
),
|
||||||
|
heatWeightInterestCount: pick(
|
||||||
|
SETTINGS_KEYS.heatWeightInterestCount,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightInterestCount,
|
||||||
|
),
|
||||||
|
heatWeightEoiCount: pick(
|
||||||
|
SETTINGS_KEYS.heatWeightEoiCount,
|
||||||
|
asNumber,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightEoiCount,
|
||||||
|
),
|
||||||
|
tierLadderHideLateStage: pick(
|
||||||
|
SETTINGS_KEYS.tierLadderHideLateStage,
|
||||||
|
asBool,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS.tierLadderHideLateStage,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tier mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STAGE_ORDER: Record<string, number> = {
|
||||||
|
open: 1,
|
||||||
|
details_sent: 2,
|
||||||
|
in_communication: 3,
|
||||||
|
eoi_sent: 4,
|
||||||
|
eoi_signed: 5,
|
||||||
|
deposit_10pct: 6,
|
||||||
|
contract_sent: 7,
|
||||||
|
contract_signed: 8,
|
||||||
|
completed: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stage at which a berth is "in late stage" (Tier D when active). */
|
||||||
|
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_10pct!; // 6
|
||||||
|
|
||||||
|
export type Tier = 'A' | 'B' | 'C' | 'D';
|
||||||
|
|
||||||
|
interface TierInputs {
|
||||||
|
activeInterestCount: number;
|
||||||
|
lostCount: number;
|
||||||
|
maxActiveStage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyTier(t: TierInputs): Tier {
|
||||||
|
if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D';
|
||||||
|
if (t.activeInterestCount > 0) return 'C';
|
||||||
|
if (t.lostCount > 0) return 'B';
|
||||||
|
return 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heat scoring ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HeatInputs {
|
||||||
|
latestFallthroughAt: Date | null;
|
||||||
|
totalInterestCount: number;
|
||||||
|
eoiSignedCount: number;
|
||||||
|
/** Stage code at the time of the most recent fall-through. */
|
||||||
|
fallthroughMaxStage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatBreakdown {
|
||||||
|
recency: number;
|
||||||
|
furthestStage: number;
|
||||||
|
interestCount: number;
|
||||||
|
eoiCount: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0..100 heat score with per-port-tunable weights. Each component is
|
||||||
|
* normalized 0..1 before being multiplied by its weight; the totals sum
|
||||||
|
* to 100 by construction (the four weights are expected to sum to 100,
|
||||||
|
* but the function clamps regardless so admin-tuning errors don't
|
||||||
|
* produce out-of-range scores).
|
||||||
|
*/
|
||||||
|
export function computeHeat(
|
||||||
|
inputs: HeatInputs,
|
||||||
|
weights: Pick<
|
||||||
|
RecommenderSettings,
|
||||||
|
| 'heatWeightRecency'
|
||||||
|
| 'heatWeightFurthestStage'
|
||||||
|
| 'heatWeightInterestCount'
|
||||||
|
| 'heatWeightEoiCount'
|
||||||
|
>,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): HeatBreakdown {
|
||||||
|
const weightSum =
|
||||||
|
weights.heatWeightRecency +
|
||||||
|
weights.heatWeightFurthestStage +
|
||||||
|
weights.heatWeightInterestCount +
|
||||||
|
weights.heatWeightEoiCount;
|
||||||
|
const norm = weightSum > 0 ? 100 / weightSum : 0;
|
||||||
|
|
||||||
|
// 1.0 if fallthrough <= 30 days, decays linearly to 0 at 365 days.
|
||||||
|
let recency = 0;
|
||||||
|
if (inputs.latestFallthroughAt) {
|
||||||
|
const ageDays = (now.getTime() - inputs.latestFallthroughAt.getTime()) / 86_400_000;
|
||||||
|
if (ageDays <= 30) recency = 1;
|
||||||
|
else if (ageDays >= 365) recency = 0;
|
||||||
|
else recency = 1 - (ageDays - 30) / (365 - 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 if max stage is open; 1 at signed/deposit/contract.
|
||||||
|
const furthestStage =
|
||||||
|
inputs.fallthroughMaxStage <= 1
|
||||||
|
? 0
|
||||||
|
: Math.min(1, (inputs.fallthroughMaxStage - 1) / (LATE_STAGE_THRESHOLD - 1));
|
||||||
|
|
||||||
|
// 0 at 0 interests, 1 at 5+ interests.
|
||||||
|
const interestCount = Math.min(1, inputs.totalInterestCount / 5);
|
||||||
|
|
||||||
|
// 0 at 0 EOIs, 1 at 3+ EOIs.
|
||||||
|
const eoiCount = Math.min(1, inputs.eoiSignedCount / 3);
|
||||||
|
|
||||||
|
const r = recency * weights.heatWeightRecency * norm;
|
||||||
|
const f = furthestStage * weights.heatWeightFurthestStage * norm;
|
||||||
|
const ic = interestCount * weights.heatWeightInterestCount * norm;
|
||||||
|
const ec = eoiCount * weights.heatWeightEoiCount * norm;
|
||||||
|
const total = Math.max(0, Math.min(100, r + f + ic + ec));
|
||||||
|
|
||||||
|
return {
|
||||||
|
recency: Math.round(r * 100) / 100,
|
||||||
|
furthestStage: Math.round(f * 100) / 100,
|
||||||
|
interestCount: Math.round(ic * 100) / 100,
|
||||||
|
eoiCount: Math.round(ec * 100) / 100,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recommend ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RecommendBerthsArgs {
|
||||||
|
interestId: string;
|
||||||
|
portId: string;
|
||||||
|
/** Override the per-port topNDefault. */
|
||||||
|
topN?: number;
|
||||||
|
/** Override the per-port maxOversizePct. */
|
||||||
|
maxOversizePct?: number;
|
||||||
|
/** Show late-stage (Tier D) berths even when the per-port setting hides them. */
|
||||||
|
showLateStage?: boolean;
|
||||||
|
/** Optional rep-supplied amenity filters. */
|
||||||
|
amenityFilters?: {
|
||||||
|
minPowerCapacityKw?: number;
|
||||||
|
requiredVoltage?: number;
|
||||||
|
requiredAccess?: string;
|
||||||
|
requiredMooringType?: string;
|
||||||
|
requiredCleatCapacity?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
area: string | null;
|
||||||
|
tier: Tier;
|
||||||
|
fitScore: number;
|
||||||
|
sizeBufferPct: number | null;
|
||||||
|
heat: HeatBreakdown | null;
|
||||||
|
reasons: {
|
||||||
|
dimensional: string;
|
||||||
|
pipeline: string;
|
||||||
|
amenities?: string;
|
||||||
|
heat?: string;
|
||||||
|
};
|
||||||
|
lengthFt: number | null;
|
||||||
|
widthFt: number | null;
|
||||||
|
draftFt: number | null;
|
||||||
|
status: string;
|
||||||
|
amenities: {
|
||||||
|
powerCapacity: number | null;
|
||||||
|
voltage: number | null;
|
||||||
|
access: string | null;
|
||||||
|
mooringType: string | null;
|
||||||
|
cleatCapacity: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawRow extends Record<string, unknown> {
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
area: string | null;
|
||||||
|
status: string;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
powerCapacity: string | null;
|
||||||
|
voltage: string | null;
|
||||||
|
access: string | null;
|
||||||
|
mooringType: string | null;
|
||||||
|
cleatCapacity: string | null;
|
||||||
|
activeInterestCount: number;
|
||||||
|
lostCount: number;
|
||||||
|
maxActiveStage: number;
|
||||||
|
latestFallthroughAt: Date | null;
|
||||||
|
fallthroughMaxStage: number;
|
||||||
|
totalInterestCount: number;
|
||||||
|
eoiSignedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InterestInput {
|
||||||
|
desiredLengthFt: number | null;
|
||||||
|
desiredWidthFt: number | null;
|
||||||
|
desiredDraftFt: number | null;
|
||||||
|
portId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInterestInput(interestId: string): Promise<InterestInput | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
desiredLengthFt: interests.desiredLengthFt,
|
||||||
|
desiredWidthFt: interests.desiredWidthFt,
|
||||||
|
desiredDraftFt: interests.desiredDraftFt,
|
||||||
|
portId: interests.portId,
|
||||||
|
})
|
||||||
|
.from(interests)
|
||||||
|
.where(eq(interests.id, interestId))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) return null;
|
||||||
|
const toNum = (v: string | null): number | null => {
|
||||||
|
if (v === null) return null;
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
desiredLengthFt: toNum(row.desiredLengthFt),
|
||||||
|
desiredWidthFt: toNum(row.desiredWidthFt),
|
||||||
|
desiredDraftFt: toNum(row.desiredDraftFt),
|
||||||
|
portId: row.portId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the recommender. Returns ranked recommendations top-N. Multi-port
|
||||||
|
* isolation is enforced both in the CTE (`b.port_id = $portId`) and via
|
||||||
|
* the interest's own port_id - cross-port queries fail explicitly
|
||||||
|
* rather than leak (§14.10 critical).
|
||||||
|
*/
|
||||||
|
export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recommendation[]> {
|
||||||
|
const settings = await loadRecommenderSettings(args.portId);
|
||||||
|
const interestInput = await loadInterestInput(args.interestId);
|
||||||
|
if (!interestInput) return [];
|
||||||
|
if (interestInput.portId !== args.portId) {
|
||||||
|
// Defensive: caller passed a port that doesn't own this interest.
|
||||||
|
throw new Error(
|
||||||
|
`Recommender: interest ${args.interestId} belongs to port ${interestInput.portId}, not ${args.portId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizePct = args.maxOversizePct ?? settings.maxOversizePct;
|
||||||
|
const topN = args.topN ?? settings.topNDefault;
|
||||||
|
const showLateStage = args.showLateStage ?? !settings.tierLadderHideLateStage;
|
||||||
|
|
||||||
|
const oversizeMultiplier = 1 + oversizePct / 100;
|
||||||
|
|
||||||
|
const predicates: ReturnType<typeof sql>[] = [
|
||||||
|
sql`b.port_id = ${args.portId}`,
|
||||||
|
sql`b.status <> 'sold'`,
|
||||||
|
];
|
||||||
|
if (interestInput.desiredLengthFt !== null) {
|
||||||
|
predicates.push(sql`b.length_ft::numeric >= ${interestInput.desiredLengthFt}`);
|
||||||
|
predicates.push(
|
||||||
|
sql`b.length_ft::numeric <= ${interestInput.desiredLengthFt}::numeric * ${oversizeMultiplier}::numeric`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (interestInput.desiredWidthFt !== null) {
|
||||||
|
predicates.push(sql`b.width_ft::numeric >= ${interestInput.desiredWidthFt}`);
|
||||||
|
}
|
||||||
|
if (interestInput.desiredDraftFt !== null) {
|
||||||
|
predicates.push(sql`b.draft_ft::numeric >= ${interestInput.desiredDraftFt}`);
|
||||||
|
}
|
||||||
|
if (args.amenityFilters?.minPowerCapacityKw != null) {
|
||||||
|
predicates.push(sql`b.power_capacity::numeric >= ${args.amenityFilters.minPowerCapacityKw}`);
|
||||||
|
}
|
||||||
|
if (args.amenityFilters?.requiredVoltage != null) {
|
||||||
|
predicates.push(sql`b.voltage::numeric = ${args.amenityFilters.requiredVoltage}`);
|
||||||
|
}
|
||||||
|
if (args.amenityFilters?.requiredAccess) {
|
||||||
|
predicates.push(sql`b.access = ${args.amenityFilters.requiredAccess}`);
|
||||||
|
}
|
||||||
|
if (args.amenityFilters?.requiredMooringType) {
|
||||||
|
predicates.push(sql`b.mooring_type = ${args.amenityFilters.requiredMooringType}`);
|
||||||
|
}
|
||||||
|
if (args.amenityFilters?.requiredCleatCapacity) {
|
||||||
|
predicates.push(sql`b.cleat_capacity = ${args.amenityFilters.requiredCleatCapacity}`);
|
||||||
|
}
|
||||||
|
const whereClause = sql.join(predicates, sql` AND `);
|
||||||
|
|
||||||
|
const rawRows = await db.execute<RawRow>(sql`
|
||||||
|
WITH feasible AS (
|
||||||
|
SELECT b.*
|
||||||
|
FROM berths b
|
||||||
|
WHERE ${whereClause}
|
||||||
|
),
|
||||||
|
aggregates AS (
|
||||||
|
SELECT
|
||||||
|
f.id AS berth_id,
|
||||||
|
COUNT(*) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL) AS active_interest_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE i.outcome IS NOT NULL AND i.outcome::text LIKE 'lost%'
|
||||||
|
) AS lost_count,
|
||||||
|
COALESCE(
|
||||||
|
MAX(CASE i.pipeline_stage
|
||||||
|
WHEN 'open' THEN 1
|
||||||
|
WHEN 'details_sent' THEN 2
|
||||||
|
WHEN 'in_communication' THEN 3
|
||||||
|
WHEN 'eoi_sent' THEN 4
|
||||||
|
WHEN 'eoi_signed' THEN 5
|
||||||
|
WHEN 'deposit_10pct' THEN 6
|
||||||
|
WHEN 'contract_sent' THEN 7
|
||||||
|
WHEN 'contract_signed' THEN 8
|
||||||
|
ELSE 0 END
|
||||||
|
) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL),
|
||||||
|
0
|
||||||
|
) AS max_active_stage,
|
||||||
|
MAX(i.outcome_at) FILTER (
|
||||||
|
WHERE i.outcome IS NOT NULL AND i.outcome::text LIKE 'lost%'
|
||||||
|
) AS latest_fallthrough_at,
|
||||||
|
COALESCE(
|
||||||
|
MAX(CASE i.pipeline_stage
|
||||||
|
WHEN 'open' THEN 1
|
||||||
|
WHEN 'details_sent' THEN 2
|
||||||
|
WHEN 'in_communication' THEN 3
|
||||||
|
WHEN 'eoi_sent' THEN 4
|
||||||
|
WHEN 'eoi_signed' THEN 5
|
||||||
|
WHEN 'deposit_10pct' THEN 6
|
||||||
|
WHEN 'contract_sent' THEN 7
|
||||||
|
WHEN 'contract_signed' THEN 8
|
||||||
|
ELSE 0 END
|
||||||
|
) FILTER (WHERE i.outcome IS NOT NULL AND i.outcome::text LIKE 'lost%'),
|
||||||
|
0
|
||||||
|
) AS fallthrough_max_stage,
|
||||||
|
COUNT(*) AS total_interest_count,
|
||||||
|
COUNT(*) FILTER (WHERE i.eoi_status = 'signed') AS eoi_signed_count
|
||||||
|
FROM feasible f
|
||||||
|
LEFT JOIN interest_berths ib ON ib.berth_id = f.id
|
||||||
|
LEFT JOIN interests i ON i.id = ib.interest_id
|
||||||
|
GROUP BY f.id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.id AS "berthId",
|
||||||
|
f.mooring_number AS "mooringNumber",
|
||||||
|
f.area,
|
||||||
|
f.status,
|
||||||
|
f.length_ft AS "lengthFt",
|
||||||
|
f.width_ft AS "widthFt",
|
||||||
|
f.draft_ft AS "draftFt",
|
||||||
|
f.power_capacity AS "powerCapacity",
|
||||||
|
f.voltage,
|
||||||
|
f.access,
|
||||||
|
f.mooring_type AS "mooringType",
|
||||||
|
f.cleat_capacity AS "cleatCapacity",
|
||||||
|
a.active_interest_count::int AS "activeInterestCount",
|
||||||
|
a.lost_count::int AS "lostCount",
|
||||||
|
a.max_active_stage::int AS "maxActiveStage",
|
||||||
|
a.latest_fallthrough_at AS "latestFallthroughAt",
|
||||||
|
a.fallthrough_max_stage::int AS "fallthroughMaxStage",
|
||||||
|
a.total_interest_count::int AS "totalInterestCount",
|
||||||
|
a.eoi_signed_count::int AS "eoiSignedCount"
|
||||||
|
FROM feasible f
|
||||||
|
JOIN aggregates a ON a.berth_id = f.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = (rawRows as { rows?: RawRow[] }).rows ?? (rawRows as unknown as RawRow[]);
|
||||||
|
|
||||||
|
// Apply tier classification + fall-through cooldown filter + heat scoring + fit ranking in JS.
|
||||||
|
const cooldownCutoff =
|
||||||
|
settings.fallthroughPolicy === 'cooldown'
|
||||||
|
? new Date(Date.now() - settings.fallthroughCooldownDays * 86_400_000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const recommendations: Recommendation[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const tier = classifyTier(r);
|
||||||
|
if (tier === 'D' && !showLateStage) continue;
|
||||||
|
|
||||||
|
if (tier === 'B' && settings.fallthroughPolicy === 'never_auto_recommend') continue;
|
||||||
|
if (
|
||||||
|
tier === 'B' &&
|
||||||
|
cooldownCutoff &&
|
||||||
|
r.latestFallthroughAt &&
|
||||||
|
new Date(r.latestFallthroughAt) > cooldownCutoff
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthFt = r.lengthFt === null ? null : parseFloat(r.lengthFt);
|
||||||
|
const widthFt = r.widthFt === null ? null : parseFloat(r.widthFt);
|
||||||
|
const draftFt = r.draftFt === null ? null : parseFloat(r.draftFt);
|
||||||
|
|
||||||
|
const sizeBufferPct =
|
||||||
|
interestInput.desiredLengthFt && lengthFt
|
||||||
|
? Math.round(
|
||||||
|
((lengthFt - interestInput.desiredLengthFt) / interestInput.desiredLengthFt) * 1000,
|
||||||
|
) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const heat =
|
||||||
|
tier === 'B'
|
||||||
|
? computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: r.latestFallthroughAt ? new Date(r.latestFallthroughAt) : null,
|
||||||
|
totalInterestCount: r.totalInterestCount,
|
||||||
|
eoiSignedCount: r.eoiSignedCount,
|
||||||
|
fallthroughMaxStage: r.fallthroughMaxStage,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Fit score: tier weight + 1/(1+buffer) closeness + heat boost.
|
||||||
|
// Tier base scores: A=80, B=60+heat, C=40, D=20. Smaller buffer adds up to 20.
|
||||||
|
const tierBase = { A: 80, B: 60, C: 40, D: 20 }[tier];
|
||||||
|
const closeness =
|
||||||
|
sizeBufferPct === null
|
||||||
|
? 10
|
||||||
|
: Math.max(0, 20 - Math.abs(sizeBufferPct) / Math.max(1, oversizePct / 20));
|
||||||
|
const heatBoost = heat ? heat.total / 5 : 0;
|
||||||
|
const fitScore = Math.max(0, Math.min(100, tierBase + closeness + heatBoost));
|
||||||
|
|
||||||
|
recommendations.push({
|
||||||
|
berthId: r.berthId,
|
||||||
|
mooringNumber: r.mooringNumber,
|
||||||
|
area: r.area,
|
||||||
|
tier,
|
||||||
|
fitScore: Math.round(fitScore * 10) / 10,
|
||||||
|
sizeBufferPct,
|
||||||
|
heat,
|
||||||
|
reasons: {
|
||||||
|
dimensional:
|
||||||
|
sizeBufferPct === null
|
||||||
|
? 'No length specified - all feasible berths shown'
|
||||||
|
: `Within ${Math.abs(sizeBufferPct)}% of desired length`,
|
||||||
|
pipeline: pipelineReason(tier, r),
|
||||||
|
...(heat ? { heat: heatSummary(heat) } : {}),
|
||||||
|
...(args.amenityFilters && Object.keys(args.amenityFilters).length > 0
|
||||||
|
? { amenities: 'All required amenities matched' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
lengthFt,
|
||||||
|
widthFt,
|
||||||
|
draftFt,
|
||||||
|
status: r.status,
|
||||||
|
amenities: {
|
||||||
|
powerCapacity: r.powerCapacity === null ? null : parseFloat(r.powerCapacity),
|
||||||
|
voltage: r.voltage === null ? null : parseFloat(r.voltage),
|
||||||
|
access: r.access,
|
||||||
|
mooringType: r.mooringType,
|
||||||
|
cleatCapacity: r.cleatCapacity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recommendations.sort((a, b) => {
|
||||||
|
const tierRank = (t: Tier): number => ({ A: 1, B: 2, C: 3, D: 4 })[t];
|
||||||
|
if (tierRank(a.tier) !== tierRank(b.tier)) return tierRank(a.tier) - tierRank(b.tier);
|
||||||
|
return b.fitScore - a.fitScore;
|
||||||
|
});
|
||||||
|
|
||||||
|
return recommendations.slice(0, topN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipelineReason(tier: Tier, r: RawRow): string {
|
||||||
|
switch (tier) {
|
||||||
|
case 'A':
|
||||||
|
return 'No prior interest history';
|
||||||
|
case 'B':
|
||||||
|
return `Previously fell through (${r.lostCount} time${r.lostCount === 1 ? '' : 's'})`;
|
||||||
|
case 'C':
|
||||||
|
return `${r.activeInterestCount} active interest${r.activeInterestCount === 1 ? '' : 's'} in early stage`;
|
||||||
|
case 'D':
|
||||||
|
return 'Active interest in late stage';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatSummary(h: HeatBreakdown): string {
|
||||||
|
return `Heat ${h.total.toFixed(0)}/100 (recency ${h.recency.toFixed(0)}, stage ${h.furthestStage.toFixed(0)}, count ${h.interestCount.toFixed(0)}, EOI ${h.eoiCount.toFixed(0)})`;
|
||||||
|
}
|
||||||
186
tests/unit/services/berth-recommender.test.ts
Normal file
186
tests/unit/services/berth-recommender.test.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyTier,
|
||||||
|
computeHeat,
|
||||||
|
DEFAULT_RECOMMENDER_SETTINGS,
|
||||||
|
} from '@/lib/services/berth-recommender.service';
|
||||||
|
|
||||||
|
describe('classifyTier', () => {
|
||||||
|
it('"A" when there is no interest history at all', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 0, lostCount: 0, maxActiveStage: 0 })).toBe('A');
|
||||||
|
});
|
||||||
|
it('"B" when only lost interests exist (no active)', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B');
|
||||||
|
});
|
||||||
|
it('"C" when an active interest is in an early stage', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 3 })).toBe('C');
|
||||||
|
});
|
||||||
|
it('"C" even when a prior interest was lost, if there is an active one', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C');
|
||||||
|
});
|
||||||
|
it('"D" when an active interest is at deposit or beyond', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 6 })).toBe('D');
|
||||||
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 8 })).toBe('D');
|
||||||
|
});
|
||||||
|
it('still "C" at eoi_signed (stage 5) - tier D only kicks in at deposit', () => {
|
||||||
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('C');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeHeat', () => {
|
||||||
|
const w = DEFAULT_RECOMMENDER_SETTINGS;
|
||||||
|
const NOW = new Date('2026-05-05T00:00:00Z');
|
||||||
|
|
||||||
|
it('zero heat when nothing in history', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: null,
|
||||||
|
totalInterestCount: 0,
|
||||||
|
eoiSignedCount: 0,
|
||||||
|
fallthroughMaxStage: 0,
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full recency for a fall-through within the last 30 days', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: new Date('2026-04-25T00:00:00Z'),
|
||||||
|
totalInterestCount: 0,
|
||||||
|
eoiSignedCount: 0,
|
||||||
|
fallthroughMaxStage: 1,
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
// recency component should be the full heat_weight_recency (30)
|
||||||
|
expect(h.recency).toBeCloseTo(30, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero recency for an ancient fall-through (>1 year)', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
totalInterestCount: 0,
|
||||||
|
eoiSignedCount: 0,
|
||||||
|
fallthroughMaxStage: 1,
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.recency).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full furthest-stage when the fall-through reached deposit', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: null,
|
||||||
|
totalInterestCount: 0,
|
||||||
|
eoiSignedCount: 0,
|
||||||
|
fallthroughMaxStage: 6, // deposit_10pct
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.furthestStage).toBeCloseTo(40, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saturates interest-count at 5+', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: null,
|
||||||
|
totalInterestCount: 10,
|
||||||
|
eoiSignedCount: 0,
|
||||||
|
fallthroughMaxStage: 0,
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.interestCount).toBeCloseTo(15, 1); // full weight
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saturates EOI-count at 3+', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: null,
|
||||||
|
totalInterestCount: 0,
|
||||||
|
eoiSignedCount: 5,
|
||||||
|
fallthroughMaxStage: 0,
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.eoiCount).toBeCloseTo(15, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('total ≈ 100 when everything is maxed', () => {
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: new Date('2026-04-25T00:00:00Z'),
|
||||||
|
totalInterestCount: 5,
|
||||||
|
eoiSignedCount: 3,
|
||||||
|
fallthroughMaxStage: 6,
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.total).toBeGreaterThanOrEqual(99);
|
||||||
|
expect(h.total).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects tunable per-port weights (skewed toward recency)', () => {
|
||||||
|
const skewed = {
|
||||||
|
heatWeightRecency: 100,
|
||||||
|
heatWeightFurthestStage: 0,
|
||||||
|
heatWeightInterestCount: 0,
|
||||||
|
heatWeightEoiCount: 0,
|
||||||
|
};
|
||||||
|
const recent = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: new Date('2026-04-25T00:00:00Z'),
|
||||||
|
totalInterestCount: 0,
|
||||||
|
eoiSignedCount: 0,
|
||||||
|
fallthroughMaxStage: 0,
|
||||||
|
},
|
||||||
|
skewed,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(recent.total).toBeCloseTo(100, 1);
|
||||||
|
|
||||||
|
const old = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
totalInterestCount: 5,
|
||||||
|
eoiSignedCount: 3,
|
||||||
|
fallthroughMaxStage: 6,
|
||||||
|
},
|
||||||
|
skewed,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(old.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero-weights guard (no division-by-zero blow-up)', () => {
|
||||||
|
const zeros = {
|
||||||
|
heatWeightRecency: 0,
|
||||||
|
heatWeightFurthestStage: 0,
|
||||||
|
heatWeightInterestCount: 0,
|
||||||
|
heatWeightEoiCount: 0,
|
||||||
|
};
|
||||||
|
const h = computeHeat(
|
||||||
|
{
|
||||||
|
latestFallthroughAt: new Date(),
|
||||||
|
totalInterestCount: 5,
|
||||||
|
eoiSignedCount: 3,
|
||||||
|
fallthroughMaxStage: 6,
|
||||||
|
},
|
||||||
|
zeros,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(h.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user