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>
53 lines
1.6 KiB
TypeScript
53 lines
1.6 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|