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.
870 lines
30 KiB
TypeScript
870 lines
30 KiB
TypeScript
/**
|
||
* 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;
|