Files
Jyotisha/frontend/src/lib/synastry-full-generate.ts
T
jesse-uxandCursor e1afd0ea3d fix(synastry): drop a section title repeated at the start of the body
The gate now rejects that opening and asks for a rewrite, and the saved text no longer keeps the duplicate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:09:19 +08:00

283 lines
12 KiB
TypeScript

/**
* One section at a time, at most three at once. A failed gate rewrites only
* the failed sections, once. The timeline basis lines stay program-owned.
*/
import { buildTechnicalBasis, type TechnicalBlock } from "./synastry-full-technical.ts";
import {
SECTION_TITLES,
SYNASTRY_PROSE_IDS,
SYNASTRY_SECTION_IDS,
sectionEvidence,
systemPrompt,
userPrompt,
type SynastrySectionId,
} from "./synastry-full-sections.ts";
import { buildTimelineSegments, type TimelineSegment, type TimingPeriod } from "./synastry-full-timeline.ts";
export type SynastryReason = Readonly<{
plain: string;
basis: string;
evidence: string;
label: string;
}>;
export type SynastryDelivery = Readonly<{
sections: readonly Record<string, unknown>[];
}>;
export type SynastrySnapshot = Readonly<{
names: Readonly<{ a: string; b: string }>;
sections: ReadonlyArray<Readonly<{ id: SynastrySectionId; title: string; text: string; reasons: readonly SynastryReason[] }>>;
timeline: ReadonlyArray<TimelineSegment & Readonly<{ phase: string; text: string }>>;
practices: ReadonlyArray<Readonly<{ text: string; evidence: string }>>;
technical: readonly TechnicalBlock[];
gate: Readonly<{ ok: true; language: "zh" | "en" }>;
english?: Readonly<{
sections: SynastrySnapshot["sections"];
timeline: SynastrySnapshot["timeline"];
practices: SynastrySnapshot["practices"];
gate: Readonly<{ ok: true; language: "en" }>;
}>;
}>;
export type SectionWriter = (input: Readonly<{
system: string;
user: string;
sectionId: SynastrySectionId;
signal: AbortSignal;
}>) => Promise<Readonly<{ body: unknown; inputTokens: number; outputTokens: number }>>;
export type ReaderGate = (input: Readonly<{
delivery: SynastryDelivery;
sectionInputs: Readonly<Record<string, unknown>>;
language: "zh" | "en";
}>) => Promise<Readonly<{ ok: boolean; failures: readonly Readonly<{ section: string; code: string; detail?: string }>[] }>>;
type ProseDraft = Readonly<{ text: string; reasons: readonly SynastryReason[] }>;
type TimelineDraft = ReadonlyArray<Readonly<{ id: string; phase: string; text: string }>>;
type PracticeDraft = ReadonlyArray<Readonly<{ text: string; evidence: string }>>;
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function proseDraft(value: unknown): ProseDraft | null {
const row = asRecord(value);
if (!row || typeof row.text !== "string" || !Array.isArray(row.reasons)) return null;
const reasons = row.reasons.flatMap((item) => {
const reason = asRecord(item);
if (!reason || typeof reason.plain !== "string" || typeof reason.basis !== "string" || typeof reason.evidence !== "string") return [];
return [{ plain: reason.plain, basis: reason.basis, evidence: reason.evidence, label: typeof reason.label === "string" ? reason.label : "" }];
});
return { text: row.text, reasons };
}
function timelineDraft(value: unknown): TimelineDraft | null {
const row = asRecord(value);
const segments = row && Array.isArray(row.segments) ? row.segments : null;
if (!segments) return null;
const parsed = segments.flatMap((item) => {
const segment = asRecord(item);
if (!segment || typeof segment.id !== "string" || typeof segment.phase !== "string" || typeof segment.text !== "string") return [];
return [{ id: segment.id, phase: segment.phase, text: segment.text }];
});
return parsed.length === segments.length ? parsed : null;
}
function practiceDraft(value: unknown): PracticeDraft | null {
const row = asRecord(value);
const items = row && Array.isArray(row.items) ? row.items : null;
if (!items) return null;
const parsed = items.flatMap((item) => {
const practice = asRecord(item);
if (!practice || typeof practice.text !== "string" || typeof practice.evidence !== "string") return [];
return [{ text: practice.text, evidence: practice.evidence }];
});
return parsed.length > 0 ? parsed : null;
}
async function pool<T>(items: readonly T[], limit: number, run: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
await run(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
}
function deliveryFrom(parts: ReadonlyMap<SynastrySectionId, Record<string, unknown>>): SynastryDelivery {
return { sections: SYNASTRY_SECTION_IDS.map((id) => parts.get(id) ?? { id }) };
}
/** A body that opens with its own section title, including a repeated title. */
export function startsWithSectionTitle(text: string, title: string): boolean {
const heading = title.trim();
const body = text.replace(/^\s+/, "");
if (!heading || !body.startsWith(heading)) return false;
const rest = body.slice(heading.length);
return rest.length === 0 || /^[\s\u3000]/.test(rest) || rest.startsWith(heading);
}
export function stripLeadingSectionTitle(text: string, title: string): string {
const heading = title.trim();
if (!heading) return text;
let rest = text.replace(/^\s+/, "");
for (let guard = 0; guard < 4 && startsWithSectionTitle(rest, heading); guard += 1) {
rest = rest.slice(heading.length).replace(/^\s+/, "");
}
return rest;
}
function headingFailures(parts: ReadonlyMap<SynastrySectionId, Record<string, unknown>>): { section: string; code: string }[] {
const failures: { section: string; code: string }[] = [];
for (const id of SYNASTRY_PROSE_IDS) {
const row = parts.get(id);
const title = typeof row?.title === "string" ? row.title : "";
const text = typeof row?.text === "string" ? row.text : "";
if (startsWithSectionTitle(text, title)) failures.push({ section: id, code: "repeated_heading" });
}
return failures;
}
export async function writeSynastryLanguage(input: Readonly<{
language: "zh" | "en";
names: Readonly<{ a: string; b: string }>;
dossier: unknown;
raman?: unknown;
segments: readonly TimelineSegment[];
signal: AbortSignal;
write: SectionWriter;
gate: ReaderGate;
onProgress?: (phase: string) => Promise<void> | void;
timeoutMs?: number;
}>): Promise<Readonly<{ snapshotParts: Pick<SynastrySnapshot, "sections" | "timeline" | "practices" | "technical" | "gate">; inputTokens: number; outputTokens: number }>> {
const timeoutMs = input.timeoutMs ?? 90_000;
const evidence = Object.fromEntries(SYNASTRY_SECTION_IDS.map((id) => [id, sectionEvidence(input.dossier, id, input.segments, input.language)])) as Record<string, unknown>;
const drafts = new Map<SynastrySectionId, unknown>();
let inputTokens = 0;
let outputTokens = 0;
async function writeOne(sectionId: SynastrySectionId, failures?: readonly string[]) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
if (input.signal.aborted) throw lastError ?? new Error("synastry_section_aborted");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onAbort = () => controller.abort();
input.signal.addEventListener("abort", onAbort, { once: true });
try {
const written = await input.write({
system: systemPrompt(input.language),
user: userPrompt({
language: input.language,
names: input.names,
sectionId,
evidence: evidence[sectionId],
failures,
}),
sectionId,
signal: controller.signal,
});
inputTokens += written.inputTokens;
outputTokens += written.outputTokens;
drafts.set(sectionId, written.body);
return;
} catch (error) {
lastError = error;
if (input.signal.aborted) throw error;
} finally {
clearTimeout(timer);
input.signal.removeEventListener("abort", onAbort);
}
}
throw lastError instanceof Error ? lastError : new Error("synastry_section_failed");
}
await input.onProgress?.("writing_report");
await pool(SYNASTRY_SECTION_IDS, 3, (sectionId) => writeOne(sectionId));
function materialize(): Map<SynastrySectionId, Record<string, unknown>> {
const parts = new Map<SynastrySectionId, Record<string, unknown>>();
for (const sectionId of SYNASTRY_PROSE_IDS) {
const draft = proseDraft(drafts.get(sectionId));
if (!draft) throw new Error("synastry_section_invalid");
parts.set(sectionId, { id: sectionId, title: SECTION_TITLES[sectionId][input.language], ...draft });
}
const phases = timelineDraft(drafts.get("timeline"));
if (!phases) throw new Error("synastry_section_invalid");
parts.set("timeline", {
id: "timeline",
segments: input.segments.map((segment) => {
const written = phases.find((item) => item.id === segment.id);
return { ...segment, phase: written?.phase ?? "", text: written?.text ?? "" };
}),
});
const practices = practiceDraft(drafts.get("practices"));
if (!practices) throw new Error("synastry_section_invalid");
parts.set("practices", { id: "practices", items: practices });
return parts;
}
await input.onProgress?.("checking_report");
let parts = materialize();
let gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
const firstHeadings = headingFailures(parts);
if (firstHeadings.length > 0) {
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
gated = {
ok: false,
failures: [...gated.failures, ...firstHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
};
}
if (!gated.ok) {
const failed = new Set(gated.failures.map((failure) => failure.section).filter((section) => SYNASTRY_SECTION_IDS.includes(section as SynastrySectionId)));
const targets = failed.size > 0 ? [...failed] as SynastrySectionId[] : [...SYNASTRY_SECTION_IDS];
await pool(targets, 3, (sectionId) => writeOne(sectionId, gated.failures.filter((failure) => failure.section === sectionId).map((failure) => failure.code)));
parts = materialize();
gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
const secondHeadings = headingFailures(parts);
if (secondHeadings.length > 0) {
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
gated = {
ok: false,
failures: [...gated.failures, ...secondHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
};
}
}
if (!gated.ok) throw new Error("synastry_gate_rejected");
const sections = SYNASTRY_PROSE_IDS.map((id) => {
const section = parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] };
return { ...section, text: stripLeadingSectionTitle(section.text, section.title) };
});
const timeline = (parts.get("timeline")?.segments ?? []) as SynastrySnapshot["timeline"];
const practices = (parts.get("practices")?.items ?? []) as SynastrySnapshot["practices"];
const technical = buildTechnicalBasis({
dossier: input.dossier,
raman: input.raman,
names: input.names,
contacts: sections.flatMap((section) => section.reasons.map((reason) => reason.basis)),
timing: timeline.map((segment) => segment.basis),
});
return {
snapshotParts: { sections, timeline, practices, technical, gate: { ok: true, language: input.language } },
inputTokens,
outputTokens,
};
}
export function timelineForPair(input: Readonly<{
nameA: string;
nameB: string;
periodsA: readonly TimingPeriod[];
periodsB: readonly TimingPeriod[];
from: string;
language?: "zh" | "en";
}>): TimelineSegment[] {
return buildTimelineSegments(input);
}