Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts
T
jesse-ux 8221c6121b
Independent Staging Quality Gate / validate (push) Successful in 10m39s
Independent Staging Quality Gate / publish (push) Successful in 2m9s
fix(rectification): restore targeted collect cards from spoken focus (BUG-673)
Keep targeted existence questions as A-D cards. Recover collect-schema
stock by question-id prefix, surface the stem when persist fails, and
send spoken targeted existence to the repair exit instead of a naked prompt.
2026-09-14 01:51:55 +08:00

870 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* S1 collection question pool: invite → anchored follow-ups from the
* user's own years → yearless generic prompts. Ranking is by information
* value, not a fixed domain rotation. Age-band years never enter prompts.
*/
import { USER_COLLECT_QUESTION } from "../user-copy.ts";
import { canonicalCollectDomain } from "./domain-alias.ts";
export const COLLECT_KIND_ORDER = [
"education",
"career",
"relocation",
"relationship",
"family",
"finance",
"health_pressure",
] as const;
export type CollectKind = (typeof COLLECT_KIND_ORDER)[number];
export type CollectionPoolKind = "invite" | "anchor" | "generic" | "targeted";
export type CollectionEvidence = Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
summary?: string | null;
}>;
export type CollectionTopic = Readonly<Record<string, unknown>>;
export type CollectionPoolItem = Readonly<{
kind: CollectionPoolKind;
value: number;
prompt: string;
key: string;
domain: string;
targetKind: string | null;
year: number | null;
examples?: readonly string[];
existencePrompt?: string;
yearPrompt?: string;
remainingLine?: string;
}>;
const KIND_EXAMPLES: Readonly<Record<CollectKind, string>> = {
education: "上大学或毕业",
career: "第一份工作",
relocation: "搬到别的城市",
relationship: "谈恋爱或结婚",
family: "家里添丁或长辈住院",
finance: "收入明显变过或大笔支出",
health_pressure: "生病受伤",
};
const KIND_LABEL: Readonly<Record<CollectKind, string>> = {
education: "上学",
career: "工作",
relocation: "搬家",
relationship: "感情",
family: "家里",
finance: "钱的方面",
health_pressure: "身体或压力",
};
const GENERIC_PROMPTS: Readonly<Record<CollectKind, string>> = {
education: USER_COLLECT_QUESTION.education,
career: USER_COLLECT_QUESTION.career,
relocation: USER_COLLECT_QUESTION.relocation,
relationship: USER_COLLECT_QUESTION.relationship,
family: USER_COLLECT_QUESTION.family,
finance: USER_COLLECT_QUESTION.finance,
health_pressure: USER_COLLECT_QUESTION.health_pressure,
};
/** T2 lets the table start at 0.5; named rules keep the product examples. */
const ANCHOR_PROBABILITY = 0.5;
const INVITE_VALUE = 2;
const GENERIC_VALUE = 0.15;
export function normalizeCollectKind(domain: string | null | undefined): CollectKind | null {
const canonical = canonicalCollectDomain(domain);
if ((COLLECT_KIND_ORDER as readonly string[]).includes(canonical)) {
return canonical as CollectKind;
}
return null;
}
function topicQuestionId(topic: CollectionTopic): string {
return typeof topic.questionId === "string"
? topic.questionId
: typeof topic.question_id === "string"
? topic.question_id
: "";
}
function topicStatus(topic: CollectionTopic): string {
return typeof topic.status === "string" ? topic.status : "";
}
function topicDomain(topic: CollectionTopic): string | null {
return typeof topic.target_domain === "string"
? topic.target_domain
: typeof topic.targetDomain === "string"
? topic.targetDomain
: null;
}
function topicKind(topic: CollectionTopic): string {
return typeof topic.target_kind === "string"
? topic.target_kind
: typeof topic.targetKind === "string"
? topic.targetKind
: "";
}
function topicSchema(topic: CollectionTopic): Readonly<Record<string, unknown>> | null {
const schema = topic.expected_answer_schema ?? topic.expectedAnswerSchema;
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as Readonly<Record<string, unknown>>
: null;
}
export function topicCollectKind(topic: CollectionTopic): string {
const schema = topicSchema(topic);
const fromSchema = schema && typeof schema.collect_kind === "string"
? schema.collect_kind.trim()
: "";
return fromSchema || topicKind(topic);
}
export function isInviteCollectTopic(topic: CollectionTopic): boolean {
return topicQuestionId(topic).startsWith("collect:invite:")
|| topicCollectKind(topic) === "invite_more";
}
export function askedCollectKeys(topics: readonly CollectionTopic[]): ReadonlySet<string> {
const keys = new Set<string>();
for (const topic of topics) {
const status = topicStatus(topic);
if (status === "active") continue;
if (isInviteCollectTopic(topic)) continue;
const questionId = topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, "");
if (questionId.startsWith("collect:")) keys.add(questionId);
const kind = topicCollectKind(topic);
if (kind.startsWith("anchor:") || kind.startsWith("generic:") || kind.startsWith("targeted:")) {
keys.add(`collect:${kind}`);
}
}
return keys;
}
export function inviteDeclined(topics: readonly CollectionTopic[]): boolean {
return topics.some((topic) => {
if (!isInviteCollectTopic(topic)) return false;
const status = topicStatus(topic);
return status === "declined" || status === "skipped";
});
}
function isConfirmedDated(item: CollectionEvidence): boolean {
return item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& Boolean(item.occurredFrom || item.occurredTo);
}
export function evidenceYear(item: CollectionEvidence): number | null {
const raw = item.occurredFrom || item.occurredTo;
if (!raw || raw.length < 4 || !/^\d{4}/.test(raw)) return null;
const year = Number(raw.slice(0, 4));
return year >= 1900 && year <= 2100 ? year : null;
}
function evidenceMonth(item: CollectionEvidence): number | null {
const raw = item.occurredFrom || item.occurredTo || "";
if (raw.length < 7 || raw[4] !== "-") return null;
const month = Number(raw.slice(5, 7));
return month >= 1 && month <= 12 ? month : null;
}
export function coveredCollectKinds(evidence: readonly CollectionEvidence[]): ReadonlySet<CollectKind> {
const covered = new Set<CollectKind>();
for (const item of evidence) {
if (!isConfirmedDated(item)) continue;
const kind = normalizeCollectKind(item.domain);
if (kind) covered.add(kind);
}
return covered;
}
function declinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<CollectKind> {
const declined = new Set<CollectKind>();
for (const topic of topics) {
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") continue;
const questionId = topicQuestionId(topic);
if (questionId.startsWith("collect:invite:")) continue;
if (questionId.startsWith("collect:other:")) continue;
const kind = normalizeCollectKind(topicDomain(topic))
?? (questionId.startsWith("collect:generic:")
? normalizeCollectKind(questionId.split(":")[2] ?? "")
: null);
if (kind) declined.add(kind);
}
return declined;
}
function uncoveredKinds(
evidence: readonly CollectionEvidence[],
declined: ReadonlySet<CollectKind>,
): CollectKind[] {
const covered = coveredCollectKinds(evidence);
return COLLECT_KIND_ORDER.filter((kind) => !covered.has(kind) && !declined.has(kind));
}
export function examplePhrases(
evidence: readonly CollectionEvidence[],
declined: ReadonlySet<CollectKind>,
limit = 4,
): string[] {
return uncoveredKinds(evidence, declined)
.map((kind) => KIND_EXAMPLES[kind])
.slice(0, limit);
}
function invitePrompt(evidence: readonly CollectionEvidence[], declined: ReadonlySet<CollectKind>): string {
const examples = examplePhrases(evidence, declined, 4);
if (examples.length >= 2) {
return `还有吗?比如${examples.join("、")}。`;
}
if (examples.length === 1) {
return `还有吗?比如${examples[0]}、家里添丁或长辈住院。`;
}
return "还有吗?比如第一份工作、搬到别的城市。";
}
type AnchorRule = Readonly<{
keyKind: string;
target: CollectKind;
probability: number;
prompt: (year: number, month: number | null) => string;
applies: (item: CollectionEvidence, year: number) => boolean;
}>;
function kindLooksLike(item: CollectionEvidence, tokens: readonly string[]): boolean {
const blob = `${item.eventKind ?? ""} ${item.summary ?? ""}`.toLowerCase();
return tokens.some((token) => blob.includes(token));
}
const ANCHOR_RULES: readonly AnchorRule[] = [
{
keyKind: "education_completion",
target: "career",
probability: 0.8,
prompt: (year) => `${year} 年毕业后第一份工作大概哪年开始?`,
applies: (item, year) => {
if (normalizeCollectKind(item.domain) !== "education") return false;
if (item.eventKind === "education_completion") return true;
return kindLooksLike(item, ["毕业", "completion"]) && evidenceYear(item) === year;
},
},
{
keyKind: "education_start",
target: "relocation",
probability: 0.6,
prompt: (year) => `${year} 年上大学是搬到别的城市住吗?`,
applies: (item, year) => {
if (normalizeCollectKind(item.domain) !== "education") return false;
if (item.eventKind === "education_start") return true;
return kindLooksLike(item, ["入学", "大学", "start"]) && evidenceYear(item) === year;
},
},
{
keyKind: "relationship_follow",
target: "relationship",
probability: ANCHOR_PROBABILITY,
prompt: () => "后来是分开了还是结婚了,大概哪年?",
applies: (item) => {
if (normalizeCollectKind(item.domain) !== "relationship") return false;
return item.eventKind === "relationship_start"
|| kindLooksLike(item, ["在一起", "交往", "恋爱", "start"]);
},
},
{
keyKind: "after_event",
target: "career",
probability: ANCHOR_PROBABILITY,
prompt: (year, month) => (
month
? `${year}${month} 月那次之后,工作或住处有没有变?`
: `${year} 年那次之后,工作或住处有没有变?`
),
applies: () => true,
},
];
function gapWeight(target: CollectKind, covered: ReadonlySet<CollectKind>): number {
return covered.has(target) ? 0.5 : 1;
}
function educationCompletionYear(evidence: readonly CollectionEvidence[]): number | null {
const edu = evidence
.filter((item) => isConfirmedDated(item) && normalizeCollectKind(item.domain) === "education")
.map((item) => ({ item, year: evidenceYear(item) }))
.filter((row): row is { item: CollectionEvidence; year: number } => row.year != null)
.sort((left, right) => left.year - right.year);
if (edu.length === 0) return null;
const marked = edu.find((row) => (
row.item.eventKind === "education_completion" || kindLooksLike(row.item, ["毕业", "completion"])
));
return (marked ?? edu[edu.length - 1]).year;
}
export function anchoredFollowups(
evidence: readonly CollectionEvidence[],
declined: ReadonlySet<CollectKind>,
asked: ReadonlySet<string>,
): CollectionPoolItem[] {
const covered = coveredCollectKinds(evidence);
const items: CollectionPoolItem[] = [];
const seen = new Set<string>();
const completionYear = educationCompletionYear(evidence);
for (const item of evidence) {
if (!isConfirmedDated(item)) continue;
const year = evidenceYear(item);
if (year == null) continue;
const month = evidenceMonth(item);
const sourceKind = normalizeCollectKind(item.domain);
if (!sourceKind) continue;
let specific = 0;
for (const rule of ANCHOR_RULES) {
if (rule.keyKind === "after_event" && specific > 0) continue;
if (!rule.applies(item, year)) continue;
if (rule.keyKind === "education_completion" && completionYear != null && year !== completionYear) {
continue;
}
if (declined.has(rule.target) && rule.target !== sourceKind) continue;
const key = `collect:anchor:${rule.keyKind}:${year}`;
if (asked.has(key) || seen.has(key)) continue;
seen.add(key);
if (rule.keyKind !== "after_event") specific += 1;
items.push({
kind: "anchor",
value: rule.probability * gapWeight(rule.target, covered),
prompt: rule.prompt(year, month),
key,
domain: rule.target,
targetKind: rule.keyKind,
year,
});
}
}
return items.sort((left, right) => right.value - left.value || left.key.localeCompare(right.key));
}
function genericItems(
evidence: readonly CollectionEvidence[],
declined: ReadonlySet<CollectKind>,
asked: ReadonlySet<string>,
): CollectionPoolItem[] {
const covered = coveredCollectKinds(evidence);
return uncoveredKinds(evidence, declined)
.filter((kind) => !asked.has(`collect:generic:${kind}`))
.map((kind) => ({
kind: "generic" as const,
value: GENERIC_VALUE * gapWeight(kind, covered),
prompt: GENERIC_PROMPTS[kind],
key: `collect:generic:${kind}`,
domain: kind,
targetKind: null,
year: null,
}))
.sort((left, right) => right.value - left.value || left.key.localeCompare(right.key));
}
export function collectionQuestionPool(
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
inviteHistory: readonly CollectionTopic[] = declinedTopics,
): CollectionPoolItem[] {
const topics = [...declinedTopics, ...inviteHistory];
const asked = askedCollectKeys(topics);
const declined = declinedKinds(topics);
const pool: CollectionPoolItem[] = [];
if (!inviteDeclined(topics)) {
pool.push({
kind: "invite",
value: INVITE_VALUE,
prompt: invitePrompt(evidence, declined),
key: "collect:invite:more",
domain: "other",
targetKind: null,
year: null,
});
}
const anchors = anchoredFollowups(evidence, declined, asked);
pool.push(...anchors);
if (!pool.some((item) => item.kind === "invite") && anchors.length === 0) {
pool.push(...genericItems(evidence, declined, asked));
}
return [...pool].sort((left, right) => right.value - left.value || left.key.localeCompare(right.key));
}
function eventPhrase(item: CollectionEvidence): string {
const year = evidenceYear(item);
const month = evidenceMonth(item);
const when = year == null
? ""
: month
? `${year}${month} 月`
: `${year} 年`;
if (item.eventKind === "education_completion" || kindLooksLike(item, ["毕业", "completion"])) {
return `${when}毕业`.trim();
}
if (item.eventKind === "education_start" || kindLooksLike(item, ["入学", "大学", "start"])) {
return `${when}上大学`.trim();
}
const kind = normalizeCollectKind(item.domain);
const label = kind ? KIND_LABEL[kind] : "这件事";
const summary = item.summary?.trim();
if (summary && !/^[a-z_]+$/i.test(summary)) return `${when}${summary}`.trim();
return `${when}${label}`.trim();
}
export function preciseGapNarration(
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
): string {
const dated = evidence.filter(isConfirmedDated);
const phrases = dated.map(eventPhrase).filter(Boolean);
const covered = coveredCollectKinds(evidence);
const declined = declinedKinds(declinedTopics);
const examples = examplePhrases(evidence, declined, 4);
const exampleText = examples.length >= 2
? examples.slice(0, 3).join("、")
: "第一份工作哪年开始、哪年搬到别的城市";
const recorded = phrases.length > 0
? `现在记下的是${phrases.join("和")}。`
: "现在还没有记下带年月的经历。";
const sameKind = covered.size === 1
? `都是${KIND_LABEL[[...covered][0]]}的事。`
: covered.size > 1
? ""
: "";
const needOther = covered.size === 1
? `再来一件不是${KIND_LABEL[[...covered][0]]}的、记得大概年月的事就能开始筛,比如${exampleText}。`
: `再来一件记得大概年月的事就能开始筛,比如${exampleText}。`;
return `${recorded}${sameKind}${needOther}`;
}
export function moreCollectHint(
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
): string {
const remaining = collectionQuestionPool(evidence, declinedTopics)
.filter((item) => item.kind !== "invite");
const top = remaining[0];
if (top?.kind === "anchor") {
const trimmed = top.prompt.replace(/[?]$/, "");
return `如果还记得${trimmed.replace(/大概/g, "")},范围还能再收一截。`;
}
const examples = examplePhrases(evidence, declinedKinds(declinedTopics), 3);
const exampleText = examples.length >= 2
? examples.slice(0, 2).join("、")
: "第一份工作哪年开始、哪年搬到别的城市";
return `如果还记得${exampleText},范围还能再收一截。`;
}
export const REMAINING_LAYER_DOMAIN: Readonly<Record<string, CollectKind>> = {
d9: "relationship",
d10: "career",
d4: "relocation",
d5: "education",
d24: "education",
d7: "family",
d12: "family",
d2: "finance",
d11: "finance",
d30: "health_pressure",
};
const TARGETED_EXAMPLES: Readonly<Record<CollectKind, readonly [string, string]>> = {
education: ["哪年升学或毕业", "哪年考试发挥明显变过"],
career: ["哪年换工作", "哪年岗位性质变过"],
relocation: ["哪年搬家", "哪年换城市或出国"],
relationship: ["哪年结婚或订婚", "哪年确定长期关系"],
family: ["家里哪年添丁", "哪年长辈住院"],
finance: ["哪年收入明显变过", "哪年有过大笔进出"],
health_pressure: ["哪年住院或手术", "哪年身体明显垮过一截"],
};
const TARGETED_EXISTENCE_PROMPT: Readonly<Record<CollectKind, string>> = {
education: "升过学或考试发挥明显变过吗?",
career: "换过工作或岗位变过吗?",
relocation: "搬过家或换过城市吗?",
relationship: "结过婚或订过婚吗?",
family: "家里添过丁或长辈住过院吗?",
finance: "收入明显变过或有过大笔进出吗?",
health_pressure: "住过院、做过手术或身体明显垮过吗?",
};
export const TARGETED_YEAR_PROMPT = "大概哪年几月?";
/** Range-endpoint wording that used to name two clocks as if they were the remaining candidates. */
export const SPLIT_ENDPOINT_PHRASE = /能把 (?:[01]\d|2[0-3]):[0-5]\d 和 (?:[01]\d|2[0-3]):[0-5]\d 分开/;
const SCAN_LAYER_FLAGS: ReadonlyArray<readonly [string, string]> = [
["d9", "d9_candidates_differ"],
["d10", "d10_candidates_differ"],
["d4", "d4_candidates_differ"],
["d5", "d5_candidates_differ"],
["d24", "d24_candidates_differ"],
["d7", "d7_candidates_differ"],
["d12", "d12_candidates_differ"],
["d2", "d2_candidates_differ"],
["d11", "d11_candidates_differ"],
["d30", "d30_candidates_differ"],
];
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
function clockValue(value: string | null | undefined): string | null {
const clock = (value ?? "").slice(0, 5);
return CLOCK.test(clock) ? clock : null;
}
export function remainingSplitLayers(input: {
transitions?: readonly Readonly<{ layer?: string; at?: string }>[];
scanFlags?: Readonly<Record<string, unknown>> | null;
activeTimes?: readonly string[];
}): string[] {
const active = [...new Set((input.activeTimes ?? []).map((item) => clockValue(item)).filter((item): item is string => Boolean(item)))].sort();
const fromTransitions: string[] = [];
for (const row of input.transitions ?? []) {
const layer = typeof row.layer === "string" ? row.layer.trim().toLowerCase() : "";
if (!layer || !(layer in REMAINING_LAYER_DOMAIN)) continue;
const at = clockValue(row.at);
if (active.length >= 2 && at && (at < active[0] || at > active[active.length - 1])) continue;
if (!fromTransitions.includes(layer)) fromTransitions.push(layer);
}
if (fromTransitions.length > 0) return fromTransitions;
const flags = input.scanFlags ?? {};
const fromScan: string[] = [];
for (const [layer, flag] of SCAN_LAYER_FLAGS) {
if (flags[flag] === true && !fromScan.includes(layer)) fromScan.push(layer);
}
return fromScan;
}
export function remainingCandidateClocks(
activeTimes: readonly string[] = [],
): string[] {
return [...new Set(activeTimes.map((item) => clockValue(item)).filter((item): item is string => Boolean(item)))].sort();
}
export function remainingSplitTimes(
activeTimes: readonly string[] = [],
): readonly [string, string] | null {
const clocks = remainingCandidateClocks(activeTimes);
if (clocks.length < 2) return null;
return [clocks[0], clocks[clocks.length - 1]];
}
export function remainingCandidateCount(activeTimes: readonly string[] = []): number {
return remainingCandidateClocks(activeTimes).length;
}
export type TargetedCollectStage = "existence" | "year";
export type TargetedCollectRef = Readonly<{
domain: CollectKind;
stage: TargetedCollectStage;
}>;
export function parseTargetedCollectQuestionId(
questionId: string | null | undefined,
): TargetedCollectRef | null {
const trimmed = (questionId ?? "").replace(/:(?:next|next2|next3)$/, "").trim();
const match = /^collect:targeted:([a-z_]+)(?::(year))?$/.exec(trimmed);
if (!match) return null;
const domain = normalizeCollectKind(match[1]);
if (!domain) return null;
return { domain, stage: match[2] === "year" ? "year" : "existence" };
}
export function parseTargetedCollectKind(
kind: string | null | undefined,
): TargetedCollectRef | null {
const trimmed = (kind ?? "").trim();
const match = /^targeted:([a-z_]+)(?::(year))?$/.exec(trimmed);
if (!match) return null;
const domain = normalizeCollectKind(match[1]);
if (!domain) return null;
return { domain, stage: match[2] === "year" ? "year" : "existence" };
}
export function targetedCollectQuestionId(
domain: CollectKind,
stage: TargetedCollectStage = "existence",
): string {
return stage === "year" ? `collect:targeted:${domain}:year` : `collect:targeted:${domain}`;
}
export function targetedCollectRefFromTopic(topic: CollectionTopic): TargetedCollectRef | null {
return parseTargetedCollectQuestionId(topicQuestionId(topic))
?? parseTargetedCollectKind(topicCollectKind(topic));
}
export function isTargetedCollectTopic(topic: CollectionTopic): boolean {
const questionId = topicQuestionId(topic);
const kind = topicCollectKind(topic);
return questionId.startsWith("collect:targeted:")
|| kind.startsWith("targeted:")
|| topicDomain(topic) === "targeted";
}
export function isTargetedCollectClosed(
topics: readonly CollectionTopic[] = [],
): boolean {
return topics.some((topic) => {
const status = topicStatus(topic);
if (status === "active" || !status) return false;
return isTargetedCollectTopic(topic);
});
}
export function isTargetedCollectFollowup(followup: {
collection_key?: string;
kind_hint?: string | null;
} | null | undefined): boolean {
if (!followup) return false;
const key = followup.collection_key ?? "";
const hint = followup.kind_hint ?? "";
return key.startsWith("collect:targeted:")
|| hint.startsWith("targeted:");
}
export function isTargetedCollectExistenceFollowup(followup: {
collection_key?: string;
kind_hint?: string | null;
} | null | undefined): boolean {
if (!followup) return false;
const ref = parseTargetedCollectQuestionId(followup.collection_key)
?? parseTargetedCollectKind(followup.kind_hint);
return ref?.stage === "existence";
}
export function isTargetedCollectExistenceFocus(focus: {
questionId?: string | null;
targetKind?: string | null;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
} | null | undefined): boolean {
if (!focus) return false;
const schema = focus.expectedAnswerSchema;
const schemaKind = schema && typeof schema.collect_kind === "string"
? schema.collect_kind
: null;
const ref = parseTargetedCollectQuestionId(focus.questionId)
?? parseTargetedCollectKind(focus.targetKind)
?? parseTargetedCollectKind(schemaKind);
return ref?.stage === "existence";
}
function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<CollectKind> {
const declined = new Set<CollectKind>();
for (const topic of topics) {
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") continue;
const intent = typeof topic.intent === "string" ? topic.intent : "";
const questionId = topicQuestionId(topic);
const collectIntent = intent === "collect_method_evidence"
|| questionId.startsWith("collect:");
if (!collectIntent) continue;
if (questionId.startsWith("collect:invite:")) continue;
if (questionId.startsWith("collect:other:")) continue;
const targeted = targetedCollectRefFromTopic(topic);
if (targeted) {
declined.add(targeted.domain);
continue;
}
const kind = normalizeCollectKind(topicDomain(topic))
?? (questionId.startsWith("collect:generic:")
? normalizeCollectKind(questionId.split(":")[2] ?? "")
: null);
if (kind) declined.add(kind);
}
return declined;
}
function targetedDomainClosed(
topics: readonly CollectionTopic[],
domain: CollectKind,
): boolean {
for (const topic of topics) {
const status = topicStatus(topic);
if (status === "active" || !status) continue;
const targeted = targetedCollectRefFromTopic(topic);
if (targeted?.domain !== domain) continue;
if (status === "declined" || status === "skipped") return true;
if (status === "resolved" && targeted.stage === "existence") return true;
if (status === "resolved" && targeted.stage === "year") return true;
}
return false;
}
export function pendingTargetedYearDomain(
topics: readonly CollectionTopic[] = [],
evidence: readonly CollectionEvidence[] = [],
): CollectKind | null {
const covered = coveredCollectKinds(evidence);
const yearClosed = new Set<CollectKind>();
const existenceResolved = new Set<CollectKind>();
for (const topic of topics) {
const targeted = targetedCollectRefFromTopic(topic);
if (!targeted) continue;
const status = topicStatus(topic);
if (status === "active" || !status) continue;
if (targeted.stage === "year" && (status === "declined" || status === "skipped" || status === "resolved")) {
yearClosed.add(targeted.domain);
}
if (targeted.stage === "existence" && status === "resolved") {
existenceResolved.add(targeted.domain);
}
}
for (const domain of existenceResolved) {
if (covered.has(domain) || yearClosed.has(domain)) continue;
return domain;
}
return null;
}
function remainingTargetedDomains(
layers: readonly string[],
evidence: readonly CollectionEvidence[],
declined: ReadonlySet<CollectKind>,
topics: readonly CollectionTopic[] = [],
): CollectKind[] {
const covered = coveredCollectKinds(evidence);
const domains: CollectKind[] = [];
for (const layer of layers) {
const domain = REMAINING_LAYER_DOMAIN[layer];
if (!domain || declined.has(domain) || covered.has(domain)) continue;
if (targetedDomainClosed(topics, domain)) continue;
if (!domains.includes(domain)) domains.push(domain);
}
return domains;
}
export function remainingCandidatesLine(
splitTimes: readonly [string, string] | null | undefined,
candidateCount: number,
examples: readonly string[],
): string | null {
if (!splitTimes?.[0] || !splitTimes[1] || candidateCount < 1 || examples.length === 0) return null;
return `现在还剩 ${splitTimes[0]}${splitTimes[1]}${candidateCount} 个候选,能把它们分开的是这几条线:${examples.join("、")}`;
}
export function targetedCollectPool(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
): CollectionPoolItem[] {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return [];
const declined = collectDeclinedKinds(declinedTopics);
const domains = remainingTargetedDomains(remainingLayers, evidence, declined, declinedTopics);
if (domains.length === 0) return [];
const count = candidateCount ?? 0;
const examples = domains.map((domain) => TARGETED_EXAMPLES[domain][0]);
const remainingLine = remainingCandidatesLine(splitTimes, count, examples);
return domains.map((domain) => {
const existencePrompt = TARGETED_EXISTENCE_PROMPT[domain];
return {
kind: "targeted" as const,
value: 1.5,
prompt: existencePrompt,
key: targetedCollectQuestionId(domain),
domain,
targetKind: `targeted:${domain}`,
year: null,
examples: [TARGETED_EXAMPLES[domain][0], TARGETED_EXAMPLES[domain][1]],
existencePrompt,
yearPrompt: TARGETED_YEAR_PROMPT,
...(remainingLine ? { remainingLine } : {}),
};
});
}
export function targetedCollectHint(item: CollectionPoolItem | null | undefined): string | null {
if (!item) return null;
const examples = item.examples?.filter(Boolean) ?? [];
if (examples.length >= 2) {
return `还能再收窄:如果记得${examples.slice(0, 2).join("、")}`;
}
const body = item.prompt.replace(/[。??]$/, "");
return `还能再收窄:如果记得${body}`;
}
export function targetedCollectHintFromPool(
items: readonly CollectionPoolItem[],
): string | null {
const examples = [...new Set(items.flatMap((item) => (
item.examples?.length ? [item.examples[0]!] : []
)))];
if (examples.length >= 2) {
return `还能再收窄:如果记得${examples.slice(0, 2).join("、")}`;
}
return targetedCollectHint(items[0]);
}
/** Card copy ignores a declined targeted collect so the delivered range still says what would narrow it. */
export function rangeNarrowHint(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
): string {
const open = targetedCollectPool(remainingLayers, evidence, [], splitTimes, candidateCount);
const remaining = remainingCandidatesLine(
splitTimes,
candidateCount ?? 0,
open.map((item) => TARGETED_EXAMPLES[item.domain as CollectKind]?.[0] ?? item.examples?.[0] ?? "").filter(Boolean),
);
const hint = targetedCollectHintFromPool(open)
?? targetedCollectHint(targetedCollectPool(remainingLayers, evidence, declinedTopics, splitTimes, candidateCount)[0])
?? `还能再收窄:${moreCollectHint(evidence, declinedTopics)}`;
if (remaining && !hint.startsWith(remaining)) {
return `${remaining}${hint}`;
}
return hint;
}
export function targetedCollectExhausted(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
): boolean {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return false;
return targetedCollectPool(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
).length === 0;
}
export const COLLECT_FLOW_BANNED_PHRASES = [
"任何领域",
"领域不限",
"方法覆盖",
"训练门",
"holdout",
"做不了",
"材料不够",
"还差",
"探针",
"分盘",
"领域",
] as const;