Retire the 09-17 opener shape (coined pattern name, push/tidy periods, play-a-role) that produced riddles; one shape definition in consultation-thinking-plan; parents card names mother/father roles with MK; empty-house rule; thinking bar closes the opener at any H2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
927 lines
39 KiB
TypeScript
927 lines
39 KiB
TypeScript
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
|
|
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
|
|
import type { ProfileGender } from "./profile-gender.ts";
|
|
|
|
/**
|
|
* The evidence card: what the answer-writing model reads about the chart
|
|
* (TASK-consult-evidence-card-20260927, D1; v2 per
|
|
* TASK-consult-evidence-card-v2-20260927, the astrologer's review).
|
|
*
|
|
* The consultation still calculates the full spectrum (D6); the full result
|
|
* stays on the server for receipts, the 「本轮技法」 panel, reports and the
|
|
* one-shot evidence lookup. The card is a base section (ascendant, twelve
|
|
* house signs, planet placements, functional benefics/malefics with house
|
|
* lordship, the running Vimshottari MD/AD/PD with dates, the running Narayana
|
|
* period) plus one section per domain, cut to that domain's vargas, houses,
|
|
* planets and layers.
|
|
*
|
|
* Every value is copied from the model projection (`toModelOutput`) or, for
|
|
* the three layers the projection does not carry (chart Shadbala summary,
|
|
* Ashtakavarga house scores, chara karakas), from the engine's own values in
|
|
* the agent context. Nothing is rewritten, rounded, converted or translated;
|
|
* what is missing is listed in `gaps`, never filled in. v2 adds the native
|
|
* technique layers the engine now attaches (scripts/consultation_native_layers.py):
|
|
* a D9 summary in every base section, the annual Tajika chart, Double
|
|
* Transit, Punarphoo, Vivah Saham and the slow-planet transits. The only
|
|
* card-authored strings are `EVIDENCE_CARD_LABELS` (and domain ids that point
|
|
* at another section); selections (which houses, which conclusions) are made
|
|
* here, values never.
|
|
*
|
|
* Pure functions, no React.
|
|
*/
|
|
|
|
export const EVIDENCE_CARD_VERSION = "evidence-card-v2" as const;
|
|
|
|
/** The only strings the card writes itself (decision table, 2026-09-27). */
|
|
export const EVIDENCE_CARD_LABELS = {
|
|
/**
|
|
* The current subject's optional gender (TASK-consult-gender-optional-20260927):
|
|
* 「性别未知」 when it is not filled, otherwise 「女」 / 「男」.
|
|
*/
|
|
genderUnknown: "性别未知",
|
|
genderFemale: "女",
|
|
genderMale: "男",
|
|
/** The annual pack could not be produced reliably; natal data is never put in its place. */
|
|
annualNotAttached: "年盘未接入",
|
|
/** Who a parents-section entry is about (TASK-consult-plain-answer-20261001 D6). */
|
|
roleMother: "母亲",
|
|
roleFather: "父亲",
|
|
} as const;
|
|
|
|
/** KP stays off every card: exact cusps are still blocked (decision table). */
|
|
export const EVIDENCE_LOOKUP_KP_NOTE = "KP 精确宫头仍 blocked:只作参考,不得作为结论依据";
|
|
|
|
/** D4: the one line the card carries instead of the long status layers. */
|
|
export const EVIDENCE_CARD_BACKSTAGE_LINE = "全网资料核对、真实案例校准:未做,置信度封顶";
|
|
|
|
/** chars / 3.5, the research's estimate (not a vendor tokenizer). */
|
|
export const EVIDENCE_CARD_CHARS_PER_TOKEN = 3.5;
|
|
|
|
export type EvidenceCardDomainSpec = Readonly<{
|
|
vargas: readonly string[];
|
|
houses: readonly number[];
|
|
arudha: readonly string[];
|
|
karakas: readonly string[];
|
|
planets: readonly string[];
|
|
yogas: boolean;
|
|
transits: boolean;
|
|
ashtakavarga: boolean;
|
|
shadbala: boolean;
|
|
/** SAV of these houses alone (career: 10H), without the full Ashtakavarga block. */
|
|
savHouses: readonly number[];
|
|
/**
|
|
* Slow-planet transits: `sav` Jupiter / Saturn sign, natal house and SAV
|
|
* (career); `nodes_sav_bav` Jupiter / Saturn with SAV and BAV plus Rahu /
|
|
* Ketu and their ingress dates (timing); `key_dates` the four bodies now and
|
|
* every ingress / station date of the next twelve months (annual).
|
|
*/
|
|
slowTransits: "none" | "sav" | "nodes_sav_bav" | "key_dates";
|
|
/** `seventh_and_karakas`: 7H / 7L (house 7 run) and DK / UL; `all_houses`: every conclusion. */
|
|
doubleTransit: "none" | "seventh_and_karakas" | "all_houses";
|
|
punarphoo: boolean;
|
|
dayNight: boolean;
|
|
vivahSaham: boolean;
|
|
/** The section carries the current subject's gender (「性别未知」 when not filled). */
|
|
gender: boolean;
|
|
/** The annual Tajika chart (annual route only). */
|
|
annualChart: boolean;
|
|
/** Houses of the running and next AD lords and of the year's Jupiter / Saturn transits. */
|
|
annualHouses: boolean;
|
|
/** Vargas follow the other domain of the turn; `vargas` is the default when alone. */
|
|
followVargas: boolean;
|
|
/**
|
|
* Who each piece of the section is about (parents: mother / father), so the
|
|
* answer can keep the people apart (TASK-consult-plain-answer-20261001 D6).
|
|
*/
|
|
roles: readonly EvidenceCardRole[];
|
|
}>;
|
|
|
|
export type EvidenceCardRole = Readonly<{
|
|
key: string;
|
|
label: string;
|
|
house: number;
|
|
planets: readonly string[];
|
|
/** Chara karakas that must be on the card; a missing one is a gap. */
|
|
karakas: readonly string[];
|
|
/** Chara karakas taken only when the engine's scheme has them (PiK is 8-karaka only). */
|
|
optionalKarakas: readonly string[];
|
|
/** The varga whose same-numbered house is named for this role. */
|
|
varga?: string;
|
|
}>;
|
|
|
|
const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSpec, "vargas">): EvidenceCardDomainSpec => ({
|
|
houses: [],
|
|
arudha: [],
|
|
karakas: [],
|
|
planets: [],
|
|
yogas: false,
|
|
transits: false,
|
|
ashtakavarga: false,
|
|
shadbala: false,
|
|
savHouses: [],
|
|
slowTransits: "none",
|
|
doubleTransit: "none",
|
|
punarphoo: false,
|
|
dayNight: false,
|
|
vivahSaham: false,
|
|
gender: false,
|
|
annualChart: false,
|
|
annualHouses: false,
|
|
followVargas: false,
|
|
roles: [],
|
|
...value,
|
|
});
|
|
|
|
/**
|
|
* Domain sections, ported from the research draft `CARD_SPECS`
|
|
* (scripts/research/consult_evidence_card_lib.py; docs/research/
|
|
* consult_evidence_card_draft_2026_09_27.json) as the single source, with the
|
|
* v2 decision table on top (TASK-consult-evidence-card-v2-20260927):
|
|
* - career: + AL, 10H SAV, SAV of the Jupiter / Saturn transit signs;
|
|
* - marriage: + 5H / 5L, day / night, Punarphoo (observation_only), Double
|
|
* Transit on 7H / 7L / DK / UL, Vivah Saham, the subject's gender (「性别未知」
|
|
* when not filled);
|
|
* - wealth: 8H / 12H named;
|
|
* - annual: the annual Tajika chart, houses of the running / next AD lords and
|
|
* of the year's Jupiter / Saturn transits, ingress and station dates only;
|
|
* - timing: Rahu / Ketu, Double Transit conclusions, Jupiter / Saturn SAV and
|
|
* BAV; vargas follow the turn's other domain (D9 when alone);
|
|
* - Chara Dasha leaves every card (lookup); KP is never on a card.
|
|
* Health carries D30 and education D5 as in the Python focus techniques.
|
|
*/
|
|
export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCardDomainSpec>> = {
|
|
career: spec({
|
|
vargas: ["D10"], houses: [10], arudha: ["A10", "AL"], karakas: ["AmK"], yogas: true, transits: true, shadbala: true,
|
|
savHouses: [10], slowTransits: "sav",
|
|
}),
|
|
marriage: spec({
|
|
vargas: ["D9"], houses: [7, 5], arudha: ["UL", "A7"], karakas: ["DK"], planets: ["Venus", "Jupiter"], transits: true,
|
|
punarphoo: true, dayNight: true, vivahSaham: true, doubleTransit: "seventh_and_karakas", gender: true,
|
|
}),
|
|
wealth: spec({ vargas: ["D2", "D11"], houses: [2, 11, 9, 5, 8, 12], yogas: true, ashtakavarga: true, shadbala: true }),
|
|
health: spec({ vargas: ["D6", "D8", "D30"], houses: [6, 8], transits: true, shadbala: true }),
|
|
education: spec({ vargas: ["D5", "D24"], houses: [5, 9], planets: ["Mercury", "Jupiter"] }),
|
|
migration: spec({ vargas: ["D4", "D12"], houses: [4, 12] }),
|
|
family: spec({ vargas: ["D7", "D12"], houses: [4, 5, 9] }),
|
|
parents: spec({
|
|
vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"], karakas: ["MK"],
|
|
roles: [
|
|
{ key: "mother", label: EVIDENCE_CARD_LABELS.roleMother, house: 4, planets: ["Moon"], karakas: ["MK"], optionalKarakas: [], varga: "D12" },
|
|
{ key: "father", label: EVIDENCE_CARD_LABELS.roleFather, house: 9, planets: ["Sun"], karakas: [], optionalKarakas: ["PiK"], varga: "D12" },
|
|
],
|
|
}),
|
|
children: spec({ vargas: ["D7"], houses: [5], karakas: ["PK"], planets: ["Jupiter"] }),
|
|
annual: spec({ vargas: ["D1"], houses: [1], annualChart: true, annualHouses: true, slowTransits: "key_dates" }),
|
|
timing: spec({
|
|
vargas: ["D1", "D9"], planets: ["Saturn", "Jupiter"], transits: true, followVargas: true,
|
|
slowTransits: "nodes_sav_bav", doubleTransit: "all_houses",
|
|
}),
|
|
general: spec({ vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, transits: true, ashtakavarga: true, shadbala: true }),
|
|
};
|
|
|
|
/** The formal vargas the engine materialises (varga_spectrum.formal). */
|
|
export const FORMAL_VARGA_CODES = [
|
|
"D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10",
|
|
"D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60",
|
|
] as const;
|
|
|
|
export const WESTERN_TECHNIQUE_IDS = [
|
|
"transits", "solar_return", "secondary_progressions", "solar_arc_directions",
|
|
"converse_secondary_progressions", "converse_solar_arc_directions", "midpoints",
|
|
"lunar_return", "transit_duration_scan", "parans",
|
|
] as const;
|
|
|
|
/**
|
|
* Sections outside the card that the one-shot lookup tool may return from the
|
|
* request's own calculation (D8). A closed list, so the model cannot ask for
|
|
* anything the server did not compute.
|
|
*/
|
|
export const EVIDENCE_LOOKUP_SECTIONS = [
|
|
"varga:D1", "varga:D2", "varga:D3", "varga:D4", "varga:D5", "varga:D6", "varga:D7",
|
|
"varga:D8", "varga:D9", "varga:D10", "varga:D11", "varga:D12", "varga:D16", "varga:D20",
|
|
"varga:D24", "varga:D27", "varga:D30", "varga:D40", "varga:D45", "varga:D60",
|
|
"varga:research_dn",
|
|
"varga:extended",
|
|
"western:natal",
|
|
"western:transits", "western:solar_return", "western:secondary_progressions",
|
|
"western:solar_arc_directions", "western:converse_secondary_progressions",
|
|
"western:converse_solar_arc_directions", "western:midpoints", "western:lunar_return",
|
|
"western:transit_duration_scan", "western:parans",
|
|
"yogas",
|
|
"ashtakavarga",
|
|
"shadbala",
|
|
"transits",
|
|
"chara_dasha",
|
|
"arudha_padas",
|
|
"chara_karakas",
|
|
"kp_cusps",
|
|
"gulika",
|
|
"kakshya",
|
|
"vimshottari_mahadashas",
|
|
"domain_thematic_evidence",
|
|
// Evidence card v2: optional per the astrologer's table, never on a card.
|
|
"karakamsha",
|
|
"dispositor_chains",
|
|
"inter_chart_linkage",
|
|
"argala",
|
|
"moon_transit",
|
|
] as const satisfies readonly (
|
|
| `varga:${typeof FORMAL_VARGA_CODES[number]}`
|
|
| "varga:research_dn"
|
|
| "varga:extended"
|
|
| "western:natal"
|
|
| `western:${typeof WESTERN_TECHNIQUE_IDS[number]}`
|
|
| "yogas" | "ashtakavarga" | "shadbala" | "transits" | "chara_dasha" | "arudha_padas"
|
|
| "chara_karakas" | "kp_cusps" | "gulika" | "kakshya" | "vimshottari_mahadashas"
|
|
| "domain_thematic_evidence"
|
|
| "karakamsha" | "dispositor_chains" | "inter_chart_linkage" | "argala" | "moon_transit"
|
|
)[];
|
|
|
|
export type EvidenceLookupSection = typeof EVIDENCE_LOOKUP_SECTIONS[number];
|
|
|
|
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
|
type JsonRecord = { [key: string]: Json };
|
|
|
|
export type EvidenceCardDomainSection = {
|
|
label: string;
|
|
vargas: JsonRecord;
|
|
houses: JsonRecord;
|
|
focus_planets?: string[];
|
|
arudha?: JsonRecord;
|
|
karakas?: JsonRecord;
|
|
yogas?: JsonRecord;
|
|
transits?: Json;
|
|
ashtakavarga?: JsonRecord;
|
|
shadbala?: Json;
|
|
sav_by_house?: JsonRecord;
|
|
slow_transits?: Json;
|
|
double_transit?: Json;
|
|
punarphoo?: Json;
|
|
day_night?: Json;
|
|
vivah_saham?: Json;
|
|
gender?: EvidenceCardGenderLabel;
|
|
annual_chart?: Json;
|
|
/** Why each annual house is on the card (AD lords' houses, the year's Jupiter / Saturn transits). */
|
|
house_basis?: JsonRecord;
|
|
/** The turn's other domain whose section already carries the vargas this section follows. */
|
|
vargas_follow?: ConsultationDomain;
|
|
/** Per-person index into this section (parents: mother / father). */
|
|
roles?: JsonRecord;
|
|
/**
|
|
* Present only when this domain's own base differs from the card's base
|
|
* (the first domain's). The same chart gives the same base; a difference is
|
|
* shown per domain instead of one side being presented as shared.
|
|
*/
|
|
base?: EvidenceCard["base"];
|
|
};
|
|
|
|
export type EvidenceCard = {
|
|
card_version: typeof EVIDENCE_CARD_VERSION;
|
|
domains: ConsultationDomain[];
|
|
base: {
|
|
natal: {
|
|
ascendant: Json;
|
|
houses: JsonRecord;
|
|
planets: JsonRecord;
|
|
functional_benefic_malefic: JsonRecord;
|
|
/** D9 lagna, each planet's D9 sign and dignity, Vargottama, D1↔D9 reversals. */
|
|
d9: JsonRecord;
|
|
};
|
|
timing: {
|
|
vimshottari: JsonRecord;
|
|
narayana: JsonRecord;
|
|
};
|
|
};
|
|
domain_sections: Partial<Record<ConsultationDomain, EvidenceCardDomainSection>>;
|
|
meta: {
|
|
backstage: typeof EVIDENCE_CARD_BACKSTAGE_LINE;
|
|
supplementable_sections: EvidenceLookupSection[];
|
|
gaps: string[];
|
|
};
|
|
};
|
|
|
|
export type EvidenceCardGenderLabel =
|
|
| typeof EVIDENCE_CARD_LABELS.genderUnknown
|
|
| typeof EVIDENCE_CARD_LABELS.genderFemale
|
|
| typeof EVIDENCE_CARD_LABELS.genderMale;
|
|
|
|
/** The card label for the current subject's gender; not filled → 「性别未知」. */
|
|
export function evidenceCardGenderLabel(gender: ProfileGender | null | undefined): EvidenceCardGenderLabel {
|
|
if (gender === "female") return EVIDENCE_CARD_LABELS.genderFemale;
|
|
if (gender === "male") return EVIDENCE_CARD_LABELS.genderMale;
|
|
return EVIDENCE_CARD_LABELS.genderUnknown;
|
|
}
|
|
|
|
/** Card-wide inputs that are not engine output (the bound subject's profile). */
|
|
export type EvidenceCardOptions = Readonly<{
|
|
/** The current subject's optional gender; null / undefined = not filled. */
|
|
gender?: ProfileGender | null;
|
|
}>;
|
|
|
|
/** One domain's inputs: the projected packet and the agent context behind it. */
|
|
export type EvidenceCardSource = Readonly<{
|
|
domain: ConsultationDomain;
|
|
/** `toModelOutput(...)` for this domain. */
|
|
packet: unknown;
|
|
/** `toAgentConsultationContext(...)` for this domain (engine values). */
|
|
context: unknown;
|
|
}>;
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function isJson(value: unknown): value is Json {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
if (typeof value === "number") return Number.isFinite(value);
|
|
if (Array.isArray(value)) return value.every(isJson);
|
|
if (typeof value === "object") return Object.values(value as object).every(isJson);
|
|
return false;
|
|
}
|
|
|
|
/** A JSON value copied as is, or undefined. Never reshaped. */
|
|
function copy(value: unknown): Json | undefined {
|
|
return value !== undefined && isJson(value) ? structuredClone(value) : undefined;
|
|
}
|
|
|
|
function pickKeys(source: unknown, keys: readonly string[]): JsonRecord {
|
|
const from = record(source);
|
|
const out: JsonRecord = {};
|
|
for (const key of keys) {
|
|
const value = copy(from[key]);
|
|
if (value !== undefined) out[key] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function caseInsensitive(source: unknown, name: string): [string, unknown] | undefined {
|
|
const lowered = name.toLowerCase();
|
|
return Object.entries(record(source)).find(([key]) => key.toLowerCase() === lowered);
|
|
}
|
|
|
|
function claimEvidence(packet: unknown, category: string): Record<string, unknown> {
|
|
const cards = record(packet).claim_cards;
|
|
if (!Array.isArray(cards)) return {};
|
|
const card = cards.find((item) => record(item).category === category);
|
|
return record(record(card).evidence);
|
|
}
|
|
|
|
const PLANET_ROW_KEYS = ["sign", "house", "degree_in_sign", "nakshatra", "retrograde", "status"] as const;
|
|
const FUNCTIONAL_KEYS = [
|
|
"functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses",
|
|
] as const;
|
|
const PERIOD_KEYS = ["lord", "start", "end"] as const;
|
|
const NARAYANA_PERIOD_KEYS = ["sign", "lord", "years", "start_age", "end_age"] as const;
|
|
const KARAKA_KEYS = ["planet", "degree_in_sign", "rank"] as const;
|
|
|
|
const ZODIAC_SIGNS = [
|
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
|
] as const;
|
|
|
|
/** The sign and occupants of house `number` counted from a varga's own lagna. */
|
|
function vargaHouse(row: unknown, number: number): JsonRecord | undefined {
|
|
const lagna = record(row).lagna;
|
|
const start = typeof lagna === "string" ? ZODIAC_SIGNS.indexOf(lagna as (typeof ZODIAC_SIGNS)[number]) : -1;
|
|
if (start < 0) return undefined;
|
|
const sign = ZODIAC_SIGNS[(start + number - 1) % 12]!;
|
|
const occupants = Object.entries(record(record(row).planets))
|
|
.filter(([, value]) => value === sign)
|
|
.map(([name]) => name);
|
|
return { house: number, sign, occupants };
|
|
}
|
|
const ARUDHA_KEYS = ["sign", "lord"] as const;
|
|
/** The engine writes the Arudha Lagna as pada A1 (its own name: "Arudha Lagna (AL)"). */
|
|
const ARUDHA_ENGINE_KEY: Readonly<Record<string, string>> = { AL: "A1" };
|
|
const D9_ROW_KEYS = ["d9_sign", "d9_dignity", "vargottama"] as const;
|
|
const DOUBLE_TRANSIT_ROW_KEYS = ["layer", "target", "strength", "jupiter_pac", "saturn_pac"] as const;
|
|
const DOUBLE_TRANSIT_ALL_KEYS = ["house", "layer", "target", "strength"] as const;
|
|
const KARAKA_TARGET_KEYS = ["target", "jupiter_pac", "saturn_pac", "double_transit"] as const;
|
|
const ANNUAL_KEYS = [
|
|
"status", "varsha_year", "annual_lagna", "varshesha", "muntha", "mudda_dasha", "tajika_aspects", "boundary",
|
|
] as const;
|
|
|
|
function nonEmpty(value: JsonRecord) {
|
|
return Object.keys(value).length > 0;
|
|
}
|
|
|
|
function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
|
|
const ascendant = copy(natal.ascendant);
|
|
if (ascendant === undefined) gaps.push("base.ascendant");
|
|
const houses: JsonRecord = {};
|
|
for (const [number, row] of Object.entries(record(natal.houses))) {
|
|
const sign = record(row).sign;
|
|
if (typeof sign === "string") houses[number] = sign;
|
|
}
|
|
if (!nonEmpty(houses)) gaps.push("base.houses");
|
|
const planets: JsonRecord = {};
|
|
for (const [name, row] of Object.entries(record(natal.planets))) {
|
|
const picked = pickKeys(row, PLANET_ROW_KEYS);
|
|
if (nonEmpty(picked)) planets[name] = picked;
|
|
}
|
|
if (!nonEmpty(planets)) gaps.push("base.planets");
|
|
const functional = pickKeys(natal.functional_benefic_malefic, FUNCTIONAL_KEYS);
|
|
if (!nonEmpty(functional)) gaps.push("base.functional_benefic_malefic");
|
|
const summary = record(natal.d9_summary);
|
|
const d9: JsonRecord = {};
|
|
if (typeof summary.lagna === "string") {
|
|
d9.lagna = summary.lagna;
|
|
const lagnaVargottama = copy(summary.lagna_vargottama);
|
|
if (lagnaVargottama !== undefined) d9.lagna_vargottama = lagnaVargottama;
|
|
const rows: JsonRecord = {};
|
|
for (const [name, row] of Object.entries(record(summary.planets))) {
|
|
const picked = pickKeys(row, D9_ROW_KEYS);
|
|
if (nonEmpty(picked)) rows[name] = picked;
|
|
}
|
|
d9.planets = rows;
|
|
const reversals = copy(summary.reversals);
|
|
if (reversals !== undefined) d9.reversals = reversals;
|
|
} else {
|
|
gaps.push("base.d9");
|
|
}
|
|
return { ascendant: ascendant ?? null, houses, planets, functional_benefic_malefic: functional, d9 };
|
|
}
|
|
|
|
function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
|
|
const sub = record(timing.dasha_sub_periods);
|
|
const current = record(sub.current);
|
|
const pratyantar = record(sub.pratyantar_dasha_timeline);
|
|
const vimshottari: JsonRecord = {};
|
|
const set = (key: string, value: unknown) => {
|
|
const picked = pickKeys(value, PERIOD_KEYS);
|
|
if (nonEmpty(picked)) vimshottari[key] = picked;
|
|
};
|
|
set("mahadasha", current.mahadasha);
|
|
set("antardasha", current.antardasha);
|
|
set("pratyantardasha", pratyantar.current);
|
|
set("next_antardasha", sub.next);
|
|
set("next_pratyantardasha", pratyantar.next);
|
|
if (Array.isArray(sub.boundaries)) {
|
|
const rows = sub.boundaries.map((row) => pickKeys(row, PERIOD_KEYS)).filter(nonEmpty);
|
|
if (rows.length) vimshottari.antardashas_in_mahadasha = rows;
|
|
}
|
|
for (const key of ["mahadasha", "antardasha", "pratyantardasha"]) {
|
|
if (!vimshottari[key]) gaps.push(`base.vimshottari.${key}`);
|
|
}
|
|
const currentNarayana = record(record(timing.narayana_dasha).current_dasha);
|
|
const narayana: JsonRecord = {};
|
|
for (const level of ["md", "ad", "pd"] as const) {
|
|
const picked = pickKeys(currentNarayana[level], NARAYANA_PERIOD_KEYS);
|
|
if (nonEmpty(picked)) narayana[level] = picked;
|
|
}
|
|
const remaining = copy(currentNarayana.remaining_years);
|
|
if (remaining !== undefined) narayana.remaining_years = remaining;
|
|
if (!narayana.md) gaps.push("base.narayana.md");
|
|
return { vimshottari, narayana };
|
|
}
|
|
|
|
/** Vargas the timing section follows: the other domain's (D1 aside), else the spec default. */
|
|
export type EvidenceCardVargaPlan = Readonly<{ codes: readonly string[]; follow?: ConsultationDomain }>;
|
|
|
|
function uniqueNumbers(values: readonly unknown[]): number[] {
|
|
const out: number[] = [];
|
|
for (const value of values) if (typeof value === "number" && !out.includes(value)) out.push(value);
|
|
return out;
|
|
}
|
|
|
|
function ownedBy(base: EvidenceCard["base"], lord: unknown): number[] {
|
|
if (typeof lord !== "string") return [];
|
|
const houses = record(base.natal.functional_benefic_malefic.owned_houses)[lord];
|
|
return Array.isArray(houses) ? uniqueNumbers(houses) : [];
|
|
}
|
|
|
|
function domainSection(
|
|
domain: ConsultationDomain,
|
|
source: EvidenceCardSource,
|
|
base: EvidenceCard["base"],
|
|
gaps: string[],
|
|
vargaPlan?: EvidenceCardVargaPlan,
|
|
options: EvidenceCardOptions = {},
|
|
): EvidenceCardDomainSection {
|
|
const rule = EVIDENCE_CARD_SPECS[domain];
|
|
const natal = claimEvidence(source.packet, "natal_foundation");
|
|
const timing = claimEvidence(source.packet, "timing");
|
|
const context = record(source.context);
|
|
const localLayers = record(context.local_layers);
|
|
const chart = record(context.chart);
|
|
const formal = record(record(natal.varga_spectrum).formal);
|
|
const section: EvidenceCardDomainSection = {
|
|
label: consultationDomainDefinition(domain).label,
|
|
vargas: {},
|
|
houses: {},
|
|
};
|
|
for (const code of vargaPlan?.codes ?? rule.vargas) {
|
|
const chartRow = pickKeys(formal[code], ["lagna", "planets"]);
|
|
if (nonEmpty(chartRow)) section.vargas[code] = chartRow;
|
|
else gaps.push(`${domain}.varga.${code}`);
|
|
}
|
|
if (vargaPlan?.follow) section.vargas_follow = vargaPlan.follow;
|
|
const slow = record(timing.slow_transits);
|
|
let houses: number[] = [...rule.houses];
|
|
if (rule.annualHouses) {
|
|
const antardasha = record(base.timing.vimshottari.antardasha).lord;
|
|
const nextAntardasha = record(base.timing.vimshottari.next_antardasha).lord;
|
|
const inWindow = record(slow.houses_in_window);
|
|
const jupiter = Array.isArray(inWindow.Jupiter) ? uniqueNumbers(inWindow.Jupiter) : [];
|
|
const saturn = Array.isArray(inWindow.Saturn) ? uniqueNumbers(inWindow.Saturn) : [];
|
|
if (!Array.isArray(inWindow.Jupiter) || !Array.isArray(inWindow.Saturn)) gaps.push(`${domain}.transit_houses`);
|
|
const basis: JsonRecord = {};
|
|
if (typeof antardasha === "string") {
|
|
basis.antardasha_lord = antardasha;
|
|
basis.antardasha_houses = ownedBy(base, antardasha);
|
|
}
|
|
if (typeof nextAntardasha === "string") {
|
|
basis.next_antardasha_lord = nextAntardasha;
|
|
basis.next_antardasha_houses = ownedBy(base, nextAntardasha);
|
|
}
|
|
basis.jupiter_transit_houses = jupiter;
|
|
basis.saturn_transit_houses = saturn;
|
|
section.house_basis = basis;
|
|
houses = uniqueNumbers([...houses, ...ownedBy(base, antardasha), ...ownedBy(base, nextAntardasha), ...jupiter, ...saturn]);
|
|
}
|
|
const owned = record(base.natal.functional_benefic_malefic.owned_houses);
|
|
for (const number of houses) {
|
|
const sign = base.natal.houses[String(number)];
|
|
if (typeof sign !== "string") {
|
|
gaps.push(`${domain}.house.${number}`);
|
|
continue;
|
|
}
|
|
// Selection only: planets the projection places in this house, and the
|
|
// planets the functional layer lists as owning it.
|
|
const occupants = Object.entries(base.natal.planets)
|
|
.filter(([, row]) => record(row).house === number)
|
|
.map(([name]) => name);
|
|
const lords = Object.entries(owned)
|
|
.filter(([, houses]) => Array.isArray(houses) && houses.includes(number))
|
|
.map(([name]) => name);
|
|
section.houses[String(number)] = { sign, occupants, lords };
|
|
}
|
|
if (rule.planets.length) {
|
|
const focus: string[] = [];
|
|
for (const name of rule.planets) {
|
|
const hit = caseInsensitive(base.natal.planets, name);
|
|
if (hit) focus.push(hit[0]);
|
|
else gaps.push(`${domain}.planet.${name}`);
|
|
}
|
|
section.focus_planets = focus;
|
|
}
|
|
if (rule.arudha.length) {
|
|
const padas = record(record(natal.arudha_padas).padas);
|
|
const arudha: JsonRecord = {};
|
|
for (const code of rule.arudha) {
|
|
const picked = pickKeys(padas[ARUDHA_ENGINE_KEY[code] ?? code], ARUDHA_KEYS);
|
|
if (nonEmpty(picked)) arudha[code] = picked;
|
|
else gaps.push(`${domain}.arudha.${code}`);
|
|
}
|
|
section.arudha = arudha;
|
|
}
|
|
if (rule.karakas.length) {
|
|
const karakas: JsonRecord = {};
|
|
const table = record(record(localLayers.jaimini).chara_karakas);
|
|
for (const code of rule.karakas) {
|
|
const picked = pickKeys(table[code], KARAKA_KEYS);
|
|
if (nonEmpty(picked)) karakas[code] = picked;
|
|
else gaps.push(`${domain}.karaka.${code}`);
|
|
}
|
|
section.karakas = karakas;
|
|
}
|
|
if (rule.roles.length) {
|
|
const table = record(record(localLayers.jaimini).chara_karakas);
|
|
const roles: JsonRecord = {};
|
|
for (const role of rule.roles) {
|
|
const houseRow = record(section.houses[String(role.house)]);
|
|
// The role names its karakas; their values sit once in section.karakas.
|
|
// A required one missing there is already a gap; an optional one (PiK)
|
|
// joins section.karakas only when the engine's scheme has it.
|
|
const karakas = role.karakas.filter((code) => section.karakas?.[code] !== undefined);
|
|
for (const code of role.optionalKarakas) {
|
|
const picked = pickKeys(table[code], KARAKA_KEYS);
|
|
if (!nonEmpty(picked)) continue;
|
|
section.karakas = { ...(section.karakas ?? {}), [code]: picked };
|
|
karakas.push(code);
|
|
}
|
|
const entry: JsonRecord = {
|
|
label: role.label,
|
|
house: role.house,
|
|
house_lords: Array.isArray(houseRow.lords) ? houseRow.lords : [],
|
|
significators: role.planets.filter((name) => caseInsensitive(base.natal.planets, name)),
|
|
};
|
|
if (karakas.length) entry.karakas = karakas;
|
|
if (role.varga) {
|
|
const varga = vargaHouse(section.vargas[role.varga], role.house);
|
|
if (varga) entry[`${role.varga.toLowerCase()}_house`] = varga;
|
|
else gaps.push(`${domain}.role.${role.key}.${role.varga}`);
|
|
}
|
|
roles[role.key] = entry;
|
|
}
|
|
section.roles = roles;
|
|
}
|
|
if (rule.yogas) {
|
|
const yogas = record(natal.yogas);
|
|
const rows = Array.isArray(yogas.yogas) ? yogas.yogas.map(record) : [];
|
|
const names = rows.map((row) => row.name).filter((name): name is string => typeof name === "string");
|
|
const boundaries = [...new Set(rows.map((row) => row.claim_boundary).filter((value): value is string => typeof value === "string"))];
|
|
if (names.length || typeof yogas.count === "number") {
|
|
section.yogas = {
|
|
...(typeof yogas.count === "number" ? { count: yogas.count } : {}),
|
|
names,
|
|
...(boundaries.length ? { claim_boundaries: boundaries } : {}),
|
|
};
|
|
} else {
|
|
gaps.push(`${domain}.yogas`);
|
|
}
|
|
}
|
|
if (rule.transits) {
|
|
const transits = copy(timing.transits);
|
|
if (transits !== undefined) section.transits = transits;
|
|
else gaps.push(`${domain}.transits`);
|
|
}
|
|
if (rule.ashtakavarga) {
|
|
const table = record(localLayers.ashtakavarga);
|
|
const houseScores = record(table.house_scores);
|
|
const byHouse: JsonRecord = {};
|
|
for (const number of rule.houses) {
|
|
const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]);
|
|
if (nonEmpty(row)) byHouse[String(number)] = row;
|
|
}
|
|
const strongest = copy(table.strongest_signs);
|
|
const weakest = copy(table.weakest_signs);
|
|
if (nonEmpty(byHouse) || strongest !== undefined) {
|
|
section.ashtakavarga = {
|
|
sav_by_house: byHouse,
|
|
...(strongest !== undefined ? { strongest_signs: strongest } : {}),
|
|
...(weakest !== undefined ? { weakest_signs: weakest } : {}),
|
|
};
|
|
} else {
|
|
gaps.push(`${domain}.ashtakavarga`);
|
|
}
|
|
}
|
|
if (rule.shadbala) {
|
|
const shadbala = copy(chart.shadbala);
|
|
if (shadbala !== undefined && shadbala !== null) section.shadbala = shadbala;
|
|
else gaps.push(`${domain}.shadbala`);
|
|
}
|
|
if (rule.savHouses.length) {
|
|
const houseScores = record(record(localLayers.ashtakavarga).house_scores);
|
|
const byHouse: JsonRecord = {};
|
|
for (const number of rule.savHouses) {
|
|
const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]);
|
|
if (nonEmpty(row)) byHouse[String(number)] = row;
|
|
else gaps.push(`${domain}.sav.${number}`);
|
|
}
|
|
section.sav_by_house = byHouse;
|
|
}
|
|
if (rule.slowTransits !== "none") {
|
|
const current = record(slow.current);
|
|
const rows: JsonRecord = {};
|
|
const bodies = rule.slowTransits === "sav" ? ["Jupiter", "Saturn"] : ["Jupiter", "Saturn", "Rahu", "Ketu"];
|
|
const keys = {
|
|
sav: ["sign", "natal_house", "sav"],
|
|
nodes_sav_bav: ["sign", "degree_in_sign", "retrograde", "natal_house", "sav", "bav"],
|
|
key_dates: ["sign", "natal_house"],
|
|
}[rule.slowTransits];
|
|
for (const body of bodies) {
|
|
const picked = pickKeys(current[body], keys);
|
|
if (nonEmpty(picked)) rows[body] = picked;
|
|
else gaps.push(`${domain}.slow_transits.${body}`);
|
|
}
|
|
const dates = Array.isArray(slow.key_dates) ? slow.key_dates.map(record) : [];
|
|
if (rule.slowTransits === "sav") {
|
|
section.slow_transits = rows;
|
|
} else if (rule.slowTransits === "nodes_sav_bav") {
|
|
section.slow_transits = {
|
|
current: rows,
|
|
node_ingresses: dates.filter((row) => row.body === "rahu" || row.body === "ketu")
|
|
.map((row) => copy(row))
|
|
.filter((row): row is Json => row !== undefined),
|
|
...(copy(slow.window) !== undefined ? { window: copy(slow.window)! } : {}),
|
|
};
|
|
} else {
|
|
section.slow_transits = {
|
|
current: rows,
|
|
key_dates: dates.map((row) => copy(row)).filter((row): row is Json => row !== undefined),
|
|
...(copy(slow.window) !== undefined ? { window: copy(slow.window)! } : {}),
|
|
};
|
|
}
|
|
}
|
|
if (rule.doubleTransit !== "none") {
|
|
const double = record(timing.double_transit);
|
|
const conclusions = Array.isArray(double.conclusions) ? double.conclusions.map(record) : [];
|
|
if (!Array.isArray(double.conclusions)) {
|
|
gaps.push(`${domain}.double_transit`);
|
|
} else if (rule.doubleTransit === "seventh_and_karakas") {
|
|
const karakas = record(double.karaka_targets);
|
|
const dk = pickKeys(karakas.DK, KARAKA_TARGET_KEYS);
|
|
const ul = pickKeys(karakas.UL, KARAKA_TARGET_KEYS);
|
|
section.double_transit = {
|
|
...(typeof double.date === "string" ? { date: double.date } : {}),
|
|
house_7: conclusions.filter((row) => row.house === 7).map((row) => pickKeys(row, DOUBLE_TRANSIT_ROW_KEYS)),
|
|
...(nonEmpty(dk) ? { DK: dk } : {}),
|
|
...(nonEmpty(ul) ? { UL: ul } : {}),
|
|
...(typeof record(karakas.DK).method === "string" ? { karaka_method: record(karakas.DK).method as string } : {}),
|
|
...(typeof double.boundary === "string" ? { boundary: double.boundary } : {}),
|
|
};
|
|
if (!nonEmpty(dk)) gaps.push(`${domain}.double_transit.DK`);
|
|
if (!nonEmpty(ul)) gaps.push(`${domain}.double_transit.UL`);
|
|
} else {
|
|
section.double_transit = {
|
|
...(typeof double.date === "string" ? { date: double.date } : {}),
|
|
...(copy(double.active_houses) !== undefined ? { active_houses: copy(double.active_houses)! } : {}),
|
|
conclusions: conclusions.map((row) => pickKeys(row, DOUBLE_TRANSIT_ALL_KEYS)),
|
|
...(typeof double.boundary === "string" ? { boundary: double.boundary } : {}),
|
|
};
|
|
}
|
|
}
|
|
if (rule.punarphoo) {
|
|
const punarphoo = record(natal.punarphoo);
|
|
if (typeof punarphoo.natal_present === "boolean") {
|
|
section.punarphoo = {
|
|
...pickKeys(punarphoo, ["status", "natal_present", "orb_deg"]),
|
|
saturn_return_to_natal_moon: pickKeys(punarphoo.saturn_return_to_natal_moon, ["active", "orb_deg"]),
|
|
...pickKeys(punarphoo, ["claim_boundary"]),
|
|
};
|
|
} else {
|
|
gaps.push(`${domain}.punarphoo`);
|
|
}
|
|
}
|
|
if (rule.dayNight) {
|
|
const dayNight = pickKeys(natal.day_night, ["is_day"]);
|
|
if (nonEmpty(dayNight)) section.day_night = dayNight;
|
|
else gaps.push(`${domain}.day_night`);
|
|
}
|
|
if (rule.vivahSaham) {
|
|
const vivah = pickKeys(natal.vivah_saham, ["longitude", "sign", "degree_in_sign", "house", "natal_conjuncts"]);
|
|
if (typeof vivah.house === "number") section.vivah_saham = vivah;
|
|
else gaps.push(`${domain}.vivah_saham`);
|
|
}
|
|
if (rule.gender) section.gender = evidenceCardGenderLabel(options.gender);
|
|
if (rule.annualChart) {
|
|
const annual = record(timing.annual_tajika);
|
|
if (annual.status === "parameter_sensitive") {
|
|
section.annual_chart = pickKeys(annual, ANNUAL_KEYS);
|
|
} else {
|
|
// Blocked or not attached: say so; never put natal data in its place.
|
|
if (!Object.keys(annual).length) gaps.push(`${domain}.annual_chart`);
|
|
section.annual_chart = {
|
|
status: EVIDENCE_CARD_LABELS.annualNotAttached,
|
|
...(typeof annual.reason === "string" ? { reason: annual.reason } : {}),
|
|
};
|
|
}
|
|
}
|
|
return section;
|
|
}
|
|
|
|
function onCard(card: Pick<EvidenceCard, "domain_sections" | "base">, section: EvidenceLookupSection) {
|
|
const sections = Object.values(card.domain_sections);
|
|
if (section.startsWith("varga:")) {
|
|
const code = section.slice("varga:".length);
|
|
// The base D9 summary already holds the D9 lagna and every planet's D9 sign.
|
|
if (code === "D9" && typeof card.base.natal.d9.lagna === "string") return true;
|
|
return sections.some((item) => item && code in item.vargas);
|
|
}
|
|
// Chara Dasha, KP and the v2 optional layers are never on a card.
|
|
const key = ({
|
|
yogas: "yogas",
|
|
ashtakavarga: "ashtakavarga",
|
|
shadbala: "shadbala",
|
|
transits: "transits",
|
|
} as const)[section as "yogas"];
|
|
return key ? sections.some((item) => item && item[key] !== undefined) : false;
|
|
}
|
|
|
|
/**
|
|
* Build the card for up to two domains (the existing per-turn cap). The base
|
|
* section is taken once, from the first domain's sources; it is the same chart.
|
|
*/
|
|
export function buildEvidenceCard(
|
|
sources: readonly EvidenceCardSource[],
|
|
options: EvidenceCardOptions = {},
|
|
): EvidenceCard {
|
|
if (sources.length === 0) throw new Error("evidence_card_needs_a_domain");
|
|
const gaps: string[] = [];
|
|
const first = sources[0]!;
|
|
const base = {
|
|
natal: baseNatal(claimEvidence(first.packet, "natal_foundation"), gaps),
|
|
timing: baseTiming(claimEvidence(first.packet, "timing"), gaps),
|
|
};
|
|
const domainSections: EvidenceCard["domain_sections"] = {};
|
|
const domains: ConsultationDomain[] = [];
|
|
const planFor = (domain: ConsultationDomain): EvidenceCardVargaPlan | undefined => {
|
|
if (!EVIDENCE_CARD_SPECS[domain].followVargas) return undefined;
|
|
const other = sources.map((item) => item.domain).find((item) => (
|
|
!EVIDENCE_CARD_SPECS[item].followVargas
|
|
&& EVIDENCE_CARD_SPECS[item].vargas.some((code) => code !== "D1")
|
|
));
|
|
if (!other) return undefined;
|
|
// D1 stays; the other section already carries the followed vargas.
|
|
return {
|
|
codes: EVIDENCE_CARD_SPECS[domain].vargas.filter((code) => code === "D1"),
|
|
follow: other,
|
|
};
|
|
};
|
|
for (const source of sources) {
|
|
if (domains.includes(source.domain)) continue;
|
|
domains.push(source.domain);
|
|
let own = base;
|
|
if (source !== first) {
|
|
const ownGaps: string[] = [];
|
|
const candidate = {
|
|
natal: baseNatal(claimEvidence(source.packet, "natal_foundation"), ownGaps),
|
|
timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps),
|
|
};
|
|
if (JSON.stringify(candidate) !== JSON.stringify(base)) {
|
|
own = candidate;
|
|
gaps.push(`${source.domain}.base_differs`, ...ownGaps.map((gap) => `${source.domain}.${gap}`));
|
|
}
|
|
}
|
|
const section = domainSection(source.domain, source, own, gaps, planFor(source.domain), options);
|
|
if (own !== base) section.base = own;
|
|
domainSections[source.domain] = section;
|
|
}
|
|
const card: EvidenceCard = {
|
|
card_version: EVIDENCE_CARD_VERSION,
|
|
domains,
|
|
base,
|
|
domain_sections: domainSections,
|
|
meta: {
|
|
backstage: EVIDENCE_CARD_BACKSTAGE_LINE,
|
|
supplementable_sections: [],
|
|
gaps,
|
|
},
|
|
};
|
|
card.meta.supplementable_sections = EVIDENCE_LOOKUP_SECTIONS.filter((section) => !onCard(card, section));
|
|
return card;
|
|
}
|
|
|
|
export function evidenceCardTokenEstimate(chars: number) {
|
|
return Math.round(chars / EVIDENCE_CARD_CHARS_PER_TOKEN);
|
|
}
|
|
|
|
type FieldValue = string | number | boolean | null;
|
|
|
|
/**
|
|
* The card's scalar leaves by field id. A field id is the card path with the
|
|
* domain name dropped (`base.planets.Moon.sign`, `domain.vargas.D12.lagna`),
|
|
* so ids aggregate across domains; array positions are dropped as well.
|
|
*/
|
|
export function evidenceCardFieldValues(card: EvidenceCard): Map<string, FieldValue[]> {
|
|
const out = new Map<string, FieldValue[]>();
|
|
const walk = (value: unknown, path: string) => {
|
|
if (value === null || typeof value !== "object") {
|
|
if (value === undefined) return;
|
|
const list = out.get(path) ?? [];
|
|
list.push(value as FieldValue);
|
|
out.set(path, list);
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) walk(item, path);
|
|
return;
|
|
}
|
|
for (const [key, item] of Object.entries(value)) walk(item, path ? `${path}.${key}` : key);
|
|
};
|
|
walk(card.base, "base");
|
|
for (const section of Object.values(card.domain_sections)) {
|
|
if (!section) continue;
|
|
const { label: _label, ...rest } = section;
|
|
void _label;
|
|
walk(rest, "domain");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
const PLANET_NAMES = new Set(["sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu"]);
|
|
|
|
/**
|
|
* Which card fields the answer quoted (research R5). A field counts only when
|
|
* its value is an ISO date, a degree (a number with a fractional part) or a
|
|
* planet-in-sign phrase of at least 8 characters (`Moon in Pisces`), and that
|
|
* text appears verbatim in the answer. Bare planet or sign names do not count.
|
|
* Only field ids are returned; the matched text is not kept.
|
|
*/
|
|
export function citedEvidenceCardFields(card: EvidenceCard, answer: string): string[] {
|
|
if (!answer) return [];
|
|
const cited = new Set<string>();
|
|
for (const [fieldId, values] of evidenceCardFieldValues(card)) {
|
|
for (const value of values) {
|
|
if (typeof value === "string" && ISO_DATE.test(value) && answer.includes(value)) {
|
|
cited.add(fieldId);
|
|
} else if (typeof value === "number" && !Number.isInteger(value) && answer.includes(String(value))) {
|
|
cited.add(fieldId);
|
|
} else if (typeof value === "string" && fieldId.endsWith(".sign")) {
|
|
const parts = fieldId.split(".");
|
|
const planet = parts[parts.length - 2] ?? "";
|
|
const phrase = `${planet} in ${value}`;
|
|
if (PLANET_NAMES.has(planet.toLowerCase()) && phrase.length >= 8 && answer.includes(phrase)) cited.add(fieldId);
|
|
} else if (typeof value === "string" && /\.planets\.[A-Za-z]+$/.test(fieldId)) {
|
|
// Varga planets map a planet straight to its sign.
|
|
const planet = fieldId.slice(fieldId.lastIndexOf(".") + 1);
|
|
const phrase = `${planet} in ${value}`;
|
|
if (PLANET_NAMES.has(planet.toLowerCase()) && phrase.length >= 8 && answer.includes(phrase)) cited.add(fieldId);
|
|
}
|
|
}
|
|
}
|
|
return [...cited].sort().slice(0, 64);
|
|
}
|