/** * Nearest divisional-chart change, from window-scan transitions only. * Question order is a switch and stays off unless the env flag is "on". */ export const CHART_LAYERS = ["d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1"] as const; const LAYER_DOMAIN: Readonly> = { d9: "relationship", d10: "career", d4: "relocation", d5: "education", d24: "education", d7: "family", d12: "family", d2: "finance", d11: "finance", d30: "health_pressure", }; const LAYER_TOPIC: Readonly> = { d9: "婚恋", d10: "工作", d4: "住处", d5: "学业", d24: "学业", d7: "子女", d12: "父母", d2: "钱", d11: "钱", d30: "身体", }; /** Not adopted. The 77-case gate was not cleared, so the switch stays off. */ export const NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5; export type BoundaryLayer = Readonly<{ layer: string; fromSign: string; toSign: string; topic: string | null; domain: string | null; }>; export type NearestBoundary = Readonly<{ found: boolean; reason: "unavailable" | "no_transition" | null; at: string | null; distanceMinutes: number | null; layers: readonly BoundaryLayer[]; topics: readonly string[]; domains: readonly string[]; }>; const EMPTY: NearestBoundary = { found: false, reason: "unavailable", at: null, distanceMinutes: null, layers: [], topics: [], domains: [], }; function clock(value: string | null | undefined): number | null { const raw = value?.trim().slice(0, 5) ?? ""; if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(raw)) return null; return Number(raw.slice(0, 2)) * 60 + Number(raw.slice(3, 5)); } function distance(left: number, right: number): number { const span = Math.abs(left - right); return Math.min(span, 1440 - span); } export function nearestBoundaryFromTransitions( transitions: readonly Readonly<{ layer?: string; at?: string; from_sign?: string; to_sign?: string }>[] | null | undefined, recordedTime: string | null | undefined, ): NearestBoundary { const recorded = clock(recordedTime); if (recorded === null || !transitions) return EMPTY; const changes: { at: number; layer: string; fromSign: string; toSign: string }[] = []; for (const row of transitions) { const layer = row.layer ?? ""; if (!CHART_LAYERS.includes(layer as (typeof CHART_LAYERS)[number])) continue; const before = row.from_sign; const after = row.to_sign; if (!before || !after || before === after) continue; const at = clock(row.at); if (at === null) continue; changes.push({ at, layer, fromSign: before, toSign: after }); } if (!changes.length) return { ...EMPTY, reason: "no_transition" }; const bestAt = changes.reduce((best, row) => { const next = distance(row.at, recorded); const current = distance(best, recorded); return next < current || (next === current && row.at < best) ? row.at : best; }, changes[0]!.at); const layers: BoundaryLayer[] = []; const seen = new Set(); for (const layer of CHART_LAYERS) { const row = changes.find((item) => item.at === bestAt && item.layer === layer); if (!row || seen.has(layer)) continue; seen.add(layer); layers.push({ layer, fromSign: row.fromSign, toSign: row.toSign, topic: LAYER_TOPIC[layer] ?? null, domain: LAYER_DOMAIN[layer] ?? null, }); } const topics = topicsForLayers(layers.map((row) => row.layer)); const domains: string[] = []; for (const row of layers) { if (row.domain && !domains.includes(row.domain)) domains.push(row.domain); } return { found: true, reason: null, at: `${String(Math.floor(bestAt / 60)).padStart(2, "0")}:${String(bestAt % 60).padStart(2, "0")}`, distanceMinutes: distance(bestAt, recorded), layers, topics, domains, }; } /** Spoken topics, in chart order. Unknown layers and D1 add nothing. */ export function topicsForLayers(layers: readonly string[]): string[] { const topics: string[] = []; for (const layer of CHART_LAYERS) { if (!layers.includes(layer)) continue; const topic = LAYER_TOPIC[layer]; if (topic && !topics.includes(topic)) topics.push(topic); } return topics; } export function nearestBoundaryProbesEnabled(flag: string | undefined = process.env.RECTIFICATION_NEAREST_BOUNDARY_PROBES): boolean { return flag === "on"; } /** Boundary-domain questions first; everything else keeps its incoming order. */ export function orderByBoundaryDomains( probes: readonly T[], domains: readonly string[], domainOf: (probe: T) => string, ): readonly T[] { if (!domains.length || probes.length < 2) return probes; const wanted = new Set(domains); const first: T[] = []; const rest: T[] = []; for (const probe of probes) { const domain = domainOf(probe); (domain && wanted.has(domain) ? first : rest).push(probe); } if (!first.length || !rest.length) return probes; return [...first, ...rest]; }