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>
283 lines
12 KiB
TypeScript
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);
|
|
}
|