Files
Jyotisha/frontend/src/lib/timing-output-guard.ts
T
jesse-ux cd4775aec6 fix(consult): BUG-950~953 Pass 4 按句放行,无分钟按句丢弃
正文不再整段 hold:闭合句立刻过 Pass 4,无 reject 即发(950)。
无出生分钟改为按句丢弃,全丢才用兜底句(951);该模式日期记 observe(952)。
校正流 token 级 thinking 死链按 P2 删除,测试翻转成否定合同(953)。
2026-09-18 14:08:45 +08:00

254 lines
11 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.
const exactTimingPatterns = [
/\b(?:19|20)\d{2}[-/.年]\s?\d{1,2}(?:[-/.月]\s?\d{1,2}(?:日|号)?)?\b/g,
/\b(?:19|20)\d{2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)(?:\s+\d{1,2})?\b/gi,
/\b\d{1,2}\/\d{1,2}\/(?:\d{2}|\d{4})\b/g,
/(?:今年|明年|后年|(?:19|20)\d{2}年)?\s*\d{1,2}月(?:\s*\d{1,2}[日号])?/g,
/\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s*(?:19|20)\d{2})?\b/gi,
];
const guaranteeConclusionPatterns = [
/(?:^|[。!?.!?\n])[^。!?.!?\n]*(?:一定|必然|保证|肯定|必定|注定|绝对)(?:会|能|将|发生|成功|结婚|复合|怀孕|发财|升职|得到|实现|出现)[^。!?.!?\n]*/g,
/(?:^|[.?!\n])[^.?!\n]*\b(?:will definitely|guaranteed? to|certain to|without doubt)\b[^.?!\n]*/gi,
];
// The boundary is structural: an educational question frame is removed first,
// then a personal subject/context, chart object, and chart predicate must agree.
// This avoids treating every sentence that merely contains `你` and `宫` as a
// personal chart claim.
const chineseZodiacSignSource = String.raw`(?:白羊|金牛|双子|巨蟹|狮子|处女|天秤|天蝎|射手|摩羯|水瓶|双鱼)`;
const chineseNamedHouseSource = String.raw`(?:命|财帛|兄弟|田宅|子女|夫妻|婚姻|疾厄|迁移|事业|官禄|交友|仆役|福德|父母)宫`;
const chineseChartObjectSource = String.raw`(?:盘面?|星盘|命盘|出生盘|本命盘|D\s*\d+|上升(?:星座)?|星座|${chineseZodiacSignSource}(?:座|上升)|(?:第\s*)?[一二三四五六七八九十百0-9]+\s*宫|${chineseNamedHouseSource}|宫位|行星|星体|太阳|月亮|火星|水星|木星|金星|土星|罗喉|凯图|Rahu|Ketu)`;
const chineseChartObjectPattern = new RegExp(chineseChartObjectSource, "iu");
const chineseChartPredicatePattern = /(?:落(?:在|入|座)?|位于|进入|为|是|有|显示|表明|说明|意味着|主宰|很?强|很?弱|旺|受克|有力|无力|[::])/iu;
const chineseOwnedChartSubjectPattern = new RegExp(
String.raw`(?:你|您)\s*(?:的|个人(?:的)?)?\s*${chineseChartObjectSource}`,
"iu",
);
const chineseUserHasChartPattern = new RegExp(
String.raw`(?:你|您)\s*(?:为|是|有|拥有)\s*(?:一(?:个|颗)\s*)?${chineseChartObjectSource}`,
"iu",
);
const chinesePersonalChartContextPattern = /(?:对(?:你|您)而言|基于(?:你|您)(?:的)?(?:盘面?|星盘|命盘|出生盘|本命盘)|在(?:你|您)(?:的)?(?:盘面?|星盘|命盘|出生盘|本命盘)(?:中|里)?)/iu;
const chineseChartAddressesUserPattern = new RegExp(
String.raw`${chineseChartObjectSource}\s*(?:显示|表明|说明|意味着)[^。!?.!?\n]{0,24}(?:你|您)`,
"iu",
);
const englishZodiacSignSource = String.raw`(?:aries|taurus|gemini|cancer|leo|virgo|libra|scorpio|sagittarius|capricorn|aquarius|pisces)`;
const englishChartObjectSource = String.raw`(?:natal\s+chart|birth\s+chart|chart|ascendant|rising(?:\s+sign)?|D\s*\d+(?:\s+chart)?|placement|(?:\d+(?:st|nd|rd|th)|[a-z]+)\s+house|house|planet|sun|moon|mars|mercury|jupiter|venus|saturn|rahu|ketu)`;
const englishChartObjectPattern = new RegExp(String.raw`\b${englishChartObjectSource}\b`, "iu");
const englishChartPredicatePattern = /(?:\b(?:has|have|is|are|occup(?:y|ies)|falls?|lands?|sits?|placed?|located?|shows?|indicates?|means?|rules?|strong|weak)\b|[::])/iu;
const englishOwnedChartSubjectPattern = new RegExp(
String.raw`\b(?:your|the\s+user(?:'s)?)\s+(?:personal\s+)?${englishChartObjectSource}\b`,
"iu",
);
const englishUserIsOrHasChartPattern = new RegExp(
String.raw`\byou\s+(?:are|have)\s+(?:an?\s+)?(?:${englishZodiacSignSource}\s+(?:rising|ascendant)|${englishChartObjectSource})\b`,
"iu",
);
const englishPersonalChartContextPattern = /\b(?:for\s+you|(?:in|from|based\s+on|according\s+to)\s+your\s+(?:natal\s+|birth\s+)?chart)\b/iu;
const englishChartAddressesUserPattern = new RegExp(
String.raw`\b${englishChartObjectSource}\s+(?:shows?|indicates?|means?)\s+(?:that\s+)?you\b`,
"iu",
);
const chineseEducationalFramePattern = /^(?:(?:你|您)(?:的)?问题(?:是|为)|(?:你|您)(?:所)?问(?:的)?(?:是)?)[\s,,::]*/iu;
const chineseAboutGeneralMeaningPattern = /^关于\s*(?:你|您)(?:的)?\s*(.+?(?:一般含义|一般意义))$/iu;
const chineseChartMetaReferencePattern = new RegExp(
String.raw`${chineseChartObjectSource}\s*(?:(?:的\s*)?一般问题|方面的问题|问题(?:的\s*)?提问者|(?:的\s*)?提问者)`,
"giu",
);
const englishEducationalFramePattern = /^(?:your\s+question\s+is|you\s+asked\s+about)\s*/iu;
function normalizeClaimClause(clause: string) {
let normalized = clause
.normalize("NFKC")
.replace(/[*_`~]+/gu, "")
.replace(/\s+/gu, " ")
.trim();
normalized = normalized
.replace(chineseEducationalFramePattern, "")
.replace(englishEducationalFramePattern, "")
.replace(chineseChartMetaReferencePattern, "咨询问题");
const aboutGeneralMeaning = chineseAboutGeneralMeaningPattern.exec(normalized);
return aboutGeneralMeaning?.[1] ?? normalized;
}
function isPersonalChartConclusion(clause: string) {
const normalized = normalizeClaimClause(clause);
const chineseStructure = (chineseOwnedChartSubjectPattern.test(normalized)
|| chineseUserHasChartPattern.test(normalized)
|| chinesePersonalChartContextPattern.test(normalized)
|| chineseChartAddressesUserPattern.test(normalized))
&& chineseChartObjectPattern.test(normalized)
&& chineseChartPredicatePattern.test(normalized);
const englishStructure = (englishOwnedChartSubjectPattern.test(normalized)
|| englishUserIsOrHasChartPattern.test(normalized)
|| englishPersonalChartContextPattern.test(normalized)
|| englishChartAddressesUserPattern.test(normalized))
&& englishChartObjectPattern.test(normalized)
&& englishChartPredicatePattern.test(normalized);
return chineseStructure || englishStructure;
}
export const GENERAL_NO_BIRTH_TIME_REFUSAL =
"这部分需要具体出生分钟才能判断,我不会补造时间";
export type TimingGuardKind = "exact-timing" | "guarantee" | "personal-chart" | "methodology";
export type TimingGuardViolation = Readonly<{
kind: TimingGuardKind;
excerpt: string;
}>;
function collectMatches(text: string, pattern: RegExp): string[] {
return [...text.matchAll(new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`))]
.map((match) => match[0]?.trim() ?? "")
.filter(Boolean);
}
export function detectPreciseTimingViolations(text: string): TimingGuardViolation[] {
const hits: TimingGuardViolation[] = [];
for (const pattern of exactTimingPatterns) {
for (const excerpt of collectMatches(text, pattern)) {
hits.push({ kind: "exact-timing", excerpt: excerpt.slice(0, 80) });
}
}
for (const pattern of guaranteeConclusionPatterns) {
for (const excerpt of collectMatches(text, pattern)) {
hits.push({ kind: "guarantee", excerpt: excerpt.slice(0, 80) });
}
}
return hits;
}
export function detectGeneralNoBirthTimeViolations(text: string): TimingGuardViolation[] {
const hits = detectPreciseTimingViolations(text);
for (const part of text.split(/[。!?.!?\n]+/u)) {
if (isPersonalChartConclusion(part)) {
hits.push({ kind: "personal-chart", excerpt: part.trim().slice(0, 80) });
}
}
return hits;
}
export function detectMethodologyBookkeeping(text: string): TimingGuardViolation[] {
const hits: TimingGuardViolation[] = [];
if (/##\s*统一参数/.test(text) || text.includes("统一参数与原始结构")) {
hits.push({ kind: "methodology", excerpt: "统一参数与原始结构" });
}
if (text.includes("技法审计表")) {
hits.push({ kind: "methodology", excerpt: "技法审计表" });
}
return hits;
}
export type Pass4Mode =
| "verified_chart"
| "unverified_birth_time"
| "declared_birth_window"
| "general_no_birth_time";
export type Pass4Step = Readonly<{
action: "observe" | "reject";
kind: TimingGuardKind;
excerpt: string;
}>;
export type Pass4Result = Readonly<{
text: string;
steps: readonly Pass4Step[];
retry: boolean;
retryHint?: string;
}>;
const SENTENCE_TERMINATOR = /[。!?.!?\n]/u;
/**
* Pass 4 is a sentence gate, not a rewrite knife. A closed sentence is sent
* whole or dropped whole; nothing inside a sentence is rewritten.
*/
export function takeClosedSentences(buffer: string): { closed: string[]; rest: string } {
const closed: string[] = [];
let cursor = 0;
for (let index = 0; index < buffer.length; index += 1) {
if (!SENTENCE_TERMINATOR.test(buffer[index]!)) continue;
let end = index + 1;
while (end < buffer.length && SENTENCE_TERMINATOR.test(buffer[end]!)) end += 1;
closed.push(buffer.slice(cursor, end));
cursor = end;
index = end - 1;
}
return { closed, rest: buffer.slice(cursor) };
}
function dropRejectedClauses(text: string, mode: Pass4Mode): string {
const { closed, rest } = takeClosedSentences(text);
const pieces = rest ? [...closed, rest] : closed;
let output = "";
for (const sentence of pieces) {
if (classifyPass4(sentence, mode).some((step) => step.action === "reject")) continue;
output += sentence;
}
return output;
}
export function classifyPass4(
text: string,
mode: Pass4Mode,
): Pass4Step[] {
const steps: Pass4Step[] = [];
for (const hit of detectPreciseTimingViolations(text)) {
if (hit.kind === "exact-timing") {
steps.push({ action: "observe", kind: hit.kind, excerpt: hit.excerpt });
continue;
}
if (hit.kind === "guarantee") {
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
}
}
if (mode === "general_no_birth_time") {
for (const hit of detectGeneralNoBirthTimeViolations(text)) {
if (hit.kind === "personal-chart") {
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
}
}
}
for (const hit of detectMethodologyBookkeeping(text)) {
steps.push({ action: "reject", kind: hit.kind, excerpt: hit.excerpt });
}
return steps;
}
export const PASS4_RETRY_HINT =
"不要写保证性结论(一定、保证、注定、will definitely)。无出生分钟时不要对用户作个人星盘断言。不要在正文写统一参数或技法审计表。";
function isRetryableReject(kind: TimingGuardKind) {
return kind === "guarantee" || kind === "personal-chart" || kind === "methodology";
}
export function applyPass4Policy(
text: string,
mode: Pass4Mode,
options?: { secondPass?: boolean },
): Pass4Result {
const steps = classifyPass4(text, mode);
const rejects = steps.filter((step) => step.action === "reject");
if (!options?.secondPass && rejects.some((step) => isRetryableReject(step.kind))) {
return {
text,
steps,
retry: true,
retryHint: PASS4_RETRY_HINT,
};
}
let next = text;
if (options?.secondPass && rejects.some((step) => isRetryableReject(step.kind))) {
next = dropRejectedClauses(next, mode);
if (!/\S/.test(next) && mode === "general_no_birth_time") {
next = GENERAL_NO_BIRTH_TIME_REFUSAL;
}
}
return { text: next, steps, retry: false };
}