["observedImageOccurrences"]
@@ -71,10 +52,6 @@ export type RecordViewAndGetStatusInputWire = z.input<
typeof import("./schemas.js").recordViewAndGetStatusInputSchema
>;
-export type InvestigationStatusOutput = z.infer<
- typeof import("./schemas.js").investigationStatusOutputSchema
->;
-
export type GetInvestigationInput = z.infer<
typeof import("./schemas.js").getInvestigationInputSchema
>;
@@ -160,9 +137,7 @@ export type ExtensionApiInput =
export type ExtensionPostStatus = z.infer;
-export type ExtensionSkippedStatus = z.infer<
- typeof import("./schemas.js").extensionSkippedStatusSchema
->;
+export type ExtensionSkippedStatus = Extract;
export type ExtensionSkippedReason = ExtensionSkippedStatus["reason"];
@@ -172,6 +147,23 @@ export type ExtensionRuntimeErrorCode = z.infer<
typeof import("./schemas.js").extensionRuntimeErrorCodeSchema
>;
-// ── Extension message protocol ────────────────────────────────────────────
+export type TabSessionId = z.infer;
+
+// ── Extension message protocol (spec §3.8.1) ──────────────────────────────
-export type ExtensionMessage = z.infer;
+type BackgroundRequests = typeof import("./schemas.js").BACKGROUND_REQUESTS;
+type ContentRequests = typeof import("./schemas.js").CONTENT_REQUESTS;
+
+export type BackgroundRequestType = keyof BackgroundRequests;
+export type BackgroundRequestPayload = z.infer<
+ BackgroundRequests[T]["payload"]
+>;
+export type BackgroundResponse = z.infer<
+ BackgroundRequests[T]["response"]
+>;
+
+export type ContentRequestType = keyof ContentRequests;
+export type ContentRequestPayload = z.infer<
+ ContentRequests[T]["payload"]
+>;
+export type ContentResponse = z.infer;
diff --git a/src/typescript/shared/src/version-identity.ts b/src/typescript/shared/src/version-identity.ts
index 1e7a4fe..3604a82 100644
--- a/src/typescript/shared/src/version-identity.ts
+++ b/src/typescript/shared/src/version-identity.ts
@@ -1,6 +1,6 @@
import { validateAndSortImageOccurrences } from "./image-occurrence-validation.js";
-export interface VersionIdentityImageOccurrence {
+interface VersionIdentityImageOccurrence {
originalIndex: number;
normalizedTextOffset: number;
sourceUrl: string;
diff --git a/src/typescript/shared/src/wikipedia-canonicalization.ts b/src/typescript/shared/src/wikipedia-canonicalization.ts
index ace625d..651124d 100644
--- a/src/typescript/shared/src/wikipedia-canonicalization.ts
+++ b/src/typescript/shared/src/wikipedia-canonicalization.ts
@@ -96,16 +96,112 @@ export function effectiveHeadingText(
return node.textContent;
}
-export const WIKIPEDIA_EXCLUDED_SECTION_TITLES = [
- "references",
- "notes",
- "further reading",
- "external links",
- "bibliography",
- "sources",
- "citations",
-] as const;
+/**
+ * Appendix sections that list citations, sources and outbound links rather
+ * than carry article prose, by title (compared after
+ * `normalizeWikipediaSectionTitle`). Section titles are the one signal here
+ * that is per-language: these are the same kinds of section English excludes,
+ * as titled on the largest Wikipedias, taken from the titles those wikis' own
+ * articles use. A title matches on any wiki, since some wikis' articles use
+ * another language's titles. "See also" sections and their equivalents stay:
+ * English keeps them.
+ */
+const WIKIPEDIA_EXCLUDED_SECTION_TITLES_BY_LANGUAGE = {
+ en: [
+ "references",
+ "notes",
+ "further reading",
+ "external links",
+ "bibliography",
+ "sources",
+ "citations",
+ ],
+ de: [
+ "einzelnachweise",
+ "nachweise",
+ "belege",
+ "anmerkungen",
+ "fußnoten",
+ "literatur",
+ "weiterführende literatur",
+ "weblinks",
+ "quellen",
+ ],
+ fr: ["notes et références", "références", "bibliographie", "liens externes", "lien externe"],
+ es: [
+ "referencias",
+ "notas",
+ "bibliografía",
+ "bibliografía consultada",
+ "bibliografía básica",
+ "enlaces externos",
+ "enlace externo",
+ "fuentes",
+ "fuente",
+ ],
+ it: ["note", "bibliografia", "collegamenti esterni", "altri progetti", "fonti"],
+ pt: [
+ "referências",
+ "notas",
+ "notas explicativas",
+ "bibliografia",
+ "leitura adicional",
+ "ligações externas",
+ "links externos",
+ "fontes",
+ ],
+ nl: [
+ "noten",
+ "voetnoten",
+ "referenties",
+ "bronnen",
+ "bronvermelding",
+ "literatuur",
+ "externe links",
+ "externe link",
+ ],
+ pl: ["przypisy", "uwagi", "bibliografia", "dalsza literatura", "linki zewnętrzne"],
+ ru: ["примечания", "комментарии", "литература", "библиография", "источники", "ссылки"],
+ ja: ["脚注", "注釈", "出典", "参考文献", "参考", "読書案内", "外部リンク"],
+ // zh.wikipedia serves each reader their script variant, so both forms occur.
+ zh: [
+ "注释",
+ "註釋",
+ "注解",
+ "註解",
+ "脚注",
+ "腳註",
+ "参考文献",
+ "參考文獻",
+ "参考资料",
+ "參考資料",
+ "参考来源",
+ "參考來源",
+ "参考",
+ "參考",
+ "来源",
+ "來源",
+ "延伸阅读",
+ "延伸閱讀",
+ "扩展阅读",
+ "擴展閱讀",
+ "进阶读物",
+ "進階讀物",
+ "外部链接",
+ "外部鏈接",
+ "外部連結",
+ ],
+} as const satisfies Record;
+
+export const WIKIPEDIA_EXCLUDED_SECTION_TITLES: readonly string[] = Object.values(
+ WIKIPEDIA_EXCLUDED_SECTION_TITLES_BY_LANGUAGE,
+).flat();
+/**
+ * Class tokens of non-prose elements. The conventions MediaWiki and its
+ * communities apply on every wiki come first; the per-wiki tokens after them
+ * cover boxes those conventions miss.
+ */
const WIKIPEDIA_EXCLUDED_CLASS_TOKENS = [
// Navigation / metadata
"mw-editsection",
@@ -119,15 +215,38 @@ const WIKIPEDIA_EXCLUDED_CLASS_TOKENS = [
"noprint",
"navbox",
"vertical-navbox",
+ // Blocks about the article rather than of it: maintenance and quality
+ // banners, sister-project boxes, person-data tables, French "main article"
+ // banners.
+ "metadata",
+ // What Wikimedia's search index leaves out as navigation: hatnotes ("For
+ // other uses, see …"), navboxes, authority-control boxes.
+ "navigation-not-searchable",
+ // Per-wiki boxes that carry neither convention above: fr.wikipedia's portal
+ // bar; nl.wikipedia's appendix box (sources, footnotes and external links
+ // under bold labels rather than section headings) and sister-project boxes.
+ "bandeau-portail",
+ "appendix",
+ "interproject",
+ "interprojecttemplate",
// Interactive UI injected by Wikipedia's JavaScript — not present in the
// Wikipedia Parse API response and not article content.
"mw-collapsible-toggle", // "show"/"hide" toggle buttons on collapsible infobox rows
"mw-tmh-player", // Video/audio player wrapper added by TimedMediaHandler JS
// (contains "Duration: N seconds." and time display)
+ "cachelinks", // fr.wikipedia gadget appending "[archive]" (Wikiwix) after external links
] as const;
+/**
+ * ARIA roles of non-prose landmarks. Navboxes, series sidebars and "main
+ * article" links declare `role="navigation"` on every wiki, whatever their
+ * per-wiki class names.
+ */
+const WIKIPEDIA_EXCLUDED_ROLES = ["navigation"] as const;
+
const WIKIPEDIA_EXCLUDED_SECTION_TITLE_SET = new Set(WIKIPEDIA_EXCLUDED_SECTION_TITLES);
const WIKIPEDIA_EXCLUDED_CLASS_TOKEN_SET = new Set(WIKIPEDIA_EXCLUDED_CLASS_TOKENS);
+const WIKIPEDIA_EXCLUDED_ROLE_SET = new Set(WIKIPEDIA_EXCLUDED_ROLES);
export function normalizeWikipediaSectionTitle(value: string): string {
return normalizeContent(value).toLowerCase();
@@ -152,15 +271,25 @@ function isExcludedWikipediaTag(tagName: string): boolean {
return NON_CONTENT_TAGS.has(tagName.toLowerCase());
}
+function isExcludedWikipediaRole(role: string | null): boolean {
+ return role !== null && WIKIPEDIA_EXCLUDED_ROLE_SET.has(role.trim().toLowerCase());
+}
+
+/** What the exclusion predicate reads of an element, from a DOM Element or a parse5 node alike. */
+interface WikipediaExclusionDescriptor extends WikipediaElementDescriptor {
+ /** The `role` attribute, or null when the element has none. */
+ role: string | null;
+}
+
/**
* Shared Wikipedia element exclusion predicate used by both the browser
* adapter (DOM traversal) and API canonical fetcher (parse5 traversal).
* Keeping this centralized prevents client/server canonicalization drift.
+ * It reads only markup the Parse API returns and Wikipedia's scripts leave
+ * alone (tags, classes, roles), never inline styles, which scripts and reader
+ * interaction change on the live page.
*/
-export function shouldExcludeWikipediaElement(input: {
- tagName: string;
- classTokens: readonly string[];
-}): boolean {
+export function shouldExcludeWikipediaElement(input: WikipediaExclusionDescriptor): boolean {
if (isExcludedWikipediaTag(input.tagName)) {
return true;
}
@@ -169,5 +298,9 @@ export function shouldExcludeWikipediaElement(input: {
return true;
}
+ if (isExcludedWikipediaRole(input.role)) {
+ return true;
+ }
+
return input.classTokens.some((token) => isExcludedWikipediaClassToken(token));
}
diff --git a/src/typescript/shared/src/wikipedia-identity.ts b/src/typescript/shared/src/wikipedia-identity.ts
index 189509d..00f261d 100644
--- a/src/typescript/shared/src/wikipedia-identity.ts
+++ b/src/typescript/shared/src/wikipedia-identity.ts
@@ -3,14 +3,26 @@ const WIKIPEDIA_ARTICLE_PATH_PREFIX = "/wiki/";
const WIKIPEDIA_INDEX_PATH_REGEX = /^\/w\/index\.php(?:[/?#]|$)/i;
const WIKIPEDIA_PAGE_ID_REGEX = /^\d+$/;
-const NON_ARTICLE_NAMESPACE_PREFIXES = new Set([
+/**
+ * Canonical (English) names of MediaWiki's non-article namespaces. MediaWiki
+ * accepts these canonical names on every language edition, so they are
+ * recognized regardless of host language. Localized namespace names (e.g.
+ * German "Diskussion:") cannot be enumerated here; URL-level parsing therefore
+ * only rules out *known* non-article pages, and callers with access to the
+ * page itself must treat MediaWiki's `wgNamespaceNumber` as authoritative.
+ */
+const CANONICAL_NON_ARTICLE_NAMESPACE_PREFIXES = new Set([
"talk",
"user",
"user talk",
"wikipedia",
"wikipedia talk",
+ "project",
+ "project talk",
"file",
"file talk",
+ "image",
+ "image talk",
"mediawiki",
"mediawiki talk",
"template",
@@ -83,7 +95,7 @@ function isArticleNamespace(title: string): boolean {
}
const namespacePrefix = title.slice(0, separator).replace(/_/g, " ").trim().toLowerCase();
- return !NON_ARTICLE_NAMESPACE_PREFIXES.has(namespacePrefix);
+ return !CANONICAL_NON_ARTICLE_NAMESPACE_PREFIXES.has(namespacePrefix);
}
function normalizeWikipediaPageIdToken(rawToken: string | null): string | null {
@@ -102,27 +114,31 @@ function readWikipediaPageIdFromQuery(parsedUrl: URL): string | null {
return normalizeWikipediaPageIdToken(parsedUrl.searchParams.get("pageid"));
}
-function wikipediaExternalIdFromTitle(language: string, title: string): string {
- return `${language}:${title}`;
-}
-
+/**
+ * The stored external ID of a Wikipedia article. It is always derived from the
+ * numeric page ID (never the title), because titles change on page moves while
+ * page IDs do not.
+ */
export function wikipediaExternalIdFromPageId(language: string, pageId: string): string {
return `${language}:${pageId}`;
}
-interface ParsedWikipediaIdentity {
- language: string;
- title: string | null;
- pageId: string | null;
- identityKind: "TITLE" | "PAGE_ID";
- externalId: string;
-}
+/**
+ * What a Wikipedia URL alone says about which article it shows. A URL either
+ * names the article by numeric page ID (`?curid=` / `?pageid=`, possibly
+ * alongside a title) or only by title; a title-only URL cannot yield the
+ * external ID, which needs the page ID from the page itself.
+ */
+export type WikipediaUrlIdentity =
+ | { kind: "PAGE_ID"; language: string; pageId: string; title: string | null }
+ | { kind: "TITLE"; language: string; title: string };
/**
- * Parse canonical Wikipedia page identity from URL for both extension and API.
- * This function excludes non-article namespaces (e.g. Talk:, File:).
+ * Parse Wikipedia article identity from a URL, for both extension and API.
+ * Returns null for non-Wikipedia URLs and for titles in known non-article
+ * namespaces (see `CANONICAL_NON_ARTICLE_NAMESPACE_PREFIXES`).
*/
-export function parseWikipediaIdentity(url: string): ParsedWikipediaIdentity | null {
+export function parseWikipediaUrlIdentity(url: string): WikipediaUrlIdentity | null {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
@@ -150,28 +166,11 @@ export function parseWikipediaIdentity(url: string): ParsedWikipediaIdentity | n
if (title !== null && !isArticleNamespace(title)) {
return null;
}
- if (title === null && pageId === null) {
- return null;
- }
-
if (pageId !== null) {
- return {
- language,
- title,
- pageId,
- identityKind: "PAGE_ID",
- externalId: wikipediaExternalIdFromPageId(language, pageId),
- };
+ return { kind: "PAGE_ID", language, pageId, title };
}
- if (title === null) {
- return null;
+ if (title !== null) {
+ return { kind: "TITLE", language, title };
}
-
- return {
- language,
- title,
- pageId: null,
- identityKind: "TITLE",
- externalId: wikipediaExternalIdFromTitle(language, title),
- };
+ return null;
}
diff --git a/src/typescript/shared/test/unit/block-separator-exhaustiveness.test.ts b/src/typescript/shared/test/unit/block-separator-exhaustiveness.test.ts
index 2cc12a3..70097a0 100644
--- a/src/typescript/shared/test/unit/block-separator-exhaustiveness.test.ts
+++ b/src/typescript/shared/test/unit/block-separator-exhaustiveness.test.ts
@@ -1,13 +1,14 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { CONTENT_BLOCK_SEPARATOR_TAGS } from "../../src/normalize.js";
+import { WORD_SEPARATOR_TAGS } from "../../src/normalize.js";
-// ── Block separator tag exhaustiveness ──────────────────────────────────
+// ── Word separator tag exhaustiveness ───────────────────────────────────
// Every HTML spec block-level element must be either in
-// CONTENT_BLOCK_SEPARATOR_TAGS or in the explicit exclusion list below with
+// WORD_SEPARATOR_TAGS or in the explicit exclusion list below with
// a documented rationale. This is a "living documentation" test — when new
// block elements become relevant, the test forces a conscious decision
-// about inclusion or exclusion.
+// about inclusion or exclusion. The only non-block separators are the
+// line-breaking void elements.
/**
* HTML spec block-level elements. This list covers the elements defined as
@@ -61,7 +62,7 @@ const HTML_BLOCK_LEVEL_ELEMENTS = [
] as const;
/**
- * Block-level elements explicitly excluded from CONTENT_BLOCK_SEPARATOR_TAGS
+ * Block-level elements explicitly excluded from WORD_SEPARATOR_TAGS
* with documented rationale. Each entry must explain why the element does not
* need a word-boundary separator.
*/
@@ -82,7 +83,6 @@ const EXCLUDED_BLOCK_ELEMENTS: Record = {
form: "Form container; not article prose",
header: "Page/section header; not article prose",
hgroup: "Heading group container; child headings already in separator set",
- hr: "Horizontal rule; void element with no text content",
main: "Structural container; child block elements provide separators",
nav: "Navigation container; not article prose",
ol: "Ordered list container; child li elements already in separator set",
@@ -98,11 +98,17 @@ const EXCLUDED_BLOCK_ELEMENTS: Record = {
caption: "Table caption; rare, and table cell separators handle table content",
};
-test("every HTML block-level element is either in CONTENT_BLOCK_SEPARATOR_TAGS or explicitly excluded", () => {
+/**
+ * Inline-level elements that still separate words: void elements that end the
+ * line, so the text either side of them never reads as one word.
+ */
+const LINE_BREAKING_VOID_ELEMENTS = ["br", "hr"] as const;
+
+test("every HTML block-level element is either in WORD_SEPARATOR_TAGS or explicitly excluded", () => {
const missingElements: string[] = [];
for (const tag of HTML_BLOCK_LEVEL_ELEMENTS) {
- if (!CONTENT_BLOCK_SEPARATOR_TAGS.has(tag) && !(tag in EXCLUDED_BLOCK_ELEMENTS)) {
+ if (!WORD_SEPARATOR_TAGS.has(tag) && !(tag in EXCLUDED_BLOCK_ELEMENTS)) {
missingElements.push(tag);
}
}
@@ -110,19 +116,26 @@ test("every HTML block-level element is either in CONTENT_BLOCK_SEPARATOR_TAGS o
assert.equal(
missingElements.length,
0,
- `Block-level elements missing from both CONTENT_BLOCK_SEPARATOR_TAGS and EXCLUDED_BLOCK_ELEMENTS: ${missingElements.join(", ")}.\n` +
- `Add each to CONTENT_BLOCK_SEPARATOR_TAGS (if it separates prose words) or to EXCLUDED_BLOCK_ELEMENTS (with rationale).`,
+ `Block-level elements missing from both WORD_SEPARATOR_TAGS and EXCLUDED_BLOCK_ELEMENTS: ${missingElements.join(", ")}.\n` +
+ `Add each to WORD_SEPARATOR_TAGS (if it separates prose words) or to EXCLUDED_BLOCK_ELEMENTS (with rationale).`,
);
});
-test("CONTENT_BLOCK_SEPARATOR_TAGS contains no unrecognized elements", () => {
+test("line-breaking void elements are word separators", () => {
+ for (const tag of LINE_BREAKING_VOID_ELEMENTS) {
+ assert.ok(WORD_SEPARATOR_TAGS.has(tag), `<${tag}> must separate the words around it`);
+ }
+});
+
+test("WORD_SEPARATOR_TAGS contains no unrecognized elements", () => {
const allKnown = new Set([
...HTML_BLOCK_LEVEL_ELEMENTS,
...Object.keys(EXCLUDED_BLOCK_ELEMENTS),
+ ...LINE_BREAKING_VOID_ELEMENTS,
]);
const unrecognized: string[] = [];
- for (const tag of CONTENT_BLOCK_SEPARATOR_TAGS) {
+ for (const tag of WORD_SEPARATOR_TAGS) {
if (!allKnown.has(tag)) {
unrecognized.push(tag);
}
@@ -131,7 +144,7 @@ test("CONTENT_BLOCK_SEPARATOR_TAGS contains no unrecognized elements", () => {
assert.equal(
unrecognized.length,
0,
- `CONTENT_BLOCK_SEPARATOR_TAGS contains elements not in the HTML block-level list: ${unrecognized.join(", ")}.\n` +
- `Either add them to HTML_BLOCK_LEVEL_ELEMENTS or remove from CONTENT_BLOCK_SEPARATOR_TAGS.`,
+ `WORD_SEPARATOR_TAGS contains elements that are neither block-level nor line-breaking: ${unrecognized.join(", ")}.\n` +
+ `Either add them to HTML_BLOCK_LEVEL_ELEMENTS / LINE_BREAKING_VOID_ELEMENTS or remove from WORD_SEPARATOR_TAGS.`,
);
});
diff --git a/src/typescript/shared/test/unit/claim-payload-schema.test.ts b/src/typescript/shared/test/unit/claim-payload-schema.test.ts
new file mode 100644
index 0000000..6eee204
--- /dev/null
+++ b/src/typescript/shared/test/unit/claim-payload-schema.test.ts
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ httpUrlSchema,
+ investigationClaimPayloadSchema,
+ investigationResultSchema,
+} from "../../src/index.js";
+
+const NON_HTTP_URLS = [
+ "data:text/plain,hello",
+ "mailto:someone@example.com",
+ "ftp://example.com/file",
+ "file:///etc/passwd",
+];
+
+function claimWithSourceUrl(url: string) {
+ return {
+ text: "Claim",
+ context: "Context",
+ summary: "Summary",
+ reasoning: "Reasoning",
+ sources: [{ url, title: "Title", snippet: "Snippet" }],
+ };
+}
+
+test("httpUrlSchema accepts only absolute http(s) URLs", () => {
+ for (const url of ["https://example.com/a?b=c", "http://example.com", "HTTPS://EXAMPLE.COM/"]) {
+ assert.equal(httpUrlSchema.safeParse(url).success, true, url);
+ }
+ for (const url of [...NON_HTTP_URLS, "example.com/page", ""]) {
+ assert.equal(httpUrlSchema.safeParse(url).success, false, url);
+ }
+});
+
+test("claim sources must link to http(s) URLs", () => {
+ assert.equal(
+ investigationClaimPayloadSchema.safeParse(claimWithSourceUrl("https://example.com")).success,
+ true,
+ );
+ for (const url of NON_HTTP_URLS) {
+ assert.equal(investigationClaimPayloadSchema.safeParse(claimWithSourceUrl(url)).success, false);
+ assert.equal(
+ investigationResultSchema.safeParse({ claims: [claimWithSourceUrl(url)] }).success,
+ false,
+ url,
+ );
+ }
+});
diff --git a/src/typescript/shared/test/unit/observed-image-occurrence-schema.test.ts b/src/typescript/shared/test/unit/observed-image-occurrence-schema.test.ts
index 88a95d3..5ef4efd 100644
--- a/src/typescript/shared/test/unit/observed-image-occurrence-schema.test.ts
+++ b/src/typescript/shared/test/unit/observed-image-occurrence-schema.test.ts
@@ -1,8 +1,9 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ BACKGROUND_REQUESTS,
MAX_OBSERVED_IMAGE_OCCURRENCES,
- extensionMessageSchema,
+ observedImageUrlsFromOccurrences,
viewPostInputSchema,
} from "../../src/index.js";
@@ -48,28 +49,60 @@ test("viewPostInputSchema rejects observedImageOccurrences over limit", () => {
assert.equal(result.success, false);
});
-test("extensionMessageSchema rejects PAGE_CONTENT imageOccurrences over limit", () => {
- const result = extensionMessageSchema.safeParse({
- v: 1,
- type: "PAGE_CONTENT",
- payload: {
- tabSessionId: 1,
- content: {
- platform: "X",
- externalId: "1900000000000000000",
- url: "https://x.com/example/status/1900000000000000000",
- contentText: "Hello world",
- mediaState: "has_images",
- imageUrls: [],
- imageOccurrences: buildObservedOccurrences(MAX_OBSERVED_IMAGE_OCCURRENCES + 1),
- metadata: {
- authorHandle: "example",
- text: "Hello world",
- mediaUrls: [],
- },
+test("PAGE_CONTENT payload rejects imageOccurrences over limit", () => {
+ const result = BACKGROUND_REQUESTS.PAGE_CONTENT.payload.safeParse({
+ tabSessionId: "5f0b8d0e-7c55-4c1b-9d0a-1e2f3a4b5c6d",
+ content: {
+ platform: "X",
+ externalId: "1900000000000000000",
+ url: "https://x.com/example/status/1900000000000000000",
+ contentText: "Hello world",
+ hasVideo: false,
+ imageOccurrences: buildObservedOccurrences(MAX_OBSERVED_IMAGE_OCCURRENCES + 1),
+ metadata: {
+ authorHandle: "example",
+ text: "Hello world",
+ mediaUrls: [],
},
},
});
assert.equal(result.success, false);
});
+
+test("PAGE_CONTENT payload rejects an external ID in the wrong platform format", () => {
+ const result = BACKGROUND_REQUESTS.PAGE_CONTENT.payload.safeParse({
+ tabSessionId: "5f0b8d0e-7c55-4c1b-9d0a-1e2f3a4b5c6d",
+ content: {
+ platform: "SUBSTACK",
+ // A Substack slug is not a Substack post ID.
+ externalId: "my-post-slug",
+ url: "https://example.substack.com/p/my-post-slug",
+ contentText: "Hello world",
+ hasVideo: false,
+ imageOccurrences: [],
+ metadata: {
+ substackPostId: "123",
+ publicationSubdomain: "example",
+ slug: "my-post-slug",
+ title: "Title",
+ authorName: "Author",
+ },
+ },
+ });
+
+ assert.equal(result.success, false);
+});
+
+test("observedImageUrlsFromOccurrences lists distinct URLs in page order", () => {
+ const occurrences = [
+ { originalIndex: 2, normalizedTextOffset: 9, sourceUrl: "https://images.example/a.jpg" },
+ { originalIndex: 0, normalizedTextOffset: 0, sourceUrl: "https://images.example/b.jpg" },
+ { originalIndex: 1, normalizedTextOffset: 4, sourceUrl: "https://images.example/a.jpg" },
+ ];
+ assert.deepEqual(observedImageUrlsFromOccurrences(occurrences), [
+ "https://images.example/b.jpg",
+ "https://images.example/a.jpg",
+ ]);
+ assert.deepEqual(observedImageUrlsFromOccurrences(undefined), []);
+});
diff --git a/src/typescript/shared/test/unit/wikipedia-canonicalization.test.ts b/src/typescript/shared/test/unit/wikipedia-canonicalization.test.ts
index e5260e3..3f5f1ee 100644
--- a/src/typescript/shared/test/unit/wikipedia-canonicalization.test.ts
+++ b/src/typescript/shared/test/unit/wikipedia-canonicalization.test.ts
@@ -1,46 +1,95 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ WIKIPEDIA_EXCLUDED_SECTION_TITLES,
effectiveHeadingLevel,
effectiveHeadingText,
headingLevelFromTag,
isExcludedWikipediaSectionTitle,
+ normalizeWikipediaSectionTitle,
shouldExcludeWikipediaElement,
type WikipediaHeadingLevelDescriptor,
type WikipediaNodeDescriptor,
} from "../../src/wikipedia-canonicalization.js";
+function element(tagName: string, classTokens: string[] = [], role: string | null = null) {
+ return { tagName, classTokens, role };
+}
+
test("isExcludedWikipediaSectionTitle normalizes whitespace and casing", () => {
assert.equal(isExcludedWikipediaSectionTitle(" References "), true);
assert.equal(isExcludedWikipediaSectionTitle("Further Reading"), true);
assert.equal(isExcludedWikipediaSectionTitle("History"), false);
});
+test("isExcludedWikipediaSectionTitle matches the appendix titles of the largest wikis", () => {
+ for (const title of [
+ "Einzelnachweise",
+ "Weblinks",
+ "Notes et références",
+ "Liens externes",
+ "Enlaces externos",
+ "Collegamenti esterni",
+ "Ligações externas",
+ "Externe links",
+ "Przypisy",
+ "Примечания",
+ "脚注",
+ "外部リンク",
+ "參考文獻",
+ "外部链接",
+ ]) {
+ assert.equal(isExcludedWikipediaSectionTitle(title), true, title);
+ }
+});
+
+test("isExcludedWikipediaSectionTitle keeps See also sections in every language, as English does", () => {
+ for (const title of [
+ "See also",
+ "Siehe auch",
+ "Voir aussi",
+ "Véase también",
+ "Voci correlate",
+ "関連項目",
+ ]) {
+ assert.equal(isExcludedWikipediaSectionTitle(title), false, title);
+ }
+});
+
+test("excluded section titles are stored in their normalized form", () => {
+ // Matching compares normalized heading text against the list verbatim.
+ for (const title of WIKIPEDIA_EXCLUDED_SECTION_TITLES) {
+ assert.equal(normalizeWikipediaSectionTitle(title), title);
+ }
+});
+
test("shouldExcludeWikipediaElement excludes references-class blocks", () => {
- assert.equal(
- shouldExcludeWikipediaElement({
- tagName: "ol",
- classTokens: ["references"],
- }),
- true,
- );
+ assert.equal(shouldExcludeWikipediaElement(element("ol", ["references"])), true);
});
test("shouldExcludeWikipediaElement excludes citation superscripts only", () => {
+ assert.equal(shouldExcludeWikipediaElement(element("sup", ["reference"])), true);
+ assert.equal(shouldExcludeWikipediaElement(element("sup")), false);
+});
+
+test("shouldExcludeWikipediaElement excludes navigation landmarks whatever their classes", () => {
+ // de "Hauptartikel" links, nl navboxes, en series sidebars.
+ assert.equal(shouldExcludeWikipediaElement(element("div", ["hauptartikel"], "navigation")), true);
+ assert.equal(shouldExcludeWikipediaElement(element("table", ["sidebar"], " Navigation ")), true);
+ assert.equal(shouldExcludeWikipediaElement(element("table", ["infobox"], "presentation")), false);
+ assert.equal(shouldExcludeWikipediaElement(element("div", [], "note")), false);
+});
+
+test("shouldExcludeWikipediaElement excludes the cross-wiki non-prose conventions", () => {
+ // Hatnotes, navboxes and authority control are kept out of search.
assert.equal(
- shouldExcludeWikipediaElement({
- tagName: "sup",
- classTokens: ["reference"],
- }),
+ shouldExcludeWikipediaElement(element("div", ["hatnote", "navigation-not-searchable"], "note")),
true,
);
- assert.equal(
- shouldExcludeWikipediaElement({
- tagName: "sup",
- classTokens: [],
- }),
- false,
- );
+ // Banners and person-data tables are about the article, not of it.
+ assert.equal(shouldExcludeWikipediaElement(element("table", ["metadata", "ambox"])), true);
+ assert.equal(shouldExcludeWikipediaElement(element("p")), false);
+ assert.equal(shouldExcludeWikipediaElement(element("table", ["wikitable"])), false);
});
// ---------------------------------------------------------------------------
diff --git a/src/typescript/shared/test/unit/wikipedia-identity.test.ts b/src/typescript/shared/test/unit/wikipedia-identity.test.ts
index 58d0a42..f1e7b09 100644
--- a/src/typescript/shared/test/unit/wikipedia-identity.test.ts
+++ b/src/typescript/shared/test/unit/wikipedia-identity.test.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
normalizeWikipediaTitleToken,
- parseWikipediaIdentity,
+ parseWikipediaUrlIdentity,
wikipediaExternalIdFromPageId,
} from "../../src/wikipedia-identity.js";
@@ -11,33 +11,39 @@ test("normalizeWikipediaTitleToken normalizes spacing and underscores", () => {
assert.equal(normalizeWikipediaTitleToken(" "), null);
});
-test("parseWikipediaIdentity prefers page ID identity when available", () => {
- const parsed = parseWikipediaIdentity("https://en.wikipedia.org/wiki/OpenAI?curid=48795986");
+test("parseWikipediaUrlIdentity prefers page ID identity when available", () => {
+ const parsed = parseWikipediaUrlIdentity("https://en.wikipedia.org/wiki/OpenAI?curid=48795986");
assert.deepEqual(parsed, {
+ kind: "PAGE_ID",
language: "en",
title: "OpenAI",
pageId: "48795986",
- identityKind: "PAGE_ID",
- externalId: "en:48795986",
});
});
-test("parseWikipediaIdentity parses title identity from /w/index.php route", () => {
- const parsed = parseWikipediaIdentity(
+test("parseWikipediaUrlIdentity accepts page-ID-only index.php URLs", () => {
+ const parsed = parseWikipediaUrlIdentity("https://de.wikipedia.org/w/index.php?curid=736");
+ assert.deepEqual(parsed, { kind: "PAGE_ID", language: "de", title: null, pageId: "736" });
+});
+
+test("parseWikipediaUrlIdentity parses title identity from /w/index.php route", () => {
+ const parsed = parseWikipediaUrlIdentity(
"https://en.wikipedia.org/w/index.php?title=OpenAI&oldid=1340968511",
);
- assert.deepEqual(parsed, {
- language: "en",
- title: "OpenAI",
- pageId: null,
- identityKind: "TITLE",
- externalId: "en:OpenAI",
- });
+ assert.deepEqual(parsed, { kind: "TITLE", language: "en", title: "OpenAI" });
+});
+
+test("parseWikipediaUrlIdentity rejects canonical non-article namespaces on any language edition", () => {
+ assert.equal(parseWikipediaUrlIdentity("https://en.wikipedia.org/wiki/Talk:OpenAI"), null);
+ assert.equal(parseWikipediaUrlIdentity("https://en.wikipedia.org/wiki/File:Example.jpg"), null);
+ // MediaWiki accepts canonical namespace names on every wiki.
+ assert.equal(parseWikipediaUrlIdentity("https://de.wikipedia.org/wiki/Talk:OpenAI"), null);
});
-test("parseWikipediaIdentity rejects non-article namespaces", () => {
- assert.equal(parseWikipediaIdentity("https://en.wikipedia.org/wiki/Talk:OpenAI"), null);
- assert.equal(parseWikipediaIdentity("https://en.wikipedia.org/wiki/File:Example.jpg"), null);
+test("parseWikipediaUrlIdentity rejects non-Wikipedia hosts and namespace-less paths", () => {
+ assert.equal(parseWikipediaUrlIdentity("https://example.org/wiki/OpenAI"), null);
+ assert.equal(parseWikipediaUrlIdentity("https://en.wikipedia.org/"), null);
+ assert.equal(parseWikipediaUrlIdentity("not a url"), null);
});
test("wikipediaExternalIdFromPageId builds deterministic external IDs", () => {
From 994afad8b1306e2c98c9f6531662b44968d093ce Mon Sep 17 00:00:00 2001
From: Dean Valentine
Date: Fri, 2 Oct 2026 17:06:38 -0700
Subject: [PATCH 10/15] api: gpt-6.1-sol, truthful audit, security and queue
fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Investigator
- gpt-6.1-sol in one request config (web_search with sources, verified
reasoning summaries); OPENAI_MODEL_ID removed
- Investigation.model is the model that ran, recorded at completion
- One audit record per provider request (fact-check rounds, validations);
typed SDK responses; incomplete responses fail without retries
- One request-shaped probe for worker startup and user-key validation;
live smoke script (pnpm smoke:openai)
Security and correctness
- Post URL/author from server-verified data; client URLs validated
- User OpenAI keys verified before use, never take over server-paid runs,
and are dropped (not FAILED) when OpenAI rejects them
- SSRF-safe fetching (validated, pinned addresses) for images and fetch_url
- FAILED is terminal; attempt numbers never reset; audit is insert-only
- recordViewAndGetStatus reports INVESTIGATING/FAILED with investigationId
- Interim claims carry forward from the latest finished investigation,
limited to claims whose text is still in the version
Queue
- Selector admits at most SELECTOR_DAILY_BUDGET investigations per UTC day
- Lease ⇔ PROCESSING enforced by deferred triggers; one recovery path;
lease loss aborts the run; update lineage shared with the selector
Migrations 0024 (model + per-request audit) and 0025 (admission origin,
lease invariant, input snapshot). Removes the unused attestation HMAC and
public tRPC router.
Co-Authored-By: Claude Opus 5.5
---
src/typescript/api/package.json | 2 +
.../migration.sql | 204 ++++
.../migration.sql | 351 +++++++
src/typescript/api/prisma/schema.prisma | 280 ++++--
.../api/scripts/openai-live-smoke.ts | 249 +++++
src/typescript/api/src/lib/config/env.ts | 14 +-
src/typescript/api/src/lib/config/runtime.ts | 5 +-
src/typescript/api/src/lib/config/startup.ts | 50 +-
src/typescript/api/src/lib/date.ts | 7 +
src/typescript/api/src/lib/db/client.ts | 5 +-
.../api/src/lib/db/prisma-enum-compat.ts | 4 -
.../api/src/lib/graphql/public-schema.ts | 6 +-
.../api/src/lib/investigators/errors.ts | 66 ++
.../src/lib/investigators/fetch-url-tool.ts | 163 +---
.../api/src/lib/investigators/interface.ts | 285 +++---
.../openai-attempt-audit-builder.ts | 97 --
.../lib/investigators/openai-claim-tools.ts | 139 +++
.../openai-claim-validation-scheduler.ts | 33 +-
.../investigators/openai-claim-validator.ts | 187 ++--
.../src/lib/investigators/openai-errors.ts | 11 -
.../lib/investigators/openai-input-builder.ts | 98 +-
.../openai-investigation-run-state.ts | 14 +-
.../api/src/lib/investigators/openai-probe.ts | 26 +
.../investigators/openai-request-config.ts | 153 +++
.../investigators/openai-response-audit.ts | 610 +++---------
.../src/lib/investigators/openai-schemas.ts | 35 -
.../lib/investigators/openai-tool-dispatch.ts | 174 +---
.../src/lib/investigators/openai-tool-loop.ts | 207 ++--
.../api/src/lib/investigators/openai.ts | 567 ++++-------
.../api/src/lib/network/host-safety.ts | 179 +---
src/typescript/api/src/lib/network/ip.ts | 165 +---
.../api/src/lib/network/public-http-fetch.ts | 186 ++++
src/typescript/api/src/lib/openai/errors.ts | 74 +-
.../api/src/lib/services/attempt-audit.ts | 296 ++----
.../api/src/lib/services/blob-storage.ts | 9 -
.../src/lib/services/canonical-resolution.ts | 2 +-
.../api/src/lib/services/content-fetcher.ts | 262 +++--
src/typescript/api/src/lib/services/hmac.ts | 28 -
.../api/src/lib/services/html-to-markdown.ts | 72 +-
.../api/src/lib/services/image-downloader.ts | 175 +---
.../api/src/lib/services/investigate-now.ts | 172 ++++
.../lib/services/investigation-admission.ts | 106 ++
.../src/lib/services/investigation-input.ts | 234 +++++
.../src/lib/services/investigation-lease.ts | 463 +++++----
.../lib/services/investigation-lifecycle.ts | 420 --------
.../src/lib/services/markdown-resolution.ts | 73 +-
.../services/openai-key-validation-core.ts | 105 +-
.../src/lib/services/openai-key-validation.ts | 30 +-
.../src/lib/services/orchestrator-errors.ts | 24 +-
.../api/src/lib/services/orchestrator.ts | 382 +++++---
.../api/src/lib/services/prompt-context.ts | 235 -----
.../api/src/lib/services/public-read-model.ts | 138 ++-
.../api/src/lib/services/queue-lifecycle.ts | 195 ----
src/typescript/api/src/lib/services/queue.ts | 48 +-
.../api/src/lib/services/request-identity.ts | 92 +-
.../src/lib/services/selector-entrypoint.ts | 13 +-
.../api/src/lib/services/selector.ts | 293 +++++-
.../api/src/lib/services/update-lineage.ts | 87 ++
.../api/src/lib/services/user-key-source.ts | 136 +--
.../api/src/lib/services/view-credit.ts | 9 +-
.../lib/services/wikipedia-content-filter.ts | 84 +-
.../api/src/lib/services/word-count.ts | 7 +
.../api/src/lib/services/worker-runner.ts | 2 +
src/typescript/api/src/lib/trpc/context.ts | 23 +-
src/typescript/api/src/lib/trpc/router.ts | 3 +-
.../api/src/lib/trpc/routes/post.ts | 422 ++++----
.../lib/trpc/routes/post/content-storage.ts | 1 -
.../trpc/routes/post/content-storage/blobs.ts | 2 +-
.../content-storage/content-preparation.ts | 2 +-
.../routes/post/content-storage/metadata.ts | 56 +-
.../post/content-storage/observed-url.ts | 82 ++
.../post/content-storage/post-upsert.ts | 240 ++---
.../register-observed-version.ts | 24 +-
.../routes/post/content-storage/shared.ts | 48 +-
.../trpc/routes/post/investigation-queries.ts | 373 ++-----
.../api/src/lib/trpc/routes/post/wikipedia.ts | 42 +-
.../api/src/lib/trpc/routes/public.ts | 104 --
.../api/test/helpers/fake-openai.ts | 202 ++++
...ndpoints.attempt-audit.integration.test.ts | 329 +++++++
...api-endpoints.integration.attempt-audit.ts | 94 +-
.../api-endpoints.integration.shared.ts | 128 ++-
...pi-endpoints.lifecycle.integration.test.ts | 919 +++++++++---------
...points.public-and-auth.integration.test.ts | 692 +++++++------
...content-and-versioning.integration.test.ts | 183 +++-
...record-and-investigate.integration.test.ts | 11 +-
...terim-source-selection.integration.test.ts | 130 ++-
...nts.record-view-status.integration.test.ts | 207 ++++
...ion-identity-and-retry.integration.test.ts | 86 +-
.../integration/helpers/external-api-mocks.ts | 59 ++
.../helpers/investigate-now-scenario-dsl.ts | 8 +-
.../api/test/integration/integration-env.ts | 6 +-
.../test/integration/lesswrong-fixtures.ts | 3 +
.../api/test/integration/live-canary.test.ts | 86 +-
.../trigger-behavior.integration.test.ts | 129 ++-
.../api/test/unit/attempt-audit.test.ts | 188 ----
.../test/unit/canonical-resolution.test.ts | 24 +-
.../api/test/unit/content-fetcher.test.ts | 158 ++-
.../api/test/unit/env-config.test.ts | 30 +-
.../api/test/unit/env-example.test.ts | 12 +
.../unit/frontend-graphql-contract.test.ts | 134 +++
src/typescript/api/test/unit/hmac.test.ts | 30 -
.../api/test/unit/host-safety.test.ts | 143 ++-
.../api/test/unit/html-to-markdown.test.ts | 166 ++--
.../api/test/unit/investigation-input.test.ts | 204 ++++
.../api/test/unit/investigation-lease.test.ts | 78 +-
.../test/unit/investigation-lifecycle.test.ts | 98 --
.../api/test/unit/ip-prefix.test.ts | 17 +-
.../markdown-resolution-placeholders.test.ts | 65 --
.../api/test/unit/markdown-resolution.test.ts | 7 +-
.../unit/openai-attempt-audit-builder.test.ts | 108 --
.../api/test/unit/openai-claim-tools.test.ts | 140 +++
.../openai-claim-validation-scheduler.test.ts | 25 +-
.../test/unit/openai-claim-validator.test.ts | 327 ++-----
.../test/unit/openai-input-builder.test.ts | 59 +-
.../openai-investigate-claim-order.test.ts | 197 ----
.../openai-investigation-run-state.test.ts | 41 +-
.../api/test/unit/openai-investigator.test.ts | 403 ++++++++
.../unit/openai-key-validation-core.test.ts | 140 ++-
.../test/unit/openai-response-audit.test.ts | 918 +++--------------
.../api/test/unit/openai-schemas.test.ts | 64 --
.../test/unit/openai-tool-dispatch.test.ts | 164 ++--
.../api/test/unit/openai-tool-loop.test.ts | 318 +++---
.../api/test/unit/orchestrator-errors.test.ts | 94 +-
.../unit/orchestrator-persist-guard.test.ts | 66 +-
.../unit/post-investigation-queries.test.ts | 227 ++---
.../api/test/unit/prompt-context.test.ts | 468 ---------
.../api/test/unit/public-read-model.test.ts | 39 +-
.../api/test/unit/public-schema.test.ts | 211 ++--
.../api/test/unit/queue-lifecycle.test.ts | 354 -------
.../api/test/unit/request-identity.test.ts | 80 +-
.../api/test/unit/startup-config.test.ts | 36 +
.../api/test/unit/update-lineage.test.ts | 21 +
.../api/test/unit/word-count.test.ts | 37 +
src/typescript/api/tsconfig.json | 2 +-
134 files changed, 9659 insertions(+), 9766 deletions(-)
create mode 100644 src/typescript/api/prisma/migrations/0024_investigation_model_and_attempt_requests/migration.sql
create mode 100644 src/typescript/api/prisma/migrations/0025_investigation_admission_and_lease_invariant/migration.sql
create mode 100644 src/typescript/api/scripts/openai-live-smoke.ts
create mode 100644 src/typescript/api/src/lib/investigators/errors.ts
delete mode 100644 src/typescript/api/src/lib/investigators/openai-attempt-audit-builder.ts
create mode 100644 src/typescript/api/src/lib/investigators/openai-claim-tools.ts
delete mode 100644 src/typescript/api/src/lib/investigators/openai-errors.ts
create mode 100644 src/typescript/api/src/lib/investigators/openai-probe.ts
create mode 100644 src/typescript/api/src/lib/investigators/openai-request-config.ts
delete mode 100644 src/typescript/api/src/lib/investigators/openai-schemas.ts
create mode 100644 src/typescript/api/src/lib/network/public-http-fetch.ts
delete mode 100644 src/typescript/api/src/lib/services/hmac.ts
create mode 100644 src/typescript/api/src/lib/services/investigate-now.ts
create mode 100644 src/typescript/api/src/lib/services/investigation-admission.ts
create mode 100644 src/typescript/api/src/lib/services/investigation-input.ts
delete mode 100644 src/typescript/api/src/lib/services/investigation-lifecycle.ts
delete mode 100644 src/typescript/api/src/lib/services/prompt-context.ts
delete mode 100644 src/typescript/api/src/lib/services/queue-lifecycle.ts
create mode 100644 src/typescript/api/src/lib/services/update-lineage.ts
create mode 100644 src/typescript/api/src/lib/services/word-count.ts
create mode 100644 src/typescript/api/src/lib/trpc/routes/post/content-storage/observed-url.ts
delete mode 100644 src/typescript/api/src/lib/trpc/routes/public.ts
create mode 100644 src/typescript/api/test/helpers/fake-openai.ts
create mode 100644 src/typescript/api/test/integration/api-endpoints.attempt-audit.integration.test.ts
create mode 100644 src/typescript/api/test/integration/api-endpoints.record-view-status.integration.test.ts
create mode 100644 src/typescript/api/test/integration/helpers/external-api-mocks.ts
delete mode 100644 src/typescript/api/test/unit/attempt-audit.test.ts
create mode 100644 src/typescript/api/test/unit/env-example.test.ts
create mode 100644 src/typescript/api/test/unit/frontend-graphql-contract.test.ts
delete mode 100644 src/typescript/api/test/unit/hmac.test.ts
create mode 100644 src/typescript/api/test/unit/investigation-input.test.ts
delete mode 100644 src/typescript/api/test/unit/investigation-lifecycle.test.ts
delete mode 100644 src/typescript/api/test/unit/markdown-resolution-placeholders.test.ts
delete mode 100644 src/typescript/api/test/unit/openai-attempt-audit-builder.test.ts
create mode 100644 src/typescript/api/test/unit/openai-claim-tools.test.ts
delete mode 100644 src/typescript/api/test/unit/openai-investigate-claim-order.test.ts
create mode 100644 src/typescript/api/test/unit/openai-investigator.test.ts
delete mode 100644 src/typescript/api/test/unit/openai-schemas.test.ts
delete mode 100644 src/typescript/api/test/unit/prompt-context.test.ts
delete mode 100644 src/typescript/api/test/unit/queue-lifecycle.test.ts
create mode 100644 src/typescript/api/test/unit/startup-config.test.ts
create mode 100644 src/typescript/api/test/unit/update-lineage.test.ts
create mode 100644 src/typescript/api/test/unit/word-count.test.ts
diff --git a/src/typescript/api/package.json b/src/typescript/api/package.json
index 4034900..85958ac 100644
--- a/src/typescript/api/package.json
+++ b/src/typescript/api/package.json
@@ -14,6 +14,7 @@
"worker": "tsx --tsconfig tsconfig.runtime.json src/lib/services/worker-entrypoint.ts",
"selector": "tsx --tsconfig tsconfig.runtime.json src/lib/services/selector-entrypoint.ts",
"instance-api-key": "tsx --tsconfig tsconfig.runtime.json src/lib/services/instance-api-key-entrypoint.ts",
+ "smoke:openai": "tsx --tsconfig tsconfig.runtime.json scripts/openai-live-smoke.ts",
"prisma:generate": "svelte-kit sync && prisma generate",
"prisma:migrate:dev": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
@@ -44,6 +45,7 @@
"parse5": "^8.0.0",
"pg": "^8.18.0",
"turndown": "^7.2.2",
+ "undici": "^7.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
diff --git a/src/typescript/api/prisma/migrations/0024_investigation_model_and_attempt_requests/migration.sql b/src/typescript/api/prisma/migrations/0024_investigation_model_and_attempt_requests/migration.sql
new file mode 100644
index 0000000..38901c5
--- /dev/null
+++ b/src/typescript/api/prisma/migrations/0024_investigation_model_and_attempt_requests/migration.sql
@@ -0,0 +1,204 @@
+-- Two audit-truthfulness changes:
+--
+-- 1. Investigation.model stops being an InvestigationModel enum value guessed
+-- at queue time. It becomes the provider model id the stage-1 fact-check
+-- requests were actually sent to, recorded at completion: set iff
+-- status = COMPLETE (INV-INV-MODEL-AT-COMPLETION). Existing COMPLETE rows
+-- take the requestModel of their SUCCEEDED attempt; every other row becomes
+-- NULL. A COMPLETE investigation without exactly one SUCCEEDED attempt has
+-- no truthful value, so the migration aborts instead of inventing one.
+--
+-- 2. InvestigationAttempt audits move to one InvestigationAttemptRequest row
+-- per provider request (fact-check rounds, per-claim validations), each
+-- with its own response. Existing attempts squashed every request into one
+-- record that cannot be split truthfully, so each becomes a single
+-- LEGACY_COMBINED request (with one response when it had one) and keeps its
+-- children unchanged. Dropped legacy data: responseOutputText (the SDK's
+-- concatenation of the last response's output_text parts, which remain as
+-- text parts) and tool-call capturedAt/providerStartedAt/providerCompletedAt
+-- (parse-time stamps and always-null guesses); tool-call id/type/status
+-- columns duplicated their output item's and are dropped.
+
+-- ── 1a. Preconditions ────────────────────────────────────────────────────────
+
+DO $$
+DECLARE
+ offending_investigation_ids TEXT;
+ offending_attempt_ids TEXT;
+BEGIN
+ SELECT string_agg(i."id", ', ' ORDER BY i."id")
+ INTO offending_investigation_ids
+ FROM "Investigation" i
+ WHERE i."status" = 'COMPLETE'
+ AND (
+ SELECT COUNT(*)
+ FROM "InvestigationAttempt" a
+ WHERE a."investigationId" = i."id"
+ AND a."outcome" = 'SUCCEEDED'
+ ) <> 1;
+
+ IF offending_investigation_ids IS NOT NULL THEN
+ RAISE EXCEPTION
+ 'Cannot record Investigation.model: COMPLETE investigations without exactly one SUCCEEDED InvestigationAttempt have no recorded request model: %',
+ offending_investigation_ids;
+ END IF;
+
+ SELECT string_agg(a."id", ', ' ORDER BY a."id")
+ INTO offending_attempt_ids
+ FROM "InvestigationAttempt" a
+ WHERE (
+ a."responseId" IS NULL
+ AND (
+ a."responseStatus" IS NOT NULL
+ OR a."responseModelVersion" IS NOT NULL
+ OR a."responseOutputText" IS NOT NULL
+ OR EXISTS (SELECT 1 FROM "InvestigationAttemptOutputItem" o WHERE o."attemptId" = a."id")
+ OR EXISTS (SELECT 1 FROM "InvestigationAttemptUsage" u WHERE u."attemptId" = a."id")
+ )
+ )
+ OR (a."responseId" IS NOT NULL AND a."responseModelVersion" IS NULL);
+
+ IF offending_attempt_ids IS NOT NULL THEN
+ RAISE EXCEPTION
+ 'Cannot migrate InvestigationAttempt audits: attempts with a partially recorded response (response data without responseId, or responseId without responseModelVersion): %',
+ offending_attempt_ids;
+ END IF;
+END
+$$;
+
+-- ── 1b. Investigation.model: recorded at completion ─────────────────────────
+
+ALTER TABLE "Investigation" ALTER COLUMN "model" DROP NOT NULL;
+ALTER TABLE "Investigation" ALTER COLUMN "model" TYPE TEXT USING NULL;
+
+UPDATE "Investigation" i
+SET "model" = a."requestModel"
+FROM "InvestigationAttempt" a
+WHERE a."investigationId" = i."id"
+ AND a."outcome" = 'SUCCEEDED'
+ AND i."status" = 'COMPLETE';
+
+ALTER TABLE "Investigation"
+ ADD CONSTRAINT "Investigation_model_consistency_check"
+ CHECK (("status" = 'COMPLETE') = ("model" IS NOT NULL));
+
+DROP TYPE "InvestigationModel";
+
+-- ── 2a. Per-request audit tables ────────────────────────────────────────────
+
+CREATE TYPE "InvestigationAttemptRequestKind" AS ENUM ('FACT_CHECK_ROUND', 'CLAIM_VALIDATION', 'LEGACY_COMBINED');
+
+CREATE TABLE "InvestigationAttemptRequest" (
+ "id" TEXT NOT NULL,
+ "attemptId" TEXT NOT NULL,
+ "kind" "InvestigationAttemptRequestKind" NOT NULL,
+ "factCheckRound" INTEGER,
+ "claimIndex" INTEGER,
+ "model" TEXT NOT NULL,
+ "instructions" TEXT NOT NULL,
+ "input" JSONB NOT NULL,
+ "previousResponseId" TEXT,
+ "reasoningEffort" TEXT,
+ "reasoningSummary" TEXT,
+ "include" TEXT[],
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "InvestigationAttemptRequest_pkey" PRIMARY KEY ("id"),
+ -- INV-ATTEMPT-REQUEST-SUBJECT
+ CONSTRAINT "InvestigationAttemptRequest_subject_check" CHECK (
+ ("factCheckRound" IS NOT NULL) = ("kind" = 'FACT_CHECK_ROUND')
+ AND ("claimIndex" IS NOT NULL) = ("kind" = 'CLAIM_VALIDATION')
+ AND COALESCE("factCheckRound", 0) >= 0
+ AND COALESCE("claimIndex", 0) >= 0
+ )
+);
+
+CREATE TABLE "InvestigationAttemptResponse" (
+ "id" TEXT NOT NULL,
+ "requestId" TEXT NOT NULL,
+ "providerResponseId" TEXT NOT NULL,
+ "status" TEXT,
+ "modelVersion" TEXT NOT NULL,
+ "receivedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "InvestigationAttemptResponse_pkey" PRIMARY KEY ("id")
+);
+
+-- ── 2b. Legacy attempts → one LEGACY_COMBINED request each ─────────────────
+-- The legacy request and response reuse their attempt's id (distinct tables),
+-- which lets the children below be re-parented without a mapping table.
+
+INSERT INTO "InvestigationAttemptRequest" (
+ "id", "attemptId", "kind", "factCheckRound", "claimIndex", "model", "instructions", "input",
+ "previousResponseId", "reasoningEffort", "reasoningSummary", "include", "createdAt"
+)
+SELECT
+ a."id", a."id", 'LEGACY_COMBINED', NULL, NULL, a."requestModel", a."requestInstructions",
+ to_jsonb(a."requestInput"), NULL, a."requestReasoningEffort", a."requestReasoningSummary",
+ -- Legacy requests sent no `include` parameter.
+ ARRAY[]::TEXT[], a."createdAt"
+FROM "InvestigationAttempt" a;
+
+INSERT INTO "InvestigationAttemptResponse" (
+ "id", "requestId", "providerResponseId", "status", "modelVersion", "receivedAt", "createdAt"
+)
+SELECT a."id", a."id", a."responseId", a."responseStatus", a."responseModelVersion", NULL, a."createdAt"
+FROM "InvestigationAttempt" a
+WHERE a."responseId" IS NOT NULL;
+
+ALTER TABLE "InvestigationAttemptRequestedTool" DROP CONSTRAINT "InvestigationAttemptRequestedTool_attemptId_fkey";
+DROP INDEX "InvestigationAttemptRequestedTool_attemptId_idx";
+DROP INDEX "InvestigationAttemptRequestedTool_attemptId_requestOrder_key";
+ALTER TABLE "InvestigationAttemptRequestedTool" RENAME COLUMN "attemptId" TO "requestId";
+
+ALTER TABLE "InvestigationAttemptOutputItem" DROP CONSTRAINT "InvestigationAttemptOutputItem_attemptId_fkey";
+DROP INDEX "InvestigationAttemptOutputItem_attemptId_idx";
+DROP INDEX "InvestigationAttemptOutputItem_attemptId_outputIndex_key";
+ALTER TABLE "InvestigationAttemptOutputItem" RENAME COLUMN "attemptId" TO "responseId";
+
+ALTER TABLE "InvestigationAttemptUsage" DROP CONSTRAINT "InvestigationAttemptUsage_attemptId_fkey";
+DROP INDEX "InvestigationAttemptUsage_attemptId_key";
+ALTER TABLE "InvestigationAttemptUsage" RENAME COLUMN "attemptId" TO "responseId";
+
+ALTER TABLE "InvestigationAttemptToolCall" DROP CONSTRAINT "InvestigationAttemptToolCall_attemptId_fkey";
+DROP INDEX "InvestigationAttemptToolCall_attemptId_idx";
+DROP INDEX "InvestigationAttemptToolCall_attemptId_outputIndex_key";
+ALTER TABLE "InvestigationAttemptToolCall"
+ DROP COLUMN "attemptId",
+ DROP COLUMN "outputIndex",
+ DROP COLUMN "providerToolCallId",
+ DROP COLUMN "toolType",
+ DROP COLUMN "status",
+ DROP COLUMN "capturedAt",
+ DROP COLUMN "providerStartedAt",
+ DROP COLUMN "providerCompletedAt";
+
+ALTER TABLE "InvestigationAttempt"
+ DROP COLUMN "requestModel",
+ DROP COLUMN "requestInstructions",
+ DROP COLUMN "requestInput",
+ DROP COLUMN "requestReasoningEffort",
+ DROP COLUMN "requestReasoningSummary",
+ DROP COLUMN "responseId",
+ DROP COLUMN "responseStatus",
+ DROP COLUMN "responseModelVersion",
+ DROP COLUMN "responseOutputText";
+
+-- ── 2c. Indexes and foreign keys ────────────────────────────────────────────
+
+CREATE INDEX "InvestigationAttemptRequest_attemptId_idx" ON "InvestigationAttemptRequest"("attemptId");
+CREATE UNIQUE INDEX "InvestigationAttemptRequest_attemptId_factCheckRound_key" ON "InvestigationAttemptRequest"("attemptId", "factCheckRound");
+CREATE UNIQUE INDEX "InvestigationAttemptRequest_attemptId_claimIndex_key" ON "InvestigationAttemptRequest"("attemptId", "claimIndex");
+CREATE UNIQUE INDEX "InvestigationAttemptResponse_requestId_key" ON "InvestigationAttemptResponse"("requestId");
+CREATE INDEX "InvestigationAttemptOutputItem_responseId_idx" ON "InvestigationAttemptOutputItem"("responseId");
+CREATE UNIQUE INDEX "InvestigationAttemptOutputItem_responseId_outputIndex_key" ON "InvestigationAttemptOutputItem"("responseId", "outputIndex");
+CREATE INDEX "InvestigationAttemptRequestedTool_requestId_idx" ON "InvestigationAttemptRequestedTool"("requestId");
+CREATE UNIQUE INDEX "InvestigationAttemptRequestedTool_requestId_requestOrder_key" ON "InvestigationAttemptRequestedTool"("requestId", "requestOrder");
+CREATE UNIQUE INDEX "InvestigationAttemptUsage_responseId_key" ON "InvestigationAttemptUsage"("responseId");
+
+ALTER TABLE "InvestigationAttemptRequest" ADD CONSTRAINT "InvestigationAttemptRequest_attemptId_fkey" FOREIGN KEY ("attemptId") REFERENCES "InvestigationAttempt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "InvestigationAttemptRequestedTool" ADD CONSTRAINT "InvestigationAttemptRequestedTool_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "InvestigationAttemptRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "InvestigationAttemptResponse" ADD CONSTRAINT "InvestigationAttemptResponse_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "InvestigationAttemptRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "InvestigationAttemptOutputItem" ADD CONSTRAINT "InvestigationAttemptOutputItem_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "InvestigationAttemptResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "InvestigationAttemptUsage" ADD CONSTRAINT "InvestigationAttemptUsage_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "InvestigationAttemptResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/src/typescript/api/prisma/migrations/0025_investigation_admission_and_lease_invariant/migration.sql b/src/typescript/api/prisma/migrations/0025_investigation_admission_and_lease_invariant/migration.sql
new file mode 100644
index 0000000..c3e9274
--- /dev/null
+++ b/src/typescript/api/prisma/migrations/0025_investigation_admission_and_lease_invariant/migration.sql
@@ -0,0 +1,351 @@
+-- ============================================================================
+-- Migration 0025: investigation admission, lease invariant, input snapshot
+-- ============================================================================
+--
+-- 1. Investigation.origin + admittedAt record who admitted (and therefore pays
+-- for) each investigation, so the selector budget can count its own
+-- admissions per UTC day.
+-- 2. "InvestigationLease row exists iff status = PROCESSING" moves from code
+-- comments into deferred constraint triggers checked at commit.
+-- 3. Post.identityVerifiedAt latches once url/author come from a server fetch;
+-- stored non-HTTPS post URLs are rebuilt from platform identity.
+-- 4. InvestigationInput snapshots the prompt context (post URL, author,
+-- publication time, video flag) and the source URL behind every [IMAGE:N]
+-- markdown placeholder at queue time.
+-- 5. Source.snapshotText/snapshotHash/retrievedAt are dropped: they only ever
+-- held a copy of the snippet and the save time.
+-- 6. SubstackVersionMeta.serverHtmlBlobId is dropped: Substack has no
+-- server-side fetch, so it could never be populated.
+-- 7. The LessWrong/Substack/Wikipedia version-meta triggers are renamed: they
+-- enforce an update policy, they no longer reject updates.
+
+-- ── 1. Investigation origin / admission ─────────────────────────────────────
+
+CREATE TYPE "InvestigationOrigin" AS ENUM ('SELECTOR', 'INSTANCE_REQUEST', 'USER_KEY_REQUEST');
+
+ALTER TABLE "Investigation"
+ ADD COLUMN "origin" "InvestigationOrigin",
+ ADD COLUMN "admittedAt" TIMESTAMP(3);
+
+-- Historical rows: an attached user-key source identifies a user-key request.
+-- Selector and instance-key requests cannot be told apart after the fact, so
+-- they are recorded as SELECTOR. That is the conservative choice for the daily
+-- budget: rows created earlier today count against today's selector budget.
+UPDATE "Investigation" i
+SET
+ "origin" = CASE
+ WHEN EXISTS (
+ SELECT 1 FROM "InvestigationOpenAiKeySource" ks WHERE ks."investigationId" = i."id"
+ ) THEN 'USER_KEY_REQUEST'::"InvestigationOrigin"
+ ELSE 'SELECTOR'::"InvestigationOrigin"
+ END,
+ "admittedAt" = i."createdAt";
+
+ALTER TABLE "Investigation"
+ ALTER COLUMN "origin" SET NOT NULL,
+ ALTER COLUMN "admittedAt" SET NOT NULL;
+
+CREATE INDEX "Investigation_origin_admittedAt_idx" ON "Investigation"("origin", "admittedAt");
+
+-- ── 2. Lease row exists iff status = PROCESSING ─────────────────────────────
+
+-- Repair rows that violate the invariant before enforcing it.
+DELETE FROM "InvestigationLease" l
+USING "Investigation" i
+WHERE i."id" = l."investigationId"
+ AND i."status" <> 'PROCESSING';
+
+UPDATE "Investigation" i
+SET "status" = 'PENDING', "queuedAt" = CURRENT_TIMESTAMP
+WHERE i."status" = 'PROCESSING'
+ AND NOT EXISTS (
+ SELECT 1 FROM "InvestigationLease" l WHERE l."investigationId" = i."id"
+ );
+
+CREATE FUNCTION "assert_investigation_lease_matches_status"(target_investigation_id TEXT)
+RETURNS void
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ current_status "CheckStatus";
+ has_lease BOOLEAN;
+BEGIN
+ SELECT i."status"
+ INTO current_status
+ FROM "Investigation" i
+ WHERE i."id" = target_investigation_id;
+
+ IF NOT FOUND THEN
+ -- The investigation was deleted; its lease row cascades with it.
+ RETURN;
+ END IF;
+
+ has_lease := EXISTS (
+ SELECT 1 FROM "InvestigationLease" l WHERE l."investigationId" = target_investigation_id
+ );
+
+ IF (current_status = 'PROCESSING') IS DISTINCT FROM has_lease THEN
+ RAISE EXCEPTION
+ 'Investigation % has status % but its lease row %; a lease row must exist iff status = PROCESSING',
+ target_investigation_id,
+ current_status,
+ CASE WHEN has_lease THEN 'exists' ELSE 'is missing' END
+ USING ERRCODE = 'check_violation';
+ END IF;
+END;
+$$;
+
+CREATE FUNCTION "enforce_lease_status_on_investigation"()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ PERFORM "assert_investigation_lease_matches_status"(NEW."id");
+ RETURN NULL;
+END;
+$$;
+
+CREATE FUNCTION "enforce_lease_status_on_lease"()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF TG_OP <> 'INSERT' THEN
+ PERFORM "assert_investigation_lease_matches_status"(OLD."investigationId");
+ END IF;
+ IF TG_OP <> 'DELETE' THEN
+ PERFORM "assert_investigation_lease_matches_status"(NEW."investigationId");
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+-- Deferred so status and lease changes made in one transaction are checked
+-- together at commit.
+CREATE CONSTRAINT TRIGGER "enforce_lease_status_on_investigation_trigger"
+AFTER INSERT OR UPDATE OF "status"
+ON "Investigation"
+DEFERRABLE INITIALLY DEFERRED
+FOR EACH ROW
+EXECUTE FUNCTION "enforce_lease_status_on_investigation"();
+
+CREATE CONSTRAINT TRIGGER "enforce_lease_status_on_lease_trigger"
+AFTER INSERT OR UPDATE OR DELETE
+ON "InvestigationLease"
+DEFERRABLE INITIALLY DEFERRED
+FOR EACH ROW
+EXECUTE FUNCTION "enforce_lease_status_on_lease"();
+
+-- ── 3. Post identity ───────────────────────────────────────────────────────
+
+ALTER TABLE "Post" ADD COLUMN "identityVerifiedAt" TIMESTAMP(3);
+
+CREATE FUNCTION "enforce_post_identity_verified_at_latch"()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF OLD."identityVerifiedAt" IS NOT NULL AND NEW."identityVerifiedAt" IS NULL THEN
+ RAISE EXCEPTION
+ 'Post.identityVerifiedAt cannot be cleared once a server fetch verified the post identity (postId=%)',
+ NEW."id";
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER "enforce_post_identity_verified_at_latch_trigger"
+BEFORE UPDATE OF "identityVerifiedAt"
+ON "Post"
+FOR EACH ROW
+EXECUTE FUNCTION "enforce_post_identity_verified_at_latch"();
+
+-- Post.url used to be overwritten by whatever an unauthenticated client sent,
+-- including non-HTTP(S) schemes. Rebuild any such URL from platform identity.
+UPDATE "Post" p
+SET "url" = CASE p."platform"
+ WHEN 'LESSWRONG' THEN 'https://www.lesswrong.com/posts/' || p."externalId"
+ WHEN 'X' THEN 'https://x.com/i/status/' || p."externalId"
+ WHEN 'WIKIPEDIA' THEN
+ 'https://' || split_part(p."externalId", ':', 1) || '.wikipedia.org/?curid=' || split_part(p."externalId", ':', 2)
+ WHEN 'SUBSTACK' THEN (
+ SELECT 'https://' || svm."publicationSubdomain" || '.substack.com/p/' || svm."slug"
+ FROM "SubstackVersionMeta" svm
+ JOIN "PostVersion" pv ON pv."id" = svm."postVersionId"
+ WHERE pv."postId" = p."id"
+ ORDER BY pv."lastSeenAt" DESC, pv."id" DESC
+ LIMIT 1
+ )
+END
+WHERE p."url" !~* '^https://';
+
+-- ── 4. InvestigationInput prompt-context snapshot ───────────────────────────
+
+-- Inputs whose investigation was deleted have nothing to snapshot and are
+-- never read again.
+DELETE FROM "InvestigationInput" ii
+WHERE NOT EXISTS (
+ SELECT 1 FROM "Investigation" i WHERE i."inputId" = ii."investigationId"
+);
+
+ALTER TABLE "InvestigationInput"
+ ADD COLUMN "imagePlaceholderSourceUrls" TEXT[],
+ ADD COLUMN "postUrl" TEXT,
+ ADD COLUMN "authorName" TEXT,
+ ADD COLUMN "postPublishedAt" TIMESTAMP(3),
+ ADD COLUMN "hasVideo" BOOLEAN;
+
+ALTER TABLE "InvestigationInput" DISABLE TRIGGER "reject_investigation_input_updates_trigger";
+
+-- Backfill from the live rows the worker used to read at run time. Placeholder
+-- source URLs cannot be recovered from stored markdown, so historical rows get
+-- none (see the in-flight downgrade below).
+UPDATE "InvestigationInput" ii
+SET
+ "postUrl" = p."url",
+ "authorName" = a."displayName",
+ "postPublishedAt" = COALESCE(
+ lwm."publishedAt",
+ xvm."postedAt",
+ svm."publishedAt",
+ wvm."lastModifiedAt"
+ ),
+ "hasVideo" = COALESCE(
+ (
+ SELECT bool_or(
+ lower(split_part(split_part(media_url, '?', 1), '#', 1)) ~ '\.(mp4|webm|m3u8|mov|m4v)$'
+ )
+ FROM unnest(xvm."mediaUrls") AS media(media_url)
+ ),
+ false
+ ),
+ "imagePlaceholderSourceUrls" = ARRAY[]::TEXT[]
+FROM "Investigation" i
+JOIN "PostVersion" pv ON pv."id" = i."postVersionId"
+JOIN "Post" p ON p."id" = pv."postId"
+LEFT JOIN "Author" a ON a."id" = p."authorId"
+LEFT JOIN "LesswrongVersionMeta" lwm ON lwm."postVersionId" = pv."id"
+LEFT JOIN "XVersionMeta" xvm ON xvm."postVersionId" = pv."id"
+LEFT JOIN "SubstackVersionMeta" svm ON svm."postVersionId" = pv."id"
+LEFT JOIN "WikipediaVersionMeta" wvm ON wvm."postVersionId" = pv."id"
+WHERE i."inputId" = ii."investigationId";
+
+-- Investigations still waiting to run whose markdown contains image
+-- placeholders would otherwise run with placeholders that match no image
+-- (their source URLs were never stored). Run them on the flat-text input
+-- instead, where images are placed by normalized text offset.
+UPDATE "InvestigationInput" ii
+SET "markdownSource" = 'NONE', "markdown" = NULL, "markdownRendererVersion" = NULL
+FROM "Investigation" i
+WHERE i."inputId" = ii."investigationId"
+ AND i."status" IN ('PENDING', 'PROCESSING')
+ AND ii."markdown" LIKE '%[IMAGE:%';
+
+ALTER TABLE "InvestigationInput" ENABLE TRIGGER "reject_investigation_input_updates_trigger";
+
+ALTER TABLE "InvestigationInput"
+ ALTER COLUMN "postUrl" SET NOT NULL,
+ ALTER COLUMN "hasVideo" SET NOT NULL;
+
+ALTER TABLE "InvestigationInput"
+ ADD CONSTRAINT "InvestigationInput_imagePlaceholderSourceUrls_not_null_chk"
+ CHECK ("imagePlaceholderSourceUrls" IS NOT NULL);
+
+ALTER TABLE "InvestigationInput"
+ ADD CONSTRAINT "InvestigationInput_image_placeholders_require_markdown_chk"
+ CHECK (
+ "markdownSource" <> 'NONE'::"MarkdownSource"
+ OR cardinality("imagePlaceholderSourceUrls") = 0
+ );
+
+-- ── 5. Source snapshot placeholders ─────────────────────────────────────────
+
+ALTER TABLE "Source"
+ DROP COLUMN "snapshotText",
+ DROP COLUMN "snapshotHash",
+ DROP COLUMN "retrievedAt";
+
+-- ── 6. SubstackVersionMeta.serverHtmlBlobId ─────────────────────────────────
+
+-- Substack versions can never be server-verified, so the server-HTML snapshot
+-- check rejects serverVerifiedAt on them instead of looking for a column that
+-- no longer exists.
+CREATE OR REPLACE FUNCTION "enforce_server_verified_html_snapshot"()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ post_platform "Platform";
+ has_server_html BOOLEAN;
+BEGIN
+ IF NEW."serverVerifiedAt" IS NULL THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT p."platform"
+ INTO post_platform
+ FROM "Post" p
+ WHERE p."id" = NEW."postId";
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION
+ 'PostVersion references missing Post (postVersionId=%, postId=%)',
+ NEW."id",
+ NEW."postId";
+ END IF;
+
+ IF post_platform = 'X' THEN
+ RETURN NEW;
+ END IF;
+
+ IF post_platform = 'LESSWRONG' THEN
+ SELECT (lwm."serverHtmlBlobId" IS NOT NULL)
+ INTO has_server_html
+ FROM "LesswrongVersionMeta" lwm
+ WHERE lwm."postVersionId" = NEW."id";
+ ELSIF post_platform = 'WIKIPEDIA' THEN
+ SELECT (wvm."serverHtmlBlobId" IS NOT NULL)
+ INTO has_server_html
+ FROM "WikipediaVersionMeta" wvm
+ WHERE wvm."postVersionId" = NEW."id";
+ ELSIF post_platform = 'SUBSTACK' THEN
+ RAISE EXCEPTION
+ 'Substack posts have no server-side verification; serverVerifiedAt must stay null (postVersionId=%)',
+ NEW."id";
+ ELSE
+ RAISE EXCEPTION
+ 'Unsupported platform on PostVersion (postVersionId=%, platform=%)',
+ NEW."id",
+ post_platform;
+ END IF;
+
+ IF has_server_html IS DISTINCT FROM TRUE THEN
+ RAISE EXCEPTION
+ 'serverVerifiedAt requires a server HTML snapshot (postVersionId=%, platform=%)',
+ NEW."id",
+ post_platform;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+ALTER TABLE "SubstackVersionMeta" DROP CONSTRAINT "SubstackVersionMeta_serverHtmlBlobId_fkey";
+ALTER TABLE "SubstackVersionMeta" DROP COLUMN "serverHtmlBlobId";
+
+-- ── 7. Version-meta update policy trigger names ─────────────────────────────
+
+ALTER FUNCTION "reject_lesswrong_version_meta_updates"()
+ RENAME TO "enforce_lesswrong_version_meta_update_policy";
+ALTER TRIGGER "reject_lesswrong_version_meta_updates_trigger" ON "LesswrongVersionMeta"
+ RENAME TO "enforce_lesswrong_version_meta_update_policy_trigger";
+
+ALTER FUNCTION "reject_substack_version_meta_updates"()
+ RENAME TO "enforce_substack_version_meta_update_policy";
+ALTER TRIGGER "reject_substack_version_meta_updates_trigger" ON "SubstackVersionMeta"
+ RENAME TO "enforce_substack_version_meta_update_policy_trigger";
+
+ALTER FUNCTION "reject_wikipedia_version_meta_updates"()
+ RENAME TO "enforce_wikipedia_version_meta_update_policy";
+ALTER TRIGGER "reject_wikipedia_version_meta_updates_trigger" ON "WikipediaVersionMeta"
+ RENAME TO "enforce_wikipedia_version_meta_update_policy_trigger";
diff --git a/src/typescript/api/prisma/schema.prisma b/src/typescript/api/prisma/schema.prisma
index b3195e5..09d3869 100644
--- a/src/typescript/api/prisma/schema.prisma
+++ b/src/typescript/api/prisma/schema.prisma
@@ -30,13 +30,6 @@ enum InvestigationProvider {
ANTHROPIC
}
-enum InvestigationModel {
- OPENAI_GPT_5
- OPENAI_GPT_5_MINI
- ANTHROPIC_CLAUDE_SONNET
- ANTHROPIC_CLAUDE_OPUS
-}
-
enum ContentProvenance {
SERVER_VERIFIED
CLIENT_FALLBACK
@@ -53,22 +46,53 @@ enum InvestigationAttemptOutcome {
FAILED
}
+enum InvestigationAttemptRequestKind {
+ // One round of the stage-1 fact-check tool loop.
+ FACT_CHECK_ROUND
+ // One stage-2 per-claim validation call.
+ CLAIM_VALIDATION
+ // Pre-2026-10 merged audit: attempts recorded before per-request auditing
+ // squashed every provider request of the attempt into one record (request
+ // fields of stage 1 with stage-2 text appended, output items of all
+ // responses, summed usage). Migrated as-is; never written by current code.
+ LEGACY_COMBINED
+}
+
+// Who admitted an investigation for execution, which also fixes who pays for
+// its runs (SPEC §3.7):
+// SELECTOR — background selection; server key; counts against the
+// selector's per-UTC-day budget (SELECTOR_DAILY_BUDGET).
+// INSTANCE_REQUEST — investigateNow from an instance-API-key client; server key.
+// USER_KEY_REQUEST — investigateNow funded by the requester's OpenAI key
+// (InvestigationOpenAiKeySource). If that key is dropped
+// (unusable or rejected by OpenAI) the investigation is
+// unfunded until the selector or a new request admits it.
+enum InvestigationOrigin {
+ SELECTOR
+ INSTANCE_REQUEST
+ USER_KEY_REQUEST
+}
+
// ─── Models ──────────────────────────────────────────────────────────
model Post {
- id String @id @default(cuid())
- platform Platform
- externalId String
- url String
- authorId String?
- author Author? @relation(fields: [authorId], references: [id])
- viewCount Int @default(0)
- uniqueViewScore Int @default(0)
- lastViewedAt DateTime?
- versions PostVersion[]
- viewCredits PostViewCredit[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ platform Platform
+ externalId String
+ // url and author are identity-bound (SPEC §2.9): when a server fetch verifies
+ // the post they come from the platform response and identityVerifiedAt is
+ // set; once set, unverified client data never overwrites them.
+ url String
+ authorId String?
+ author Author? @relation(fields: [authorId], references: [id])
+ identityVerifiedAt DateTime?
+ viewCount Int @default(0)
+ uniqueViewScore Int @default(0)
+ lastViewedAt DateTime?
+ versions PostVersion[]
+ viewCredits PostViewCredit[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
@@unique([platform, externalId])
@@index([viewCount])
@@ -132,7 +156,6 @@ model HtmlBlob {
lesswrongServerVersionMetas LesswrongVersionMeta[] @relation("LesswrongServerHtml")
lesswrongClientVersionMetas LesswrongVersionMeta[] @relation("LesswrongClientHtml")
- substackServerVersionMetas SubstackVersionMeta[] @relation("SubstackServerHtml")
substackClientVersionMetas SubstackVersionMeta[] @relation("SubstackClientHtml")
wikipediaServerVersionMetas WikipediaVersionMeta[] @relation("WikipediaServerHtml")
wikipediaClientVersionMetas WikipediaVersionMeta[] @relation("WikipediaClientHtml")
@@ -232,8 +255,7 @@ model SubstackVersionMeta {
slug String
title String
subtitle String?
- serverHtmlBlobId String?
- serverHtmlBlob HtmlBlob? @relation("SubstackServerHtml", fields: [serverHtmlBlobId], references: [id], onDelete: Restrict)
+ // Substack has no server-side canonical fetch, so only client HTML exists.
clientHtmlBlobId String?
clientHtmlBlob HtmlBlob? @relation("SubstackClientHtml", fields: [clientHtmlBlobId], references: [id], onDelete: Restrict)
imageUrls String[]
@@ -300,23 +322,32 @@ model Investigation {
promptId String
prompt Prompt @relation(fields: [promptId], references: [id])
provider InvestigationProvider
- model InvestigationModel
+ // Provider model id the stage-1 fact-check requests were sent to (e.g.
+ // "gpt-6.1-sol"), recorded when the investigation completes.
+ // INV-INV-MODEL-AT-COMPLETION: model is set iff status = COMPLETE.
+ // Enforced by CHECK constraint "Investigation_model_consistency_check".
+ model String?
+ // Provider-reported model revision of the final stage-1 fact-check response.
modelVersion String?
// INV-INV-CHECKED-AT-CONSISTENCY: checkedAt is set iff status = COMPLETE.
// Enforced by CHECK constraint "Investigation_checked_at_consistency_check".
checkedAt DateTime?
queuedAt DateTime @default(now())
- // Monotonically increasing attempt counter. Incremented atomically when a
- // worker claims the lease. Gives each retry a distinct attemptNumber for
- // the InvestigationAttempt audit trail.
+ origin InvestigationOrigin
+ // When the current origin admitted (agreed to fund) the investigation. The
+ // selector's daily budget counts SELECTOR admissions per UTC day.
+ admittedAt DateTime
+ // Monotonically increasing attempt counter, never reset. Incremented
+ // atomically when a worker claims the lease, so every attempt gets a distinct
+ // attemptNumber in the InvestigationAttempt audit trail.
attemptCount Int @default(0)
// Set on transient-failure reclaim with exponential backoff. The selector
// skips PENDING investigations where retryAfter > now, preventing immediate
// re-enqueue from defeating the backoff delay. Cleared on lease claim.
retryAfter DateTime?
- // INV-LEASE: The InvestigationLease row exists iff the investigation is
- // PROCESSING and has an active lease holder. Structurally prevents
- // leaseOwner/leaseExpiresAt without PROCESSING, and vice versa.
+ // INV-LEASE: an InvestigationLease row exists iff status = PROCESSING.
+ // Enforced at commit by the deferred constraint triggers
+ // "enforce_lease_status_on_*_trigger".
lease InvestigationLease?
openAiKeySource InvestigationOpenAiKeySource?
attempts InvestigationAttempt[]
@@ -330,14 +361,14 @@ model Investigation {
@@unique([postVersionId])
@@index([parentInvestigationId])
@@index([status])
+ @@index([origin, admittedAt])
}
-// INV-LEASE: The existence of an InvestigationLease row means "this
-// investigation is PROCESSING and has an active lease holder". All fields
+// INV-LEASE: an InvestigationLease row exists iff its investigation is
+// PROCESSING (deferred constraint triggers check this at commit). All fields
// are NOT NULL — structurally prevents partial lease states. The row is
-// deleted on every terminal transition (COMPLETE, FAILED) and on lease
-// release (transient failure → PENDING), so progressClaims is automatically
-// cleaned up without needing CLEARED_PROGRESS_CLAIMS sentinel values.
+// deleted on every transition out of PROCESSING (COMPLETE, FAILED, release to
+// PENDING, expired-lease recovery), so progressClaims is cleaned up with it.
model InvestigationLease {
investigationId String @id
investigation Investigation @relation(fields: [investigationId], references: [id], onDelete: Cascade)
@@ -351,16 +382,26 @@ model InvestigationLease {
@@index([leaseExpiresAt])
}
+// Everything the worker feeds the investigator that could change after queue
+// time, captured once when the investigation is created so every attempt sees
+// the same input (SPEC §2.4.4, §2.12).
model InvestigationInput {
- investigationId String @id
- investigation Investigation? @relation("InvestigationInputOwner")
+ investigationId String @id
+ investigation Investigation? @relation("InvestigationInputOwner")
// Immutable after insert; enforced by trigger "reject_investigation_input_updates_trigger".
- provenance ContentProvenance
- contentHash String
- markdownSource MarkdownSource
- markdown String? // null iff markdownSource = NONE
- markdownRendererVersion String? // null iff markdownSource = NONE
- createdAt DateTime @default(now())
+ provenance ContentProvenance
+ contentHash String
+ markdownSource MarkdownSource
+ markdown String? // null iff markdownSource = NONE
+ markdownRendererVersion String? // null iff markdownSource = NONE
+ // Source URL of the image behind each `[IMAGE:N]` placeholder in markdown,
+ // indexed by N. Empty when markdownSource = NONE.
+ imagePlaceholderSourceUrls String[]
+ postUrl String
+ authorName String?
+ postPublishedAt DateTime?
+ hasVideo Boolean
+ createdAt DateTime @default(now())
}
model InvestigationOpenAiKeySource {
@@ -402,63 +443,103 @@ model InvestigationImage {
@@index([imageBlobId])
}
+// One row per worker execution of an investigation (SPEC §2.12). Rows are
+// insert-only: attemptNumber strictly increases per investigation, so each
+// attempt's audit is written exactly once, at its terminal transition.
model InvestigationAttempt {
- id String @id @default(cuid())
- investigationId String
- investigation Investigation @relation(fields: [investigationId], references: [id], onDelete: Cascade)
- attemptNumber Int
- outcome InvestigationAttemptOutcome
- requestModel String
- requestInstructions String
- requestInput String
- requestReasoningEffort String?
- requestReasoningSummary String?
- responseId String?
- responseStatus String?
- responseModelVersion String?
- responseOutputText String?
- startedAt DateTime
- completedAt DateTime?
- requestedTools InvestigationAttemptRequestedTool[]
- outputItems InvestigationAttemptOutputItem[]
- toolCalls InvestigationAttemptToolCall[]
- usage InvestigationAttemptUsage?
- error InvestigationAttemptError?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ investigationId String
+ investigation Investigation @relation(fields: [investigationId], references: [id], onDelete: Cascade)
+ attemptNumber Int
+ outcome InvestigationAttemptOutcome
+ startedAt DateTime
+ completedAt DateTime?
+ requests InvestigationAttemptRequest[]
+ error InvestigationAttemptError?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
@@unique([investigationId, attemptNumber])
@@index([investigationId, startedAt])
}
+// One provider request made during an attempt, exactly as sent.
+model InvestigationAttemptRequest {
+ id String @id @default(cuid())
+ attemptId String
+ attempt InvestigationAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade)
+ kind InvestigationAttemptRequestKind
+ // INV-ATTEMPT-REQUEST-SUBJECT: factCheckRound is set iff kind = FACT_CHECK_ROUND;
+ // claimIndex is set iff kind = CLAIM_VALIDATION.
+ // Enforced by CHECK constraint "InvestigationAttemptRequest_subject_check".
+ factCheckRound Int?
+ claimIndex Int?
+ model String
+ instructions String
+ // The request's `input` parameter. Image parts carry `imageContentHash`
+ // (ImageBlob.contentHash) in place of the inline data URI that was sent.
+ input Json
+ previousResponseId String?
+ reasoningEffort String?
+ reasoningSummary String?
+ include String[]
+ requestedTools InvestigationAttemptRequestedTool[]
+ // Absent when the request failed before the provider returned a response.
+ response InvestigationAttemptResponse?
+ createdAt DateTime @default(now())
+
+ @@unique([attemptId, factCheckRound])
+ @@unique([attemptId, claimIndex])
+ @@index([attemptId])
+}
+
model InvestigationAttemptRequestedTool {
- id String @id @default(cuid())
- attemptId String
- attempt InvestigationAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade)
+ id String @id @default(cuid())
+ requestId String
+ request InvestigationAttemptRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
requestOrder Int
toolType String
rawDefinition Json
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
- @@unique([attemptId, requestOrder])
- @@index([attemptId])
+ @@unique([requestId, requestOrder])
+ @@index([requestId])
+}
+
+model InvestigationAttemptResponse {
+ id String @id @default(cuid())
+ requestId String @unique
+ request InvestigationAttemptRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
+ providerResponseId String
+ // Provider-reported response status; null when the provider omitted it
+ // (the attempt then fails).
+ status String?
+ modelVersion String
+ // When the response was received. Null only on LEGACY_COMBINED requests,
+ // which merged responses received at different times.
+ receivedAt DateTime?
+ outputItems InvestigationAttemptOutputItem[]
+ usage InvestigationAttemptUsage?
+ createdAt DateTime @default(now())
}
model InvestigationAttemptOutputItem {
id String @id @default(cuid())
- attemptId String
- attempt InvestigationAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade)
+ responseId String
+ response InvestigationAttemptResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
outputIndex Int
providerItemId String?
itemType String
itemStatus String?
+ // Exactly one of the following is populated, by itemType: textParts for
+ // "message", reasoningSummaries for "reasoning", toolCall for anything else.
textParts InvestigationAttemptOutputTextPart[]
reasoningSummaries InvestigationAttemptReasoningSummary[]
toolCall InvestigationAttemptToolCall?
createdAt DateTime @default(now())
- @@unique([attemptId, outputIndex])
- @@index([attemptId])
+ @@unique([responseId, outputIndex])
+ @@index([responseId])
}
model InvestigationAttemptOutputTextPart {
@@ -505,36 +586,24 @@ model InvestigationAttemptReasoningSummary {
}
model InvestigationAttemptToolCall {
- id String @id @default(cuid())
- attemptId String
- attempt InvestigationAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade)
- outputItemId String
- outputItem InvestigationAttemptOutputItem @relation(fields: [outputItemId], references: [id], onDelete: Cascade)
- outputIndex Int
- providerToolCallId String?
- toolType String
- status String?
- rawPayload Json
- capturedAt DateTime
- providerStartedAt DateTime?
- providerCompletedAt DateTime?
- createdAt DateTime @default(now())
-
- @@unique([attemptId, outputIndex])
- @@unique([outputItemId])
- @@index([attemptId])
+ id String @id @default(cuid())
+ outputItemId String @unique
+ outputItem InvestigationAttemptOutputItem @relation(fields: [outputItemId], references: [id], onDelete: Cascade)
+ // Full provider output item, as received.
+ rawPayload Json
+ createdAt DateTime @default(now())
}
model InvestigationAttemptUsage {
- id String @id @default(cuid())
- attemptId String @unique
- attempt InvestigationAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade)
+ id String @id @default(cuid())
+ responseId String @unique
+ response InvestigationAttemptResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
inputTokens Int
outputTokens Int
totalTokens Int
cachedInputTokens Int?
reasoningOutputTokens Int?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
}
model InvestigationAttemptError {
@@ -574,15 +643,12 @@ model Claim {
}
model Source {
- id String @id @default(cuid())
- claimId String
- claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade)
- url String
- title String
- snippet String
- snapshotText String?
- snapshotHash String?
- retrievedAt DateTime
+ id String @id @default(cuid())
+ claimId String
+ claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade)
+ url String
+ title String
+ snippet String
@@index([claimId])
}
diff --git a/src/typescript/api/scripts/openai-live-smoke.ts b/src/typescript/api/scripts/openai-live-smoke.ts
new file mode 100644
index 0000000..d23ced8
--- /dev/null
+++ b/src/typescript/api/scripts/openai-live-smoke.ts
@@ -0,0 +1,249 @@
+/**
+ * Live smoke test of the investigation pipeline against the real OpenAI API.
+ * Spends real tokens; needs no database.
+ *
+ * OPENAI_API_KEY=sk-... pnpm --filter @openerrata/api smoke:openai
+ *
+ * SMOKE_REASONING_SUMMARY=none|auto|concise|detailed overrides the reasoning
+ * summary setting for this run only, to check which values the model accepts
+ * before changing INVESTIGATION_REASONING_SUMMARY.
+ *
+ * Runs (a) the request probe the worker runs at startup and (b) one full
+ * investigation through OpenAIInvestigator of a short synthetic post with one
+ * false and one true checkable claim, then prints what came back.
+ */
+import "dotenv/config";
+import process from "node:process";
+import OpenAI, { APIError } from "openai";
+import type { Reasoning } from "openai/resources/shared";
+import { InvestigatorExecutionError } from "../src/lib/investigators/errors.js";
+import type {
+ InvestigatorInput,
+ InvestigatorRequestAudit,
+} from "../src/lib/investigators/interface.js";
+import { OpenAIInvestigator } from "../src/lib/investigators/openai.js";
+import { probeInvestigationRequest } from "../src/lib/investigators/openai-probe.js";
+import {
+ INVESTIGATION_REQUEST_CONFIG,
+ type InvestigationRequestConfig,
+} from "../src/lib/investigators/openai-request-config.js";
+
+// Plenty for a two-claim post; production uses OPENAI_MAX_RESPONSE_TOOL_ROUNDS.
+const SMOKE_MAX_TOOL_ROUNDS = 40;
+
+const SYNTHETIC_POST: InvestigatorInput = {
+ platform: "LESSWRONG",
+ url: "https://www.lesswrong.com/posts/smoke0test/notes-on-engineering-timelines",
+ authorName: "Smoke Test",
+ postPublishedAt: "2026-09-30T12:00:00.000Z",
+ contentText: [
+ "Notes on engineering timelines",
+ "People routinely underestimate how long large projects take, and the planning fallacy shows up even in famous megaprojects.",
+ "The Eiffel Tower, for example, was completed in 1925 after more than a decade of construction delays.",
+ "By contrast, the Empire State Building went up remarkably fast: it opened in 1931, barely over a year after construction began.",
+ "The lesson I take from this is to pad estimates generously when the work is novel.",
+ ].join("\n\n"),
+};
+
+type ReasoningSummarySetting = NonNullable | null;
+
+function parseReasoningSummaryOverride(value: string | undefined): ReasoningSummarySetting {
+ switch (value) {
+ case undefined:
+ return INVESTIGATION_REQUEST_CONFIG.reasoningSummary;
+ case "none":
+ return null;
+ case "auto":
+ case "concise":
+ case "detailed":
+ return value;
+ default:
+ throw new Error(
+ `SMOKE_REASONING_SUMMARY must be one of none|auto|concise|detailed (got "${value}")`,
+ );
+ }
+}
+
+function isOpenAiApiError(error: unknown): error is APIError {
+ return error instanceof APIError;
+}
+
+function describeError(error: unknown): string {
+ if (isOpenAiApiError(error)) {
+ return [
+ `${error.constructor.name}: HTTP ${error.status?.toString() ?? "(no status)"}`,
+ `request id: ${error.requestID ?? "(none)"}`,
+ `body: ${JSON.stringify(error.error, null, 2)}`,
+ ].join("\n");
+ }
+ if (error instanceof Error) {
+ return `${error.name}: ${error.message}${error.stack === undefined ? "" : `\n${error.stack}`}`;
+ }
+ return String(error);
+}
+
+function readValidationVerdict(request: InvestigatorRequestAudit): string {
+ if (request.response === null) return "no response (request failed)";
+ const text = request.response.outputItems
+ .flatMap((item) => (item.content.kind === "MESSAGE" ? item.content.textParts : []))
+ .map((part) => part.text)
+ .join("");
+ return text.length > 0 ? text : `no verdict text (status=${String(request.response.status)})`;
+}
+
+function summarizeRequests(requests: InvestigatorRequestAudit[]): void {
+ const responses = requests.flatMap((request) =>
+ request.response === null ? [] : [request.response],
+ );
+ console.log(
+ `\nProvider requests: ${requests.length.toString()} sent, ${responses.length.toString()} answered`,
+ );
+ for (const request of requests) {
+ const label =
+ request.subject.kind === "FACT_CHECK_ROUND"
+ ? `fact-check round ${request.subject.round.toString()}`
+ : `validation of claim ${request.subject.claimIndex.toString()}`;
+ const response = request.response;
+ console.log(
+ ` - ${label}: ${
+ response === null
+ ? "no response"
+ : `${response.providerResponseId} status=${String(response.status)} model=${response.modelVersion}`
+ }`,
+ );
+ }
+
+ const webSearches = responses.flatMap((response) =>
+ response.outputItems.flatMap((item) =>
+ item.itemType === "web_search_call" && item.content.kind === "TOOL_CALL"
+ ? [item.content.rawPayload]
+ : [],
+ ),
+ );
+ const sourceCount = webSearches
+ .map((payload) => {
+ const action = payload["action"];
+ const sources =
+ typeof action === "object" && action !== null && !Array.isArray(action)
+ ? action["sources"]
+ : undefined;
+ return Array.isArray(sources) ? sources.length : 0;
+ })
+ .reduce((total, count) => total + count, 0);
+ console.log(
+ `\nWeb search calls: ${webSearches.length.toString()}, sources returned: ${sourceCount.toString()}`,
+ );
+
+ const summaries = responses.flatMap((response) =>
+ response.outputItems.flatMap((item) =>
+ item.content.kind === "REASONING" ? item.content.summaries : [],
+ ),
+ );
+ const reasoningItemCount = responses
+ .flatMap((response) => response.outputItems)
+ .filter((item) => item.content.kind === "REASONING").length;
+ console.log(
+ `Reasoning summaries: ${summaries.length > 0 ? "yes" : "no"} (${summaries.length.toString()} summary parts across ${reasoningItemCount.toString()} reasoning items)`,
+ );
+ const [firstSummary] = summaries;
+ if (firstSummary !== undefined) {
+ console.log(` first summary: ${firstSummary.slice(0, 300)}`);
+ }
+
+ console.log("\nClaim validations:");
+ const validations = requests.filter((request) => request.subject.kind === "CLAIM_VALIDATION");
+ if (validations.length === 0) console.log(" (none — no claims were submitted)");
+ for (const validation of validations) {
+ const claimIndex =
+ validation.subject.kind === "CLAIM_VALIDATION" ? validation.subject.claimIndex : -1;
+ console.log(` - claim ${claimIndex.toString()}: ${readValidationVerdict(validation)}`);
+ }
+
+ const usages = responses.map((response) => response.usage);
+ const totals = usages.every((usage) => usage !== null)
+ ? usages.reduce(
+ (sum, usage) => ({
+ input: sum.input + usage.inputTokens,
+ cached: sum.cached + usage.cachedInputTokens,
+ output: sum.output + usage.outputTokens,
+ reasoning: sum.reasoning + usage.reasoningOutputTokens,
+ total: sum.total + usage.totalTokens,
+ }),
+ { input: 0, cached: 0, output: 0, reasoning: 0, total: 0 },
+ )
+ : null;
+ console.log(
+ totals === null
+ ? "\nToken usage: unknown (a response reported no usage)"
+ : `\nToken usage: input=${totals.input.toString()} (cached ${totals.cached.toString()}), output=${totals.output.toString()} (reasoning ${totals.reasoning.toString()}), total=${totals.total.toString()}`,
+ );
+}
+
+async function main(): Promise {
+ const requestConfig: InvestigationRequestConfig = {
+ ...INVESTIGATION_REQUEST_CONFIG,
+ reasoningSummary: parseReasoningSummaryOverride(process.env["SMOKE_REASONING_SUMMARY"]),
+ };
+ const apiKey = process.env["OPENAI_API_KEY"]?.trim();
+ if (apiKey === undefined || apiKey.length === 0) {
+ console.error("OPENAI_API_KEY is required (set it in the environment or api/.env).");
+ return false;
+ }
+ console.log(
+ `Model ${requestConfig.model}, reasoning effort=${requestConfig.reasoningEffort}, summary=${String(requestConfig.reasoningSummary)}, include=${requestConfig.include.join(",")}`,
+ );
+ const client = new OpenAI({ apiKey });
+
+ console.log("\n(a) Request probe");
+ try {
+ await probeInvestigationRequest(client, requestConfig);
+ console.log(" accepted");
+ } catch (error) {
+ console.log(` REJECTED\n${describeError(error)}`);
+ return false;
+ }
+
+ console.log("\n(b) Full investigation of a synthetic post");
+ const investigator = new OpenAIInvestigator({
+ client,
+ requestConfig,
+ maxToolRounds: SMOKE_MAX_TOOL_ROUNDS,
+ });
+ const startedAt = Date.now();
+ try {
+ const output = await investigator.investigate(SYNTHETIC_POST, {
+ signal: new AbortController().signal,
+ });
+ console.log(
+ ` completed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s; model=${output.model}, modelVersion=${output.modelVersion}`,
+ );
+ summarizeRequests(output.attemptAudit.requests);
+ console.log(`\nConfirmed claims: ${output.result.claims.length.toString()}`);
+ for (const claim of output.result.claims) {
+ console.log(` - "${claim.text}"\n ${claim.summary}`);
+ for (const source of claim.sources) {
+ console.log(` source: ${source.url} (${source.title})`);
+ }
+ }
+ return true;
+ } catch (error) {
+ console.log(` FAILED after ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
+ if (error instanceof InvestigatorExecutionError) {
+ console.log(` ${error.message}\n${describeError(error.cause)}`);
+ summarizeRequests(error.attemptAudit.requests);
+ } else {
+ console.log(describeError(error));
+ }
+ return false;
+ }
+}
+
+main().then(
+ (passed) => {
+ process.exit(passed ? 0 : 1);
+ },
+ (error: unknown) => {
+ console.error(describeError(error));
+ process.exit(1);
+ },
+);
diff --git a/src/typescript/api/src/lib/config/env.ts b/src/typescript/api/src/lib/config/env.ts
index 5f4c521..7ceab71 100644
--- a/src/typescript/api/src/lib/config/env.ts
+++ b/src/typescript/api/src/lib/config/env.ts
@@ -7,7 +7,7 @@ import { z } from "zod";
* the API changes in a way that breaks older extensions, bump this constant
* alongside that change.
*/
-export const MINIMUM_SUPPORTED_EXTENSION_VERSION = "0.2.0";
+export const MINIMUM_SUPPORTED_EXTENSION_VERSION = "0.4.0";
const positiveIntegerFromEnv = z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
@@ -42,16 +42,14 @@ const baseEnvironmentSchema = z.object({
"DATABASE_URL must use postgres:// or postgresql://",
),
OPENAI_API_KEY: z.string().trim().min(1).optional(),
- OPENAI_MODEL_ID: z.string().trim().min(1).default("gpt-5.4"),
- OPENAI_MAX_RESPONSE_TOOL_ROUNDS: positiveIntegerFromEnv.default(150),
- WORKER_CONCURRENCY: positiveIntegerFromEnv.default(250),
- HMAC_SECRET: z.string().trim().min(1, "HMAC_SECRET is required"),
- SELECTOR_BUDGET: positiveIntegerFromEnv.default(100),
- IP_RANGE_CREDIT_CAP: positiveIntegerFromEnv.default(10),
+ OPENAI_MAX_RESPONSE_TOOL_ROUNDS: positiveIntegerFromEnv,
+ WORKER_CONCURRENCY: positiveIntegerFromEnv,
+ /** Maximum investigations the selector admits per UTC day. */
+ SELECTOR_DAILY_BUDGET: positiveIntegerFromEnv,
+ IP_RANGE_CREDIT_CAP: positiveIntegerFromEnv,
BLOB_STORAGE_BUCKET: requiredNonEmptyStringFromEnv,
BLOB_STORAGE_ACCESS_KEY_ID: requiredNonEmptyStringFromEnv,
BLOB_STORAGE_SECRET_ACCESS_KEY: requiredNonEmptyStringFromEnv,
- BLOB_STORAGE_PUBLIC_URL_PREFIX: requiredNonEmptyStringFromEnv,
DATABASE_ENCRYPTION_KEY: requiredNonEmptyStringFromEnv,
DATABASE_ENCRYPTION_KEY_ID: optionalNonEmptyStringFromEnv.default("primary"),
});
diff --git a/src/typescript/api/src/lib/config/runtime.ts b/src/typescript/api/src/lib/config/runtime.ts
index bc51d69..eef0847 100644
--- a/src/typescript/api/src/lib/config/runtime.ts
+++ b/src/typescript/api/src/lib/config/runtime.ts
@@ -1,7 +1,8 @@
import { getEnv } from "./env.js";
-export function getSelectorBudget(): number {
- return getEnv().SELECTOR_BUDGET;
+/** Maximum SELECTOR admissions per UTC day (SPEC §2.10). */
+export function getSelectorDailyBudget(): number {
+ return getEnv().SELECTOR_DAILY_BUDGET;
}
export function getIpRangeCreditCap(): number {
diff --git a/src/typescript/api/src/lib/config/startup.ts b/src/typescript/api/src/lib/config/startup.ts
index ed9b9fd..f0e80bd 100644
--- a/src/typescript/api/src/lib/config/startup.ts
+++ b/src/typescript/api/src/lib/config/startup.ts
@@ -1,5 +1,7 @@
import OpenAI from "openai";
import { getPrisma } from "$lib/db/client";
+import { INVESTIGATION_REQUEST_CONFIG } from "$lib/investigators/openai-request-config.js";
+import { probeInvestigationRequest } from "$lib/investigators/openai-probe.js";
import { getEnv, requireOpenAiApiKey } from "./env.js";
type StartupComponent = "api" | "worker" | "selector";
@@ -7,12 +9,13 @@ type StartupComponent = "api" | "worker" | "selector";
interface StartupCheckPolicy {
checkDatabase: boolean;
checkOpenAiCredentials: boolean;
+ checkClientAddressSource: boolean;
}
const startupCheckPolicyByComponent: Record = {
- api: { checkDatabase: true, checkOpenAiCredentials: false },
- selector: { checkDatabase: true, checkOpenAiCredentials: false },
- worker: { checkDatabase: true, checkOpenAiCredentials: true },
+ api: { checkDatabase: true, checkOpenAiCredentials: false, checkClientAddressSource: true },
+ selector: { checkDatabase: true, checkOpenAiCredentials: false, checkClientAddressSource: false },
+ worker: { checkDatabase: true, checkOpenAiCredentials: true, checkClientAddressSource: false },
};
const startupCheckPromises = new Map>();
@@ -22,9 +25,35 @@ function startupCheckKey(component: StartupComponent, policy: StartupCheckPolicy
component,
policy.checkDatabase ? "db:1" : "db:0",
policy.checkOpenAiCredentials ? "openai:1" : "openai:0",
+ policy.checkClientAddressSource ? "client-address:1" : "client-address:0",
].join("|");
}
+/**
+ * In production the API runs behind the chart's ingress proxy, so the socket
+ * peer is the proxy, not the viewer. adapter-node must be told which header
+ * carries the client address (ADDRESS_HEADER, plus XFF_DEPTH for
+ * X-Forwarded-For); without it every anonymous viewer shares the proxy's IP
+ * range and the per-range view-credit cap (SPEC §2.10) collapses selector
+ * ranking. Exported for unit tests.
+ */
+export function assertClientAddressSourceConfigured(env: NodeJS.ProcessEnv): void {
+ const addressHeader = env["ADDRESS_HEADER"]?.trim().toLowerCase() ?? "";
+ if (addressHeader.length === 0) {
+ throw new Error(
+ "ADDRESS_HEADER must name the proxy header carrying the client IP (e.g. x-forwarded-for) when NODE_ENV=production",
+ );
+ }
+ if (addressHeader === "x-forwarded-for") {
+ const xffDepth = env["XFF_DEPTH"]?.trim() ?? "";
+ if (!/^[1-9]\d*$/.test(xffDepth)) {
+ throw new Error(
+ "XFF_DEPTH must be set to the number of trusted proxies in front of the API when ADDRESS_HEADER=x-forwarded-for",
+ );
+ }
+ }
+}
+
async function assertDatabaseCredentials(component: StartupComponent): Promise {
try {
await getPrisma().$queryRaw`SELECT 1`;
@@ -35,12 +64,12 @@ async function assertDatabaseCredentials(component: StartupComponent): Promise {
try {
- const client = new OpenAI({ apiKey: requireOpenAiApiKey() });
- await client.responses.create({
- model: getEnv().OPENAI_MODEL_ID,
- input: "Reply with the single word pong.",
- max_output_tokens: 16,
- });
+ // Probes the investigation request shape, not just the key, so a model or
+ // request-parameter mismatch stops the worker before it fails every job.
+ await probeInvestigationRequest(
+ new OpenAI({ apiKey: requireOpenAiApiKey() }),
+ INVESTIGATION_REQUEST_CONFIG,
+ );
} catch (error) {
throw new Error(`[startup:${component}] OpenAI credential check failed`, { cause: error });
}
@@ -52,6 +81,9 @@ export async function runStartupChecks(component: StartupComponent): Promise {
+ if (policy.checkClientAddressSource && getEnv().NODE_ENV === "production") {
+ assertClientAddressSourceConfigured(process.env);
+ }
if (policy.checkDatabase) {
await assertDatabaseCredentials(component);
}
diff --git a/src/typescript/api/src/lib/date.ts b/src/typescript/api/src/lib/date.ts
index 6ecb2e3..cad77b6 100644
--- a/src/typescript/api/src/lib/date.ts
+++ b/src/typescript/api/src/lib/date.ts
@@ -18,3 +18,10 @@ export function toOptionalDate(
}
return parsed;
}
+
+/** Midnight UTC at the start of the day containing `date`. */
+export function startOfUtcDay(date: Date): Date {
+ const dayStart = new Date(date);
+ dayStart.setUTCHours(0, 0, 0, 0);
+ return dayStart;
+}
diff --git a/src/typescript/api/src/lib/db/client.ts b/src/typescript/api/src/lib/db/client.ts
index 7c92018..5925bab 100644
--- a/src/typescript/api/src/lib/db/client.ts
+++ b/src/typescript/api/src/lib/db/client.ts
@@ -1,12 +1,15 @@
import "./prisma-enum-compat.js";
import { PrismaPg } from "@prisma/adapter-pg";
import { normalizePgConnectionStringForNode } from "$lib/db/connection-string.js";
-import { PrismaClient } from "$lib/db/prisma-client";
+import { PrismaClient, type Prisma } from "$lib/db/prisma-client";
import { getEnv } from "$lib/config/env.js";
import { Pool } from "pg";
export type { PrismaClient } from "$lib/db/prisma-client";
+/** Either the root client or an interactive-transaction client. */
+export type DbClient = PrismaClient | Prisma.TransactionClient;
+
declare global {
// Reused across HMR reloads in development.
var __openerrataPrisma: PrismaClient | undefined;
diff --git a/src/typescript/api/src/lib/db/prisma-enum-compat.ts b/src/typescript/api/src/lib/db/prisma-enum-compat.ts
index 1cc65fd..f4055e5 100644
--- a/src/typescript/api/src/lib/db/prisma-enum-compat.ts
+++ b/src/typescript/api/src/lib/db/prisma-enum-compat.ts
@@ -1,7 +1,6 @@
import type {
CheckStatus as PrismaCheckStatus,
ContentProvenance as PrismaContentProvenance,
- InvestigationModel as PrismaInvestigationModel,
InvestigationProvider as PrismaInvestigationProvider,
MarkdownSource as PrismaMarkdownSource,
Platform as PrismaPlatform,
@@ -9,7 +8,6 @@ import type {
import type {
CheckStatus as SharedCheckStatus,
ContentProvenance as SharedContentProvenance,
- InvestigationModel as SharedInvestigationModel,
InvestigationProvider as SharedInvestigationProvider,
MarkdownSource as SharedMarkdownSource,
Platform as SharedPlatform,
@@ -21,13 +19,11 @@ const platformTypesMatch: IsExactly = true;
const statusTypesMatch: IsExactly = true;
const providerTypesMatch: IsExactly =
true;
-const modelTypesMatch: IsExactly = true;
const provenanceTypesMatch: IsExactly = true;
const markdownSourceTypesMatch: IsExactly = true;
void platformTypesMatch;
void statusTypesMatch;
void providerTypesMatch;
-void modelTypesMatch;
void provenanceTypesMatch;
void markdownSourceTypesMatch;
diff --git a/src/typescript/api/src/lib/graphql/public-schema.ts b/src/typescript/api/src/lib/graphql/public-schema.ts
index 3f20ff8..3b98e10 100644
--- a/src/typescript/api/src/lib/graphql/public-schema.ts
+++ b/src/typescript/api/src/lib/graphql/public-schema.ts
@@ -83,7 +83,7 @@ const typeDefs = /* GraphQL */ `
"""
provider: String!
"""
- LLM model used (e.g. "GPT4O").
+ Provider model id the investigation ran on (e.g. "gpt-6.1-sol").
"""
model: String!
}
@@ -282,9 +282,9 @@ const typeDefs = /* GraphQL */ `
"""
investigatedPostsWithFlags: Int!
"""
- Ratio of posts with flags to total investigated posts (investigatedPostsWithFlags / totalInvestigatedPosts).
+ Ratio of posts with flags to total investigated posts (investigatedPostsWithFlags / totalInvestigatedPosts). Null when no posts match the filter.
"""
- factCheckIncidence: Float!
+ factCheckIncidence: Float
}
type Query {
diff --git a/src/typescript/api/src/lib/investigators/errors.ts b/src/typescript/api/src/lib/investigators/errors.ts
new file mode 100644
index 0000000..a181488
--- /dev/null
+++ b/src/typescript/api/src/lib/investigators/errors.ts
@@ -0,0 +1,66 @@
+import type { InvestigatorFailedAttemptAudit } from "./interface.js";
+
+/**
+ * An investigation attempt failed after making provider requests. Carries the
+ * attempt's audit so it can be persisted (SPEC §2.12); `cause` is the failure
+ * the orchestrator classifies for retry (SPEC §3.7).
+ */
+export class InvestigatorExecutionError extends Error {
+ readonly attemptAudit: InvestigatorFailedAttemptAudit;
+
+ constructor(message: string, attemptAudit: InvestigatorFailedAttemptAudit, cause: unknown) {
+ super(message, { cause });
+ this.name = "InvestigatorExecutionError";
+ this.attemptAudit = attemptAudit;
+ }
+}
+
+/**
+ * The provider returned output that is well-formed but unusable by the
+ * pipeline (e.g. unparseable validation verdict, tool-round limit exceeded).
+ * Deterministic for a given input, so non-retryable.
+ */
+export class InvestigatorStructuredOutputError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "InvestigatorStructuredOutputError";
+ }
+}
+
+/**
+ * A provider response ended with a status other than "completed" (or with no
+ * status at all). SPEC §3.7 classes truncated/incomplete output as PARTIAL:
+ * the investigation is marked FAILED, not retried.
+ */
+export class InvestigatorIncompleteResponseError extends Error {
+ readonly responseStatus: string | null;
+ readonly responseId: string;
+ readonly incompleteReason: string | null;
+
+ constructor(input: {
+ responseStatus: string | null;
+ responseId: string;
+ incompleteReason: string | null;
+ }) {
+ super(
+ "OpenAI response did not complete " +
+ `(status=${input.responseStatus ?? "missing"}, reason=${input.incompleteReason ?? "none"}, responseId=${input.responseId})`,
+ );
+ this.name = "InvestigatorIncompleteResponseError";
+ this.responseStatus = input.responseStatus;
+ this.responseId = input.responseId;
+ this.incompleteReason = input.incompleteReason;
+ }
+}
+
+/**
+ * The investigator was called with input that violates its contract (a caller
+ * bug, e.g. inconsistent image occurrences). Non-retryable: the same input
+ * fails the same way.
+ */
+export class InvestigatorInputError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "InvestigatorInputError";
+ }
+}
diff --git a/src/typescript/api/src/lib/investigators/fetch-url-tool.ts b/src/typescript/api/src/lib/investigators/fetch-url-tool.ts
index c0c5ff5..bbb5308 100644
--- a/src/typescript/api/src/lib/investigators/fetch-url-tool.ts
+++ b/src/typescript/api/src/lib/investigators/fetch-url-tool.ts
@@ -1,21 +1,14 @@
-import { normalizeContent } from "@openerrata/shared";
+import { httpUrlSchema, normalizeContent } from "@openerrata/shared";
import { decodeHTML } from "entities";
import { z } from "zod";
-import { isBlockedHost } from "$lib/network/host-safety.js";
-import { isRedirectStatus } from "$lib/network/http-status.js";
+import { fetchPublicHttp, readBodyPrefix } from "$lib/network/public-http-fetch.js";
const MAX_FETCH_URL_BYTES = 1_000_000;
const MAX_FETCH_URL_TEXT_LENGTH = 20_000;
const FETCH_URL_TIMEOUT_MS = 15_000;
-const MAX_REDIRECT_HOPS = 5;
const fetchUrlToolArgumentsSchema = z.object({
- url: z.preprocess(
- (value) => (typeof value === "string" ? value.trim() : value),
- z
- .url("url must be a valid URL")
- .refine((value) => /^https?:\/\//i.test(value), "url must use http:// or https://"),
- ),
+ url: z.preprocess((value) => (typeof value === "string" ? value.trim() : value), httpUrlSchema),
});
interface FetchUrlToolSuccess {
@@ -103,10 +96,6 @@ function parseContentType(contentTypeHeader: string | null): string {
return contentTypeHeader.split(";")[0]?.trim().toLowerCase() ?? "";
}
-function hasEmbeddedCredentials(url: URL): boolean {
- return url.username.length > 0 || url.password.length > 0;
-}
-
function extractContentText(
contentType: string,
rawBody: string,
@@ -142,7 +131,16 @@ function extractContentText(
};
}
-export async function executeFetchUrlTool(rawArguments: string): Promise {
+/**
+ * Run the `fetch_url` tool: GET a public URL chosen by the model and return
+ * its normalized text. Untrusted URLs go through the SSRF-safe public fetcher;
+ * bodies are read up to MAX_FETCH_URL_BYTES. Aborting `signal` (e.g. the run
+ * lost its lease) aborts the request.
+ */
+export async function executeFetchUrlTool(
+ rawArguments: string,
+ signal: AbortSignal,
+): Promise {
let parsedArguments: z.infer;
try {
parsedArguments = fetchUrlToolArgumentsSchema.parse(JSON.parse(rawArguments));
@@ -157,140 +155,37 @@ export async function executeFetchUrlTool(rawArguments: string): Promise 0) {
- const contentLength = Number.parseInt(contentLengthHeader, 10);
- if (Number.isFinite(contentLength) && contentLength > MAX_FETCH_URL_BYTES) {
- return {
- ok: false,
- errorKind: "FETCH_FAILED",
- requestedUrl,
- error: `Response too large (${contentLength.toString()} bytes)`,
- };
- }
- }
+ const { finalUrl, response } = await fetchPublicHttp({
+ url: new URL(requestedUrl),
+ headers: {
+ "User-Agent": "OpenErrataInvestigator/1.0 (+https://openerrata.com)",
+ Accept: "text/html,application/json,text/plain;q=0.9,*/*;q=0.5",
+ },
+ signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_URL_TIMEOUT_MS)]),
+ });
- const rawBody = await response.text();
- const byteTruncation = truncateUtf8(rawBody, MAX_FETCH_URL_BYTES);
+ const body = await readBodyPrefix(response, MAX_FETCH_URL_BYTES);
+ const rawBody = new TextDecoder().decode(body.bytes);
const normalizedContentType = parseContentType(response.headers.get("content-type"));
- const extracted = extractContentText(normalizedContentType, byteTruncation.value);
+ const extracted = extractContentText(normalizedContentType, rawBody);
const textTruncation = truncateUtf8(extracted.contentText, MAX_FETCH_URL_TEXT_LENGTH);
return {
ok: true,
requestedUrl,
- finalUrl: currentUrl.toString(),
+ finalUrl: finalUrl.toString(),
status: response.status,
contentType: normalizedContentType.length > 0 ? normalizedContentType : null,
title: extracted.title,
contentText: textTruncation.value,
- truncated: byteTruncation.truncated || textTruncation.truncated,
+ truncated: body.truncated || textTruncation.truncated,
retrievedAt: new Date().toISOString(),
};
} catch (error) {
+ if (signal.aborted) {
+ throw error;
+ }
return {
ok: false,
errorKind: "FETCH_FAILED",
diff --git a/src/typescript/api/src/lib/investigators/interface.ts b/src/typescript/api/src/lib/investigators/interface.ts
index d6458de..3633098 100644
--- a/src/typescript/api/src/lib/investigators/interface.ts
+++ b/src/typescript/api/src/lib/investigators/interface.ts
@@ -1,13 +1,4 @@
-import { z } from "zod";
-import type {
- InvestigationModel,
- InvestigationProvider,
- InvestigationResult,
- InvestigationClaim,
- Platform,
-} from "@openerrata/shared";
-
-const isoDateTimeSchema = z.iso.datetime();
+import type { InvestigationClaim, InvestigationResult, Platform } from "@openerrata/shared";
export type InvestigatorJsonValue =
| string
@@ -17,20 +8,7 @@ export type InvestigatorJsonValue =
| InvestigatorJsonValue[]
| { [key: string]: InvestigatorJsonValue };
-const investigatorJsonValueSchema: z.ZodType = z.lazy(() =>
- z.union([
- z.string(),
- z.number(),
- z.boolean(),
- z.null(),
- z.array(investigatorJsonValueSchema),
- z.record(z.string(), investigatorJsonValueSchema),
- ]),
-);
-const investigatorJsonRecordSchema: z.ZodType> = z.record(
- z.string(),
- investigatorJsonValueSchema,
-);
+export type InvestigatorJsonRecord = Record;
export type InvestigatorImageOccurrence =
| {
@@ -86,154 +64,116 @@ export type InvestigatorInput =
oldClaims: InvestigationClaim[];
});
-export const investigatorRequestedToolAuditSchema = z.object({
- requestOrder: z.number().int().nonnegative(),
- toolType: z.string().min(1),
- rawDefinition: investigatorJsonRecordSchema,
-});
+// ── Attempt audit (SPEC §2.12) ──────────────────────────────────────────────
+// Mirrors the persisted tree: InvestigationAttempt → InvestigationAttemptRequest
+// (one per provider request) → InvestigationAttemptResponse → output items.
+// Every positional index (request order of tools, output index, part index,
+// annotation index, summary index) is the element's position in its array.
-export const investigatorOutputItemAuditSchema = z
- .object({
- outputIndex: z.number().int().nonnegative(),
- providerItemId: z.string().nullable(),
- itemType: z.string().min(1),
- itemStatus: z.string().nullable(),
- })
- .superRefine((audit, context) => {
- const providerItemIdMissing = audit.providerItemId === null;
- const itemStatusMissing = audit.itemStatus === null;
- if (providerItemIdMissing !== itemStatusMissing) {
- context.addIssue({
- code: "custom",
- path: ["providerItemId"],
- message: "providerItemId and itemStatus must be either both present or both null",
- });
- }
- });
+/** Which provider request of the attempt this was. */
+export type InvestigatorRequestSubject =
+ | { kind: "FACT_CHECK_ROUND"; round: number }
+ | { kind: "CLAIM_VALIDATION"; claimIndex: number };
-export const investigatorOutputTextPartAuditSchema = z.object({
- outputIndex: z.number().int().nonnegative(),
- partIndex: z.number().int().nonnegative(),
- partType: z.string().min(1),
- text: z.string(),
-});
+export interface InvestigatorRequestedToolAudit {
+ toolType: string;
+ rawDefinition: InvestigatorJsonRecord;
+}
-export const investigatorOutputTextAnnotationAuditSchema = z.object({
- outputIndex: z.number().int().nonnegative(),
- partIndex: z.number().int().nonnegative(),
- annotationIndex: z.number().int().nonnegative(),
- annotationType: z.string().min(1),
- characterPosition: z
- .object({
- start: z.number().int(),
- end: z.number().int(),
- })
- .optional(),
- url: z.string().nullable(),
- title: z.string().nullable(),
- fileId: z.string().nullable(),
-});
+export interface InvestigatorOutputTextAnnotationAudit {
+ annotationType: string;
+ startIndex: number | null;
+ endIndex: number | null;
+ url: string | null;
+ title: string | null;
+ fileId: string | null;
+}
-export const investigatorReasoningSummaryAuditSchema = z.object({
- outputIndex: z.number().int().nonnegative(),
- summaryIndex: z.number().int().nonnegative(),
- text: z.string(),
-});
+export interface InvestigatorOutputTextPartAudit {
+ partType: "output_text" | "refusal";
+ text: string;
+ annotations: InvestigatorOutputTextAnnotationAudit[];
+}
-export const investigatorToolCallAuditSchema = z
- .object({
- outputIndex: z.number().int().nonnegative(),
- providerToolCallId: z.string().nullable(),
- toolType: z.string().min(1),
- status: z.string().nullable(),
- rawPayload: investigatorJsonRecordSchema,
- capturedAt: isoDateTimeSchema,
- providerStartedAt: isoDateTimeSchema.nullable(),
- providerCompletedAt: isoDateTimeSchema.nullable(),
- })
- .superRefine((toolCall, context) => {
- const providerToolCallIdMissing = toolCall.providerToolCallId === null;
- const statusMissing = toolCall.status === null;
- if (providerToolCallIdMissing !== statusMissing) {
- context.addIssue({
- code: "custom",
- path: ["providerToolCallId"],
- message: "providerToolCallId and status must be either both present or both null",
- });
- }
- });
+export type InvestigatorOutputItemContentAudit =
+ | { kind: "MESSAGE"; textParts: InvestigatorOutputTextPartAudit[] }
+ | { kind: "REASONING"; summaries: string[] }
+ | {
+ kind: "TOOL_CALL";
+ /** Full provider output item, as received. */
+ rawPayload: InvestigatorJsonRecord;
+ };
+
+export interface InvestigatorOutputItemAudit {
+ providerItemId: string | null;
+ itemType: string;
+ itemStatus: string | null;
+ content: InvestigatorOutputItemContentAudit;
+}
-export const investigatorUsageAuditSchema = z.object({
- inputTokens: z.number().int().nonnegative(),
- outputTokens: z.number().int().nonnegative(),
- totalTokens: z.number().int().nonnegative(),
- cachedInputTokens: z.number().int().nonnegative().nullable(),
- reasoningOutputTokens: z.number().int().nonnegative().nullable(),
-});
+export interface InvestigatorUsageAudit {
+ inputTokens: number;
+ outputTokens: number;
+ totalTokens: number;
+ cachedInputTokens: number;
+ reasoningOutputTokens: number;
+}
-export const investigatorResponseAuditSchema = z.object({
- responseId: z.string().nullable(),
- responseStatus: z.string().nullable(),
- responseModelVersion: z.string().nullable(),
- responseOutputText: z.string().nullable(),
- outputItems: z.array(investigatorOutputItemAuditSchema),
- outputTextParts: z.array(investigatorOutputTextPartAuditSchema),
- outputTextAnnotations: z.array(investigatorOutputTextAnnotationAuditSchema),
- reasoningSummaries: z.array(investigatorReasoningSummaryAuditSchema),
- toolCalls: z.array(investigatorToolCallAuditSchema),
- usage: investigatorUsageAuditSchema.nullable(),
-});
+export interface InvestigatorResponseAudit {
+ providerResponseId: string;
+ /** Provider-reported status; null when the provider omitted it. */
+ status: string | null;
+ modelVersion: string;
+ receivedAt: Date;
+ outputItems: InvestigatorOutputItemAudit[];
+ usage: InvestigatorUsageAudit | null;
+}
-export const investigatorErrorAuditSchema = z.object({
- errorName: z.string().min(1),
- errorMessage: z.string(),
- statusCode: z.number().int().nullable(),
-});
+export interface InvestigatorRequestAudit {
+ subject: InvestigatorRequestSubject;
+ model: string;
+ instructions: string;
+ /**
+ * The request's `input` parameter as sent, except that image parts carry
+ * `imageContentHash` (the stored ImageBlob's content hash) instead of the
+ * inline data URI.
+ */
+ input: string | InvestigatorJsonRecord[];
+ previousResponseId: string | null;
+ reasoningEffort: string | null;
+ reasoningSummary: string | null;
+ include: string[];
+ tools: InvestigatorRequestedToolAudit[];
+ /** Null when the request failed before the provider returned a response. */
+ response: InvestigatorResponseAudit | null;
+}
-const investigatorAttemptAuditBaseSchema = z.object({
- startedAt: isoDateTimeSchema,
- completedAt: isoDateTimeSchema.nullable(),
- requestModel: z.string().min(1),
- requestInstructions: z.string(),
- requestInput: z.string(),
- requestReasoningEffort: z.string().nullable(),
- requestReasoningSummary: z.string().nullable(),
- requestedTools: z.array(investigatorRequestedToolAuditSchema),
-});
+export interface InvestigatorErrorAudit {
+ errorName: string;
+ errorMessage: string;
+ statusCode: number | null;
+}
-const investigatorAttemptSucceededAuditSchema = investigatorAttemptAuditBaseSchema.extend({
- response: investigatorResponseAuditSchema,
- error: z.null(),
-});
+interface InvestigatorAttemptAuditBase {
+ startedAt: Date;
+ completedAt: Date;
+ requests: InvestigatorRequestAudit[];
+}
-const investigatorAttemptFailedAuditSchema = investigatorAttemptAuditBaseSchema.extend({
- response: investigatorResponseAuditSchema.nullable(),
- error: investigatorErrorAuditSchema,
-});
+export type InvestigatorSucceededAttemptAudit = InvestigatorAttemptAuditBase & {
+ outcome: "SUCCEEDED";
+};
-export const investigatorAttemptAuditSchema = z.union([
- investigatorAttemptSucceededAuditSchema,
- investigatorAttemptFailedAuditSchema,
-]);
+export type InvestigatorFailedAttemptAudit = InvestigatorAttemptAuditBase & {
+ outcome: "FAILED";
+ error: InvestigatorErrorAudit;
+};
-export type InvestigatorRequestedToolAudit = z.infer;
-export type InvestigatorOutputItemAudit = z.infer;
-export type InvestigatorOutputTextPartAudit = z.infer;
-export type InvestigatorOutputTextAnnotationAudit = z.infer<
- typeof investigatorOutputTextAnnotationAuditSchema
->;
-export type InvestigatorReasoningSummaryAudit = z.infer<
- typeof investigatorReasoningSummaryAuditSchema
->;
-export type InvestigatorToolCallAudit = z.infer;
-export type InvestigatorUsageAudit = z.infer;
-export type InvestigatorResponseAudit = z.infer;
-export type InvestigatorErrorAudit = z.infer;
-export type InvestigatorAttemptAudit = z.infer;
+export type InvestigatorAttemptAudit =
+ | InvestigatorSucceededAttemptAudit
+ | InvestigatorFailedAttemptAudit;
-export function parseInvestigatorAttemptAudit(value: unknown): InvestigatorAttemptAudit {
- return investigatorAttemptAuditSchema.parse(value);
-}
+// ── Investigator contract ───────────────────────────────────────────────────
export interface InvestigationProgressCallbacks {
onProgressUpdate: (
@@ -242,17 +182,30 @@ export interface InvestigationProgressCallbacks {
) => void;
}
+export interface InvestigateOptions {
+ /** Aborts every provider request and tool fetch when the run must stop. */
+ signal: AbortSignal;
+ callbacks?: InvestigationProgressCallbacks;
+}
+
export interface InvestigatorOutput {
result: InvestigationResult;
- attemptAudit: InvestigatorAttemptAudit;
- modelVersion?: string;
+ attemptAudit: InvestigatorSucceededAttemptAudit;
+ /** Provider model id the stage-1 fact-check requests were sent to. */
+ model: string;
+ /** Provider-reported model revision of the final stage-1 fact-check response. */
+ modelVersion: string;
}
+/**
+ * Runs one investigation attempt. Failures reject with
+ * `InvestigatorExecutionError` (carrying the failed attempt's audit) once a
+ * provider request has been made, or with `InvestigatorInputError` when the
+ * input itself violates this contract.
+ */
export interface Investigator {
- investigate(
- input: InvestigatorInput,
- callbacks?: InvestigationProgressCallbacks,
- ): Promise;
- readonly provider: InvestigationProvider;
- readonly model: InvestigationModel;
+ investigate(input: InvestigatorInput, options: InvestigateOptions): Promise;
}
+
+/** Builds an investigator that authenticates to the provider with `apiKey`. */
+export type InvestigatorFactory = (apiKey: string) => Investigator;
diff --git a/src/typescript/api/src/lib/investigators/openai-attempt-audit-builder.ts b/src/typescript/api/src/lib/investigators/openai-attempt-audit-builder.ts
deleted file mode 100644
index 235726c..0000000
--- a/src/typescript/api/src/lib/investigators/openai-attempt-audit-builder.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { InvestigatorAttemptAudit, InvestigatorResponseAudit } from "./interface.js";
-import { parseInvestigatorAttemptAudit } from "./interface.js";
-import { buildTwoStepRequestInputAudit } from "./openai-input-builder.js";
-import {
- buildErrorAudit,
- extractRequestedTools,
- mergeResponseAudits,
- offsetResponseAuditIndices,
-} from "./openai-response-audit.js";
-import { INVESTIGATION_VALIDATION_SYSTEM_PROMPT } from "./prompt.js";
-
-type AttemptAuditBase = Omit;
-
-interface RequestReasoning {
- effort: "low" | "medium" | "high";
- summary: "auto" | "concise" | "detailed";
-}
-
-export function createStageOneAttemptAuditBase(input: {
- startedAt: string;
- openAiModelId: string;
- systemPrompt: string;
- userPrompt: string;
- requestReasoning: RequestReasoning;
- requestedTools: unknown;
-}): AttemptAuditBase {
- return {
- startedAt: input.startedAt,
- completedAt: null,
- requestModel: input.openAiModelId,
- requestInstructions: input.systemPrompt,
- requestInput: input.userPrompt,
- requestReasoningEffort: input.requestReasoning.effort,
- requestReasoningSummary: input.requestReasoning.summary,
- requestedTools: extractRequestedTools(input.requestedTools),
- };
-}
-
-export function createStageTwoAttemptAuditBase(input: {
- stageOneBase: AttemptAuditBase;
- userPrompt: string;
- validationInputSummary: string;
-}): AttemptAuditBase {
- return {
- ...input.stageOneBase,
- requestInstructions:
- `=== Stage 1: Fact-check instructions ===\n${input.stageOneBase.requestInstructions}` +
- `\n\n=== Stage 2: Validation instructions ===\n${INVESTIGATION_VALIDATION_SYSTEM_PROMPT}`,
- requestInput: buildTwoStepRequestInputAudit(input.userPrompt, input.validationInputSummary),
- };
-}
-
-export function buildFailedAttemptAudit(input: {
- base: AttemptAuditBase;
- response: InvestigatorResponseAudit | null;
- error: unknown;
- completedAt?: string;
-}): InvestigatorAttemptAudit {
- return parseInvestigatorAttemptAudit({
- ...input.base,
- completedAt: input.completedAt ?? new Date().toISOString(),
- response: input.response,
- error: buildErrorAudit(input.error),
- });
-}
-
-export function buildSuccessfulAttemptAudit(input: {
- base: AttemptAuditBase;
- response: InvestigatorResponseAudit;
- completedAt?: string;
-}): InvestigatorAttemptAudit {
- return parseInvestigatorAttemptAudit({
- ...input.base,
- completedAt: input.completedAt ?? new Date().toISOString(),
- response: input.response,
- error: null,
- });
-}
-
-export function buildFullAttemptResponseAudit(input: {
- factCheckResponseAudit: InvestigatorResponseAudit;
- successfulValidationResponseAudits: readonly InvestigatorResponseAudit[];
- failedValidationResponseAudits: readonly InvestigatorResponseAudit[];
-}): InvestigatorResponseAudit {
- let validationOutputOffset = input.factCheckResponseAudit.outputItems.length;
-
- const orderedValidationResponseAudits = [
- ...input.successfulValidationResponseAudits,
- ...input.failedValidationResponseAudits,
- ].map((responseAudit) => {
- const offsetAudit = offsetResponseAuditIndices(responseAudit, validationOutputOffset);
- validationOutputOffset += responseAudit.outputItems.length;
- return offsetAudit;
- });
-
- return mergeResponseAudits([input.factCheckResponseAudit, ...orderedValidationResponseAudits]);
-}
diff --git a/src/typescript/api/src/lib/investigators/openai-claim-tools.ts b/src/typescript/api/src/lib/investigators/openai-claim-tools.ts
new file mode 100644
index 0000000..034840c
--- /dev/null
+++ b/src/typescript/api/src/lib/investigators/openai-claim-tools.ts
@@ -0,0 +1,139 @@
+import type { FunctionTool } from "openai/resources/responses/responses";
+import { z } from "zod";
+import {
+ investigationClaimPayloadSchema,
+ type InvestigationClaimPayload,
+} from "@openerrata/shared";
+
+export const SUBMIT_CORRECTION_TOOL_NAME = "submit_correction";
+export const RETAIN_CORRECTION_TOOL_NAME = "retain_correction";
+
+/**
+ * A claim tool call's arguments, checked against the tool's schema. Invalid
+ * arguments are reported back to the model (as the call's output) so it can
+ * correct and resubmit within the same run.
+ */
+type ClaimToolArguments = { kind: "valid"; value: T } | { kind: "invalid"; error: string };
+
+/**
+ * JSON Schema for a function tool's parameters under OpenAI strict mode, which
+ * accepts only a subset of JSON Schema. It rejects `minLength` and
+ * `format: "uri"`, which Zod emits for the shared claim schema's non-empty
+ * strings and URLs, so those keywords are left out of the provider-facing
+ * schema; parsing the arguments with the full Zod schema enforces them.
+ */
+function toStrictModeParameters(schema: z.ZodObject): Record {
+ // Spread to a plain object: Zod attaches non-enumerable Standard Schema hooks.
+ return {
+ ...z.toJSONSchema(schema, {
+ target: "draft-07",
+ override: ({ jsonSchema }) => {
+ delete jsonSchema.minLength;
+ if (jsonSchema.format === "uri") {
+ delete jsonSchema.format;
+ }
+ },
+ }),
+ };
+}
+
+function parseToolArguments(schema: z.ZodType, argumentsJson: string): ClaimToolArguments {
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(argumentsJson);
+ } catch {
+ return { kind: "invalid", error: "Arguments are not valid JSON" };
+ }
+ const parsed = schema.safeParse(decoded);
+ return parsed.success
+ ? { kind: "valid", value: parsed.data }
+ : { kind: "invalid", error: z.prettifyError(parsed.error) };
+}
+
+// ── submit_correction ───────────────────────────────────────────────────────
+
+const claimShape = investigationClaimPayloadSchema.shape;
+const claimSourceSchema = claimShape.sources.element;
+
+// The shared claim payload schema with model-facing field descriptions.
+// `.describe()` only attaches metadata: the tool advertises exactly the shape
+// that `parseSubmitCorrectionArguments` validates with the shared schema.
+const submitCorrectionParametersSchema = investigationClaimPayloadSchema.extend({
+ text: claimShape.text.describe("The exact text of the incorrect claim."),
+ context: claimShape.context.describe(
+ "Surrounding context that disambiguates the claim location.",
+ ),
+ summary: claimShape.summary.describe("A one-sentence summary of what is incorrect and why."),
+ reasoning: claimShape.reasoning.describe(
+ "Detailed reasoning with evidence for why the claim is incorrect.",
+ ),
+ sources: z
+ .array(
+ claimSourceSchema.extend({
+ url: claimSourceSchema.shape.url.describe("Source URL (absolute http/https)."),
+ title: claimSourceSchema.shape.title.describe("Title of the source."),
+ snippet: claimSourceSchema.shape.snippet.describe("Relevant snippet from the source."),
+ }),
+ )
+ .min(1)
+ .describe("At least one supporting source."),
+});
+
+/** Submits one correction; called as the model finds each incorrect claim. */
+export const submitCorrectionToolDefinition: FunctionTool = {
+ type: "function",
+ name: SUBMIT_CORRECTION_TOOL_NAME,
+ description:
+ "Submit a single factual correction you have found and verified. " +
+ "Call this tool for each incorrect claim you discover — do not wait " +
+ "until you have found all claims.",
+ strict: true,
+ parameters: toStrictModeParameters(submitCorrectionParametersSchema),
+};
+
+export function parseSubmitCorrectionArguments(
+ argumentsJson: string,
+): ClaimToolArguments {
+ return parseToolArguments(investigationClaimPayloadSchema, argumentsJson);
+}
+
+// ── retain_correction (update investigations only) ──────────────────────────
+
+function retainCorrectionParametersSchema(retainableClaimIds: readonly [string, ...string[]]) {
+ return z
+ .object({
+ id: z.enum(retainableClaimIds).describe("The ID of the existing claim to retain."),
+ })
+ .strict();
+}
+
+/**
+ * Carries a previously validated claim forward unchanged. The `id` enum is
+ * exactly the prior investigation's claim ids.
+ */
+export function buildRetainCorrectionToolDefinition(
+ retainableClaimIds: readonly [string, ...string[]],
+): FunctionTool {
+ return {
+ type: "function",
+ name: RETAIN_CORRECTION_TOOL_NAME,
+ description:
+ "Retain an existing claim from the previous investigation that is " +
+ "still correct and relevant. Use this instead of re-submitting the " +
+ "same claim via submit_correction.",
+ strict: true,
+ parameters: toStrictModeParameters(retainCorrectionParametersSchema(retainableClaimIds)),
+ };
+}
+
+/** Returns the claim id to retain. */
+export function parseRetainCorrectionArguments(
+ argumentsJson: string,
+ retainableClaimIds: readonly [string, ...string[]],
+): ClaimToolArguments {
+ const parsed = parseToolArguments(
+ retainCorrectionParametersSchema(retainableClaimIds),
+ argumentsJson,
+ );
+ return parsed.kind === "valid" ? { kind: "valid", value: parsed.value.id } : parsed;
+}
diff --git a/src/typescript/api/src/lib/investigators/openai-claim-validation-scheduler.ts b/src/typescript/api/src/lib/investigators/openai-claim-validation-scheduler.ts
index 23050e7..460ca23 100644
--- a/src/typescript/api/src/lib/investigators/openai-claim-validation-scheduler.ts
+++ b/src/typescript/api/src/lib/investigators/openai-claim-validation-scheduler.ts
@@ -9,18 +9,18 @@ import {
settlePendingValidation,
type InvestigationRunState,
} from "./openai-investigation-run-state.js";
-import type { PerClaimValidationResult } from "./openai-claim-validator.js";
+import type { ClaimValidationResult } from "./openai-claim-validator.js";
type StageOneClaim = InvestigationResult["claims"][number];
type ValidationLimiter = (
- task: () => Promise,
-) => Promise;
+ task: () => Promise,
+) => Promise;
type ValidationRunner = (
claimIndex: number,
claim: StageOneClaim,
-) => Promise;
+) => Promise;
type RetainClaimResult =
| {
@@ -31,21 +31,12 @@ type RetainClaimResult =
errorMessage: string;
};
-export interface ClaimValidationScheduler {
+interface ClaimValidationScheduler {
getState: () => InvestigationRunState;
scheduleClaimValidation: (claim: StageOneClaim) => void;
retainClaimById: (claimId: string) => RetainClaimResult;
- awaitAllValidations: () => Promise;
- settleAllValidations: () => Promise;
-}
-
-function toValidationErrorResult(claimIndex: number, error: unknown): PerClaimValidationResult {
- return {
- claimIndex,
- approved: false,
- responseAudit: null,
- error: error instanceof Error ? error : new Error(String(error)),
- };
+ /** Every scheduled validation's result, in scheduling order, once all have settled. */
+ awaitAllValidations: () => Promise;
}
export function createClaimValidationScheduler(input: {
@@ -60,7 +51,7 @@ export function createClaimValidationScheduler(input: {
input.callbacks?.onProgressUpdate(getPendingClaims(state), getConfirmedClaims(state));
};
- const settleValidation = (pendingIndex: number, result: PerClaimValidationResult): void => {
+ const settleValidation = (pendingIndex: number, result: ClaimValidationResult): void => {
state = settlePendingValidation(state, {
pendingIndex,
result,
@@ -70,9 +61,8 @@ export function createClaimValidationScheduler(input: {
const scheduleClaimValidation = (claim: StageOneClaim): void => {
const claimIndex = state.nextClaimIndex;
- const promise = input
- .validationLimiter(() => input.runValidation(claimIndex, claim))
- .catch((error: unknown) => toValidationErrorResult(claimIndex, error));
+ // runValidation reports failures as results, never as rejections.
+ const promise = input.validationLimiter(() => input.runValidation(claimIndex, claim));
const queued = enqueuePendingValidation(state, {
claim,
@@ -109,8 +99,5 @@ export function createClaimValidationScheduler(input: {
scheduleClaimValidation,
retainClaimById,
awaitAllValidations: async () => Promise.all(getPendingValidationPromises(state)),
- settleAllValidations: async () => {
- await Promise.allSettled(getPendingValidationPromises(state));
- },
};
}
diff --git a/src/typescript/api/src/lib/investigators/openai-claim-validator.ts b/src/typescript/api/src/lib/investigators/openai-claim-validator.ts
index 8fac6cb..68d9123 100644
--- a/src/typescript/api/src/lib/investigators/openai-claim-validator.ts
+++ b/src/typescript/api/src/lib/investigators/openai-claim-validator.ts
@@ -1,129 +1,106 @@
import type OpenAI from "openai";
-import { zodTextFormat } from "openai/helpers/zod";
-import { isNonNullObject, type InvestigationResult } from "@openerrata/shared";
-import type { InvestigatorResponseAudit } from "./interface.js";
-import { claimValidationResultSchema } from "./openai-schemas.js";
+import type { Response } from "openai/resources/responses/responses";
+import type { InvestigationClaimPayload } from "@openerrata/shared";
import {
- extractResponseAudit,
- readIncompleteReason,
- requireCompletedOutputText,
- requireJsonObject,
-} from "./openai-response-audit.js";
-import { INVESTIGATION_VALIDATION_SYSTEM_PROMPT, buildValidationPrompt } from "./prompt.js";
-
-const isRecord = isNonNullObject;
+ InvestigatorIncompleteResponseError,
+ InvestigatorStructuredOutputError,
+} from "./errors.js";
+import type { InvestigatorRequestAudit } from "./interface.js";
+import {
+ buildClaimValidationRequestParams,
+ claimValidationVerdictSchema,
+ type InvestigationRequestConfig,
+} from "./openai-request-config.js";
+import { auditRequest, auditResponse } from "./openai-response-audit.js";
+import { buildValidationPrompt } from "./prompt.js";
export const MAX_PER_CLAIM_VALIDATION_CONCURRENCY = 4;
-export class InvestigatorIncompleteResponseError extends Error {
- readonly responseStatus: string | null;
- readonly responseId: string | null;
- readonly incompleteReason: string | null;
- readonly outputTextLength: number;
+/** Outcome of one stage-2 validation call (SPEC §2.4.3.2). Never a rejection. */
+export type ClaimValidationResult =
+ | { kind: "approved"; claimIndex: number; request: InvestigatorRequestAudit }
+ | { kind: "rejected"; claimIndex: number; request: InvestigatorRequestAudit }
+ | { kind: "failed"; claimIndex: number; request: InvestigatorRequestAudit; error: Error };
+
+function toError(caught: unknown): Error {
+ return caught instanceof Error ? caught : new Error(String(caught));
+}
- constructor(input: {
- responseStatus: string | null;
- responseId: string | null;
- incompleteReason: string | null;
- outputTextLength: number;
- }) {
- const statusPart = input.responseStatus ?? "unknown";
- const reasonPart = input.incompleteReason ?? "unknown";
- const responseIdPart = input.responseId ?? "unknown";
- super(
- "OpenAI response did not complete " +
- `(status=${statusPart}, reason=${reasonPart}, responseId=${responseIdPart}, outputTextLength=${input.outputTextLength.toString()})`,
+/** The verdict in a completed validation response; throws when there is none. */
+function readVerdict(response: Response): boolean {
+ if (response.status !== "completed") {
+ throw new InvestigatorIncompleteResponseError({
+ responseStatus: response.status ?? null,
+ responseId: response.id,
+ incompleteReason: response.incomplete_details?.reason ?? null,
+ });
+ }
+
+ const outputText = response.output
+ .flatMap((item) => (item.type === "message" ? item.content : []))
+ .flatMap((part) => (part.type === "output_text" ? [part.text] : []))
+ .join("");
+
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(outputText);
+ } catch {
+ throw new InvestigatorStructuredOutputError(
+ `Claim validation response ${response.id} did not return a JSON verdict`,
+ );
+ }
+ const verdict = claimValidationVerdictSchema.safeParse(decoded);
+ if (!verdict.success) {
+ throw new InvestigatorStructuredOutputError(
+ `Claim validation response ${response.id} returned an invalid verdict: ${verdict.error.message}`,
);
- this.name = "InvestigatorIncompleteResponseError";
- this.responseStatus = input.responseStatus;
- this.responseId = input.responseId;
- this.incompleteReason = input.incompleteReason;
- this.outputTextLength = input.outputTextLength;
}
+ return verdict.data.approved;
}
-export type PerClaimValidationResult =
- | {
- claimIndex: number;
- approved: boolean;
- responseAudit: InvestigatorResponseAudit;
- error: null;
- }
- | {
- claimIndex: number;
- approved: false;
- responseAudit: InvestigatorResponseAudit | null;
- error: Error;
- };
-
-export async function validateClaim(
- client: OpenAI,
- modelId: string,
- claimIndex: number,
- claim: InvestigationResult["claims"][number],
- contentText: string,
- imageContextNotes: string | undefined,
- requestReasoning: {
- effort: "low" | "medium" | "high";
- summary: "auto" | "concise" | "detailed";
- },
-): Promise {
+export async function validateClaim(input: {
+ client: OpenAI;
+ requestConfig: InvestigationRequestConfig;
+ claimIndex: number;
+ claim: InvestigationClaimPayload;
+ contentText: string;
+ imageContextNotes: string | undefined;
+ signal: AbortSignal;
+}): Promise {
+ const { claimIndex } = input;
const validationPrompt = buildValidationPrompt({
- currentPostText: contentText,
- candidateClaim: claim,
- ...(imageContextNotes === undefined ? {} : { imageContextNotes }),
+ currentPostText: input.contentText,
+ candidateClaim: input.claim,
+ ...(input.imageContextNotes === undefined
+ ? {}
+ : { imageContextNotes: input.imageContextNotes }),
});
+ const params = buildClaimValidationRequestParams(input.requestConfig, validationPrompt);
+ const subject = { kind: "CLAIM_VALIDATION", claimIndex } as const;
- let response: unknown;
+ let response: Response;
try {
- response = await client.responses.create({
- model: modelId,
- stream: false,
- instructions: INVESTIGATION_VALIDATION_SYSTEM_PROMPT,
- input: validationPrompt,
- reasoning: requestReasoning,
- text: {
- format: zodTextFormat(claimValidationResultSchema, "claim_validation_result"),
- },
- });
+ response = await input.client.responses.create(params, { signal: input.signal });
} catch (caught) {
return {
+ kind: "failed",
claimIndex,
- approved: false,
- responseAudit: null,
- error: caught instanceof Error ? caught : new Error(String(caught)),
+ request: auditRequest({ subject, params, auditInput: validationPrompt, response: null }),
+ error: toError(caught),
};
}
- const responseRecord = isRecord(response) ? response : {};
- const responseAudit = extractResponseAudit(responseRecord);
+ const request = auditRequest({
+ subject,
+ params,
+ auditInput: validationPrompt,
+ response: auditResponse(response, new Date()),
+ });
try {
- if (responseAudit.responseStatus !== "completed") {
- throw new InvestigatorIncompleteResponseError({
- responseStatus: responseAudit.responseStatus,
- responseId: responseAudit.responseId,
- incompleteReason: readIncompleteReason(responseRecord),
- outputTextLength: responseAudit.responseOutputText?.length ?? 0,
- });
- }
-
- const outputText = requireCompletedOutputText({
- responseAudit,
- responseRecord,
- context: "Claim validation response",
- });
-
- const parsed: unknown = JSON.parse(outputText);
- const { approved } = claimValidationResultSchema.parse(
- requireJsonObject(parsed, "Claim validation structured output"),
- );
- return { claimIndex, approved, responseAudit, error: null };
+ return readVerdict(response)
+ ? { kind: "approved", claimIndex, request }
+ : { kind: "rejected", claimIndex, request };
} catch (caught) {
- return {
- claimIndex,
- approved: false,
- responseAudit,
- error: caught instanceof Error ? caught : new Error(String(caught)),
- };
+ return { kind: "failed", claimIndex, request, error: toError(caught) };
}
}
diff --git a/src/typescript/api/src/lib/investigators/openai-errors.ts b/src/typescript/api/src/lib/investigators/openai-errors.ts
deleted file mode 100644
index 94789c3..0000000
--- a/src/typescript/api/src/lib/investigators/openai-errors.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Thrown when the LLM returns structured output that is syntactically valid
- * but semantically invalid for our investigation pipeline. This is a
- * non-retryable error — the model's output cannot be fixed by retrying.
- */
-export class InvestigatorStructuredOutputError extends Error {
- constructor(message: string) {
- super(message);
- this.name = "InvestigatorStructuredOutputError";
- }
-}
diff --git a/src/typescript/api/src/lib/investigators/openai-input-builder.ts b/src/typescript/api/src/lib/investigators/openai-input-builder.ts
index 2123dcc..f8e82f2 100644
--- a/src/typescript/api/src/lib/investigators/openai-input-builder.ts
+++ b/src/typescript/api/src/lib/investigators/openai-input-builder.ts
@@ -1,11 +1,25 @@
import type { ResponseInput } from "openai/resources/responses/responses";
import { validateAndSortImageOccurrences } from "@openerrata/shared";
-import { InvestigatorStructuredOutputError } from "./openai-errors.js";
-import type { InvestigatorImageOccurrence, ImagePlaceholder } from "./interface.js";
+import { InvestigatorInputError } from "./errors.js";
+import type {
+ InvestigatorImageOccurrence,
+ ImagePlaceholder,
+ InvestigatorJsonRecord,
+} from "./interface.js";
+
+/**
+ * The stage-1 request's `input`, alongside the form recorded in the attempt
+ * audit, where each image part carries the image's content hash (its
+ * ImageBlob) instead of the inline data URI that was sent.
+ */
+export interface AuditedRequestInput {
+ request: string | ResponseInput;
+ audit: string | InvestigatorJsonRecord[];
+}
type ContentInputPart =
| { type: "input_text"; text: string }
- | { type: "input_image"; detail: "auto"; image_url: string };
+ | { type: "input_image"; dataUri: string; contentHash: string };
function appendTextInputPart(contentParts: ContentInputPart[], text: string): void {
if (text.length === 0) return;
@@ -15,6 +29,31 @@ function appendTextInputPart(contentParts: ContentInputPart[], text: string): vo
});
}
+function toAuditedUserMessage(contentParts: ContentInputPart[]): AuditedRequestInput {
+ return {
+ request: [
+ {
+ role: "user",
+ content: contentParts.map((part) =>
+ part.type === "input_text"
+ ? part
+ : { type: "input_image" as const, detail: "auto" as const, image_url: part.dataUri },
+ ),
+ },
+ ],
+ audit: [
+ {
+ role: "user",
+ content: contentParts.map((part) =>
+ part.type === "input_text"
+ ? { type: part.type, text: part.text }
+ : { type: part.type, detail: "auto", imageContentHash: part.contentHash },
+ ),
+ },
+ ],
+ };
+}
+
function requirePromptContentBounds(
userPrompt: string,
contentString: string,
@@ -27,7 +66,7 @@ function requirePromptContentBounds(
contentEnd > userPrompt.length ||
userPrompt.slice(contentStart, contentEnd) !== contentString
) {
- throw new InvestigatorStructuredOutputError(
+ throw new InvestigatorInputError(
"contentOffset does not point to contentString within the stage-1 user prompt",
);
}
@@ -43,15 +82,13 @@ function normalizeImageOccurrences(
onValidationIssue: (issue): never => {
switch (issue.code) {
case "NON_CONTIGUOUS_ORIGINAL_INDEX":
- throw new InvestigatorStructuredOutputError(
+ throw new InvestigatorInputError(
"Image occurrences must use contiguous originalIndex values starting at 0",
);
case "OFFSET_EXCEEDS_CONTENT_LENGTH":
- throw new InvestigatorStructuredOutputError(
- "Image occurrence offset exceeds contentText length",
- );
+ throw new InvestigatorInputError("Image occurrence offset exceeds contentText length");
case "DECREASING_NORMALIZED_TEXT_OFFSET":
- throw new InvestigatorStructuredOutputError(
+ throw new InvestigatorInputError(
"Image occurrences must be non-decreasing by normalizedTextOffset",
);
}
@@ -93,7 +130,7 @@ function buildInputUsingTextOffsets(input: {
contentString: string;
contentOffset: number;
normalizedOccurrences: InvestigatorImageOccurrence[];
-}): ResponseInput {
+}): AuditedRequestInput {
const { contentStart, contentEnd } = requirePromptContentBounds(
input.userPrompt,
input.contentString,
@@ -126,8 +163,8 @@ function buildInputUsingTextOffsets(input: {
seenResolvedContentHashes.add(occurrence.contentHash);
contentParts.push({
type: "input_image",
- detail: "auto",
- image_url: occurrence.imageDataUri,
+ dataUri: occurrence.imageDataUri,
+ contentHash: occurrence.contentHash,
});
continue;
}
@@ -156,12 +193,7 @@ function buildInputUsingTextOffsets(input: {
}
appendTextInputPart(contentParts, input.userPrompt.slice(contentEnd));
- return [
- {
- role: "user",
- content: contentParts,
- },
- ];
+ return toAuditedUserMessage(contentParts);
}
/**
@@ -204,7 +236,7 @@ export function buildInitialInput(
contentOffset: number,
imageOccurrences: InvestigatorImageOccurrence[] | undefined,
imagePlaceholders: ImagePlaceholder[] | undefined,
-): string | ResponseInput {
+): AuditedRequestInput {
const shouldUsePlaceholderInterleaving =
imagePlaceholders !== undefined && imagePlaceholders.length > 0;
const normalizedOccurrences = normalizeImageOccurrences(
@@ -212,7 +244,7 @@ export function buildInitialInput(
shouldUsePlaceholderInterleaving ? undefined : contentString,
);
if (normalizedOccurrences.length === 0) {
- return userPrompt;
+ return { request: userPrompt, audit: userPrompt };
}
// No markdown placeholders available (e.g. markdownSource=NONE): interleave
@@ -238,7 +270,7 @@ export function buildInitialInput(
const seenResolvedContentHashes = new Set();
// Split content at [IMAGE:N] patterns
- const placeholderPattern = /\[IMAGE:(\d+)\]/g;
+ const placeholderPattern = /\[IMAGE:\d+\]/g;
const contentParts: ContentInputPart[] = [];
// Text before the content section
@@ -249,7 +281,7 @@ export function buildInitialInput(
let match: RegExpExecArray | null;
while ((match = placeholderPattern.exec(contentString)) !== null) {
- const placeholderIndex = parseInt(match[1] ?? "0", 10);
+ const placeholderIndex = Number.parseInt(match[0].slice("[IMAGE:".length, -"]".length), 10);
const placeholder = imagePlaceholders.find((p) => p.index === placeholderIndex);
// Text between last position and this placeholder
@@ -274,8 +306,8 @@ export function buildInitialInput(
consumedUrls.add(occurrence.sourceUrl);
contentParts.push({
type: "input_image",
- detail: "auto",
- image_url: occurrence.imageDataUri,
+ dataUri: occurrence.imageDataUri,
+ contentHash: occurrence.contentHash,
});
continue;
}
@@ -314,21 +346,5 @@ export function buildInitialInput(
// Text after the content section
appendTextInputPart(contentParts, userPrompt.slice(contentEnd));
- return [
- {
- role: "user",
- content: contentParts,
- },
- ];
-}
-
-export function buildTwoStepRequestInputAudit(
- userPrompt: string,
- validationPrompt: string,
-): string {
- return `=== Stage 1: Fact-check input ===
-${userPrompt}
-
-=== Stage 2: Validation input ===
-${validationPrompt}`;
+ return toAuditedUserMessage(contentParts);
}
diff --git a/src/typescript/api/src/lib/investigators/openai-investigation-run-state.ts b/src/typescript/api/src/lib/investigators/openai-investigation-run-state.ts
index 9fd6e87..3dce5b2 100644
--- a/src/typescript/api/src/lib/investigators/openai-investigation-run-state.ts
+++ b/src/typescript/api/src/lib/investigators/openai-investigation-run-state.ts
@@ -1,6 +1,6 @@
import type { InvestigationResult } from "@openerrata/shared";
import type { InvestigatorInput } from "./interface.js";
-import type { PerClaimValidationResult } from "./openai-claim-validator.js";
+import type { ClaimValidationResult } from "./openai-claim-validator.js";
type StageOneClaim = InvestigationResult["claims"][number];
type OldClaim = Extract["oldClaims"][number];
@@ -9,7 +9,7 @@ export interface PendingValidationEntry {
claim: StageOneClaim;
claimIndex: number;
submissionOrder: number;
- promise: Promise;
+ promise: Promise;
settled: boolean;
}
@@ -60,7 +60,7 @@ export function enqueuePendingValidation(
state: InvestigationRunState,
input: {
claim: StageOneClaim;
- promise: Promise;
+ promise: Promise;
},
): {
nextState: InvestigationRunState;
@@ -94,7 +94,7 @@ export function settlePendingValidation(
state: InvestigationRunState,
input: {
pendingIndex: number;
- result: PerClaimValidationResult;
+ result: ClaimValidationResult;
},
): InvestigationRunState {
const pending = state.pendingValidations[input.pendingIndex];
@@ -103,7 +103,7 @@ export function settlePendingValidation(
}
if (pending.settled) {
- return state;
+ throw new Error(`Pending validation already settled: ${input.pendingIndex.toString()}`);
}
const pendingValidations = state.pendingValidations.map((entry, index) =>
@@ -111,7 +111,7 @@ export function settlePendingValidation(
);
const confirmedClaims =
- input.result.error === null && input.result.approved
+ input.result.kind === "approved"
? [
...state.confirmedClaims,
{
@@ -182,6 +182,6 @@ export function getConfirmedClaims(state: InvestigationRunState): StageOneClaim[
export function getPendingValidationPromises(
state: InvestigationRunState,
-): Promise[] {
+): Promise[] {
return state.pendingValidations.map((entry) => entry.promise);
}
diff --git a/src/typescript/api/src/lib/investigators/openai-probe.ts b/src/typescript/api/src/lib/investigators/openai-probe.ts
new file mode 100644
index 0000000..13e15d7
--- /dev/null
+++ b/src/typescript/api/src/lib/investigators/openai-probe.ts
@@ -0,0 +1,26 @@
+import type OpenAI from "openai";
+import {
+ buildProbeRequestParams,
+ type InvestigationRequestConfig,
+} from "./openai-request-config.js";
+
+/**
+ * Checks that the provider accepts investigation requests from this client:
+ * the key authenticates, has access to the model, and the request shape
+ * (tools, include, reasoning options) is valid for it. Rejects with the
+ * provider's error otherwise.
+ *
+ * An "incomplete" response counts as accepted: the probe's tiny output cap
+ * routinely cuts reasoning short, which says nothing about the request shape.
+ */
+export async function probeInvestigationRequest(
+ client: OpenAI,
+ requestConfig: InvestigationRequestConfig,
+): Promise {
+ const response = await client.responses.create(buildProbeRequestParams(requestConfig));
+ if (response.status !== "completed" && response.status !== "incomplete") {
+ throw new Error(
+ `OpenAI probe response ${response.id} ended with status ${response.status ?? "missing"}`,
+ );
+ }
+}
diff --git a/src/typescript/api/src/lib/investigators/openai-request-config.ts b/src/typescript/api/src/lib/investigators/openai-request-config.ts
new file mode 100644
index 0000000..e4a6abb
--- /dev/null
+++ b/src/typescript/api/src/lib/investigators/openai-request-config.ts
@@ -0,0 +1,153 @@
+import { zodTextFormat } from "openai/helpers/zod";
+import type {
+ ResponseCreateParamsNonStreaming,
+ ResponseIncludable,
+ ResponseInput,
+ Tool,
+} from "openai/resources/responses/responses";
+import type { Reasoning, ReasoningEffort } from "openai/resources/shared";
+import { z } from "zod";
+import { fetchUrlToolDefinition } from "./fetch-url-tool.js";
+import {
+ buildRetainCorrectionToolDefinition,
+ submitCorrectionToolDefinition,
+} from "./openai-claim-tools.js";
+import { INVESTIGATION_VALIDATION_SYSTEM_PROMPT } from "./prompt.js";
+
+/**
+ * The one model OpenErrata investigates with. Model choice is a code change,
+ * not configuration: every request shape below (tool types, reasoning options,
+ * `include` values) is what this model accepts.
+ */
+export const INVESTIGATION_MODEL_ID = "gpt-6.1-sol";
+
+type ReasoningSummary = NonNullable;
+// gpt-6.1-sol accepts low | medium | high | xhigh | max; it rejects "none" and "minimal".
+type InvestigationReasoningEffort = Exclude, "none" | "minimal">;
+
+/**
+ * Reasoning summaries requested on every investigation request, persisted in
+ * the attempt audit (SPEC §2.12); `null` requests none. gpt-6.1-sol's model docs
+ * do not mention reasoning summaries; "detailed" was verified live on
+ * 2026-10-02. A rejected request parameter fails every investigation
+ * non-retryably (SPEC §3.7), so verify any new value live with
+ * `pnpm --filter @openerrata/api smoke:openai` before deploying it.
+ */
+const INVESTIGATION_REASONING_SUMMARY: ReasoningSummary | null = "detailed";
+
+export interface InvestigationRequestConfig {
+ readonly model: typeof INVESTIGATION_MODEL_ID;
+ readonly reasoningEffort: InvestigationReasoningEffort;
+ readonly reasoningSummary: ReasoningSummary | null;
+ /** Extra response fields the fact-check requests ask the provider to return. */
+ readonly include: readonly ResponseIncludable[];
+}
+
+export const INVESTIGATION_REQUEST_CONFIG: InvestigationRequestConfig = {
+ model: INVESTIGATION_MODEL_ID,
+ // gpt-6.1-sol's default, stated explicitly so the audit records it.
+ reasoningEffort: "medium",
+ reasoningSummary: INVESTIGATION_REASONING_SUMMARY,
+ // Without this, web_search_call items carry no sources, and the audit would
+ // miss the URLs the model consulted.
+ include: ["web_search_call.action.sources"],
+};
+
+/** Request parameters for every provider request an investigation makes. */
+export type InvestigationRequestParams = ResponseCreateParamsNonStreaming & {
+ model: typeof INVESTIGATION_MODEL_ID;
+ instructions: string;
+ reasoning: Reasoning;
+};
+
+function toRequestReasoning(config: InvestigationRequestConfig): Reasoning {
+ return config.reasoningSummary === null
+ ? { effort: config.reasoningEffort }
+ : { effort: config.reasoningEffort, summary: config.reasoningSummary };
+}
+
+/**
+ * Tools offered to the stage-1 fact-check. retain_correction is offered only
+ * to update investigations whose parent has claims to retain.
+ */
+export function buildFactCheckTools(
+ retainableClaimIds: readonly [string, ...string[]] | null,
+): Tool[] {
+ return [
+ { type: "web_search" },
+ fetchUrlToolDefinition,
+ submitCorrectionToolDefinition,
+ ...(retainableClaimIds === null
+ ? []
+ : [buildRetainCorrectionToolDefinition(retainableClaimIds)]),
+ ];
+}
+
+/** One round of the stage-1 fact-check tool loop. */
+export function buildFactCheckRequestParams(
+ config: InvestigationRequestConfig,
+ request: {
+ instructions: string;
+ tools: Tool[];
+ input: string | ResponseInput;
+ /** The previous round's response, which this round's input continues. */
+ previousResponseId: string | null;
+ },
+): InvestigationRequestParams {
+ return {
+ model: config.model,
+ stream: false,
+ instructions: request.instructions,
+ input: request.input,
+ tools: request.tools,
+ include: [...config.include],
+ reasoning: toRequestReasoning(config),
+ ...(request.previousResponseId === null
+ ? {}
+ : { previous_response_id: request.previousResponseId }),
+ };
+}
+
+export const claimValidationVerdictSchema = z
+ .object({
+ approved: z.boolean(),
+ })
+ .strict();
+
+/** A stage-2 per-claim validation call: no tools, structured yes/no verdict. */
+export function buildClaimValidationRequestParams(
+ config: InvestigationRequestConfig,
+ validationPrompt: string,
+): InvestigationRequestParams {
+ return {
+ model: config.model,
+ stream: false,
+ instructions: INVESTIGATION_VALIDATION_SYSTEM_PROMPT,
+ input: validationPrompt,
+ reasoning: toRequestReasoning(config),
+ text: {
+ format: zodTextFormat(claimValidationVerdictSchema, "claim_validation_result"),
+ },
+ };
+}
+
+/**
+ * A minimal request with the fact-check request's shape (model, tools,
+ * include, reasoning) that forbids tool use and caps output, so the provider
+ * validates the shape without running an investigation.
+ */
+export function buildProbeRequestParams(
+ config: InvestigationRequestConfig,
+): InvestigationRequestParams {
+ return {
+ ...buildFactCheckRequestParams(config, {
+ instructions: "Reply with the single word pong.",
+ tools: buildFactCheckTools(null),
+ input: "ping",
+ previousResponseId: null,
+ }),
+ tool_choice: "none",
+ // The smallest cap the provider has accepted for reasoning models.
+ max_output_tokens: 16,
+ };
+}
diff --git a/src/typescript/api/src/lib/investigators/openai-response-audit.ts b/src/typescript/api/src/lib/investigators/openai-response-audit.ts
index c37dc42..244554c 100644
--- a/src/typescript/api/src/lib/investigators/openai-response-audit.ts
+++ b/src/typescript/api/src/lib/investigators/openai-response-audit.ts
@@ -1,482 +1,188 @@
-import { isNonNullObject } from "@openerrata/shared";
-import { InvestigatorStructuredOutputError } from "./openai-errors.js";
+import type {
+ Response,
+ ResponseOutputItem,
+ ResponseOutputRefusal,
+ ResponseOutputText,
+} from "openai/resources/responses/responses";
+import { z } from "zod";
+import { readOpenAiStatusCode } from "$lib/openai/errors.js";
import type {
InvestigatorErrorAudit,
+ InvestigatorJsonRecord,
InvestigatorJsonValue,
InvestigatorOutputItemAudit,
InvestigatorOutputTextAnnotationAudit,
InvestigatorOutputTextPartAudit,
- InvestigatorReasoningSummaryAudit,
- InvestigatorRequestedToolAudit,
+ InvestigatorRequestAudit,
+ InvestigatorRequestSubject,
InvestigatorResponseAudit,
- InvestigatorToolCallAudit,
- InvestigatorUsageAudit,
} from "./interface.js";
-import { readOpenAiStatusCode } from "$lib/openai/errors.js";
-
-const isRecord = isNonNullObject;
-
-export function sanitizeJsonValue(value: unknown, depth = 0): InvestigatorJsonValue {
- if (depth > 8) return "[max-depth]";
- if (
- typeof value === "string" ||
- typeof value === "number" ||
- typeof value === "boolean" ||
- value === null
- ) {
- return value;
- }
- if (Array.isArray(value)) {
- return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
- }
- if (isRecord(value)) {
- const sanitized: Record = {};
- for (const [key, entry] of Object.entries(value)) {
- sanitized[key] = sanitizeJsonValue(entry, depth + 1);
- }
- return sanitized;
- }
- if (typeof value === "bigint") return value.toString();
- if (typeof value === "symbol") return value.description ?? "symbol";
- if (typeof value === "function") return "[function]";
- return "[unsupported]";
-}
-
-export function sanitizeJsonRecord(value: unknown): Record {
- const record = requireJsonObject(value, "OpenAI audit payload");
- const sanitized: Record = {};
- for (const [key, entry] of Object.entries(record)) {
- sanitized[key] = sanitizeJsonValue(entry, 1);
- }
- return sanitized;
-}
-
-export function describeJsonValueType(value: unknown): string {
- if (value === null) return "null";
- if (Array.isArray(value)) return "array";
- return typeof value;
-}
-
-export function requireJsonObject(value: unknown, context: string): Record {
- if (!isRecord(value)) {
- throw new InvestigatorStructuredOutputError(
- `${context} must be a JSON object (received ${describeJsonValueType(value)})`,
- );
- }
- return value;
-}
-
-export function requireCompletedOutputText(input: {
- responseAudit: InvestigatorResponseAudit;
- responseRecord: Record;
- context: string;
-}): string {
- const outputText = input.responseAudit.responseOutputText;
- if (outputText === null) {
- const rawOutputTextType = describeJsonValueType(input.responseRecord["output_text"]);
- throw new InvestigatorStructuredOutputError(
- `${input.context} completed without output_text (responseId=${input.responseAudit.responseId ?? "unknown"}, output_text_type=${rawOutputTextType})`,
- );
- }
-
- if (outputText.trim().length === 0) {
- throw new InvestigatorStructuredOutputError(
- `${input.context} returned empty structured output`,
- );
- }
-
- return outputText;
-}
-
-export function readString(value: unknown): string | null {
- return typeof value === "string" ? value : null;
-}
-
-export function readOptionalInteger(value: unknown): number | null {
- return typeof value === "number" && Number.isInteger(value) ? value : null;
-}
-
-export function readIncompleteReason(responseRecord: Record): string | null {
- const incompleteDetails = responseRecord["incomplete_details"];
- if (!isRecord(incompleteDetails)) return null;
- return readString(incompleteDetails["reason"]);
-}
-
-export function buildErrorAudit(error: unknown): InvestigatorErrorAudit {
- if (error instanceof Error) {
+import type { InvestigationRequestParams } from "./openai-request-config.js";
+
+const jsonValueSchema: z.ZodType = z.lazy(() =>
+ z.union([
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.null(),
+ z.array(jsonValueSchema),
+ z.record(z.string(), jsonValueSchema),
+ ]),
+);
+const jsonRecordSchema = z.record(z.string(), jsonValueSchema);
+
+/**
+ * The JSON the SDK puts on (or read off) the wire for an SDK object such as a
+ * tool definition or output item, checked to be JSON so it can be stored
+ * verbatim. TypeScript's SDK types can't express JSON-ness themselves.
+ */
+function toJsonRecord(value: object): InvestigatorJsonRecord {
+ return jsonRecordSchema.parse(JSON.parse(JSON.stringify(value)));
+}
+
+function auditAnnotation(
+ annotation: ResponseOutputText["annotations"][number],
+): InvestigatorOutputTextAnnotationAudit {
+ switch (annotation.type) {
+ case "url_citation":
+ return {
+ annotationType: annotation.type,
+ startIndex: annotation.start_index,
+ endIndex: annotation.end_index,
+ url: annotation.url,
+ title: annotation.title,
+ fileId: null,
+ };
+ case "container_file_citation":
+ return {
+ annotationType: annotation.type,
+ startIndex: annotation.start_index,
+ endIndex: annotation.end_index,
+ url: null,
+ title: annotation.filename,
+ fileId: annotation.file_id,
+ };
+ case "file_citation":
+ return {
+ annotationType: annotation.type,
+ startIndex: null,
+ endIndex: null,
+ url: null,
+ title: annotation.filename,
+ fileId: annotation.file_id,
+ };
+ case "file_path":
+ return {
+ annotationType: annotation.type,
+ startIndex: null,
+ endIndex: null,
+ url: null,
+ title: null,
+ fileId: annotation.file_id,
+ };
+ }
+}
+
+function auditTextPart(
+ part: ResponseOutputText | ResponseOutputRefusal,
+): InvestigatorOutputTextPartAudit {
+ switch (part.type) {
+ case "output_text":
+ return {
+ partType: part.type,
+ text: part.text,
+ annotations: part.annotations.map(auditAnnotation),
+ };
+ case "refusal":
+ return { partType: part.type, text: part.refusal, annotations: [] };
+ }
+}
+
+function auditOutputItem(item: ResponseOutputItem): InvestigatorOutputItemAudit {
+ if (item.type === "message") {
return {
- errorName: error.name,
- errorMessage: error.message,
- statusCode: readOpenAiStatusCode(error),
+ providerItemId: item.id,
+ itemType: item.type,
+ itemStatus: item.status,
+ content: { kind: "MESSAGE", textParts: item.content.map(auditTextPart) },
};
}
-
- return {
- errorName: "UnknownError",
- errorMessage: typeof error === "string" ? error : "unknown",
- statusCode: readOpenAiStatusCode(error),
- };
-}
-
-export function parseTimestamp(value: unknown): string | null {
- if (typeof value === "number" && Number.isFinite(value)) {
- // Handle seconds and milliseconds unix timestamps.
- const normalized = value > 1_000_000_000_000 ? value : value * 1000;
- const date = new Date(normalized);
- return Number.isNaN(date.valueOf()) ? null : date.toISOString();
- }
-
- if (typeof value === "string" && value.trim().length > 0) {
- const date = new Date(value);
- return Number.isNaN(date.valueOf()) ? null : date.toISOString();
- }
-
- return null;
-}
-
-export function findTimestamp(
- value: unknown,
- candidateKeys: Set,
- depth = 0,
-): string | null {
- if (depth > 6) return null;
-
- if (isRecord(value)) {
- for (const [key, nested] of Object.entries(value)) {
- const normalizedKey = key.toLowerCase();
- if (candidateKeys.has(normalizedKey)) {
- const parsed = parseTimestamp(nested);
- if (parsed !== null && parsed.length > 0) return parsed;
- }
- const nestedResult = findTimestamp(nested, candidateKeys, depth + 1);
- if (nestedResult !== null && nestedResult.length > 0) return nestedResult;
- }
- } else if (Array.isArray(value)) {
- for (const nested of value) {
- const nestedResult = findTimestamp(nested, candidateKeys, depth + 1);
- if (nestedResult !== null && nestedResult.length > 0) return nestedResult;
- }
- }
-
- return null;
-}
-
-export function extractRequestedTools(tools: unknown): InvestigatorRequestedToolAudit[] {
- if (!Array.isArray(tools)) return [];
-
- const extracted: InvestigatorRequestedToolAudit[] = [];
- for (const [index, tool] of tools.entries()) {
- if (!isRecord(tool)) continue;
-
- extracted.push({
- requestOrder: index,
- toolType: readString(tool["type"]) ?? "unknown",
- rawDefinition: sanitizeJsonRecord(tool),
- });
- }
-
- return extracted;
-}
-
-export function extractOutputItems(outputItems: unknown[]): InvestigatorOutputItemAudit[] {
- const extracted: InvestigatorOutputItemAudit[] = [];
-
- for (const [outputIndex, outputItem] of outputItems.entries()) {
- if (!isRecord(outputItem)) {
- extracted.push({
- outputIndex,
- providerItemId: null,
- itemType: "unknown",
- itemStatus: null,
- });
- continue;
- }
-
- const providerItemId = readString(outputItem["id"]);
- const itemStatus = readString(outputItem["status"]);
- extracted.push({
- outputIndex,
- providerItemId: providerItemId === null || itemStatus === null ? null : providerItemId,
- itemType: readString(outputItem["type"]) ?? "unknown",
- itemStatus: providerItemId === null || itemStatus === null ? null : itemStatus,
- });
- }
-
- return extracted;
-}
-
-export function extractOutputTextArtifacts(outputItems: unknown[]): {
- parts: InvestigatorOutputTextPartAudit[];
- annotations: InvestigatorOutputTextAnnotationAudit[];
-} {
- const parts: InvestigatorOutputTextPartAudit[] = [];
- const annotations: InvestigatorOutputTextAnnotationAudit[] = [];
-
- for (const [outputIndex, outputItem] of outputItems.entries()) {
- if (!isRecord(outputItem) || outputItem["type"] !== "message") continue;
-
- const content = outputItem["content"];
- if (!Array.isArray(content)) continue;
-
- for (const [partIndex, part] of content.entries()) {
- if (!isRecord(part)) continue;
-
- const partType = readString(part["type"]);
- if (partType === "output_text") {
- const text = readString(part["text"]);
- if (text === null || text.length === 0) continue;
-
- parts.push({
- outputIndex,
- partIndex,
- partType,
- text,
- });
-
- const partAnnotations = part["annotations"];
- if (!Array.isArray(partAnnotations)) continue;
-
- for (const [annotationIndex, annotation] of partAnnotations.entries()) {
- if (!isRecord(annotation)) continue;
-
- const startIndex = readOptionalInteger(annotation["start_index"]);
- const endIndex = readOptionalInteger(annotation["end_index"]);
-
- const characterPosition =
- startIndex === null || endIndex === null
- ? undefined
- : {
- start: startIndex,
- end: endIndex,
- };
-
- annotations.push({
- outputIndex,
- partIndex,
- annotationIndex,
- annotationType: readString(annotation["type"]) ?? "unknown",
- characterPosition,
- url: readString(annotation["url"]),
- title: readString(annotation["title"]),
- fileId: readString(annotation["file_id"]),
- });
- }
-
- continue;
- }
-
- if (partType === "refusal") {
- const refusal = readString(part["refusal"]);
- if (refusal === null || refusal.length === 0) continue;
-
- parts.push({
- outputIndex,
- partIndex,
- partType,
- text: refusal,
- });
- }
- }
- }
-
- return { parts, annotations };
-}
-
-export function extractReasoningSummaries(
- outputItems: unknown[],
-): InvestigatorReasoningSummaryAudit[] {
- const summaries: InvestigatorReasoningSummaryAudit[] = [];
-
- for (const [outputIndex, outputItem] of outputItems.entries()) {
- if (!isRecord(outputItem) || outputItem["type"] !== "reasoning") continue;
-
- const summary = outputItem["summary"];
- if (!Array.isArray(summary)) continue;
-
- for (const [summaryIndex, summaryPart] of summary.entries()) {
- if (!isRecord(summaryPart)) continue;
- const text = readString(summaryPart["text"]);
- if (text === null || text.length === 0) continue;
-
- summaries.push({
- outputIndex,
- summaryIndex,
- text,
- });
- }
- }
-
- return summaries;
-}
-
-function toToolCallAudit(
- outputIndex: number,
- outputItem: unknown,
-): InvestigatorToolCallAudit | null {
- if (!isRecord(outputItem)) return null;
- const type = outputItem["type"];
- if (type === "message" || type === "reasoning") {
- return null;
- }
- if (typeof type !== "string" || type.length === 0) return null;
-
- const providerStartedAt = findTimestamp(
- outputItem,
- new Set(["started_at", "start_time", "created_at", "createdat", "requested_at", "timestamp"]),
- );
- const providerCompletedAt = findTimestamp(
- outputItem,
- new Set(["completed_at", "finished_at", "ended_at", "updated_at", "completedat", "finishedat"]),
- );
- const providerToolCallId = readString(outputItem["id"]);
- const status = readString(outputItem["status"]);
-
- return {
- outputIndex,
- providerToolCallId: providerToolCallId === null || status === null ? null : providerToolCallId,
- toolType: type,
- status: providerToolCallId === null || status === null ? null : status,
- rawPayload: sanitizeJsonRecord(outputItem),
- capturedAt: new Date().toISOString(),
- providerStartedAt,
- providerCompletedAt,
- };
-}
-
-export function extractToolCalls(outputItems: unknown[]): InvestigatorToolCallAudit[] {
- const entries: InvestigatorToolCallAudit[] = [];
- for (const [outputIndex, outputItem] of outputItems.entries()) {
- const entry = toToolCallAudit(outputIndex, outputItem);
- if (entry) entries.push(entry);
- }
- return entries;
-}
-
-export function extractUsage(
- responseRecord: Record,
-): InvestigatorUsageAudit | null {
- const usageValue = responseRecord["usage"];
- if (!isRecord(usageValue)) return null;
-
- const inputTokens = readOptionalInteger(usageValue["input_tokens"]);
- const outputTokens = readOptionalInteger(usageValue["output_tokens"]);
- const totalTokens = readOptionalInteger(usageValue["total_tokens"]);
- if (inputTokens === null || outputTokens === null || totalTokens === null) {
- return null;
+ if (item.type === "reasoning") {
+ return {
+ providerItemId: item.id,
+ itemType: item.type,
+ itemStatus: item.status ?? null,
+ content: { kind: "REASONING", summaries: item.summary.map((summary) => summary.text) },
+ };
}
-
- const inputDetails = isRecord(usageValue["input_tokens_details"])
- ? usageValue["input_tokens_details"]
- : null;
- const outputDetails = isRecord(usageValue["output_tokens_details"])
- ? usageValue["output_tokens_details"]
- : null;
-
+ // Every other item is a tool call (web_search_call, function_call, …). Their
+ // shapes vary by tool and some carry no status, so the item is kept verbatim.
return {
- inputTokens,
- outputTokens,
- totalTokens,
- cachedInputTokens: readOptionalInteger(inputDetails?.["cached_tokens"]),
- reasoningOutputTokens: readOptionalInteger(outputDetails?.["reasoning_tokens"]),
+ providerItemId: item.id ?? null,
+ itemType: item.type,
+ itemStatus: "status" in item ? (item.status ?? null) : null,
+ content: { kind: "TOOL_CALL", rawPayload: toJsonRecord(item) },
};
}
-export function extractResponseAudit(
- responseRecord: Record,
-): InvestigatorResponseAudit {
- const outputItems = Array.isArray(responseRecord["output"]) ? responseRecord["output"] : [];
-
- const outputTextArtifacts = extractOutputTextArtifacts(outputItems);
-
+export function auditResponse(response: Response, receivedAt: Date): InvestigatorResponseAudit {
return {
- responseId: readString(responseRecord["id"]),
- responseStatus: readString(responseRecord["status"]),
- responseModelVersion: readString(responseRecord["model"]),
- responseOutputText: readString(responseRecord["output_text"]),
- outputItems: extractOutputItems(outputItems),
- outputTextParts: outputTextArtifacts.parts,
- outputTextAnnotations: outputTextArtifacts.annotations,
- reasoningSummaries: extractReasoningSummaries(outputItems),
- toolCalls: extractToolCalls(outputItems),
- usage: extractUsage(responseRecord),
+ providerResponseId: response.id,
+ status: response.status ?? null,
+ modelVersion: response.model,
+ receivedAt,
+ outputItems: response.output.map(auditOutputItem),
+ usage:
+ response.usage === undefined
+ ? null
+ : {
+ inputTokens: response.usage.input_tokens,
+ outputTokens: response.usage.output_tokens,
+ totalTokens: response.usage.total_tokens,
+ cachedInputTokens: response.usage.input_tokens_details.cached_tokens,
+ reasoningOutputTokens: response.usage.output_tokens_details.reasoning_tokens,
+ },
};
}
-export function offsetResponseAuditIndices(
- audit: InvestigatorResponseAudit,
- outputIndexOffset: number,
-): InvestigatorResponseAudit {
- if (outputIndexOffset === 0) return audit;
-
+/** Audits a request exactly as sent (see InvestigatorRequestAudit.input for images). */
+export function auditRequest(input: {
+ subject: InvestigatorRequestSubject;
+ params: InvestigationRequestParams;
+ auditInput: InvestigatorRequestAudit["input"];
+ response: InvestigatorResponseAudit | null;
+}): InvestigatorRequestAudit {
+ const { params } = input;
return {
- ...audit,
- outputItems: audit.outputItems.map((item) => ({
- ...item,
- outputIndex: item.outputIndex + outputIndexOffset,
- })),
- outputTextParts: audit.outputTextParts.map((part) => ({
- ...part,
- outputIndex: part.outputIndex + outputIndexOffset,
- })),
- outputTextAnnotations: audit.outputTextAnnotations.map((annotation) => ({
- ...annotation,
- outputIndex: annotation.outputIndex + outputIndexOffset,
- })),
- reasoningSummaries: audit.reasoningSummaries.map((summary) => ({
- ...summary,
- outputIndex: summary.outputIndex + outputIndexOffset,
- })),
- toolCalls: audit.toolCalls.map((toolCall) => ({
- ...toolCall,
- outputIndex: toolCall.outputIndex + outputIndexOffset,
+ subject: input.subject,
+ model: params.model,
+ instructions: params.instructions,
+ input: input.auditInput,
+ previousResponseId: params.previous_response_id ?? null,
+ reasoningEffort: params.reasoning.effort ?? null,
+ reasoningSummary: params.reasoning.summary ?? null,
+ include: [...(params.include ?? [])],
+ tools: (params.tools ?? []).map((tool) => ({
+ toolType: tool.type,
+ rawDefinition: toJsonRecord(tool),
})),
+ response: input.response,
};
}
-export function aggregateUsage(
- usages: (InvestigatorUsageAudit | null)[],
-): InvestigatorUsageAudit | null {
- const presentUsages = usages.filter((usage): usage is InvestigatorUsageAudit => usage !== null);
- if (presentUsages.length === 0) return null;
-
- return presentUsages.reduce(
- (accumulator, usage) => ({
- inputTokens: accumulator.inputTokens + usage.inputTokens,
- outputTokens: accumulator.outputTokens + usage.outputTokens,
- totalTokens: accumulator.totalTokens + usage.totalTokens,
- cachedInputTokens: (accumulator.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0),
- reasoningOutputTokens:
- (accumulator.reasoningOutputTokens ?? 0) + (usage.reasoningOutputTokens ?? 0),
- }),
- {
- inputTokens: 0,
- outputTokens: 0,
- totalTokens: 0,
- cachedInputTokens: 0,
- reasoningOutputTokens: 0,
- },
- );
-}
-
-export function mergeResponseAudits(
- responseAudits: InvestigatorResponseAudit[],
-): InvestigatorResponseAudit {
- if (responseAudits.length === 0) {
- throw new Error("Cannot merge empty response audits");
+export function buildErrorAudit(error: unknown): InvestigatorErrorAudit {
+ if (error instanceof Error) {
+ return {
+ // OpenAI SDK errors keep the generic name "Error"; their class names the failure.
+ errorName: error.name === "Error" ? error.constructor.name : error.name,
+ errorMessage: error.message,
+ statusCode: readOpenAiStatusCode(error),
+ };
}
- const finalAudit = responseAudits[responseAudits.length - 1];
- if (!finalAudit) throw new Error("Cannot merge empty response audits");
return {
- responseId: finalAudit.responseId,
- responseStatus: finalAudit.responseStatus,
- responseModelVersion: finalAudit.responseModelVersion,
- responseOutputText: finalAudit.responseOutputText,
- outputItems: responseAudits.flatMap((audit) => audit.outputItems),
- outputTextParts: responseAudits.flatMap((audit) => audit.outputTextParts),
- outputTextAnnotations: responseAudits.flatMap((audit) => audit.outputTextAnnotations),
- reasoningSummaries: responseAudits.flatMap((audit) => audit.reasoningSummaries),
- toolCalls: responseAudits.flatMap((audit) => audit.toolCalls),
- usage: aggregateUsage(responseAudits.map((audit) => audit.usage)),
+ errorName: "UnknownError",
+ errorMessage: typeof error === "string" ? error : "unknown",
+ statusCode: null,
};
}
diff --git a/src/typescript/api/src/lib/investigators/openai-schemas.ts b/src/typescript/api/src/lib/investigators/openai-schemas.ts
deleted file mode 100644
index 02efbc6..0000000
--- a/src/typescript/api/src/lib/investigators/openai-schemas.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { z } from "zod";
-
-export const claimValidationResultSchema = z
- .object({
- approved: z.boolean(),
- })
- .strict();
-
-// OpenAI structured outputs currently reject JSON Schema `format: "uri"`.
-// Keep provider-facing schema to plain strings/patterns, then enforce the
-// full shared schema (`investigationResultSchema`) before returning.
-export const providerStructuredSourceUrlSchema = z
- .string()
- .min(1)
- .regex(/^https?:\/\/\S+$/i, "Source URL must be an absolute http(s) URL");
-
-export const providerStructuredInvestigationClaimPayloadSchema = z
- .object({
- text: z.string().min(1),
- context: z.string().min(1),
- summary: z.string().min(1),
- reasoning: z.string().min(1),
- sources: z
- .array(
- z
- .object({
- url: providerStructuredSourceUrlSchema,
- title: z.string().min(1),
- snippet: z.string().min(1),
- })
- .strict(),
- )
- .min(1),
- })
- .strict();
diff --git a/src/typescript/api/src/lib/investigators/openai-tool-dispatch.ts b/src/typescript/api/src/lib/investigators/openai-tool-dispatch.ts
index 8cb0941..f5e3f14 100644
--- a/src/typescript/api/src/lib/investigators/openai-tool-dispatch.ts
+++ b/src/typescript/api/src/lib/investigators/openai-tool-dispatch.ts
@@ -1,97 +1,6 @@
-import { isNonNullObject } from "@openerrata/shared";
+import type { Response } from "openai/resources/responses/responses";
import { FETCH_URL_TOOL_NAME, executeFetchUrlTool } from "./fetch-url-tool.js";
-import { readString } from "./openai-response-audit.js";
-
-const isRecord = isNonNullObject;
-
-export const SUBMIT_CORRECTION_TOOL_NAME = "submit_correction";
-export const RETAIN_CORRECTION_TOOL_NAME = "retain_correction";
-
-/**
- * OpenAI function tool definition for submitting an individual claim.
- * The model calls this as it discovers each correction during investigation.
- *
- * Uses `strict: true` to enable structured outputs for the tool parameters.
- * The JSON Schema mirrors `providerStructuredInvestigationClaimPayloadSchema`
- * from `openai-schemas.ts` but is expressed as a plain object because the
- * OpenAI SDK requires a JSON Schema object, not a Zod schema.
- */
-export const submitCorrectionToolDefinition = {
- type: "function" as const,
- name: SUBMIT_CORRECTION_TOOL_NAME,
- description:
- "Submit a single factual correction you have found and verified. " +
- "Call this tool for each incorrect claim you discover — do not wait " +
- "until you have found all claims.",
- strict: true as const,
- parameters: {
- type: "object" as const,
- properties: {
- text: { type: "string" as const, description: "The exact text of the incorrect claim." },
- context: {
- type: "string" as const,
- description: "Surrounding context that disambiguates the claim location.",
- },
- summary: {
- type: "string" as const,
- description: "A one-sentence summary of what is incorrect and why.",
- },
- reasoning: {
- type: "string" as const,
- description: "Detailed reasoning with evidence for why the claim is incorrect.",
- },
- sources: {
- type: "array" as const,
- items: {
- type: "object" as const,
- properties: {
- url: { type: "string" as const, description: "Source URL (absolute http/https)." },
- title: { type: "string" as const, description: "Title of the source." },
- snippet: {
- type: "string" as const,
- description: "Relevant snippet from the source.",
- },
- },
- required: ["url", "title", "snippet"] as const,
- additionalProperties: false as const,
- },
- description: "At least one supporting source.",
- },
- },
- required: ["text", "context", "summary", "reasoning", "sources"] as const,
- additionalProperties: false as const,
- },
-};
-
-/**
- * OpenAI function tool definition for retaining an existing claim during
- * update investigations. The model calls this to carry forward a previously
- * validated claim unchanged.
- */
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -- return type is intentionally inferred; the `as const` assertions on each field provide narrow literal types
-export function buildRetainCorrectionToolDefinition(oldClaimIds: [string, ...string[]]) {
- return {
- type: "function" as const,
- name: RETAIN_CORRECTION_TOOL_NAME,
- description:
- "Retain an existing claim from the previous investigation that is " +
- "still correct and relevant. Use this instead of re-submitting the " +
- "same claim via submit_correction.",
- strict: true as const,
- parameters: {
- type: "object" as const,
- properties: {
- id: {
- type: "string" as const,
- enum: oldClaimIds,
- description: "The ID of the existing claim to retain.",
- },
- },
- required: ["id"] as const,
- additionalProperties: false as const,
- },
- };
-}
+import { RETAIN_CORRECTION_TOOL_NAME, SUBMIT_CORRECTION_TOOL_NAME } from "./openai-claim-tools.js";
export interface PendingFunctionToolCall {
callId: string;
@@ -109,57 +18,54 @@ export function buildFunctionCallOutput(callId: string, output: string): Functio
return { type: "function_call_output", call_id: callId, output };
}
-export function extractPendingFunctionToolCalls(
- responseRecord: Record,
-): PendingFunctionToolCall[] {
- const outputItems = Array.isArray(responseRecord["output"]) ? responseRecord["output"] : [];
-
- const calls: PendingFunctionToolCall[] = [];
- for (const outputItem of outputItems) {
- if (!isRecord(outputItem) || outputItem["type"] !== "function_call") continue;
-
- const callId = readString(outputItem["call_id"]);
- const name = readString(outputItem["name"]);
- const argumentsJson = readString(outputItem["arguments"]);
- if (
- callId === null ||
- callId.length === 0 ||
- name === null ||
- name.length === 0 ||
- argumentsJson === null
- ) {
- continue;
- }
-
- calls.push({ callId, name, argumentsJson });
- }
-
- return calls;
+/** Function calls the model is waiting on, in the order it emitted them. */
+export function extractFunctionToolCalls(response: Response): PendingFunctionToolCall[] {
+ return response.output.flatMap((item) =>
+ item.type === "function_call"
+ ? [{ callId: item.call_id, name: item.name, argumentsJson: item.arguments }]
+ : [],
+ );
}
-export function deduplicateFunctionToolCalls(
- calls: PendingFunctionToolCall[],
-): PendingFunctionToolCall[] {
- const deduplicated: PendingFunctionToolCall[] = [];
- const seen = new Set();
- for (const call of calls) {
- if (seen.has(call.callId)) continue;
- seen.add(call.callId);
- deduplicated.push(call);
- }
- return deduplicated;
+interface FunctionCallHandlers {
+ submitCorrection: (call: PendingFunctionToolCall) => FunctionCallOutput;
+ retainCorrection: (call: PendingFunctionToolCall) => FunctionCallOutput;
+ /** Any other function tool (research tools such as fetch_url). */
+ research: (call: PendingFunctionToolCall) => Promise;
}
-/** Returns true if the tool call is a claim submission/retain tool handled by the investigator loop. */
-export function isClaimToolCall(call: PendingFunctionToolCall): boolean {
- return call.name === SUBMIT_CORRECTION_TOOL_NAME || call.name === RETAIN_CORRECTION_TOOL_NAME;
+/**
+ * Answers every call of a round. Claim tool calls are handled synchronously in
+ * emission order (which fixes claim submission order); research calls run
+ * concurrently.
+ */
+export async function dispatchFunctionToolCalls(
+ calls: readonly PendingFunctionToolCall[],
+ handlers: FunctionCallHandlers,
+): Promise {
+ return Promise.all(
+ // The async callback runs synchronously up to its first await, so the
+ // claim handlers still run one after another in emission order.
+ calls.map(async (call) => {
+ switch (call.name) {
+ case SUBMIT_CORRECTION_TOOL_NAME:
+ return handlers.submitCorrection(call);
+ case RETAIN_CORRECTION_TOOL_NAME:
+ return handlers.retainCorrection(call);
+ default:
+ return handlers.research(call);
+ }
+ }),
+ );
}
export async function executeFunctionToolCall(
call: PendingFunctionToolCall,
+ signal: AbortSignal,
): Promise {
+ signal.throwIfAborted();
if (call.name === FETCH_URL_TOOL_NAME) {
- const toolOutput = await executeFetchUrlTool(call.argumentsJson);
+ const toolOutput = await executeFetchUrlTool(call.argumentsJson, signal);
return buildFunctionCallOutput(call.callId, JSON.stringify(toolOutput));
}
diff --git a/src/typescript/api/src/lib/investigators/openai-tool-loop.ts b/src/typescript/api/src/lib/investigators/openai-tool-loop.ts
index c65aeb6..c200700 100644
--- a/src/typescript/api/src/lib/investigators/openai-tool-loop.ts
+++ b/src/typescript/api/src/lib/investigators/openai-tool-loop.ts
@@ -1,147 +1,108 @@
import type OpenAI from "openai";
-import type { ResponseCreateParamsNonStreaming } from "openai/resources/responses/responses";
-import { isNonNullObject } from "@openerrata/shared";
-import type { InvestigatorResponseAudit } from "./interface.js";
-import { InvestigatorStructuredOutputError } from "./openai-errors.js";
+import type { Response, Tool } from "openai/resources/responses/responses";
+import type { InvestigatorRequestAudit } from "./interface.js";
+import type { AuditedRequestInput } from "./openai-input-builder.js";
import {
- extractResponseAudit,
- offsetResponseAuditIndices,
- readString,
-} from "./openai-response-audit.js";
+ buildFactCheckRequestParams,
+ type InvestigationRequestConfig,
+} from "./openai-request-config.js";
+import { auditRequest, auditResponse } from "./openai-response-audit.js";
import {
- deduplicateFunctionToolCalls,
- extractPendingFunctionToolCalls,
- isClaimToolCall,
- RETAIN_CORRECTION_TOOL_NAME,
- SUBMIT_CORRECTION_TOOL_NAME,
+ extractFunctionToolCalls,
type FunctionCallOutput,
type PendingFunctionToolCall,
} from "./openai-tool-dispatch.js";
-const isRecord = isNonNullObject;
-
-interface RequestReasoning {
- effort: "low" | "medium" | "high";
- summary: "auto" | "concise" | "detailed";
-}
-
-type RequiredResponseInput = NonNullable;
-
-interface BaseResponseRequest {
- model: NonNullable;
- stream: false;
- instructions: string;
- tools: NonNullable;
- reasoning: RequestReasoning;
-}
-
-export class ToolLoopExecutionError extends Error {
- readonly responseAudits: readonly InvestigatorResponseAudit[];
-
- constructor(
- message: string,
- responseAudits: readonly InvestigatorResponseAudit[],
- cause?: unknown,
- ) {
- super(message, ...(cause !== undefined ? [{ cause }] : []));
- this.name = "ToolLoopExecutionError";
- this.responseAudits = responseAudits;
- }
-}
-
-interface ToolLoopResult {
- latestResponseRecord: Record | null;
- responseAudits: InvestigatorResponseAudit[];
-}
+/**
+ * How the stage-1 fact-check loop ended. `rounds` audits every request made,
+ * including a final one that failed without a response.
+ */
+type ToolLoopResult =
+ | {
+ /** The model stopped calling function tools. */
+ kind: "completed";
+ rounds: InvestigatorRequestAudit[];
+ finalResponse: Response;
+ }
+ | {
+ /** The model still had function calls pending when no round was left to answer them. */
+ kind: "round_limit";
+ rounds: InvestigatorRequestAudit[];
+ }
+ | {
+ /** A response ended with a status other than "completed". */
+ kind: "response_not_completed";
+ rounds: InvestigatorRequestAudit[];
+ response: Response;
+ }
+ | {
+ /** A provider request or a tool call threw. */
+ kind: "failed";
+ rounds: InvestigatorRequestAudit[];
+ error: unknown;
+ };
export async function runToolLoop(input: {
client: OpenAI;
- maxResponseToolRounds: number;
- baseResponseRequest: BaseResponseRequest;
- initialInput: RequiredResponseInput;
- handleSubmittedClaims: (
- calls: PendingFunctionToolCall[],
- ) => FunctionCallOutput[] | Promise;
- handleRetainedClaims: (
- calls: PendingFunctionToolCall[],
- ) => FunctionCallOutput[] | Promise;
- handleResearchCalls: (
- calls: PendingFunctionToolCall[],
- ) => FunctionCallOutput[] | Promise;
+ requestConfig: InvestigationRequestConfig;
+ /** At least 1. */
+ maxRounds: number;
+ instructions: string;
+ tools: Tool[];
+ initialInput: AuditedRequestInput;
+ signal: AbortSignal;
+ handleFunctionCalls: (calls: PendingFunctionToolCall[]) => Promise;
}): Promise {
- let outputIndexOffset = 0;
+ const rounds: InvestigatorRequestAudit[] = [];
+ let roundInput = input.initialInput;
let previousResponseId: string | null = null;
- let latestResponseRecord: Record | null = null;
- let nextInput: RequiredResponseInput = input.initialInput;
- const responseAudits: InvestigatorResponseAudit[] = [];
- let round = 0;
- while (round < input.maxResponseToolRounds) {
- const responseRequest: ResponseCreateParamsNonStreaming =
- round === 0
- ? {
- ...input.baseResponseRequest,
- input: nextInput,
- }
- : {
- ...input.baseResponseRequest,
- previous_response_id: previousResponseId,
- input: nextInput,
- };
+ for (let round = 0; ; round += 1) {
+ const subject = { kind: "FACT_CHECK_ROUND", round } as const;
+ const params = buildFactCheckRequestParams(input.requestConfig, {
+ instructions: input.instructions,
+ tools: input.tools,
+ input: roundInput.request,
+ previousResponseId,
+ });
- let response: unknown;
+ let response: Response;
try {
- response = await input.client.responses.create(responseRequest);
+ response = await input.client.responses.create(params, { signal: input.signal });
} catch (error) {
- throw new ToolLoopExecutionError(
- "OpenAI Responses API request failed",
- responseAudits,
- error,
- );
+ rounds.push(auditRequest({ subject, params, auditInput: roundInput.audit, response: null }));
+ return { kind: "failed", rounds, error };
}
-
- const responseRecord = isRecord(response) ? response : {};
- latestResponseRecord = responseRecord;
- previousResponseId = readString(responseRecord["id"]);
-
- const responseAudit = extractResponseAudit(responseRecord);
- responseAudits.push(offsetResponseAuditIndices(responseAudit, outputIndexOffset));
- outputIndexOffset += responseAudit.outputItems.length;
-
- const pendingFunctionCalls = deduplicateFunctionToolCalls(
- extractPendingFunctionToolCalls(responseRecord),
+ rounds.push(
+ auditRequest({
+ subject,
+ params,
+ auditInput: roundInput.audit,
+ response: auditResponse(response, new Date()),
+ }),
);
- if (pendingFunctionCalls.length === 0) {
- break;
- }
- if (previousResponseId === null || previousResponseId.length === 0) {
- throw new ToolLoopExecutionError(
- "Tool calls were emitted without a response id",
- responseAudits,
- new InvestigatorStructuredOutputError("Tool calls were emitted without a response id"),
- );
+ if (response.status !== "completed") {
+ return { kind: "response_not_completed", rounds, response };
}
- const submittedClaims = pendingFunctionCalls.filter(
- (call) => call.name === SUBMIT_CORRECTION_TOOL_NAME,
- );
- const retainedClaims = pendingFunctionCalls.filter(
- (call) => call.name === RETAIN_CORRECTION_TOOL_NAME,
- );
- const researchCalls = pendingFunctionCalls.filter((call) => !isClaimToolCall(call));
-
- const outputs: FunctionCallOutput[] = [];
- outputs.push(...(await input.handleSubmittedClaims(submittedClaims)));
- outputs.push(...(await input.handleRetainedClaims(retainedClaims)));
- outputs.push(...(await input.handleResearchCalls(researchCalls)));
+ const calls = extractFunctionToolCalls(response);
+ if (calls.length === 0) {
+ return { kind: "completed", rounds, finalResponse: response };
+ }
+ // Answering these calls needs another round; don't run tools (or schedule
+ // claim validations) whose outputs could never be sent.
+ if (round + 1 >= input.maxRounds) {
+ return { kind: "round_limit", rounds };
+ }
- nextInput = outputs;
- round += 1;
+ let outputs: FunctionCallOutput[];
+ try {
+ outputs = await input.handleFunctionCalls(calls);
+ } catch (error) {
+ return { kind: "failed", rounds, error };
+ }
+ roundInput = { request: outputs, audit: outputs.map((output) => ({ ...output })) };
+ previousResponseId = response.id;
}
-
- return {
- latestResponseRecord,
- responseAudits,
- };
}
diff --git a/src/typescript/api/src/lib/investigators/openai.ts b/src/typescript/api/src/lib/investigators/openai.ts
index bbd349a..01c4682 100644
--- a/src/typescript/api/src/lib/investigators/openai.ts
+++ b/src/typescript/api/src/lib/investigators/openai.ts
@@ -1,456 +1,255 @@
import OpenAI from "openai";
import pLimit from "p-limit";
-import {
- DEFAULT_INVESTIGATION_MODEL,
- DEFAULT_INVESTIGATION_PROVIDER,
- investigationResultSchema,
- isNonNullObject,
- type InvestigationResult,
-} from "@openerrata/shared";
+import type { InvestigationClaimPayload } from "@openerrata/shared";
import { getEnv } from "$lib/config/env.js";
-import { fetchUrlToolDefinition } from "./fetch-url-tool.js";
+import {
+ InvestigatorExecutionError,
+ InvestigatorIncompleteResponseError,
+ InvestigatorStructuredOutputError,
+} from "./errors.js";
import type {
- InvestigationProgressCallbacks,
+ InvestigateOptions,
Investigator,
- InvestigatorAttemptAudit,
InvestigatorInput,
InvestigatorOutput,
+ InvestigatorRequestAudit,
} from "./interface.js";
-import { InvestigatorStructuredOutputError } from "./openai-errors.js";
-import {
- INVESTIGATION_SYSTEM_PROMPT,
- INVESTIGATION_UPDATE_SYSTEM_PROMPT,
- buildUserPrompt,
-} from "./prompt.js";
-import { providerStructuredInvestigationClaimPayloadSchema } from "./openai-schemas.js";
-import { buildInitialInput, buildValidationImageContextNotes } from "./openai-input-builder.js";
-import { readIncompleteReason, mergeResponseAudits } from "./openai-response-audit.js";
import {
- RETAIN_CORRECTION_TOOL_NAME,
- SUBMIT_CORRECTION_TOOL_NAME,
- buildFunctionCallOutput,
- buildRetainCorrectionToolDefinition,
- deduplicateFunctionToolCalls,
- executeFunctionToolCall,
- extractPendingFunctionToolCalls,
- type FunctionCallOutput,
- type PendingFunctionToolCall,
- submitCorrectionToolDefinition,
-} from "./openai-tool-dispatch.js";
+ parseRetainCorrectionArguments,
+ parseSubmitCorrectionArguments,
+} from "./openai-claim-tools.js";
+import { createClaimValidationScheduler } from "./openai-claim-validation-scheduler.js";
import {
- InvestigatorIncompleteResponseError,
MAX_PER_CLAIM_VALIDATION_CONCURRENCY,
- type PerClaimValidationResult,
validateClaim,
+ type ClaimValidationResult,
} from "./openai-claim-validator.js";
-import {
- createClaimValidationScheduler,
- type ClaimValidationScheduler,
-} from "./openai-claim-validation-scheduler.js";
+import { buildInitialInput, buildValidationImageContextNotes } from "./openai-input-builder.js";
import {
createInvestigationRunState,
getConfirmedClaims,
} from "./openai-investigation-run-state.js";
-import { runToolLoop, ToolLoopExecutionError } from "./openai-tool-loop.js";
import {
- buildFailedAttemptAudit,
- buildFullAttemptResponseAudit,
- buildSuccessfulAttemptAudit,
- createStageOneAttemptAuditBase,
- createStageTwoAttemptAuditBase,
-} from "./openai-attempt-audit-builder.js";
-
-const isRecord = isNonNullObject;
-
-const DEFAULT_REASONING_EFFORT = "medium";
-const DEFAULT_REASONING_SUMMARY = "detailed";
-export { InvestigatorStructuredOutputError } from "./openai-errors.js";
-
-function getOpenAiModelId(): string {
- return getEnv().OPENAI_MODEL_ID;
-}
+ buildFactCheckTools,
+ INVESTIGATION_REQUEST_CONFIG,
+ type InvestigationRequestConfig,
+} from "./openai-request-config.js";
+import { buildErrorAudit } from "./openai-response-audit.js";
+import {
+ buildFunctionCallOutput,
+ dispatchFunctionToolCalls,
+ executeFunctionToolCall,
+ type FunctionCallOutput,
+ type PendingFunctionToolCall,
+} from "./openai-tool-dispatch.js";
+import { runToolLoop } from "./openai-tool-loop.js";
+import {
+ INVESTIGATION_SYSTEM_PROMPT,
+ INVESTIGATION_UPDATE_SYSTEM_PROMPT,
+ buildUserPrompt,
+} from "./prompt.js";
-function getMaxResponseToolRounds(): number {
- return getEnv().OPENAI_MAX_RESPONSE_TOOL_ROUNDS;
+interface OpenAIInvestigatorConfig {
+ client: OpenAI;
+ requestConfig: InvestigationRequestConfig;
+ /** Upper bound on stage-1 fact-check rounds (provider requests); at least 1. */
+ maxToolRounds: number;
}
-export class InvestigatorExecutionError extends Error {
- readonly attemptAudit: InvestigatorAttemptAudit;
- override readonly cause: unknown;
+const ACKNOWLEDGED_OUTPUT = JSON.stringify({ acknowledged: true });
- constructor(message: string, attemptAudit: InvestigatorAttemptAudit, cause?: unknown) {
- super(message);
- this.name = "InvestigatorExecutionError";
- this.attemptAudit = attemptAudit;
- this.cause = cause;
+function nonEmptyClaimIds(input: InvestigatorInput): readonly [string, ...string[]] | null {
+ if (input.isUpdate !== true) {
+ return null;
}
+ const [firstClaim, ...remainingClaims] = input.oldClaims;
+ return firstClaim === undefined
+ ? null
+ : [firstClaim.id, ...remainingClaims.map((claim) => claim.id)];
}
-type StageOneClaim = InvestigationResult["claims"][number];
-
+/**
+ * Two-stage OpenAI investigation (SPEC §2.4): a stage-1 fact-check tool loop
+ * in which the model submits candidate claims, and a stage-2 validation call
+ * per candidate, started as each claim is submitted.
+ */
export class OpenAIInvestigator implements Investigator {
- readonly provider = DEFAULT_INVESTIGATION_PROVIDER;
- readonly model = DEFAULT_INVESTIGATION_MODEL;
+ private readonly config: OpenAIInvestigatorConfig;
- private client: OpenAI;
- private readonly overrideModelId: string | undefined;
- private readonly overrideMaxToolRounds: number | undefined;
-
- constructor(
- apiKey: string,
- overrides?: { client?: OpenAI; modelId?: string; maxToolRounds?: number },
- ) {
- this.client = overrides?.client ?? new OpenAI({ apiKey });
- this.overrideModelId = overrides?.modelId;
- this.overrideMaxToolRounds = overrides?.maxToolRounds;
+ constructor(config: OpenAIInvestigatorConfig) {
+ if (!Number.isInteger(config.maxToolRounds) || config.maxToolRounds < 1) {
+ throw new Error(
+ `maxToolRounds must be a positive integer (got ${config.maxToolRounds.toString()})`,
+ );
+ }
+ this.config = config;
}
async investigate(
input: InvestigatorInput,
- callbacks?: InvestigationProgressCallbacks,
+ options: InvestigateOptions,
): Promise {
- const openAiModelId = this.overrideModelId ?? getOpenAiModelId();
- const maxResponseToolRounds = this.overrideMaxToolRounds ?? getMaxResponseToolRounds();
- const systemPrompt =
- input.isUpdate === true ? INVESTIGATION_UPDATE_SYSTEM_PROMPT : INVESTIGATION_SYSTEM_PROMPT;
- const userPromptResult = buildUserPrompt({
- contentText: input.contentText,
- ...(input.contentMarkdown !== undefined && { contentMarkdown: input.contentMarkdown }),
- platform: input.platform,
- url: input.url,
- ...(input.authorName !== undefined && { authorName: input.authorName }),
- ...(input.postPublishedAt !== undefined && { postPublishedAt: input.postPublishedAt }),
- ...(input.hasVideo !== undefined && { hasVideo: input.hasVideo }),
- ...(input.isUpdate
- ? {
- isUpdate: true as const,
- oldClaims: input.oldClaims,
- ...(input.contentDiff !== undefined && { contentDiff: input.contentDiff }),
- }
- : {}),
- });
+ const { client, requestConfig } = this.config;
+ const { signal } = options;
+ const startedAt = new Date();
+
+ const userPrompt = buildUserPrompt(input);
const initialInput = buildInitialInput(
- userPromptResult.prompt,
- userPromptResult.contentString,
- userPromptResult.contentOffset,
+ userPrompt.prompt,
+ userPrompt.contentString,
+ userPrompt.contentOffset,
input.imageOccurrences,
input.imagePlaceholders,
);
const validationImageContextNotes = buildValidationImageContextNotes(input.imageOccurrences);
- const client = this.client;
-
- // ── Build tool set ────────────────────────────────────────────────
- const nonEmptyOldClaimIds: [string, ...string[]] | null = (() => {
- if (input.isUpdate !== true) {
- return null;
- }
- const [firstClaim, ...remainingClaims] = input.oldClaims;
- if (firstClaim === undefined) {
- return null;
- }
- return [firstClaim.id, ...remainingClaims.map((claim) => claim.id)];
- })();
-
- const requestedTools = [
- { type: "web_search_preview" as const },
- fetchUrlToolDefinition,
- submitCorrectionToolDefinition,
- ...(nonEmptyOldClaimIds !== null
- ? [buildRetainCorrectionToolDefinition(nonEmptyOldClaimIds)]
- : []),
- ];
-
- const requestReasoning = {
- effort: DEFAULT_REASONING_EFFORT as "low" | "medium" | "high",
- summary: DEFAULT_REASONING_SUMMARY as "auto" | "concise" | "detailed",
- };
-
- const baseResponseRequest = {
- model: openAiModelId,
- stream: false as const,
- instructions: systemPrompt,
- tools: requestedTools,
- reasoning: requestReasoning,
- };
-
- const startedAt = new Date().toISOString();
- const stageOneAttemptAuditBase = createStageOneAttemptAuditBase({
- startedAt,
- openAiModelId,
- systemPrompt,
- userPrompt: userPromptResult.prompt,
- requestReasoning,
- requestedTools,
- });
+ const retainableClaimIds = nonEmptyClaimIds(input);
const validationLimiter = pLimit(MAX_PER_CLAIM_VALIDATION_CONCURRENCY);
- const validationScheduler: ClaimValidationScheduler = createClaimValidationScheduler({
+ const validations = createClaimValidationScheduler({
initialState: createInvestigationRunState(
input.isUpdate === true ? { oldClaims: input.oldClaims } : {},
),
validationLimiter,
runValidation: (claimIndex, claim) =>
- validateClaim(
+ validateClaim({
client,
- openAiModelId,
+ requestConfig,
claimIndex,
claim,
- input.contentText,
- validationImageContextNotes,
- requestReasoning,
- ),
- ...(callbacks === undefined ? {} : { callbacks }),
+ contentText: input.contentText,
+ imageContextNotes: validationImageContextNotes,
+ signal,
+ }),
+ ...(options.callbacks === undefined ? {} : { callbacks: options.callbacks }),
});
- const handleSubmittedClaims = (calls: PendingFunctionToolCall[]): FunctionCallOutput[] => {
- const outputs: FunctionCallOutput[] = [];
- for (const call of calls) {
- let claim: StageOneClaim;
- try {
- claim = providerStructuredInvestigationClaimPayloadSchema.parse(
- JSON.parse(call.argumentsJson),
- );
- } catch (error) {
- console.warn(
- `Malformed ${SUBMIT_CORRECTION_TOOL_NAME} tool call (call_id=${call.callId}):`,
- error instanceof Error ? error.message : error,
- );
- outputs.push(
- buildFunctionCallOutput(
- call.callId,
- JSON.stringify({ error: "Invalid claim payload" }),
- ),
- );
- continue;
- }
-
- validationScheduler.scheduleClaimValidation(claim);
- outputs.push(buildFunctionCallOutput(call.callId, '{"acknowledged":true}'));
+ const submitCorrection = (call: PendingFunctionToolCall): FunctionCallOutput => {
+ const claim = parseSubmitCorrectionArguments(call.argumentsJson);
+ if (claim.kind === "invalid") {
+ return buildFunctionCallOutput(
+ call.callId,
+ JSON.stringify({ error: `Invalid claim, not recorded: ${claim.error}` }),
+ );
}
- return outputs;
+ validations.scheduleClaimValidation(claim.value);
+ return buildFunctionCallOutput(call.callId, ACKNOWLEDGED_OUTPUT);
};
- const handleRetainedClaims = (calls: PendingFunctionToolCall[]): FunctionCallOutput[] => {
- const outputs: FunctionCallOutput[] = [];
- for (const call of calls) {
- let retainId: string;
- try {
- const raw: unknown = JSON.parse(call.argumentsJson);
- if (!isRecord(raw) || typeof raw["id"] !== "string") {
- outputs.push(
- buildFunctionCallOutput(
- call.callId,
- JSON.stringify({ error: "Invalid retain arguments: missing id" }),
- ),
- );
- continue;
- }
- retainId = raw["id"];
- } catch (error) {
- console.warn(
- `Malformed ${RETAIN_CORRECTION_TOOL_NAME} tool call (call_id=${call.callId}):`,
- error instanceof Error ? error.message : error,
- );
- outputs.push(
- buildFunctionCallOutput(
- call.callId,
- JSON.stringify({ error: "Invalid retain arguments" }),
- ),
- );
- continue;
- }
-
- const retained = validationScheduler.retainClaimById(retainId);
- if (retained.kind === "error") {
- outputs.push(
- buildFunctionCallOutput(call.callId, JSON.stringify({ error: retained.errorMessage })),
- );
- continue;
- }
-
- outputs.push(buildFunctionCallOutput(call.callId, '{"acknowledged":true}'));
+ const retainCorrection = (call: PendingFunctionToolCall): FunctionCallOutput => {
+ if (retainableClaimIds === null) {
+ return buildFunctionCallOutput(
+ call.callId,
+ JSON.stringify({ error: "There are no prior claims to retain" }),
+ );
}
- return outputs;
- };
-
- let loopResult: Awaited>;
- try {
- loopResult = await runToolLoop({
- client,
- maxResponseToolRounds,
- baseResponseRequest,
- initialInput,
- handleSubmittedClaims,
- handleRetainedClaims,
- handleResearchCalls: (calls) =>
- Promise.all(calls.map((call) => executeFunctionToolCall(call))),
- });
- } catch (error) {
- await validationScheduler.settleAllValidations();
-
- if (error instanceof ToolLoopExecutionError) {
- const responseAuditSnapshot = [...error.responseAudits];
- const attemptAudit = buildFailedAttemptAudit({
- base: stageOneAttemptAuditBase,
- response:
- responseAuditSnapshot.length > 0 ? mergeResponseAudits(responseAuditSnapshot) : null,
- error: error.cause ?? error,
- });
- throw new InvestigatorExecutionError(error.message, attemptAudit, error.cause ?? error);
+ const claimId = parseRetainCorrectionArguments(call.argumentsJson, retainableClaimIds);
+ if (claimId.kind === "invalid") {
+ return buildFunctionCallOutput(
+ call.callId,
+ JSON.stringify({ error: `Invalid retain arguments: ${claimId.error}` }),
+ );
}
-
- throw error;
- }
-
- const { latestResponseRecord, responseAudits } = loopResult;
-
- if (latestResponseRecord === null || responseAudits.length === 0) {
- const cause = new InvestigatorStructuredOutputError("Model returned no response payload");
- throw new InvestigatorExecutionError(
- cause.message,
- buildFailedAttemptAudit({
- base: stageOneAttemptAuditBase,
- response: null,
- error: cause,
- }),
- cause,
+ const retained = validations.retainClaimById(claimId.value);
+ return buildFunctionCallOutput(
+ call.callId,
+ retained.kind === "error"
+ ? JSON.stringify({ error: retained.errorMessage })
+ : ACKNOWLEDGED_OUTPUT,
);
- }
+ };
- const unfinishedToolCalls = deduplicateFunctionToolCalls(
- extractPendingFunctionToolCalls(latestResponseRecord),
- );
- if (unfinishedToolCalls.length > 0) {
- await validationScheduler.settleAllValidations();
- const cause = new InvestigatorStructuredOutputError(
- `Model exceeded tool call round limit (${maxResponseToolRounds.toString()})`,
- );
- throw new InvestigatorExecutionError(
- cause.message,
- buildFailedAttemptAudit({
- base: stageOneAttemptAuditBase,
- response: mergeResponseAudits(responseAudits),
- error: cause,
+ const loop = await runToolLoop({
+ client,
+ requestConfig,
+ maxRounds: this.config.maxToolRounds,
+ instructions:
+ input.isUpdate === true ? INVESTIGATION_UPDATE_SYSTEM_PROMPT : INVESTIGATION_SYSTEM_PROMPT,
+ tools: buildFactCheckTools(retainableClaimIds),
+ initialInput,
+ signal,
+ handleFunctionCalls: (calls) =>
+ dispatchFunctionToolCalls(calls, {
+ submitCorrection,
+ retainCorrection,
+ research: (call) => executeFunctionToolCall(call, signal),
}),
- cause,
- );
- }
+ });
- const factCheckResponseAudit = mergeResponseAudits(responseAudits);
- if (factCheckResponseAudit.responseStatus === null) {
- console.warn(
- `OpenAI response had null status (responseId=${factCheckResponseAudit.responseId ?? "unknown"}); treating as completed`,
- );
- }
- if (
- factCheckResponseAudit.responseStatus !== "completed" &&
- factCheckResponseAudit.responseStatus !== null
- ) {
- await validationScheduler.settleAllValidations();
- const incompleteReason = readIncompleteReason(latestResponseRecord);
- const cause = new InvestigatorIncompleteResponseError({
- responseStatus: factCheckResponseAudit.responseStatus,
- responseId: factCheckResponseAudit.responseId,
- incompleteReason,
- outputTextLength: factCheckResponseAudit.responseOutputText?.length ?? 0,
- });
- throw new InvestigatorExecutionError(
- "OpenAI response was incomplete",
- buildFailedAttemptAudit({
- base: stageOneAttemptAuditBase,
- response: factCheckResponseAudit,
- error: cause,
- }),
+ // Validations already scheduled run to completion on every path, so the
+ // attempt audit records each request that was made.
+ const validationResults = await validations.awaitAllValidations();
+ const requests: InvestigatorRequestAudit[] = [
+ ...loop.rounds,
+ ...validationResults.map((validation) => validation.request),
+ ];
+ const fail = (message: string, cause: unknown): InvestigatorExecutionError =>
+ new InvestigatorExecutionError(
+ message,
+ {
+ outcome: "FAILED",
+ startedAt,
+ completedAt: new Date(),
+ requests,
+ error: buildErrorAudit(cause),
+ },
cause,
);
- }
-
- const validationResults = await validationScheduler.awaitAllValidations();
- const confirmedClaims = getConfirmedClaims(validationScheduler.getState());
-
- const validationInputSummary = validationResults
- .map(
- (result) =>
- `Claim ${result.claimIndex.toString()}: ${result.approved ? "approved" : "rejected"}`,
- )
- .join("\n");
- const stageTwoInputSummary =
- validationImageContextNotes === undefined
- ? validationInputSummary
- : `${validationInputSummary}\n\nImage context notes:\n${validationImageContextNotes}`;
-
- const stageTwoAttemptAuditBase = createStageTwoAttemptAuditBase({
- stageOneBase: stageOneAttemptAuditBase,
- userPrompt: userPromptResult.prompt,
- validationInputSummary: stageTwoInputSummary,
- });
- type FailedValidation = Extract;
- type SuccessfulValidation = Extract;
+ switch (loop.kind) {
+ case "failed":
+ throw fail("OpenAI fact-check round failed", loop.error);
+ case "round_limit":
+ throw fail(
+ "Fact-check exceeded its tool round limit",
+ new InvestigatorStructuredOutputError(
+ `Model exceeded tool call round limit (${this.config.maxToolRounds.toString()})`,
+ ),
+ );
+ case "response_not_completed":
+ throw fail(
+ "OpenAI fact-check response was incomplete",
+ new InvestigatorIncompleteResponseError({
+ responseStatus: loop.response.status ?? null,
+ responseId: loop.response.id,
+ incompleteReason: loop.response.incomplete_details?.reason ?? null,
+ }),
+ );
+ case "completed":
+ break;
+ }
const failedValidations = validationResults.filter(
- (result): result is FailedValidation => result.error !== null,
- );
- const successfulValidations = validationResults.filter(
- (result): result is SuccessfulValidation => result.error === null,
+ (validation): validation is Extract =>
+ validation.kind === "failed",
);
- const validationFailureResponseAudits = failedValidations.flatMap((result) =>
- result.responseAudit === null ? [] : [result.responseAudit],
- );
-
- const fullAttemptResponseAudit = buildFullAttemptResponseAudit({
- factCheckResponseAudit,
- successfulValidationResponseAudits: successfulValidations.map(
- (result) => result.responseAudit,
- ),
- failedValidationResponseAudits: validationFailureResponseAudits,
- });
-
- if (failedValidations.length > 0) {
- const firstFailure = failedValidations[0];
- if (!firstFailure) {
- throw new Error("Invariant violation: failed validations must include at least one item");
- }
-
- const failedClaimIndicesLabel = failedValidations
- .map((failure) => failure.claimIndex.toString())
+ const [firstFailedValidation] = failedValidations;
+ if (firstFailedValidation !== undefined) {
+ const failedClaimIndices = failedValidations
+ .map((validation) => validation.claimIndex.toString())
.join(", ");
-
- throw new InvestigatorExecutionError(
- `Per-claim validation failed for claim indices: ${failedClaimIndicesLabel}`,
- buildFailedAttemptAudit({
- base: stageTwoAttemptAuditBase,
- response: fullAttemptResponseAudit,
- error: firstFailure.error,
- }),
- firstFailure.error,
- );
- }
-
- let result: InvestigationResult;
- try {
- result = investigationResultSchema.parse({ claims: confirmedClaims });
- } catch (error) {
- throw new InvestigatorExecutionError(
- "Final investigation result failed schema validation",
- buildFailedAttemptAudit({
- base: stageTwoAttemptAuditBase,
- response: fullAttemptResponseAudit,
- error,
- }),
- error,
+ throw fail(
+ `Per-claim validation failed for claim indices: ${failedClaimIndices}`,
+ firstFailedValidation.error,
);
}
+ // Submitted claims were validated against the shared claim payload schema
+ // on submission; retained claims are prior investigations' persisted claims.
+ const claims: InvestigationClaimPayload[] = getConfirmedClaims(validations.getState());
return {
- result,
- attemptAudit: buildSuccessfulAttemptAudit({
- base: stageTwoAttemptAuditBase,
- response: fullAttemptResponseAudit,
- }),
- ...(fullAttemptResponseAudit.responseModelVersion != null && {
- modelVersion: fullAttemptResponseAudit.responseModelVersion,
- }),
+ result: { claims },
+ attemptAudit: { outcome: "SUCCEEDED", startedAt, completedAt: new Date(), requests },
+ model: requestConfig.model,
+ modelVersion: loop.finalResponse.model,
};
}
}
+
+/** The production investigator factory: gpt-6.1-sol with the deployment's tool-round budget. */
+export function createOpenAIInvestigator(apiKey: string): Investigator {
+ return new OpenAIInvestigator({
+ client: new OpenAI({ apiKey }),
+ requestConfig: INVESTIGATION_REQUEST_CONFIG,
+ maxToolRounds: getEnv().OPENAI_MAX_RESPONSE_TOOL_ROUNDS,
+ });
+}
diff --git a/src/typescript/api/src/lib/network/host-safety.ts b/src/typescript/api/src/lib/network/host-safety.ts
index e962693..5add48b 100644
--- a/src/typescript/api/src/lib/network/host-safety.ts
+++ b/src/typescript/api/src/lib/network/host-safety.ts
@@ -1,41 +1,48 @@
-import { promises as dns } from "node:dns";
import ipaddr from "ipaddr.js";
-type IpFamily = 4 | 6;
-
-interface ResolvedAddress {
- address: string;
- family: IpFamily;
+type IpAddress = ipaddr.IPv4 | ipaddr.IPv6;
+
+/**
+ * IPv6 ranges that ipaddr.js still labels "unicast" but that can reach
+ * non-public networks. RFC 8215 local-use NAT64 translates into IPv4 space the
+ * operator chooses, which may be internal.
+ */
+const NON_PUBLIC_UNICAST_IPV6_RANGES: [ipaddr.IPv6, number][] = [
+ [ipaddr.IPv6.parse("64:ff9b:1::"), 48],
+];
+
+/**
+ * Whether an address is ordinary public unicast, i.e. safe for the server to
+ * connect to on behalf of untrusted input. Everything ipaddr.js classifies as
+ * anything other than "unicast" (private, loopback, link-local, multicast,
+ * CGNAT, reserved, IPv4-mapped, NAT64, 6to4, Teredo, ...) is rejected; the
+ * translation ranges are rejected because they can tunnel to internal IPv4.
+ */
+export function isPublicUnicastAddress(address: IpAddress): boolean {
+ if (address.range() !== "unicast") {
+ return false;
+ }
+ if (address instanceof ipaddr.IPv4) {
+ return true;
+ }
+ return !NON_PUBLIC_UNICAST_IPV6_RANGES.some(([network, prefixLength]) =>
+ address.match(network, prefixLength),
+ );
}
-const PRIVATE_IPV4_SUBNETS: Record = {
- unspecified: [[ipaddr.IPv4.parse("0.0.0.0"), 8]],
- private: [
- [ipaddr.IPv4.parse("10.0.0.0"), 8],
- [ipaddr.IPv4.parse("172.16.0.0"), 12],
- [ipaddr.IPv4.parse("192.168.0.0"), 16],
- ],
- carrierGradeNat: [[ipaddr.IPv4.parse("100.64.0.0"), 10]],
- loopback: [[ipaddr.IPv4.parse("127.0.0.0"), 8]],
- linkLocal: [[ipaddr.IPv4.parse("169.254.0.0"), 16]],
- ietfProtocol: [[ipaddr.IPv4.parse("192.0.0.0"), 24]],
- benchmarking: [[ipaddr.IPv4.parse("198.18.0.0"), 15]],
- reserved: [[ipaddr.IPv4.parse("240.0.0.0"), 4]],
-};
-
-function normalizeIpLiteralCandidate(input: string): string {
- const trimmed = input.trim().toLowerCase();
- const withoutBrackets =
+/**
+ * Parse a URL hostname that is an IP literal (`203.0.113.5`, `[2001:db8::1]`,
+ * `fe80::1%eth0`). Returns null for DNS names. Only strict dotted-quad IPv4 is
+ * accepted; WHATWG URL parsing already canonicalizes shorthand IPv4 forms.
+ */
+export function parseIpLiteral(hostname: string): IpAddress | null {
+ const trimmed = hostname.trim().toLowerCase();
+ const unbracketed =
trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
- const zoneSeparatorIndex = withoutBrackets.indexOf("%");
- if (zoneSeparatorIndex === -1) {
- return withoutBrackets;
- }
- return withoutBrackets.slice(0, zoneSeparatorIndex);
-}
+ const zoneSeparatorIndex = unbracketed.indexOf("%");
+ const candidate =
+ zoneSeparatorIndex === -1 ? unbracketed : unbracketed.slice(0, zoneSeparatorIndex);
-function parseIpAddress(input: string): ipaddr.IPv4 | ipaddr.IPv6 | null {
- const candidate = normalizeIpLiteralCandidate(input);
if (ipaddr.IPv4.isValidFourPartDecimal(candidate)) {
return ipaddr.IPv4.parse(candidate);
}
@@ -45,113 +52,13 @@ function parseIpAddress(input: string): ipaddr.IPv4 | ipaddr.IPv6 | null {
return null;
}
-function isIpv4Address(address: ipaddr.IPv4 | ipaddr.IPv6): address is ipaddr.IPv4 {
- return address.kind() === "ipv4";
-}
-
-function isPrivateIPv4(address: ipaddr.IPv4): boolean {
- return ipaddr.subnetMatch(address, PRIVATE_IPV4_SUBNETS, "public") !== "public";
-}
-
-function isPrivateIPv6(address: ipaddr.IPv6): boolean {
- if (address.isIPv4MappedAddress()) {
- return isPrivateIPv4(address.toIPv4Address());
- }
- const range = address.range();
- return (
- range === "unspecified" ||
- range === "loopback" ||
- range === "uniqueLocal" ||
- range === "linkLocal"
- );
-}
-
-export function isPrivateIpAddress(hostnameOrIp: string): boolean {
- const parsedAddress = parseIpAddress(hostnameOrIp);
- if (!parsedAddress) return false;
- if (isIpv4Address(parsedAddress)) {
- return isPrivateIPv4(parsedAddress);
- }
- return isPrivateIPv6(parsedAddress);
-}
-
-function normalizeAddress(address: string): string {
- return address.trim().toLowerCase();
-}
-
-async function resolveHostAddresses(hostname: string): Promise {
- const normalizedHost = hostname.trim().toLowerCase();
- const parsedAddress = parseIpAddress(normalizedHost);
- if (parsedAddress) {
- return [
- {
- address: parsedAddress.toNormalizedString(),
- family: parsedAddress.kind() === "ipv4" ? 4 : 6,
- },
- ];
- }
-
- const resolvedAddresses = await dns.lookup(normalizedHost, {
- all: true,
- verbatim: true,
- });
- const deduped = new Map();
- for (const resolved of resolvedAddresses) {
- if (resolved.family !== 4 && resolved.family !== 6) continue;
- const normalizedAddress = normalizeAddress(resolved.address);
- deduped.set(normalizedAddress, {
- address: normalizedAddress,
- family: resolved.family,
- });
- }
-
- return Array.from(deduped.values());
-}
-
-function isLocallyScopedHostname(normalizedHost: string): boolean {
+/** Hostnames that resolve on the local machine or link regardless of DNS. */
+export function isLocallyScopedHostname(hostname: string): boolean {
+ const normalizedHost = hostname.trim().toLowerCase().replace(/\.$/, "");
return (
+ normalizedHost.length === 0 ||
normalizedHost === "localhost" ||
normalizedHost.endsWith(".localhost") ||
normalizedHost.endsWith(".local")
);
}
-
-export async function resolvePublicHostAddresses(hostname: string): Promise {
- const normalizedHost = hostname.trim().toLowerCase();
- if (normalizedHost.length === 0 || isLocallyScopedHostname(normalizedHost)) {
- return null;
- }
-
- if (isPrivateIpAddress(normalizedHost)) {
- return null;
- }
-
- const resolvedAddresses = await resolveHostAddresses(normalizedHost);
- if (resolvedAddresses.length === 0) {
- return null;
- }
- if (resolvedAddresses.some((resolved) => isPrivateIpAddress(resolved.address))) {
- return null;
- }
-
- return resolvedAddresses.map((resolved) => resolved.address);
-}
-
-export function hasAddressIntersection(left: string[], right: string[]): boolean {
- if (left.length === 0 || right.length === 0) return false;
- const rightSet = new Set(right.map(normalizeAddress));
- return left.some((address) => rightSet.has(normalizeAddress(address)));
-}
-
-export async function isBlockedHost(hostname: string): Promise {
- const normalizedHost = hostname.trim().toLowerCase();
-
- if (isLocallyScopedHostname(normalizedHost)) {
- return true;
- }
-
- if (isPrivateIpAddress(normalizedHost)) return true;
-
- const resolvedAddresses = await resolveHostAddresses(normalizedHost);
- return resolvedAddresses.some((resolved) => isPrivateIpAddress(resolved.address));
-}
diff --git a/src/typescript/api/src/lib/network/ip.ts b/src/typescript/api/src/lib/network/ip.ts
index 891c77b..4f00509 100644
--- a/src/typescript/api/src/lib/network/ip.ts
+++ b/src/typescript/api/src/lib/network/ip.ts
@@ -1,136 +1,37 @@
-import { isIP } from "node:net";
-
-type Ipv4Octets = [number, number, number, number];
-
-function parseIpv4Octets(value: string): Ipv4Octets | null {
- const parts = value.split(".");
- if (parts.length !== 4) return null;
-
- const parsedOctets = parts.map((part) => {
- if (!/^\d{1,3}$/.test(part)) return Number.NaN;
- const parsed = Number.parseInt(part, 10);
- return parsed >= 0 && parsed <= 255 ? parsed : Number.NaN;
- });
-
- if (parsedOctets.some((octet) => Number.isNaN(octet))) return null;
-
- const a = parsedOctets[0];
- const b = parsedOctets[1];
- const c = parsedOctets[2];
- const d = parsedOctets[3];
- if (a === undefined || b === undefined || c === undefined || d === undefined) return null;
- return [a, b, c, d];
-}
-
-function ipv4Prefix(octets: number[]): string {
- return octets.slice(0, 3).join(".");
-}
-
-function isValidHextet(hextet: string): boolean {
- return /^[0-9a-f]{1,4}$/i.test(hextet);
-}
-
-function normalizeHextet(hextet: string): string {
- return Number.parseInt(hextet, 16).toString(16);
-}
-
-function expandIpv6(input: string): string[] | null {
- let address = input.trim().toLowerCase();
-
- const zoneIndex = address.indexOf("%");
- if (zoneIndex >= 0) {
- address = address.slice(0, zoneIndex);
- }
-
- if (address.includes(".")) {
- const lastColon = address.lastIndexOf(":");
- if (lastColon < 0) return null;
-
- const ipv4Part = address.slice(lastColon + 1);
- const octets = parseIpv4Octets(ipv4Part);
- if (!octets) return null;
-
- const high = ((octets[0] << 8) | octets[1]).toString(16);
- const low = ((octets[2] << 8) | octets[3]).toString(16);
- address = `${address.slice(0, lastColon)}:${high}:${low}`;
- }
-
- const parts = address.split("::");
- if (parts.length > 2) return null;
-
- const left =
- parts[0] !== undefined && parts[0].length > 0
- ? parts[0].split(":").filter((part) => part.length > 0)
- : [];
- const right =
- parts.length === 2 && parts[1] !== undefined && parts[1].length > 0
- ? parts[1].split(":").filter((part) => part.length > 0)
- : [];
-
- if (!left.every(isValidHextet) || !right.every(isValidHextet)) {
- return null;
+import ipaddr from "ipaddr.js";
+
+function parseClientAddress(clientAddress: string): ipaddr.IPv4 | ipaddr.IPv6 {
+ const trimmed = clientAddress.trim();
+ const zoneSeparatorIndex = trimmed.indexOf("%");
+ const withoutZone = zoneSeparatorIndex === -1 ? trimmed : trimmed.slice(0, zoneSeparatorIndex);
+ if (ipaddr.IPv4.isValidFourPartDecimal(withoutZone)) {
+ return ipaddr.IPv4.parse(withoutZone);
}
-
- const missing = 8 - (left.length + right.length);
- if (parts.length === 1 && missing !== 0) return null;
- if (parts.length === 2 && missing < 1) return null;
-
- const expanded = [
- ...left,
- ...(parts.length === 2 ? Array.from({ length: missing }, () => "0") : []),
- ...right,
- ].map(normalizeHextet);
-
- return expanded.length === 8 ? expanded : null;
-}
-
-function mappedIpv4FromIpv6(expandedIpv6: string[]): Ipv4Octets | null {
- const mappedMarker = expandedIpv6[5];
- const highHextet = expandedIpv6[6];
- const lowHextet = expandedIpv6[7];
- if (
- mappedMarker === undefined ||
- mappedMarker.length === 0 ||
- highHextet === undefined ||
- highHextet.length === 0 ||
- lowHextet === undefined ||
- lowHextet.length === 0
- ) {
- return null;
+ if (ipaddr.IPv6.isValid(withoutZone)) {
+ const ipv6 = ipaddr.IPv6.parse(withoutZone);
+ return ipv6.isIPv4MappedAddress() ? ipv6.toIPv4Address() : ipv6;
}
-
- const isMappedPrefix =
- expandedIpv6.slice(0, 5).every((hextet) => hextet === "0") && mappedMarker === "ffff";
- if (!isMappedPrefix) return null;
-
- const high = Number.parseInt(highHextet, 16);
- const low = Number.parseInt(lowHextet, 16);
- return [high >> 8, high & 0xff, low >> 8, low & 0xff];
-}
-
-function ipv6Prefix(expandedIpv6: string[]): string {
- return expandedIpv6.slice(0, 3).join(":");
-}
-
-export function deriveIpRangePrefix(clientIp: string): string {
- const trimmed = clientIp.trim();
- if (trimmed.length === 0) return "unknown";
-
- const ipv4 = parseIpv4Octets(trimmed);
- if (ipv4) {
- return ipv4Prefix(ipv4);
+ throw new Error(`Client address is not an IP address: ${JSON.stringify(clientAddress)}`);
+}
+
+/**
+ * Derive the network range a client address belongs to for the per-day
+ * IP-range view-credit cap (SPEC §2.10): the /24 for IPv4 (first three
+ * octets) and the /48 for IPv6 (first three hextets). IPv4-mapped IPv6
+ * addresses count as their IPv4 address.
+ *
+ * The client address comes from the socket peer or the trusted proxy header
+ * (ADDRESS_HEADER), so anything that is not an IP address means the proxy
+ * configuration is broken; that throws rather than bucketing such clients
+ * together.
+ */
+export function deriveIpRangePrefix(clientAddress: string): string {
+ const address = parseClientAddress(clientAddress);
+ if (address instanceof ipaddr.IPv4) {
+ return address.octets.slice(0, 3).join(".");
}
-
- if (isIP(trimmed) === 6) {
- const expanded = expandIpv6(trimmed);
- if (!expanded) return `invalid:${trimmed.toLowerCase()}`;
-
- const mappedIpv4 = mappedIpv4FromIpv6(expanded);
- if (mappedIpv4) {
- return ipv4Prefix(mappedIpv4);
- }
- return ipv6Prefix(expanded);
- }
-
- return `invalid:${trimmed.toLowerCase()}`;
+ return address.parts
+ .slice(0, 3)
+ .map((part) => part.toString(16))
+ .join(":");
}
diff --git a/src/typescript/api/src/lib/network/public-http-fetch.ts b/src/typescript/api/src/lib/network/public-http-fetch.ts
new file mode 100644
index 0000000..7675465
--- /dev/null
+++ b/src/typescript/api/src/lib/network/public-http-fetch.ts
@@ -0,0 +1,186 @@
+/**
+ * HTTP(S) fetching of URLs chosen by untrusted input (post image URLs from
+ * anonymous clients, URLs the model asks `fetch_url` to read).
+ *
+ * The SSRF check lives on the connection itself: every connection goes through
+ * an undici Agent whose DNS lookup rejects the whole answer if any resolved
+ * address is not public unicast, and hands the socket only the addresses it
+ * validated. A hostname that rebinds between "check" and "connect" therefore
+ * has nothing to rebind into — there is no separate check. IP-literal hosts
+ * never reach DNS, so they are validated before dispatch. Redirects are
+ * followed manually so every hop goes through the same agent.
+ */
+
+import { lookup as dnsLookup, type LookupAddress, type LookupOptions } from "node:dns";
+import type { LookupFunction } from "node:net";
+import ipaddr from "ipaddr.js";
+import { Agent, fetch, type Response } from "undici";
+import { isLocallyScopedHostname, isPublicUnicastAddress, parseIpLiteral } from "./host-safety.js";
+import { isRedirectStatus } from "./http-status.js";
+
+const MAX_REDIRECT_HOPS = 5;
+
+export class BlockedDestinationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "BlockedDestinationError";
+ }
+}
+
+class PublicHttpFetchError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "PublicHttpFetchError";
+ }
+}
+
+function nonPublicAddresses(addresses: LookupAddress[]): string[] {
+ return addresses
+ .filter((resolved) => !isPublicUnicastAddress(ipaddr.parse(resolved.address)))
+ .map((resolved) => resolved.address);
+}
+
+/**
+ * DNS lookup for the public-internet agent: resolves like dns.lookup but fails
+ * with BlockedDestinationError unless every address is public unicast, and
+ * returns only addresses it checked. Exported for unit tests.
+ */
+export const publicOnlyLookup: LookupFunction = (hostname, options: LookupOptions, callback) => {
+ dnsLookup(hostname, { ...options, all: true, verbatim: true }, (error, addresses) => {
+ if (error !== null) {
+ callback(error, "", 0);
+ return;
+ }
+ const [first] = addresses;
+ if (first === undefined) {
+ callback(new BlockedDestinationError(`${hostname} did not resolve to any address`), "", 0);
+ return;
+ }
+ const blocked = nonPublicAddresses(addresses);
+ if (blocked.length > 0) {
+ callback(
+ new BlockedDestinationError(
+ `${hostname} resolves to non-public address(es): ${blocked.join(", ")}`,
+ ),
+ "",
+ 0,
+ );
+ return;
+ }
+ if (options.all === true) {
+ callback(null, addresses);
+ return;
+ }
+ callback(null, first.address, first.family);
+ });
+};
+
+const publicInternetAgent = new Agent({ connect: { lookup: publicOnlyLookup } });
+
+function assertFetchableUrl(url: URL): void {
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new BlockedDestinationError(`Only HTTP(S) URLs are allowed (got ${url.protocol})`);
+ }
+ if (url.username.length > 0 || url.password.length > 0) {
+ throw new BlockedDestinationError("URLs with embedded credentials are not allowed");
+ }
+ if (isLocallyScopedHostname(url.hostname)) {
+ throw new BlockedDestinationError(`Blocked local hostname ${url.hostname}`);
+ }
+ const literal = parseIpLiteral(url.hostname);
+ if (literal !== null && !isPublicUnicastAddress(literal)) {
+ throw new BlockedDestinationError(`Blocked non-public address ${url.hostname}`);
+ }
+}
+
+interface PublicHttpResponse {
+ /** URL of the final (non-redirect) response. */
+ finalUrl: URL;
+ response: Response;
+}
+
+/**
+ * GET `url` from the public internet, following up to MAX_REDIRECT_HOPS
+ * redirects. Throws BlockedDestinationError when any hop targets a non-public
+ * destination and PublicHttpFetchError for malformed redirect chains; network
+ * errors and aborts propagate as thrown by undici.
+ */
+export async function fetchPublicHttp(input: {
+ url: URL;
+ headers: Record;
+ signal: AbortSignal;
+}): Promise {
+ let currentUrl = input.url;
+ for (let redirectHop = 0; redirectHop <= MAX_REDIRECT_HOPS; redirectHop += 1) {
+ assertFetchableUrl(currentUrl);
+ const response = await fetch(currentUrl, {
+ method: "GET",
+ redirect: "manual",
+ headers: input.headers,
+ signal: input.signal,
+ dispatcher: publicInternetAgent,
+ });
+
+ if (!isRedirectStatus(response.status)) {
+ return { finalUrl: currentUrl, response };
+ }
+
+ await response.body?.cancel();
+ const location = response.headers.get("location");
+ if (location === null || location.length === 0) {
+ throw new PublicHttpFetchError("Redirect response missing Location header");
+ }
+ currentUrl = new URL(location, currentUrl);
+ }
+
+ throw new PublicHttpFetchError(`Too many redirects (more than ${MAX_REDIRECT_HOPS.toString()})`);
+}
+
+/**
+ * Read at most `maxBytes` of a response body, cancelling the stream as soon as
+ * the limit is exceeded. `truncated` reports whether any bytes were dropped.
+ */
+export async function readBodyPrefix(
+ response: Response,
+ maxBytes: number,
+): Promise<{ bytes: Uint8Array; truncated: boolean }> {
+ if (response.body === null) {
+ return { bytes: new Uint8Array(0), truncated: false };
+ }
+
+ const reader: ReadableStreamDefaultReader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let totalBytes = 0;
+ let truncated = false;
+ try {
+ while (totalBytes < maxBytes) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const remaining = maxBytes - totalBytes;
+ if (value.byteLength > remaining) {
+ chunks.push(value.subarray(0, remaining));
+ totalBytes += remaining;
+ truncated = true;
+ break;
+ }
+ chunks.push(value);
+ totalBytes += value.byteLength;
+ }
+ if (!truncated && totalBytes >= maxBytes) {
+ truncated = !(await reader.read()).done;
+ }
+ } finally {
+ if (truncated) {
+ await reader.cancel("Response body exceeds byte limit");
+ }
+ reader.releaseLock();
+ }
+
+ const bytes = new Uint8Array(totalBytes);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return { bytes, truncated };
+}
diff --git a/src/typescript/api/src/lib/openai/errors.ts b/src/typescript/api/src/lib/openai/errors.ts
index 7c69bad..82c3b21 100644
--- a/src/typescript/api/src/lib/openai/errors.ts
+++ b/src/typescript/api/src/lib/openai/errors.ts
@@ -1,70 +1,12 @@
-const NON_RETRYABLE_OPENAI_STATUS_CODES = new Set([400, 401, 403, 404, 422]);
+import { APIError } from "openai";
-export type OpenAiKeyValidationStatusOutcome =
- | { openaiApiKeyStatus: "missing" }
- | { openaiApiKeyStatus: "valid" }
- | {
- openaiApiKeyStatus: "format_invalid";
- openaiApiKeyMessage: string;
- }
- | {
- openaiApiKeyStatus: "authenticated_restricted";
- openaiApiKeyMessage: string;
- }
- | {
- openaiApiKeyStatus: "invalid";
- openaiApiKeyMessage: string;
- }
- | {
- openaiApiKeyStatus: "error";
- openaiApiKeyMessage: string;
- };
-
-const OPENAI_KEY_VALIDATION_RESULT_BY_STATUS: Partial<
- Record
-> = {
- 401: {
- openaiApiKeyStatus: "invalid",
- openaiApiKeyMessage: "OpenAI rejected this API key.",
- },
- 403: {
- openaiApiKeyStatus: "authenticated_restricted",
- openaiApiKeyMessage:
- "OpenAI authenticated this key, but access is restricted for validation checks.",
- },
- 429: {
- openaiApiKeyStatus: "error",
- openaiApiKeyMessage: "OpenAI rate-limited key validation. Retry in a moment.",
- },
-};
-
-export function readOpenAiStatusCode(error: unknown): number | null {
- if (typeof error !== "object" || error === null) return null;
- if (!("status" in error)) return null;
- const status = error.status;
- return typeof status === "number" ? status : null;
+// A guard rather than a bare `instanceof`, which would type the generic
+// error's fields as `any`.
+function isOpenAiApiError(error: unknown): error is APIError {
+ return error instanceof APIError;
}
-export function classifyOpenAiKeyValidationStatus(
- statusCode: number | null,
-): OpenAiKeyValidationStatusOutcome | null {
- if (statusCode === null) return null;
-
- const knownStatusResult = OPENAI_KEY_VALIDATION_RESULT_BY_STATUS[statusCode];
- if (knownStatusResult !== undefined) {
- return knownStatusResult;
- }
-
- if (statusCode >= 400 && statusCode < 500) {
- return {
- openaiApiKeyStatus: "error",
- openaiApiKeyMessage: `OpenAI returned HTTP ${statusCode.toString()} while validating this key.`,
- };
- }
-
- return null;
-}
-
-export function isNonRetryableOpenAiStatusCode(statusCode: number | null): boolean {
- return statusCode !== null && NON_RETRYABLE_OPENAI_STATUS_CODES.has(statusCode);
+/** HTTP status of an OpenAI API error response; null for any other error (incl. connection errors). */
+export function readOpenAiStatusCode(error: unknown): number | null {
+ return isOpenAiApiError(error) ? (error.status ?? null) : null;
}
diff --git a/src/typescript/api/src/lib/services/attempt-audit.ts b/src/typescript/api/src/lib/services/attempt-audit.ts
index e6a8af6..ba14dd6 100644
--- a/src/typescript/api/src/lib/services/attempt-audit.ts
+++ b/src/typescript/api/src/lib/services/attempt-audit.ts
@@ -1,12 +1,99 @@
import { getPrisma } from "$lib/db/client";
-import {
- parseInvestigatorAttemptAudit,
- type InvestigatorAttemptAudit,
+import type {
+ InvestigatorAttemptAudit,
+ InvestigatorFailedAttemptAudit,
+ InvestigatorOutputItemAudit,
+ InvestigatorRequestAudit,
+ InvestigatorResponseAudit,
} from "$lib/investigators/interface.js";
-import { toDate, toOptionalDate } from "$lib/date.js";
import type { Prisma } from "$lib/db/prisma-client";
import { consumeOpenAiKeySource } from "./user-key-source.js";
+function toOutputItemCreate(
+ item: InvestigatorOutputItemAudit,
+ outputIndex: number,
+): Prisma.InvestigationAttemptOutputItemCreateWithoutResponseInput {
+ const base = {
+ outputIndex,
+ providerItemId: item.providerItemId,
+ itemType: item.itemType,
+ itemStatus: item.itemStatus,
+ };
+ switch (item.content.kind) {
+ case "MESSAGE":
+ return {
+ ...base,
+ textParts: {
+ create: item.content.textParts.map((part, partIndex) => ({
+ partIndex,
+ partType: part.partType,
+ text: part.text,
+ annotations: {
+ create: part.annotations.map((annotation, annotationIndex) => ({
+ annotationIndex,
+ ...annotation,
+ })),
+ },
+ })),
+ },
+ };
+ case "REASONING":
+ return {
+ ...base,
+ reasoningSummaries: {
+ create: item.content.summaries.map((text, summaryIndex) => ({ summaryIndex, text })),
+ },
+ };
+ case "TOOL_CALL":
+ return { ...base, toolCall: { create: { rawPayload: item.content.rawPayload } } };
+ }
+}
+
+function toResponseCreate(
+ response: InvestigatorResponseAudit,
+): Prisma.InvestigationAttemptResponseCreateWithoutRequestInput {
+ return {
+ providerResponseId: response.providerResponseId,
+ status: response.status,
+ modelVersion: response.modelVersion,
+ receivedAt: response.receivedAt,
+ outputItems: { create: response.outputItems.map(toOutputItemCreate) },
+ ...(response.usage === null ? {} : { usage: { create: response.usage } }),
+ };
+}
+
+function toRequestCreate(
+ request: InvestigatorRequestAudit,
+): Prisma.InvestigationAttemptRequestCreateWithoutAttemptInput {
+ return {
+ kind: request.subject.kind,
+ factCheckRound: request.subject.kind === "FACT_CHECK_ROUND" ? request.subject.round : null,
+ claimIndex: request.subject.kind === "CLAIM_VALIDATION" ? request.subject.claimIndex : null,
+ model: request.model,
+ instructions: request.instructions,
+ input: request.input,
+ previousResponseId: request.previousResponseId,
+ reasoningEffort: request.reasoningEffort,
+ reasoningSummary: request.reasoningSummary,
+ include: request.include,
+ requestedTools: {
+ create: request.tools.map((tool, requestOrder) => ({
+ requestOrder,
+ toolType: tool.toolType,
+ rawDefinition: tool.rawDefinition,
+ })),
+ },
+ ...(request.response === null
+ ? {}
+ : { response: { create: toResponseCreate(request.response) } }),
+ };
+}
+
+/**
+ * Inserts an attempt's audit (SPEC §2.12). Insert-only: each attemptNumber is
+ * claimed once per investigation and its audit is written once, at the
+ * attempt's terminal transition.
+ */
export async function persistAttemptAudit(
tx: Prisma.TransactionClient,
input: {
@@ -15,194 +102,19 @@ export async function persistAttemptAudit(
attemptAudit: InvestigatorAttemptAudit;
},
): Promise {
- const attemptAudit = parseInvestigatorAttemptAudit(input.attemptAudit);
- // outcome is derived from the audit's discriminated union — error !== null
- // means FAILED. No separate parameter needed, no inconsistent state possible.
- const outcome = attemptAudit.error !== null ? "FAILED" : "SUCCEEDED";
-
- const attempt = await tx.investigationAttempt.upsert({
- where: {
- investigationId_attemptNumber: {
- investigationId: input.investigationId,
- attemptNumber: input.attemptNumber,
- },
- },
- create: {
+ const { attemptAudit } = input;
+ await tx.investigationAttempt.create({
+ data: {
investigationId: input.investigationId,
attemptNumber: input.attemptNumber,
- outcome,
- requestModel: attemptAudit.requestModel,
- requestInstructions: attemptAudit.requestInstructions,
- requestInput: attemptAudit.requestInput,
- requestReasoningEffort: attemptAudit.requestReasoningEffort,
- requestReasoningSummary: attemptAudit.requestReasoningSummary,
- responseId: attemptAudit.response?.responseId ?? null,
- responseStatus: attemptAudit.response?.responseStatus ?? null,
- responseModelVersion: attemptAudit.response?.responseModelVersion ?? null,
- responseOutputText: attemptAudit.response?.responseOutputText ?? null,
- startedAt: toDate(attemptAudit.startedAt),
- completedAt: toOptionalDate(attemptAudit.completedAt, { strict: true }),
+ outcome: attemptAudit.outcome,
+ startedAt: attemptAudit.startedAt,
+ completedAt: attemptAudit.completedAt,
+ requests: { create: attemptAudit.requests.map(toRequestCreate) },
+ ...(attemptAudit.outcome === "FAILED" ? { error: { create: attemptAudit.error } } : {}),
},
- update: {
- outcome,
- requestModel: attemptAudit.requestModel,
- requestInstructions: attemptAudit.requestInstructions,
- requestInput: attemptAudit.requestInput,
- requestReasoningEffort: attemptAudit.requestReasoningEffort,
- requestReasoningSummary: attemptAudit.requestReasoningSummary,
- responseId: attemptAudit.response?.responseId ?? null,
- responseStatus: attemptAudit.response?.responseStatus ?? null,
- responseModelVersion: attemptAudit.response?.responseModelVersion ?? null,
- responseOutputText: attemptAudit.response?.responseOutputText ?? null,
- startedAt: toDate(attemptAudit.startedAt),
- completedAt: toOptionalDate(attemptAudit.completedAt, { strict: true }),
- },
- });
-
- await tx.investigationAttemptRequestedTool.deleteMany({
- where: { attemptId: attempt.id },
- });
- await tx.investigationAttemptToolCall.deleteMany({
- where: { attemptId: attempt.id },
- });
- await tx.investigationAttemptOutputItem.deleteMany({
- where: { attemptId: attempt.id },
- });
- await tx.investigationAttemptUsage.deleteMany({
- where: { attemptId: attempt.id },
+ select: { id: true },
});
- await tx.investigationAttemptError.deleteMany({
- where: { attemptId: attempt.id },
- });
-
- for (const requestedTool of attemptAudit.requestedTools) {
- await tx.investigationAttemptRequestedTool.create({
- data: {
- attemptId: attempt.id,
- requestOrder: requestedTool.requestOrder,
- toolType: requestedTool.toolType,
- rawDefinition: requestedTool.rawDefinition,
- },
- });
- }
-
- const outputItemIdByIndex = new Map();
- for (const outputItem of attemptAudit.response?.outputItems ?? []) {
- const createdOutputItem = await tx.investigationAttemptOutputItem.create({
- data: {
- attemptId: attempt.id,
- outputIndex: outputItem.outputIndex,
- providerItemId: outputItem.providerItemId,
- itemType: outputItem.itemType,
- itemStatus: outputItem.itemStatus,
- },
- });
- outputItemIdByIndex.set(outputItem.outputIndex, createdOutputItem.id);
- }
-
- const textPartIdByKey = new Map();
- for (const textPart of attemptAudit.response?.outputTextParts ?? []) {
- const outputItemId = outputItemIdByIndex.get(textPart.outputIndex);
- if (outputItemId === undefined || outputItemId.length === 0) {
- throw new Error(`Missing output item for text part outputIndex=${textPart.outputIndex}`);
- }
-
- const createdTextPart = await tx.investigationAttemptOutputTextPart.create({
- data: {
- outputItemId,
- partIndex: textPart.partIndex,
- partType: textPart.partType,
- text: textPart.text,
- },
- });
-
- textPartIdByKey.set(`${textPart.outputIndex}:${textPart.partIndex}`, createdTextPart.id);
- }
-
- for (const annotation of attemptAudit.response?.outputTextAnnotations ?? []) {
- const textPartId = textPartIdByKey.get(`${annotation.outputIndex}:${annotation.partIndex}`);
- if (textPartId === undefined || textPartId.length === 0) {
- throw new Error(
- `Missing text part for annotation outputIndex=${annotation.outputIndex} partIndex=${annotation.partIndex}`,
- );
- }
-
- await tx.investigationAttemptOutputTextAnnotation.create({
- data: {
- textPartId,
- annotationIndex: annotation.annotationIndex,
- annotationType: annotation.annotationType,
- startIndex: annotation.characterPosition?.start ?? null,
- endIndex: annotation.characterPosition?.end ?? null,
- url: annotation.url,
- title: annotation.title,
- fileId: annotation.fileId,
- },
- });
- }
-
- for (const summary of attemptAudit.response?.reasoningSummaries ?? []) {
- const outputItemId = outputItemIdByIndex.get(summary.outputIndex);
- if (outputItemId === undefined || outputItemId.length === 0) {
- throw new Error(
- `Missing output item for reasoning summary outputIndex=${summary.outputIndex}`,
- );
- }
-
- await tx.investigationAttemptReasoningSummary.create({
- data: {
- outputItemId,
- summaryIndex: summary.summaryIndex,
- text: summary.text,
- },
- });
- }
-
- for (const toolCall of attemptAudit.response?.toolCalls ?? []) {
- const outputItemId = outputItemIdByIndex.get(toolCall.outputIndex);
- if (outputItemId === undefined || outputItemId.length === 0) {
- throw new Error(`Missing output item for tool call outputIndex=${toolCall.outputIndex}`);
- }
-
- await tx.investigationAttemptToolCall.create({
- data: {
- attemptId: attempt.id,
- outputItemId,
- outputIndex: toolCall.outputIndex,
- providerToolCallId: toolCall.providerToolCallId,
- toolType: toolCall.toolType,
- status: toolCall.status,
- rawPayload: toolCall.rawPayload,
- capturedAt: toDate(toolCall.capturedAt),
- providerStartedAt: toOptionalDate(toolCall.providerStartedAt, { strict: true }),
- providerCompletedAt: toOptionalDate(toolCall.providerCompletedAt, { strict: true }),
- },
- });
- }
-
- if (attemptAudit.response?.usage) {
- await tx.investigationAttemptUsage.create({
- data: {
- attemptId: attempt.id,
- inputTokens: attemptAudit.response.usage.inputTokens,
- outputTokens: attemptAudit.response.usage.outputTokens,
- totalTokens: attemptAudit.response.usage.totalTokens,
- cachedInputTokens: attemptAudit.response.usage.cachedInputTokens,
- reasoningOutputTokens: attemptAudit.response.usage.reasoningOutputTokens,
- },
- });
- }
-
- if (attemptAudit.error) {
- await tx.investigationAttemptError.create({
- data: {
- attemptId: attempt.id,
- errorName: attemptAudit.error.errorName,
- errorMessage: attemptAudit.error.errorMessage,
- statusCode: attemptAudit.error.statusCode,
- },
- });
- }
}
/**
@@ -216,7 +128,7 @@ export async function markInvestigationFailedInTx(
investigationId: string;
workerIdentity: string;
attemptNumber: number;
- attemptAudit: InvestigatorAttemptAudit | null;
+ attemptAudit: InvestigatorFailedAttemptAudit | null;
},
): Promise {
// Guard: delete the lease row matching our workerIdentity. If it doesn't
@@ -260,7 +172,7 @@ export async function persistFailedAttemptAndMarkInvestigationFailed(input: {
investigationId: string;
workerIdentity: string;
attemptNumber: number;
- attemptAudit: InvestigatorAttemptAudit | null;
+ attemptAudit: InvestigatorFailedAttemptAudit | null;
}): Promise {
return getPrisma().$transaction((tx) => markInvestigationFailedInTx(tx, input));
}
@@ -276,7 +188,7 @@ export async function releaseLeaseToRetryInTx(
investigationId: string;
workerIdentity: string;
attemptNumber: number;
- attemptAudit: InvestigatorAttemptAudit | null;
+ attemptAudit: InvestigatorFailedAttemptAudit | null;
retryAfter: Date;
},
): Promise {
@@ -327,7 +239,7 @@ export async function persistFailedAttemptAndReleaseLease(input: {
investigationId: string;
workerIdentity: string;
attemptNumber: number;
- attemptAudit: InvestigatorAttemptAudit | null;
+ attemptAudit: InvestigatorFailedAttemptAudit | null;
retryAfter: Date;
}): Promise {
return getPrisma().$transaction((tx) => releaseLeaseToRetryInTx(tx, input));
diff --git a/src/typescript/api/src/lib/services/blob-storage.ts b/src/typescript/api/src/lib/services/blob-storage.ts
index 62a1cb3..e5349e3 100644
--- a/src/typescript/api/src/lib/services/blob-storage.ts
+++ b/src/typescript/api/src/lib/services/blob-storage.ts
@@ -7,7 +7,6 @@ interface BlobStorageConfigBase {
bucket: string;
accessKeyId: string;
secretAccessKey: string;
- publicUrlPrefix: string;
}
type AwsBlobStorageConfig = BlobStorageConfigBase & {
@@ -24,7 +23,6 @@ type BlobStorageConfig = AwsBlobStorageConfig | S3CompatibleBlobStorageConfig;
class BlobStorageService {
private readonly client: S3Client;
private readonly bucket: string;
- private readonly publicUrlPrefix: string;
constructor(config: BlobStorageConfig) {
this.client =
@@ -46,7 +44,6 @@ class BlobStorageService {
},
});
this.bucket = config.bucket;
- this.publicUrlPrefix = config.publicUrlPrefix.replace(/\/+$/, "");
}
async uploadImage(bytes: Uint8Array, contentHash: string, mimeType: string): Promise {
@@ -61,10 +58,6 @@ class BlobStorageService {
);
return storageKey;
}
-
- getPublicUrl(storageKey: string): string {
- return `${this.publicUrlPrefix}/${storageKey}`;
- }
}
let blobStorageService: BlobStorageService | undefined;
@@ -79,7 +72,6 @@ function readBlobStorageConfig(): BlobStorageConfig {
bucket: env.BLOB_STORAGE_BUCKET,
accessKeyId: env.BLOB_STORAGE_ACCESS_KEY_ID,
secretAccessKey: env.BLOB_STORAGE_SECRET_ACCESS_KEY,
- publicUrlPrefix: env.BLOB_STORAGE_PUBLIC_URL_PREFIX,
};
}
@@ -90,7 +82,6 @@ function readBlobStorageConfig(): BlobStorageConfig {
bucket: env.BLOB_STORAGE_BUCKET,
accessKeyId: env.BLOB_STORAGE_ACCESS_KEY_ID,
secretAccessKey: env.BLOB_STORAGE_SECRET_ACCESS_KEY,
- publicUrlPrefix: env.BLOB_STORAGE_PUBLIC_URL_PREFIX,
};
}
diff --git a/src/typescript/api/src/lib/services/canonical-resolution.ts b/src/typescript/api/src/lib/services/canonical-resolution.ts
index e1eda74..afd3cc0 100644
--- a/src/typescript/api/src/lib/services/canonical-resolution.ts
+++ b/src/typescript/api/src/lib/services/canonical-resolution.ts
@@ -15,7 +15,7 @@ export type CanonicalContentVersion =
provenance: "SERVER_VERIFIED";
/** HTML fetched from the canonical source API (Parse API, LessWrong GraphQL). */
sourceHtml: string;
- canonicalIdentity: CanonicalIdentity | null;
+ canonicalIdentity: CanonicalIdentity;
})
| (ObservedContentVersion & {
provenance: "CLIENT_FALLBACK";
diff --git a/src/typescript/api/src/lib/services/content-fetcher.ts b/src/typescript/api/src/lib/services/content-fetcher.ts
index 4660a71..5cda2de 100644
--- a/src/typescript/api/src/lib/services/content-fetcher.ts
+++ b/src/typescript/api/src/lib/services/content-fetcher.ts
@@ -1,12 +1,14 @@
import {
- CONTENT_BLOCK_SEPARATOR_TAGS,
NON_CONTENT_TAGS,
+ WORD_SEPARATOR_TAGS,
hashContent,
isNonNullObject,
normalizeContent,
WIKIPEDIA_LANGUAGE_CODE_REGEX,
} from "@openerrata/shared";
+import { setTimeout as sleep } from "node:timers/promises";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
+import { z } from "zod";
import {
createWikipediaNodeFilter,
hasChildren,
@@ -21,19 +23,33 @@ type ServerFetchResult =
contentText: string;
contentHash: string;
sourceHtml: string;
- canonicalIdentity: CanonicalIdentity | null;
+ canonicalIdentity: CanonicalIdentity;
}
| {
success: false;
failureReason: string;
};
-export interface CanonicalIdentity {
- platform: "WIKIPEDIA";
- language: string;
- pageId: string;
- revisionId: string;
-}
+/**
+ * Post identity as reported by the platform itself. Identity-bound fields
+ * (post URL, author, Wikipedia page/revision) come from here whenever the
+ * server fetch succeeds, never from the client (SPEC §2.9).
+ */
+export type CanonicalIdentity =
+ | {
+ platform: "LESSWRONG";
+ url: string;
+ title: string;
+ /** Null when LessWrong reports no user for the post (e.g. deleted account). */
+ author: { slug: string; displayName: string } | null;
+ }
+ | {
+ platform: "WIKIPEDIA";
+ url: string;
+ language: string;
+ pageId: string;
+ revisionId: string;
+ };
export type CanonicalContentFetchResult =
| {
@@ -41,7 +57,7 @@ export type CanonicalContentFetchResult =
contentText: string;
contentHash: string;
sourceHtml: string;
- canonicalIdentity: CanonicalIdentity | null;
+ canonicalIdentity: CanonicalIdentity;
}
| {
provenance: "CLIENT_FALLBACK";
@@ -83,6 +99,8 @@ export type CanonicalFetchInput =
}
| WikipediaCanonicalFetchInput;
+const LESSWRONG_GRAPHQL_URL = "https://www.lesswrong.com/graphql";
+
function describeFetchError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -97,53 +115,71 @@ function isTransientHttpStatus(status: number): boolean {
const TRANSIENT_RETRY_DELAYS_MS = [200, 400, 800] as const;
+/**
+ * Wall-clock budget for one canonical fetch, retries included. The fetch runs
+ * synchronously inside registerObservedVersion, so a slow or hanging platform
+ * must degrade to CLIENT_FALLBACK quickly rather than hold the request open.
+ */
+const CANONICAL_FETCH_DEADLINE_MS = 10_000;
+
+/** Largest canonical response body we will read (large Wikipedia articles are a few MB). */
+const MAX_CANONICAL_RESPONSE_BYTES = 10 * 1024 * 1024;
+
/**
* Fetch wrapper that retries on transient failures (network errors, HTTP 429,
- * HTTP 5xx) with exponential backoff. Non-transient errors (4xx except 429,
- * parse failures) propagate immediately.
+ * HTTP 5xx) with exponential backoff, all within one deadline signal.
+ * Non-transient errors (4xx except 429) are returned immediately.
*
- * Returns the successful Response, or throws the last error / returns the
- * last non-ok Response if all attempts fail.
+ * Returns the first non-transient Response or the last transient one once
+ * retries are exhausted; throws the last network error, or the abort reason
+ * once the deadline passes.
*/
async function fetchWithTransientRetry(
- input: string | URL | Request,
- init?: RequestInit,
+ input: string | URL,
+ init: RequestInit & { signal: AbortSignal },
): Promise {
- let lastError: unknown;
- for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
+ for (let attempt = 0; ; attempt += 1) {
+ const retryDelayMs = TRANSIENT_RETRY_DELAYS_MS[attempt];
try {
const response = await fetch(input, init);
- if (response.ok || !isTransientHttpStatus(response.status)) {
+ if (response.ok || !isTransientHttpStatus(response.status) || retryDelayMs === undefined) {
return response;
}
- // Transient HTTP error — retry if attempts remain.
- lastError = new Error(`HTTP ${response.status.toString()}`);
- if (attempt < TRANSIENT_RETRY_DELAYS_MS.length) {
- const delayMs = TRANSIENT_RETRY_DELAYS_MS[attempt];
- if (delayMs !== undefined) {
- await new Promise((resolve) => {
- setTimeout(resolve, delayMs);
- });
- }
- continue;
- }
- return response;
+ await response.body?.cancel();
} catch (error) {
- // Network error — retry if attempts remain.
- lastError = error;
- if (attempt < TRANSIENT_RETRY_DELAYS_MS.length) {
- const delayMs = TRANSIENT_RETRY_DELAYS_MS[attempt];
- if (delayMs !== undefined) {
- await new Promise((resolve) => {
- setTimeout(resolve, delayMs);
- });
- }
- continue;
+ if (init.signal.aborted || retryDelayMs === undefined) {
+ throw error;
}
- throw error;
}
+ await sleep(retryDelayMs, undefined, { signal: init.signal });
+ }
+}
+
+/** Read and JSON-parse a response body, refusing bodies over MAX_CANONICAL_RESPONSE_BYTES. */
+async function readJsonWithinLimit(response: Response): Promise {
+ const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
+ if (Number.isFinite(contentLength) && contentLength > MAX_CANONICAL_RESPONSE_BYTES) {
+ await response.body?.cancel();
+ throw new Error(`response is ${contentLength.toString()} bytes, over the size limit`);
+ }
+ if (response.body === null) {
+ throw new Error("response has no body");
+ }
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let totalBytes = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ totalBytes += value.byteLength;
+ if (totalBytes > MAX_CANONICAL_RESPONSE_BYTES) {
+ await reader.cancel("Canonical response exceeds size limit");
+ throw new Error("response body exceeds the size limit");
+ }
+ chunks.push(value);
}
- throw lastError;
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function parseNonNegativeIntegerId(value: unknown): string | null {
@@ -157,36 +193,32 @@ function parseNonNegativeIntegerId(value: unknown): string | null {
}
/**
- * Extract the full HTML body from a LessWrong GraphQL response.
+ * The parts of a LessWrong GraphQL `post` response we rely on.
*
- * We use the `html` field rather than `plaintextMainText` because the latter
- * is truncated to 2000 characters by LessWrong's API, which would cause a
+ * We use `contents.html` rather than `plaintextMainText` because the latter is
+ * truncated to 2000 characters by LessWrong's API, which would cause a
* canonicalization mismatch for any post longer than that.
*/
-function extractLesswrongHtml(value: unknown): string | null {
- if (!isNonNullObject(value)) return null;
-
- const data = value["data"];
- if (!isNonNullObject(data)) return null;
-
- const post = data["post"];
- if (!isNonNullObject(post)) return null;
-
- const result = post["result"];
- if (!isNonNullObject(result)) return null;
-
- const contents = result["contents"];
- if (!isNonNullObject(contents)) return null;
-
- const html = contents["html"];
- return typeof html === "string" ? html : null;
-}
+const lesswrongPostResponseSchema = z.object({
+ data: z.object({
+ post: z.object({
+ result: z.object({
+ _id: z.string().min(1),
+ slug: z.string().min(1),
+ title: z.string().min(1),
+ contents: z.object({ html: z.string().min(1) }),
+ user: z.object({ slug: z.string().min(1), displayName: z.string().min(1) }).nullable(),
+ }),
+ }),
+ }),
+});
/**
* Shared parse5 HTML-to-text traversal used by all platform extractors.
*
* Performs a stack-based DFS over the parse5 fragment tree, collecting text
- * node values and injecting word-boundary separators at block element edges.
+ * node values and injecting word-boundary separators at the edges of
+ * `WORD_SEPARATOR_TAGS` elements (blocks and line breaks).
*
* Built-in behavior (unconditional):
* - `NON_CONTENT_TAGS` (script, style, noscript) are always excluded.
@@ -197,7 +229,7 @@ function extractLesswrongHtml(value: unknown): string | null {
*/
function parse5HtmlToTextContent(html: string, nodeFilter?: Parse5NodeFilter): string {
const fragment = parseFragment(html);
- const stack: { node: DefaultTreeAdapterMap["node"]; phase: "enter" | "exit" }[] = [];
+ const stack: { node: DefaultTreeAdapterMap["childNode"]; phase: "enter" | "exit" }[] = [];
for (let index = fragment.childNodes.length - 1; index >= 0; index -= 1) {
const child = fragment.childNodes[index];
if (child !== undefined) {
@@ -213,7 +245,7 @@ function parse5HtmlToTextContent(html: string, nodeFilter?: Parse5NodeFilter): s
const { node, phase } = current;
if (phase === "exit") {
- if (isElementNode(node) && CONTENT_BLOCK_SEPARATOR_TAGS.has(node.tagName.toLowerCase())) {
+ if (isElementNode(node) && WORD_SEPARATOR_TAGS.has(node.tagName.toLowerCase())) {
chunks.push(" ");
}
continue;
@@ -238,7 +270,7 @@ function parse5HtmlToTextContent(html: string, nodeFilter?: Parse5NodeFilter): s
continue;
}
- if (isElementNode(node) && CONTENT_BLOCK_SEPARATOR_TAGS.has(node.tagName.toLowerCase())) {
+ if (isElementNode(node) && WORD_SEPARATOR_TAGS.has(node.tagName.toLowerCase())) {
chunks.push(" ");
}
@@ -304,9 +336,10 @@ async function fetchLesswrongContent(
input: Extract,
): Promise {
const postId = input.externalId;
- let response: Response;
+ const deadline = AbortSignal.timeout(CANONICAL_FETCH_DEADLINE_MS);
+ let data: unknown;
try {
- response = await fetchWithTransientRetry("https://www.lesswrong.com/graphql", {
+ const response = await fetchWithTransientRetry(LESSWRONG_GRAPHQL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -314,6 +347,7 @@ async function fetchLesswrongContent(
post(input: { selector: { _id: $id } }) {
result {
_id
+ slug
title
contents {
html
@@ -327,7 +361,13 @@ async function fetchLesswrongContent(
}`,
variables: { id: postId },
}),
+ signal: deadline,
});
+ if (!response.ok) {
+ await response.body?.cancel();
+ return { success: false, failureReason: `LW API returned ${response.status.toString()}` };
+ }
+ data = await readJsonWithinLimit(response);
} catch (error) {
return {
success: false,
@@ -335,30 +375,51 @@ async function fetchLesswrongContent(
};
}
- if (!response.ok) {
- return { success: false, failureReason: `LW API returned ${response.status}` };
- }
-
- let data: unknown;
- try {
- data = await response.json();
- } catch (error) {
+ const parsed = lesswrongPostResponseSchema.safeParse(data);
+ if (!parsed.success) {
return {
success: false,
- failureReason: `LW API returned invalid JSON: ${describeFetchError(error)}`,
+ failureReason: "Could not extract post content and identity from LW API response",
};
}
- const html = extractLesswrongHtml(data);
- if (html === null || html.length === 0) {
+ const post = parsed.data.data.post.result;
+ if (post._id !== postId) {
return {
success: false,
- failureReason: "Could not extract HTML from LW API response",
+ failureReason: `LW API returned post ${post._id} for requested post ${postId}`,
};
}
- const contentText = lesswrongHtmlToNormalizedText(html);
+ const contentText = lesswrongHtmlToNormalizedText(post.contents.html);
const contentHash = await hashContent(contentText);
- return { success: true, contentText, contentHash, sourceHtml: html, canonicalIdentity: null };
+ return {
+ success: true,
+ contentText,
+ contentHash,
+ sourceHtml: post.contents.html,
+ canonicalIdentity: {
+ platform: "LESSWRONG",
+ url: lesswrongPostUrl(post._id, post.slug),
+ title: post.title,
+ author: post.user,
+ },
+ };
+}
+
+function lesswrongPostUrl(postId: string, slug: string): string {
+ return `https://www.lesswrong.com/posts/${encodeURIComponent(postId)}/${encodeURIComponent(slug)}`;
+}
+
+/**
+ * Article URL for a Wikipedia title as returned by the parse API (spaces, not
+ * underscores). Slashes and colons stay literal so subpages and namespaces
+ * read naturally; everything else is percent-encoded.
+ */
+function wikipediaArticleUrl(language: string, title: string): string {
+ const encodedTitle = encodeURIComponent(title.replace(/ /g, "_"))
+ .replace(/%2F/g, "/")
+ .replace(/%3A/g, ":");
+ return `https://${language}.wikipedia.org/wiki/${encodedTitle}`;
}
function wikipediaHtmlToTextContent(html: string): string {
@@ -371,6 +432,7 @@ export function wikipediaHtmlToNormalizedText(html: string): string {
function extractWikipediaParsePayload(value: unknown): {
html: string;
+ title: string;
pageId: string;
revisionId: string;
} | null {
@@ -379,15 +441,17 @@ function extractWikipediaParsePayload(value: unknown): {
if (!isNonNullObject(parse)) return null;
const text = parse["text"];
+ const title = parse["title"];
const revisionId = parseNonNegativeIntegerId(parse["revid"]);
const pageId = parseNonNegativeIntegerId(parse["pageid"]);
- if (typeof text !== "string") return null;
+ if (typeof text !== "string" || typeof title !== "string" || title.length === 0) return null;
if (revisionId === null || pageId === null) {
return null;
}
return {
html: text,
+ title,
pageId,
revisionId,
};
@@ -421,29 +485,22 @@ async function fetchWikipediaContent(
endpoint.searchParams.set("prop", "text|revid");
endpoint.searchParams.set("oldid", revisionId);
- let response: Response;
- try {
- response = await fetchWithTransientRetry(endpoint);
- } catch (error) {
- return {
- success: false,
- failureReason: `Wikipedia parse request failed: ${describeFetchError(error)}`,
- };
- }
- if (!response.ok) {
- return {
- success: false,
- failureReason: `Wikipedia parse API returned ${response.status}`,
- };
- }
-
+ const deadline = AbortSignal.timeout(CANONICAL_FETCH_DEADLINE_MS);
let data: unknown;
try {
- data = await response.json();
+ const response = await fetchWithTransientRetry(endpoint, { signal: deadline });
+ if (!response.ok) {
+ await response.body?.cancel();
+ return {
+ success: false,
+ failureReason: `Wikipedia parse API returned ${response.status.toString()}`,
+ };
+ }
+ data = await readJsonWithinLimit(response);
} catch (error) {
return {
success: false,
- failureReason: `Wikipedia parse API returned invalid JSON: ${describeFetchError(error)}`,
+ failureReason: `Wikipedia parse request failed: ${describeFetchError(error)}`,
};
}
const payload = extractWikipediaParsePayload(data);
@@ -470,6 +527,7 @@ async function fetchWikipediaContent(
sourceHtml: payload.html,
canonicalIdentity: {
platform: "WIKIPEDIA",
+ url: wikipediaArticleUrl(language, payload.title),
language,
pageId: payload.pageId,
revisionId: payload.revisionId,
diff --git a/src/typescript/api/src/lib/services/hmac.ts b/src/typescript/api/src/lib/services/hmac.ts
deleted file mode 100644
index 9d7745b..0000000
--- a/src/typescript/api/src/lib/services/hmac.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-async function computeHmac(secret: string, body: string): Promise {
- const key = await crypto.subtle.importKey(
- "raw",
- new TextEncoder().encode(secret),
- { name: "HMAC", hash: "SHA-256" },
- false,
- ["sign"],
- );
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
- return Array.from(new Uint8Array(sig))
- .map((b) => b.toString(16).padStart(2, "0"))
- .join("");
-}
-
-export async function verifyHmac(
- secret: string,
- body: string,
- signature: string,
-): Promise {
- const expected = await computeHmac(secret, body);
- // Constant-time comparison
- if (expected.length !== signature.length) return false;
- let result = 0;
- for (let i = 0; i < expected.length; i++) {
- result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
- }
- return result === 0;
-}
diff --git a/src/typescript/api/src/lib/services/html-to-markdown.ts b/src/typescript/api/src/lib/services/html-to-markdown.ts
index 8722073..7013c1b 100644
--- a/src/typescript/api/src/lib/services/html-to-markdown.ts
+++ b/src/typescript/api/src/lib/services/html-to-markdown.ts
@@ -17,12 +17,12 @@
import { NON_CONTENT_TAGS } from "@openerrata/shared";
import TurndownService from "turndown";
-import type { ImagePlaceholder } from "$lib/investigators/interface.js";
import { preFilterWikipediaHtml } from "./wikipedia-content-filter.js";
interface HtmlToMarkdownResult {
markdown: string;
- imagePlaceholders: ImagePlaceholder[];
+ /** Absolute source URL of the image behind `[IMAGE:N]`, indexed by N. */
+ imageSourceUrls: string[];
}
/**
@@ -30,7 +30,26 @@ interface HtmlToMarkdownResult {
* or image placeholder format changes — ensures InvestigationInput snapshots
* record which renderer produced the stored markdown.
*/
-export const MARKDOWN_RENDERER_VERSION = "1.2.0";
+export const MARKDOWN_RENDERER_VERSION = "1.3.0";
+
+/**
+ * Resolve an `
` against the post URL (Wikipedia HTML uses
+ * protocol-relative URLs) into the absolute form image occurrences use.
+ * Returns null for sources that can never be fetched (data:, relative junk,
+ * embedded credentials).
+ */
+function resolveImageSourceUrl(src: string, baseUrl: string): string | null {
+ if (src.length === 0) return null;
+ let resolved: URL;
+ try {
+ resolved = new URL(src, baseUrl);
+ } catch {
+ return null;
+ }
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
+ if (resolved.username.length > 0 || resolved.password.length > 0) return null;
+ return resolved.toString();
+}
// ── Turndown configuration ────────────────────────────────────────────────
@@ -43,27 +62,27 @@ const TURNDOWN_OPTIONS: TurndownService.Options = {
};
/**
- * Convert HTML to markdown with `[IMAGE:N]` placeholders for each `
` tag.
+ * Convert HTML to markdown with an `[IMAGE:N]` placeholder for each `
`
+ * whose source is a fetchable URL; images without one are dropped.
*
- * Returns the markdown string and the ordered list of placeholders so the
- * input builder can match placeholders to resolved image occurrences by URL.
+ * Returns the markdown and the source URL behind each placeholder so the
+ * investigation input can match placeholders to downloaded images by URL.
*/
-function htmlToMarkdownWithImages(html: string): HtmlToMarkdownResult {
- const placeholders: ImagePlaceholder[] = [];
+function htmlToMarkdownWithImages(html: string, baseUrl: string): HtmlToMarkdownResult {
+ const imageSourceUrls: string[] = [];
const service = new TurndownService(TURNDOWN_OPTIONS);
service.addRule("imagePlaceholder", {
filter: "img",
replacement: (_content, node) => {
- const src = node.getAttribute("src")?.trim() ?? "";
- const index = placeholders.length;
- if (src.length > 0) {
- placeholders.push({ index, matchBy: "SOURCE_URL", sourceUrl: src });
- } else {
- placeholders.push({ index, matchBy: "ORIGINAL_INDEX" });
+ const sourceUrl = resolveImageSourceUrl(node.getAttribute("src")?.trim() ?? "", baseUrl);
+ if (sourceUrl === null) {
+ return "";
}
- return ` [IMAGE:${index}] `;
+ const index = imageSourceUrls.length;
+ imageSourceUrls.push(sourceUrl);
+ return ` [IMAGE:${index.toString()}] `;
},
});
@@ -71,7 +90,7 @@ function htmlToMarkdownWithImages(html: string): HtmlToMarkdownResult {
// Substack wrap images in
to make them clickable.
// Turndown's default link rule would produce "[ [IMAGE:0] ](url)" with extra
// blank lines from inner block containers (div, figure, etc.). The anchor URL
- // is redundant — the image URL is already captured in imagePlaceholders for
+ // is redundant — the image URL is already captured in imageSourceUrls for
// matching — so we discard it and return just the placeholder(s).
service.addRule("imageOnlyLink", {
filter: (node) =>
@@ -103,19 +122,26 @@ function htmlToMarkdownWithImages(html: string): HtmlToMarkdownResult {
service.remove((node) => NON_CONTENT_TAGS.has(node.nodeName.toLowerCase()));
const markdown = service.turndown(html);
- return { markdown, imagePlaceholders: placeholders };
+ return { markdown, imageSourceUrls };
}
// ── Platform wrappers ────────────────────────────────────────────────────
+// `postUrl` is the base for resolving relative image sources.
-export function lesswrongHtmlToContentMarkdown(html: string): HtmlToMarkdownResult {
- return htmlToMarkdownWithImages(html);
+export function lesswrongHtmlToContentMarkdown(
+ html: string,
+ postUrl: string,
+): HtmlToMarkdownResult {
+ return htmlToMarkdownWithImages(html, postUrl);
}
-export function wikipediaHtmlToContentMarkdown(html: string): HtmlToMarkdownResult {
- return htmlToMarkdownWithImages(preFilterWikipediaHtml(html));
+export function wikipediaHtmlToContentMarkdown(
+ html: string,
+ postUrl: string,
+): HtmlToMarkdownResult {
+ return htmlToMarkdownWithImages(preFilterWikipediaHtml(html), postUrl);
}
-export function substackHtmlToContentMarkdown(html: string): HtmlToMarkdownResult {
- return htmlToMarkdownWithImages(html);
+export function substackHtmlToContentMarkdown(html: string, postUrl: string): HtmlToMarkdownResult {
+ return htmlToMarkdownWithImages(html, postUrl);
}
diff --git a/src/typescript/api/src/lib/services/image-downloader.ts b/src/typescript/api/src/lib/services/image-downloader.ts
index cbb4449..53faa50 100644
--- a/src/typescript/api/src/lib/services/image-downloader.ts
+++ b/src/typescript/api/src/lib/services/image-downloader.ts
@@ -7,12 +7,10 @@ import {
import { getPrisma } from "$lib/db/client.js";
import { isUniqueConstraintError } from "$lib/db/errors.js";
import type { ImageBlob } from "$lib/db/prisma-client";
-import { hasAddressIntersection, resolvePublicHostAddresses } from "$lib/network/host-safety.js";
-import { isRedirectStatus } from "$lib/network/http-status.js";
+import { fetchPublicHttp, readBodyPrefix } from "$lib/network/public-http-fetch.js";
import { uploadImage } from "./blob-storage.js";
const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
-const MAX_REDIRECT_HOPS = 5;
const SUPPORTED_MIME_TYPE_SET: ReadonlySet = new Set(SUPPORTED_IMAGE_MIME_TYPE_VALUES);
@@ -29,142 +27,39 @@ export function parseImageContentType(
return normalized;
}
-function uniqueImageUrls(urls: string[]): string[] {
- const unique = new Set();
-
- for (const url of urls) {
- const trimmed = url.trim();
- if (trimmed.length === 0) continue;
- try {
- const parsed = new URL(trimmed);
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
- if (parsed.username.length > 0 || parsed.password.length > 0) continue;
- unique.add(parsed.toString());
- } catch {
- // Ignore malformed image URLs to keep investigation flow robust.
- }
- }
-
- return Array.from(unique);
-}
-
-async function readResponseBytesWithinLimit(
- response: Response,
- maxBytes: number,
-): Promise {
- if (!response.body) {
- return null;
- }
-
- const reader = response.body.getReader();
- const chunks: Uint8Array[] = [];
- let totalBytes = 0;
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- totalBytes += value.byteLength;
- if (totalBytes > maxBytes) {
- await reader.cancel("Image exceeds maximum byte limit");
- return null;
- }
- chunks.push(value);
- }
- } catch {
- await reader.cancel();
- return null;
- } finally {
- reader.releaseLock();
- }
-
- const bytes = new Uint8Array(totalBytes);
- let offset = 0;
- for (const chunk of chunks) {
- bytes.set(chunk, offset);
- offset += chunk.byteLength;
- }
-
- return bytes;
-}
-
+/**
+ * Download one image from the public internet (SSRF-safe; see
+ * public-http-fetch.ts). Returns null when the image is unreachable, blocked,
+ * not a supported image type, or over MAX_IMAGE_BYTES; a single bad image
+ * never fails the investigation. Aborting `signal` aborts the download.
+ */
async function downloadImage(
url: string,
+ signal: AbortSignal,
): Promise<{ bytes: Uint8Array; mimeType: SupportedImageMimeType } | null> {
try {
- let currentUrl = new URL(url);
-
- for (let redirectHop = 0; redirectHop <= MAX_REDIRECT_HOPS; redirectHop += 1) {
- if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") {
- return null;
- }
- const resolvedBeforeRequest = await resolvePublicHostAddresses(currentUrl.hostname);
- if (!resolvedBeforeRequest) {
- return null;
- }
-
- const response = await fetch(currentUrl, {
- method: "GET",
- redirect: "manual",
- signal: AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS),
- headers: {
- "User-Agent": "OpenErrataImageDownloader/1.0 (+https://openerrata.com)",
- Accept: "image/*",
- },
- });
-
- if (isRedirectStatus(response.status)) {
- const location = response.headers.get("location");
- if (location === null || location.length === 0) {
- return null;
- }
-
- currentUrl = new URL(location, currentUrl);
- continue;
- }
-
- if (!response.ok) {
- return null;
- }
-
- // Re-resolve and require overlap with pre-request answers. This narrows
- // DNS rebinding windows by rejecting responses when hostname resolution
- // shifts to a disjoint address set during request handling.
- const resolvedAfterRequest = await resolvePublicHostAddresses(currentUrl.hostname);
- if (
- !resolvedAfterRequest ||
- !hasAddressIntersection(resolvedBeforeRequest, resolvedAfterRequest)
- ) {
- return null;
- }
-
- const contentType = parseImageContentType(response.headers.get("content-type"));
- if (contentType === null || contentType.length === 0) {
- return null;
- }
-
- const contentLengthHeader = response.headers.get("content-length");
- if (contentLengthHeader !== null && contentLengthHeader.length > 0) {
- const contentLength = Number.parseInt(contentLengthHeader, 10);
- if (Number.isFinite(contentLength) && contentLength > MAX_IMAGE_BYTES) {
- return null;
- }
- }
-
- const bytes = await readResponseBytesWithinLimit(response, MAX_IMAGE_BYTES);
- if (bytes === null) {
- return null;
- }
+ const { response } = await fetchPublicHttp({
+ url: new URL(url),
+ headers: {
+ "User-Agent": "OpenErrataImageDownloader/1.0 (+https://openerrata.com)",
+ Accept: "image/*",
+ },
+ signal: AbortSignal.any([signal, AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS)]),
+ });
- return {
- bytes,
- mimeType: contentType,
- };
+ const contentType = parseImageContentType(response.headers.get("content-type"));
+ if (!response.ok || contentType === null) {
+ await response.body?.cancel();
+ return null;
}
- return null;
- } catch {
+ const { bytes, truncated } = await readBodyPrefix(response, MAX_IMAGE_BYTES);
+ if (truncated) {
+ return null;
+ }
+ return { bytes, mimeType: contentType };
+ } catch (error) {
+ if (signal.aborted) throw error;
return null;
}
}
@@ -225,20 +120,20 @@ type ImageDownloadResolution =
status: "failed";
};
+/**
+ * Download each URL, dedupe by content hash, and store new images in blob
+ * storage. Results are reported per input URL, in order. Throws only when
+ * `signal` aborts (the run lost its lease) or storage fails.
+ */
export async function downloadAndStoreImages(
urls: string[],
- maxCount: number,
+ signal: AbortSignal,
): Promise {
- const uniqueUrls = uniqueImageUrls(urls).slice(0, maxCount);
- if (uniqueUrls.length === 0) {
- return [];
- }
-
const resolutions: ImageDownloadResolution[] = [];
const resolvedByContentHash = new Map();
- for (const imageUrl of uniqueUrls) {
- const downloaded = await downloadImage(imageUrl);
+ for (const imageUrl of urls) {
+ const downloaded = await downloadImage(imageUrl, signal);
if (!downloaded) {
resolutions.push({
sourceUrl: imageUrl,
diff --git a/src/typescript/api/src/lib/services/investigate-now.ts b/src/typescript/api/src/lib/services/investigate-now.ts
new file mode 100644
index 0000000..283f5b8
--- /dev/null
+++ b/src/typescript/api/src/lib/services/investigate-now.ts
@@ -0,0 +1,172 @@
+/**
+ * investigateNow (SPEC §2.6, §3.3): explicit requests to investigate one
+ * content version.
+ *
+ * - No investigation yet → admit one funded by the requester (their verified
+ * OpenAI key if they sent one, else the instance key they authenticated
+ * with), with update lineage, and enqueue it.
+ * - COMPLETE or FAILED → return it as is. FAILED is terminal (SPEC §3.7).
+ * - PROCESSING → return it, after recovering it first if its lease expired.
+ * - PENDING and funded → make sure a queue job exists. The requester's key is
+ * never attached: whoever admitted it is already paying for it.
+ * - PENDING and unfunded (its user key was dropped) → fund it as above.
+ */
+
+import { WORD_COUNT_LIMIT } from "@openerrata/shared";
+import type { PrismaClient } from "$lib/db/client";
+import { isUniqueConstraintError } from "$lib/db/errors.js";
+import {
+ fundUnfundedInvestigation,
+ insertAdmittedInvestigation,
+ type InvestigationFunding,
+} from "./investigation-admission.js";
+import { buildInvestigationInputSnapshot } from "./investigation-input.js";
+import { recoverExpiredLease } from "./investigation-lease.js";
+import { enqueueInvestigation } from "./queue.js";
+import { resolveUpdateLineage } from "./update-lineage.js";
+import { verifyUserOpenAiApiKey, type UserOpenAiKeyVerification } from "./user-key-source.js";
+
+export class InvestigationWordLimitError extends Error {
+ readonly limit: number;
+ readonly observedWordCount: number;
+
+ constructor(observedWordCount: number, limit: number) {
+ super(`Post exceeds word count limit (${limit.toString()} words)`);
+ this.name = "InvestigationWordLimitError";
+ this.observedWordCount = observedWordCount;
+ this.limit = limit;
+ }
+}
+
+export class UserOpenAiKeyRejectedError extends Error {
+ readonly outcome: Extract["outcome"];
+
+ constructor(outcome: UserOpenAiKeyRejectedError["outcome"]) {
+ super(`User OpenAI key was not accepted (${outcome.openaiApiKeyStatus})`);
+ this.name = "UserOpenAiKeyRejectedError";
+ this.outcome = outcome;
+ }
+}
+
+/** Who is asking, and therefore who would pay for a run this request admits. */
+export type InvestigationRequester =
+ | { kind: "INSTANCE_API_KEY" }
+ /** The key as sent; it is verified with OpenAI only if it would be attached. */
+ | { kind: "USER_OPENAI_KEY"; apiKey: string };
+
+async function fundingFor(requester: InvestigationRequester): Promise {
+ if (requester.kind === "INSTANCE_API_KEY") {
+ return { origin: "INSTANCE_REQUEST" };
+ }
+ const verification = await verifyUserOpenAiApiKey(requester.apiKey);
+ if (!verification.verified) {
+ throw new UserOpenAiKeyRejectedError(verification.outcome);
+ }
+ return { origin: "USER_KEY_REQUEST", apiKey: verification.apiKey };
+}
+
+/**
+ * Handle one investigateNow request and return the id of the investigation for
+ * `postVersion`. Throws InvestigationWordLimitError when a new investigation
+ * would exceed the word limit, and UserOpenAiKeyRejectedError when a user key
+ * would fund the run but OpenAI does not accept it.
+ */
+export async function requestInvestigation(
+ prisma: PrismaClient,
+ input: {
+ postVersion: {
+ id: string;
+ postId: string;
+ contentBlob: { contentText: string; wordCount: number };
+ };
+ promptId: string;
+ requester: InvestigationRequester;
+ },
+): Promise<{ investigationId: string }> {
+ const { postVersion } = input;
+ // A later pass follows a concurrent creation, an expired-lease recovery, or
+ // a lost race to fund an unfunded investigation; each re-reads the row.
+ for (let pass = 0; pass < 3; pass += 1) {
+ const existing = await prisma.investigation.findUnique({
+ where: { postVersionId: postVersion.id },
+ select: {
+ id: true,
+ status: true,
+ origin: true,
+ openAiKeySource: { select: { investigationId: true } },
+ lease: { select: { leaseExpiresAt: true } },
+ },
+ });
+
+ if (existing === null) {
+ if (postVersion.contentBlob.wordCount > WORD_COUNT_LIMIT) {
+ throw new InvestigationWordLimitError(postVersion.contentBlob.wordCount, WORD_COUNT_LIMIT);
+ }
+ const lineage = await resolveUpdateLineage(prisma, {
+ id: postVersion.id,
+ postId: postVersion.postId,
+ contentText: postVersion.contentBlob.contentText,
+ });
+ const snapshot = await buildInvestigationInputSnapshot(prisma, postVersion.id);
+ const funding = await fundingFor(input.requester);
+ let created: { id: string };
+ try {
+ created = await prisma.$transaction((tx) =>
+ insertAdmittedInvestigation(tx, {
+ postVersionId: postVersion.id,
+ promptId: input.promptId,
+ funding,
+ lineage,
+ snapshot,
+ now: new Date(),
+ }),
+ );
+ } catch (error) {
+ if (isUniqueConstraintError(error)) continue;
+ throw error;
+ }
+ await enqueueInvestigation(created.id);
+ return { investigationId: created.id };
+ }
+
+ switch (existing.status) {
+ case "COMPLETE":
+ case "FAILED":
+ return { investigationId: existing.id };
+ case "PROCESSING":
+ if (existing.lease !== null && existing.lease.leaseExpiresAt.getTime() <= Date.now()) {
+ await recoverExpiredLease(prisma, existing.id);
+ continue;
+ }
+ return { investigationId: existing.id };
+ case "PENDING": {
+ const unfunded =
+ existing.origin === "USER_KEY_REQUEST" && existing.openAiKeySource === null;
+ if (unfunded) {
+ const funding = await fundingFor(input.requester);
+ let funded: boolean;
+ try {
+ funded = await prisma.$transaction((tx) =>
+ fundUnfundedInvestigation(tx, {
+ investigationId: existing.id,
+ funding,
+ now: new Date(),
+ }),
+ );
+ } catch (error) {
+ // A concurrent request attached its key first.
+ if (isUniqueConstraintError(error)) continue;
+ throw error;
+ }
+ if (!funded) continue;
+ }
+ await enqueueInvestigation(existing.id);
+ return { investigationId: existing.id };
+ }
+ }
+ }
+
+ throw new Error(
+ `investigateNow could not settle on an investigation for post version ${postVersion.id}: its state kept changing`,
+ );
+}
diff --git a/src/typescript/api/src/lib/services/investigation-admission.ts b/src/typescript/api/src/lib/services/investigation-admission.ts
new file mode 100644
index 0000000..fbb378a
--- /dev/null
+++ b/src/typescript/api/src/lib/services/investigation-admission.ts
@@ -0,0 +1,106 @@
+/**
+ * Admitting investigations for execution (SPEC §3.7).
+ *
+ * An investigation is only ever created — or revived after its user key was
+ * dropped — together with a decision about who pays for it:
+ *
+ * - SELECTOR: background selection under the per-UTC-day budget; server key.
+ * - INSTANCE_REQUEST: investigateNow from an instance-API-key client; server key.
+ * - USER_KEY_REQUEST: investigateNow funded by the requester's verified OpenAI
+ * key, attached in the same transaction that admits the investigation.
+ *
+ * A user key only ever funds what its own request admits; it never takes over
+ * an investigation someone else is already paying for.
+ */
+
+import { randomUUID } from "node:crypto";
+import type { Prisma } from "$lib/db/prisma-client";
+import { DEFAULT_INVESTIGATION_PROVIDER } from "@openerrata/shared";
+import { investigationInputRow, type InvestigationInputSnapshot } from "./investigation-input.js";
+import type { UpdateLineage } from "./update-lineage.js";
+import { attachOpenAiKeySource, type VerifiedOpenAiApiKey } from "./user-key-source.js";
+
+export type InvestigationFunding =
+ | { origin: "SELECTOR" }
+ | { origin: "INSTANCE_REQUEST" }
+ | { origin: "USER_KEY_REQUEST"; apiKey: VerifiedOpenAiApiKey };
+
+/**
+ * Investigations no one is paying for: user-key admissions whose key was
+ * dropped. They wait, unqueued, until the selector or a new request funds them.
+ */
+export const unfundedInvestigationWhere = {
+ status: "PENDING",
+ origin: "USER_KEY_REQUEST",
+ openAiKeySource: { is: null },
+} satisfies Prisma.InvestigationWhereInput;
+
+async function attachFunding(
+ tx: Prisma.TransactionClient,
+ investigationId: string,
+ funding: InvestigationFunding,
+ now: Date,
+): Promise {
+ if (funding.origin === "USER_KEY_REQUEST") {
+ await attachOpenAiKeySource(tx, { investigationId, apiKey: funding.apiKey, now });
+ }
+}
+
+/**
+ * Create the PENDING investigation (and its immutable input snapshot) for a
+ * post version that has none. Throws a unique-constraint error if another
+ * request created one first; callers treat that as "already exists".
+ */
+export async function insertAdmittedInvestigation(
+ tx: Prisma.TransactionClient,
+ input: {
+ postVersionId: string;
+ promptId: string;
+ funding: InvestigationFunding;
+ lineage: UpdateLineage | null;
+ snapshot: InvestigationInputSnapshot;
+ now: Date;
+ },
+): Promise<{ id: string }> {
+ const investigationId = randomUUID();
+ await tx.investigationInput.create({
+ data: investigationInputRow(investigationId, input.snapshot),
+ });
+ const investigation = await tx.investigation.create({
+ data: {
+ id: investigationId,
+ inputId: investigationId,
+ postVersionId: input.postVersionId,
+ status: "PENDING",
+ parentInvestigationId: input.lineage?.parentInvestigationId ?? null,
+ contentDiff: input.lineage?.contentDiff ?? null,
+ promptId: input.promptId,
+ provider: DEFAULT_INVESTIGATION_PROVIDER,
+ origin: input.funding.origin,
+ admittedAt: input.now,
+ queuedAt: input.now,
+ },
+ select: { id: true },
+ });
+ await attachFunding(tx, investigation.id, input.funding, input.now);
+ return investigation;
+}
+
+/**
+ * Fund an unfunded investigation. Returns false when it is no longer
+ * unfunded (someone else funded it first, or it is no longer PENDING).
+ */
+export async function fundUnfundedInvestigation(
+ tx: Prisma.TransactionClient,
+ input: { investigationId: string; funding: InvestigationFunding; now: Date },
+): Promise {
+ const admitted = await tx.investigation.updateMany({
+ where: { id: input.investigationId, ...unfundedInvestigationWhere },
+ data: { origin: input.funding.origin, admittedAt: input.now, queuedAt: input.now },
+ });
+ if (admitted.count === 0) {
+ return false;
+ }
+ await attachFunding(tx, input.investigationId, input.funding, input.now);
+ return true;
+}
diff --git a/src/typescript/api/src/lib/services/investigation-input.ts b/src/typescript/api/src/lib/services/investigation-input.ts
new file mode 100644
index 0000000..c1b8d2b
--- /dev/null
+++ b/src/typescript/api/src/lib/services/investigation-input.ts
@@ -0,0 +1,234 @@
+/**
+ * The immutable InvestigationInput snapshot (SPEC §2.4.4, §2.12).
+ *
+ * Everything the worker hands the investigator that could change after an
+ * investigation is queued — rendered markdown, the source URL behind each
+ * markdown image placeholder, and the post's URL, author and publication time
+ * — is captured here once, when the investigation row is created. Every
+ * attempt then runs on exactly the same input, whatever happens to the live
+ * Post and version-metadata rows afterwards.
+ */
+
+import type { ContentProvenance, Platform } from "@openerrata/shared";
+import type { DbClient } from "$lib/db/client";
+import type { Prisma } from "$lib/db/prisma-client";
+import { resolveMarkdownForInvestigation, type HtmlSnapshots } from "./markdown-resolution.js";
+
+export interface InvestigationInputSnapshot {
+ provenance: ContentProvenance;
+ contentHash: string;
+ markdown:
+ | { source: "NONE" }
+ | {
+ source: "SERVER_HTML" | "CLIENT_HTML";
+ markdown: string;
+ rendererVersion: string;
+ /** Source URL of the image behind `[IMAGE:N]`, indexed by N. */
+ imageSourceUrls: string[];
+ };
+ postUrl: string;
+ authorName: string | null;
+ postPublishedAt: Date | null;
+ hasVideo: boolean;
+}
+
+const postVersionForInputSnapshotSelect = {
+ serverVerifiedAt: true,
+ contentBlob: { select: { contentHash: true } },
+ post: {
+ select: {
+ platform: true,
+ url: true,
+ author: { select: { displayName: true } },
+ },
+ },
+ lesswrongVersionMeta: {
+ select: {
+ publishedAt: true,
+ serverHtmlBlob: { select: { htmlContent: true } },
+ clientHtmlBlob: { select: { htmlContent: true } },
+ },
+ },
+ xVersionMeta: {
+ select: {
+ postedAt: true,
+ mediaUrls: true,
+ },
+ },
+ substackVersionMeta: {
+ select: {
+ publishedAt: true,
+ clientHtmlBlob: { select: { htmlContent: true } },
+ },
+ },
+ wikipediaVersionMeta: {
+ select: {
+ lastModifiedAt: true,
+ serverHtmlBlob: { select: { htmlContent: true } },
+ clientHtmlBlob: { select: { htmlContent: true } },
+ },
+ },
+} satisfies Prisma.PostVersionSelect;
+
+type PostVersionForInputSnapshot = Prisma.PostVersionGetPayload<{
+ select: typeof postVersionForInputSnapshotSelect;
+}>;
+
+function unreachablePlatform(platform: never): never {
+ throw new Error(`Unsupported post platform: ${String(platform)}`);
+}
+
+const VIDEO_PATH_SUFFIXES = [".mp4", ".webm", ".m3u8", ".mov", ".m4v"] as const;
+
+/** Whether a media URL points at a video file, judged by its path extension. */
+export function isLikelyVideoUrl(url: string): boolean {
+ let pathname: string;
+ try {
+ pathname = new URL(url).pathname.toLowerCase();
+ } catch {
+ return false;
+ }
+ return VIDEO_PATH_SUFFIXES.some((suffix) => pathname.endsWith(suffix));
+}
+
+interface PlatformInputFields {
+ htmlSnapshots: HtmlSnapshots;
+ postPublishedAt: Date | null;
+ hasVideo: boolean;
+}
+
+function htmlSnapshots(
+ serverVerifiedAt: Date | null,
+ serverHtml: string | null,
+ clientHtml: string | null,
+ platform: Platform,
+): HtmlSnapshots {
+ if (serverVerifiedAt === null) {
+ return { serverVerifiedAt: null, serverHtml, clientHtml };
+ }
+ if (serverHtml === null) {
+ throw new Error(
+ `serverVerifiedAt is set but serverHtml is missing for platform ${platform} — violates DB invariant (serverVerifiedAt IS NOT NULL → serverHtmlBlobId IS NOT NULL)`,
+ );
+ }
+ return { serverVerifiedAt, serverHtml, clientHtml };
+}
+
+function platformInputFields(postVersion: PostVersionForInputSnapshot): PlatformInputFields {
+ const platform = postVersion.post.platform;
+ switch (platform) {
+ case "LESSWRONG": {
+ const meta = postVersion.lesswrongVersionMeta;
+ return {
+ htmlSnapshots: htmlSnapshots(
+ postVersion.serverVerifiedAt,
+ meta?.serverHtmlBlob?.htmlContent ?? null,
+ meta?.clientHtmlBlob?.htmlContent ?? null,
+ platform,
+ ),
+ postPublishedAt: meta?.publishedAt ?? null,
+ hasVideo: false,
+ };
+ }
+ case "X": {
+ const meta = postVersion.xVersionMeta;
+ return {
+ htmlSnapshots: htmlSnapshots(postVersion.serverVerifiedAt, null, null, platform),
+ postPublishedAt: meta?.postedAt ?? null,
+ hasVideo: (meta?.mediaUrls ?? []).some(isLikelyVideoUrl),
+ };
+ }
+ case "SUBSTACK": {
+ const meta = postVersion.substackVersionMeta;
+ return {
+ htmlSnapshots: htmlSnapshots(
+ postVersion.serverVerifiedAt,
+ null,
+ meta?.clientHtmlBlob?.htmlContent ?? null,
+ platform,
+ ),
+ postPublishedAt: meta?.publishedAt ?? null,
+ hasVideo: false,
+ };
+ }
+ case "WIKIPEDIA": {
+ const meta = postVersion.wikipediaVersionMeta;
+ return {
+ htmlSnapshots: htmlSnapshots(
+ postVersion.serverVerifiedAt,
+ meta?.serverHtmlBlob?.htmlContent ?? null,
+ meta?.clientHtmlBlob?.htmlContent ?? null,
+ platform,
+ ),
+ postPublishedAt: meta?.lastModifiedAt ?? null,
+ hasVideo: false,
+ };
+ }
+ default:
+ return unreachablePlatform(platform);
+ }
+}
+
+/** Capture the input snapshot for a new investigation of `postVersionId`. */
+export async function buildInvestigationInputSnapshot(
+ db: DbClient,
+ postVersionId: string,
+): Promise {
+ const postVersion = await db.postVersion.findUnique({
+ where: { id: postVersionId },
+ select: postVersionForInputSnapshotSelect,
+ });
+ if (postVersion === null) {
+ throw new Error(`PostVersion ${postVersionId} not found`);
+ }
+
+ const { post } = postVersion;
+ const fields = platformInputFields(postVersion);
+ const markdown = resolveMarkdownForInvestigation({
+ platform: post.platform,
+ snapshots: fields.htmlSnapshots,
+ postUrl: post.url,
+ });
+
+ return {
+ provenance: postVersion.serverVerifiedAt === null ? "CLIENT_FALLBACK" : "SERVER_VERIFIED",
+ contentHash: postVersion.contentBlob.contentHash,
+ markdown:
+ markdown.source === "NONE"
+ ? { source: "NONE" }
+ : {
+ source: markdown.source,
+ markdown: markdown.markdown,
+ rendererVersion: markdown.rendererVersion,
+ imageSourceUrls: markdown.imageSourceUrls,
+ },
+ postUrl: post.url,
+ authorName: post.author?.displayName ?? null,
+ postPublishedAt: fields.postPublishedAt,
+ hasVideo: fields.hasVideo,
+ };
+}
+
+/** Row data for persisting `snapshot` as the input of `investigationId`. */
+export function investigationInputRow(
+ investigationId: string,
+ snapshot: InvestigationInputSnapshot,
+): Prisma.InvestigationInputUncheckedCreateInput {
+ return {
+ investigationId,
+ provenance: snapshot.provenance,
+ contentHash: snapshot.contentHash,
+ markdownSource: snapshot.markdown.source,
+ ...(snapshot.markdown.source === "NONE"
+ ? { imagePlaceholderSourceUrls: [] }
+ : {
+ markdown: snapshot.markdown.markdown,
+ markdownRendererVersion: snapshot.markdown.rendererVersion,
+ imagePlaceholderSourceUrls: snapshot.markdown.imageSourceUrls,
+ }),
+ postUrl: snapshot.postUrl,
+ authorName: snapshot.authorName,
+ postPublishedAt: snapshot.postPublishedAt,
+ hasVideo: snapshot.hasVideo,
+ };
+}
diff --git a/src/typescript/api/src/lib/services/investigation-lease.ts b/src/typescript/api/src/lib/services/investigation-lease.ts
index c25f32b..b248d25 100644
--- a/src/typescript/api/src/lib/services/investigation-lease.ts
+++ b/src/typescript/api/src/lib/services/investigation-lease.ts
@@ -1,7 +1,21 @@
-import { getPrisma } from "$lib/db/client";
+/**
+ * Investigation leases (SPEC §3.7).
+ *
+ * A worker runs an investigation only while it holds the InvestigationLease
+ * row; the database guarantees the row exists iff status = PROCESSING. This
+ * module owns every way into and out of that state that is not a run's own
+ * outcome: claiming (PENDING → PROCESSING), renewing via heartbeat (and
+ * noticing when the lease is gone), recovering expired leases, and releasing
+ * a run whose user key turned out to be unusable.
+ */
+
+import { getPrisma, type PrismaClient } from "$lib/db/client";
import type { Prisma } from "$lib/db/prisma-client";
-import { investigationContextInclude } from "./prompt-context.js";
+import type { InvestigatorAttemptAudit } from "$lib/investigators/interface.js";
+import { persistAttemptAudit } from "./attempt-audit.js";
+import { unfundedInvestigationWhere } from "./investigation-admission.js";
import { formatErrorForLog } from "./orchestrator-errors.js";
+import { consumeOpenAiKeySource } from "./user-key-source.js";
export interface Logger {
info(msg: string): void;
@@ -13,88 +27,54 @@ const LEASE_TTL_MS = 60_000;
const HEARTBEAT_INTERVAL_MS = 15_000;
/**
- * Maximum number of orchestration attempts before marking FAILED.
- * Each attempt is a full orchestration cycle (claim → investigate → persist).
- * Transient failures reclaim to PENDING and re-enqueue; this cap prevents
- * infinite retry loops.
+ * Attempt cap. A transient failure on attempt MAX_INVESTIGATION_ATTEMPTS (or
+ * later), or an expired lease after it, marks the investigation FAILED.
*/
export const MAX_INVESTIGATION_ATTEMPTS = 4;
+/** Exponential backoff base for transient retries. */
+const BASE_BACKOFF_MS = 10_000;
+
/**
- * Exponential backoff base for transient retries, in milliseconds.
- * Delay = BASE_BACKOFF_MS * 2^(attemptCount - 1), so:
- * attempt 1 → 10s, attempt 2 → 20s, attempt 3 → 40s
+ * Delay before retrying after transient failure of attempt `attemptNumber`
+ * (1-indexed): 10s, 20s, 40s, ...
*/
-export const BASE_BACKOFF_MS = 10_000;
-
-const investigationWithContextInclude = {
- ...investigationContextInclude,
- input: true,
- parentInvestigation: {
- include: {
- claims: {
- include: {
- sources: true,
- },
- },
- },
- },
-} satisfies Prisma.InvestigationInclude;
-
-type InvestigationWithContext = Prisma.InvestigationGetPayload<{
- include: typeof investigationWithContextInclude;
-}>;
+export function retryBackoffMs(attemptNumber: number): number {
+ return BASE_BACKOFF_MS * 2 ** (attemptNumber - 1);
+}
-function nextLeaseExpiry(): Date {
- return new Date(Date.now() + LEASE_TTL_MS);
+function nextLeaseExpiry(now: Date): Date {
+ return new Date(now.getTime() + LEASE_TTL_MS);
}
type LeaseClaimResult =
- | { outcome: "CLAIMED"; attemptNumber: number }
+ | { outcome: "CLAIMED"; attemptNumber: number; leaseExpiresAt: Date }
+ /** The investigation row is gone (stale job). */
| { outcome: "MISSING" }
+ /** COMPLETE or FAILED. */
| { outcome: "TERMINAL" }
+ /** Another worker holds an unexpired lease. */
| { outcome: "LEASE_HELD" }
- | { outcome: "ATTEMPTS_EXHAUSTED" };
+ /** PENDING, but its user key was dropped and nobody has funded it since. */
+ | { outcome: "UNFUNDED" };
/**
- * Atomically claim the investigation lease for this worker.
- *
- * The InvestigationLease table structurally enforces that PROCESSING
- * investigations always have a lease holder (the row's existence IS
- * the lease). This function handles two paths:
- *
- * Path 1 (PENDING → PROCESSING): transition investigation status,
- * increment attemptCount, and create a new lease row.
- *
- * Path 2 (stale PROCESSING): delete the expired lease row, increment
- * attemptCount, and create a fresh lease for this worker.
- *
- * Returns the attemptNumber (1-indexed) on CLAIMED so the orchestrator
- * can pass it to the audit trail.
- *
- * Note on retryAfter: this function intentionally does NOT check
- * Investigation.retryAfter before claiming. retryAfter is a selector gate
- * (prevents the cron from re-enqueueing too early) and a graphile-worker
- * scheduling hint (via enqueueInvestigation's runAt). Once a job actually
- * arrives at a worker — whether from the scheduled re-enqueue or from a
- * user-triggered investigateNow call — the worker may claim immediately.
- * This means investigateNow bypasses the backoff window, which is intentional:
- * an explicit user request should not be subject to the automatic retry delay.
+ * Claim a funded PENDING investigation: PENDING → PROCESSING, increment
+ * attemptCount, create the lease. Returns the new attempt number, or null if
+ * the investigation was not a funded PENDING investigation.
*/
-export async function tryClaimLease(
+async function claimFundedPending(
investigationId: string,
workerIdentity: string,
-): Promise {
+): Promise<{ attemptNumber: number; leaseExpiresAt: Date } | null> {
const now = new Date();
- const prisma = getPrisma();
-
- // Path 1: PENDING → PROCESSING
- const claimedFromPending = await prisma.$transaction(async (tx) => {
+ const leaseExpiresAt = nextLeaseExpiry(now);
+ return getPrisma().$transaction(async (tx) => {
const transitioned = await tx.investigation.updateMany({
where: {
id: investigationId,
status: "PENDING",
- attemptCount: { lt: MAX_INVESTIGATION_ATTEMPTS },
+ NOT: unfundedInvestigationWhere,
},
data: { status: "PROCESSING", attemptCount: { increment: 1 }, retryAfter: null },
});
@@ -104,159 +84,280 @@ export async function tryClaimLease(
data: {
investigationId,
leaseOwner: workerIdentity,
- leaseExpiresAt: nextLeaseExpiry(),
+ leaseExpiresAt,
startedAt: now,
heartbeatAt: now,
},
});
- const updated = await tx.investigation.findUnique({
+ const { attemptCount } = await tx.investigation.findUniqueOrThrow({
where: { id: investigationId },
select: { attemptCount: true },
});
- return updated?.attemptCount ?? null;
+ return { attemptNumber: attemptCount, leaseExpiresAt };
});
+}
- if (claimedFromPending !== null) {
- return { outcome: "CLAIMED", attemptNumber: claimedFromPending };
- }
+/**
+ * Atomically claim the investigation lease for this worker.
+ *
+ * Only funded PENDING investigations can be claimed. An expired lease left by
+ * a dead worker is recovered first (recoverExpiredLease) and the claim retried,
+ * so stale-lease handling has exactly one path.
+ *
+ * retryAfter is intentionally not checked: it is a selector gate and a queue
+ * scheduling hint (enqueueInvestigation's runAt). Once a job reaches a worker —
+ * from the scheduled retry or from an explicit investigateNow — it may claim
+ * immediately, so investigateNow bypasses the automatic retry delay.
+ */
+export async function tryClaimLease(
+ investigationId: string,
+ workerIdentity: string,
+): Promise {
+ const prisma = getPrisma();
+ // Two passes: the second follows recovery of an expired lease, or a race in
+ // which the row changed between the claim attempt and the classification.
+ for (let pass = 0; pass < 2; pass += 1) {
+ const claimed = await claimFundedPending(investigationId, workerIdentity);
+ if (claimed !== null) {
+ return { outcome: "CLAIMED", ...claimed };
+ }
- // Check if PENDING but attempts exhausted
- const pendingExhausted = await prisma.investigation.findUnique({
- where: { id: investigationId },
- select: { status: true, attemptCount: true },
- });
- if (
- pendingExhausted?.status === "PENDING" &&
- pendingExhausted.attemptCount >= MAX_INVESTIGATION_ATTEMPTS
- ) {
- return { outcome: "ATTEMPTS_EXHAUSTED" };
+ const state = await prisma.investigation.findUnique({
+ where: { id: investigationId },
+ select: {
+ status: true,
+ origin: true,
+ openAiKeySource: { select: { investigationId: true } },
+ lease: { select: { leaseExpiresAt: true } },
+ },
+ });
+ if (state === null) {
+ return { outcome: "MISSING" };
+ }
+
+ switch (state.status) {
+ case "COMPLETE":
+ case "FAILED":
+ return { outcome: "TERMINAL" };
+ case "PENDING":
+ if (state.origin === "USER_KEY_REQUEST" && state.openAiKeySource === null) {
+ return { outcome: "UNFUNDED" };
+ }
+ continue;
+ case "PROCESSING":
+ if (state.lease === null) {
+ throw new Error(
+ `Investigation ${investigationId} is PROCESSING without a lease row, which the database forbids`,
+ );
+ }
+ if (state.lease.leaseExpiresAt.getTime() > Date.now()) {
+ return { outcome: "LEASE_HELD" };
+ }
+ await recoverExpiredLease(prisma, investigationId);
+ continue;
+ }
}
- // Path 2: Reclaim stale PROCESSING lease.
- //
- // IMPORTANT: when the deleteMany succeeds but the updateMany guard fails,
- // we must throw (not return) to rollback the transaction. A bare `return null`
- // would commit the lease deletion while leaving the investigation PROCESSING
- // with no InvestigationLease row — a zombie state the selector perpetually
- // re-selects but no worker can ever claim.
- class StaleReclaimAborted extends Error {}
- const reclaimedStale = await prisma
- .$transaction(async (tx) => {
- const deleted = await tx.investigationLease.deleteMany({
- where: { investigationId, leaseExpiresAt: { lte: now } },
- });
- if (deleted.count === 0) return null;
+ throw new Error(
+ `Could not claim or classify investigation ${investigationId}: its state kept changing`,
+ );
+}
- // Guard: only reclaim if still PROCESSING and under the attempt cap.
- // Throws to rollback the lease deletion if the guard fails — prevents
- // creating a PROCESSING investigation with no lease row.
- const incremented = await tx.investigation.updateMany({
- where: {
- id: investigationId,
- status: "PROCESSING",
- attemptCount: { lt: MAX_INVESTIGATION_ATTEMPTS },
- },
- data: { attemptCount: { increment: 1 } },
- });
- if (incremented.count === 0) {
- throw new StaleReclaimAborted("Guard failed; rolling back lease deletion");
- }
+/**
+ * Recover an investigation whose lease expired (its worker died or stalled):
+ * delete the lease and return it to PENDING, or mark it FAILED when the lost
+ * attempt was the last one allowed. Returns false if there was no expired
+ * lease to recover. This is the only stale-lease recovery path; the worker,
+ * the selector and investigateNow all use it.
+ */
+export async function recoverExpiredLease(
+ prisma: PrismaClient,
+ investigationId: string,
+): Promise {
+ return prisma.$transaction(async (tx) => {
+ const now = new Date();
+ const deleted = await tx.investigationLease.deleteMany({
+ where: { investigationId, leaseExpiresAt: { lte: now } },
+ });
+ if (deleted.count === 0) {
+ return false;
+ }
- await tx.investigationLease.create({
- data: {
- investigationId,
- leaseOwner: workerIdentity,
- leaseExpiresAt: nextLeaseExpiry(),
- startedAt: now,
- heartbeatAt: now,
- },
+ const { attemptCount } = await tx.investigation.findUniqueOrThrow({
+ where: { id: investigationId },
+ select: { attemptCount: true },
+ });
+ if (attemptCount >= MAX_INVESTIGATION_ATTEMPTS) {
+ await tx.investigation.update({
+ where: { id: investigationId },
+ data: { status: "FAILED" },
});
-
- const updated = await tx.investigation.findUnique({
+ await consumeOpenAiKeySource(tx, investigationId);
+ } else {
+ await tx.investigation.update({
where: { id: investigationId },
- select: { attemptCount: true },
+ data: { status: "PENDING", queuedAt: now },
});
- return updated?.attemptCount ?? null;
- })
- .catch((error: unknown) => {
- if (error instanceof StaleReclaimAborted) return null;
- throw error;
- });
-
- if (reclaimedStale !== null) {
- return { outcome: "CLAIMED", attemptNumber: reclaimedStale };
- }
-
- // Fallback: determine why we couldn't claim
- const investigation = await prisma.investigation.findUnique({
- where: { id: investigationId },
- select: { status: true, attemptCount: true },
- });
- const lease = await prisma.investigationLease.findUnique({
- where: { investigationId },
- select: { leaseExpiresAt: true },
+ }
+ return true;
});
+}
- if (!investigation) return { outcome: "MISSING" };
- if (investigation.status === "COMPLETE" || investigation.status === "FAILED") {
- return { outcome: "TERMINAL" };
- }
- if (investigation.status === "PROCESSING" && lease !== null && lease.leaseExpiresAt > now) {
- return { outcome: "LEASE_HELD" };
+export class LeaseLostError extends Error {
+ constructor(investigationId: string, reason: string) {
+ super(`Lost the lease on investigation ${investigationId}: ${reason}`);
+ this.name = "LeaseLostError";
}
- // PROCESSING with exhausted attempts: stale reclaim was rolled back above.
- // Signal the orchestrator to mark FAILED only when no active lease exists.
- if (
- investigation.status === "PROCESSING" &&
- investigation.attemptCount >= MAX_INVESTIGATION_ATTEMPTS
- ) {
- return { outcome: "ATTEMPTS_EXHAUSTED" };
- }
- return { outcome: "LEASE_HELD" };
}
-export async function loadClaimedInvestigation(
- investigationId: string,
-): Promise {
- return getPrisma().investigation.findUnique({
- where: { id: investigationId },
- include: investigationWithContextInclude,
- });
+interface LeaseHeartbeat {
+ /**
+ * Aborts (with a LeaseLostError reason) once this worker can no longer show
+ * it holds the lease: a renewal found no lease row owned by it, or renewals
+ * kept failing until the last confirmed expiry passed. Every write the run
+ * makes after this aborts would race the lease's new owner.
+ */
+ readonly leaseLostSignal: AbortSignal;
+ stop(): void;
}
-export function startHeartbeat(
- investigationId: string,
- workerIdentity: string,
+/** Renew the lease every HEARTBEAT_INTERVAL_MS until stopped or lost. */
+export function startLeaseHeartbeat(
+ lease: { investigationId: string; workerIdentity: string; leaseExpiresAt: Date },
logger: Logger,
-): { stop(): void } {
+): LeaseHeartbeat {
const prisma = getPrisma();
- const timer = setInterval(() => {
- void prisma.investigationLease
- .updateMany({
- where: {
- investigationId,
- leaseOwner: workerIdentity,
- },
- data: {
- leaseExpiresAt: nextLeaseExpiry(),
- heartbeatAt: new Date(),
- },
- })
- .catch((error: unknown) => {
- logger.error(
- `Investigation ${investigationId} heartbeat update failed: ${formatErrorForLog(error)}`,
- );
+ const controller = new AbortController();
+ let confirmedExpiry = lease.leaseExpiresAt.getTime();
+ let timer: ReturnType | null = null;
+ let stopped = false;
+
+ function loseLease(reason: string): void {
+ controller.abort(new LeaseLostError(lease.investigationId, reason));
+ }
+
+ async function renew(): Promise {
+ const now = new Date();
+ const renewedExpiry = nextLeaseExpiry(now);
+ try {
+ const renewed = await prisma.investigationLease.updateMany({
+ where: { investigationId: lease.investigationId, leaseOwner: lease.workerIdentity },
+ data: { leaseExpiresAt: renewedExpiry, heartbeatAt: now },
});
- }, HEARTBEAT_INTERVAL_MS);
+ if (renewed.count === 0) {
+ loseLease("the lease row is gone or owned by another worker");
+ return;
+ }
+ confirmedExpiry = renewedExpiry.getTime();
+ } catch (error) {
+ logger.error(
+ `Investigation ${lease.investigationId} heartbeat update failed: ${formatErrorForLog(error)}`,
+ );
+ if (Date.now() >= confirmedExpiry) {
+ loseLease("renewals failed until the lease expired");
+ }
+ }
+ }
- if (typeof timer.unref === "function") {
+ function schedule(): void {
+ timer = setTimeout(() => {
+ void renew().then(() => {
+ if (!stopped && !controller.signal.aborted) schedule();
+ });
+ }, HEARTBEAT_INTERVAL_MS);
timer.unref();
}
+ schedule();
return {
+ leaseLostSignal: controller.signal,
stop() {
- clearInterval(timer);
+ stopped = true;
+ if (timer !== null) clearTimeout(timer);
},
};
}
+
+/**
+ * The user key funding this run is unusable (expired, undecryptable, or
+ * refused by OpenAI): drop it and return the investigation to PENDING without
+ * re-enqueueing. It is now unfunded; the selector or a new request can fund
+ * it. The failed attempt is still recorded. Returns false if this worker no
+ * longer holds the lease.
+ */
+export async function releaseLeaseDroppingUserKey(input: {
+ investigationId: string;
+ workerIdentity: string;
+ attemptNumber: number;
+ attemptAudit: InvestigatorAttemptAudit | null;
+}): Promise {
+ return getPrisma().$transaction(async (tx) => {
+ const released = await tx.investigationLease.deleteMany({
+ where: { investigationId: input.investigationId, leaseOwner: input.workerIdentity },
+ });
+ if (released.count === 0) {
+ return false;
+ }
+
+ await tx.investigation.update({
+ where: { id: input.investigationId },
+ data: { status: "PENDING", queuedAt: new Date(), retryAfter: null },
+ });
+ await consumeOpenAiKeySource(tx, input.investigationId);
+ if (input.attemptAudit !== null) {
+ await persistAttemptAudit(tx, {
+ investigationId: input.investigationId,
+ attemptNumber: input.attemptNumber,
+ attemptAudit: input.attemptAudit,
+ });
+ }
+ return true;
+ });
+}
+
+const investigationForRunInclude = {
+ input: true,
+ postVersion: {
+ select: {
+ contentBlob: { select: { contentText: true } },
+ imageOccurrenceSet: {
+ select: {
+ occurrences: {
+ orderBy: [{ originalIndex: "asc" }],
+ select: {
+ originalIndex: true,
+ normalizedTextOffset: true,
+ sourceUrl: true,
+ captionText: true,
+ },
+ },
+ },
+ },
+ post: { select: { platform: true } },
+ },
+ },
+ parentInvestigation: {
+ include: {
+ claims: {
+ include: {
+ sources: true,
+ },
+ },
+ },
+ },
+} satisfies Prisma.InvestigationInclude;
+
+export type InvestigationForRun = Prisma.InvestigationGetPayload<{
+ include: typeof investigationForRunInclude;
+}>;
+
+/** Everything a claimed run needs: the input snapshot, version text and images, and parent claims. */
+export async function loadClaimedInvestigation(
+ investigationId: string,
+): Promise {
+ return getPrisma().investigation.findUnique({
+ where: { id: investigationId },
+ include: investigationForRunInclude,
+ });
+}
diff --git a/src/typescript/api/src/lib/services/investigation-lifecycle.ts b/src/typescript/api/src/lib/services/investigation-lifecycle.ts
deleted file mode 100644
index ea12e53..0000000
--- a/src/typescript/api/src/lib/services/investigation-lifecycle.ts
+++ /dev/null
@@ -1,420 +0,0 @@
-import { isUniqueConstraintError } from "$lib/db/errors.js";
-import type { Investigation, Prisma, PrismaClient } from "$lib/db/prisma-client";
-import {
- DEFAULT_INVESTIGATION_MODEL,
- DEFAULT_INVESTIGATION_PROVIDER,
- WORD_COUNT_LIMIT,
-} from "@openerrata/shared";
-import { resolveMarkdownForInvestigation } from "./markdown-resolution.js";
-import type { HtmlSnapshots } from "./prompt-context.js";
-import { enqueueInvestigation } from "./queue.js";
-import { randomUUID } from "node:crypto";
-
-export class InvestigationWordLimitError extends Error {
- readonly limit: number;
- readonly observedWordCount: number;
-
- constructor(observedWordCount: number, limit: number) {
- super(`Post exceeds word count limit (${limit.toString()} words)`);
- this.name = "InvestigationWordLimitError";
- this.observedWordCount = observedWordCount;
- this.limit = limit;
- }
-}
-
-export function wordCount(text: string): number {
- return text.split(/\s+/).filter(Boolean).length;
-}
-
-async function findInvestigation(
- prisma: PrismaClient,
- postVersionId: string,
-): Promise {
- return prisma.investigation.findUnique({
- where: {
- postVersionId,
- },
- });
-}
-
-async function loadPostVersionWordCount(
- prisma: PrismaClient,
- postVersionId: string,
-): Promise {
- const postVersion = await prisma.postVersion.findUnique({
- where: { id: postVersionId },
- select: {
- contentBlob: {
- select: {
- wordCount: true,
- },
- },
- },
- });
-
- if (postVersion === null) {
- throw new Error(`PostVersion ${postVersionId} not found`);
- }
-
- return postVersion.contentBlob.wordCount;
-}
-
-const postVersionForInputSnapshotSelect = {
- post: {
- select: {
- platform: true,
- },
- },
- serverVerifiedAt: true,
- contentBlob: {
- select: {
- contentHash: true,
- },
- },
- lesswrongVersionMeta: {
- select: {
- serverHtmlBlob: { select: { htmlContent: true } },
- clientHtmlBlob: { select: { htmlContent: true } },
- },
- },
- substackVersionMeta: {
- select: {
- serverHtmlBlob: { select: { htmlContent: true } },
- clientHtmlBlob: { select: { htmlContent: true } },
- },
- },
- wikipediaVersionMeta: {
- select: {
- serverHtmlBlob: { select: { htmlContent: true } },
- clientHtmlBlob: { select: { htmlContent: true } },
- },
- },
-} satisfies Prisma.PostVersionSelect;
-
-type PostVersionForInputSnapshot = Prisma.PostVersionGetPayload<{
- select: typeof postVersionForInputSnapshotSelect;
-}>;
-
-type InvestigationInputSnapshot =
- | {
- provenance: "SERVER_VERIFIED" | "CLIENT_FALLBACK";
- contentHash: string;
- markdownSource: "NONE";
- }
- | {
- provenance: "SERVER_VERIFIED" | "CLIENT_FALLBACK";
- contentHash: string;
- markdownSource: "SERVER_HTML" | "CLIENT_HTML";
- markdown: string;
- markdownRendererVersion: string;
- };
-
-function unreachablePlatform(platform: never): never {
- throw new Error(`Unsupported post platform: ${String(platform)}`);
-}
-
-function resolveHtmlSnapshotsFromPostVersion(
- postVersion: PostVersionForInputSnapshot,
-): HtmlSnapshots {
- const platform = postVersion.post.platform;
- let serverHtml: string | null;
- let clientHtml: string | null;
- switch (platform) {
- case "LESSWRONG":
- serverHtml = postVersion.lesswrongVersionMeta?.serverHtmlBlob?.htmlContent ?? null;
- clientHtml = postVersion.lesswrongVersionMeta?.clientHtmlBlob?.htmlContent ?? null;
- break;
- case "SUBSTACK":
- serverHtml = postVersion.substackVersionMeta?.serverHtmlBlob?.htmlContent ?? null;
- clientHtml = postVersion.substackVersionMeta?.clientHtmlBlob?.htmlContent ?? null;
- break;
- case "WIKIPEDIA":
- serverHtml = postVersion.wikipediaVersionMeta?.serverHtmlBlob?.htmlContent ?? null;
- clientHtml = postVersion.wikipediaVersionMeta?.clientHtmlBlob?.htmlContent ?? null;
- break;
- case "X":
- serverHtml = null;
- clientHtml = null;
- break;
- default:
- return unreachablePlatform(platform);
- }
-
- if (postVersion.serverVerifiedAt !== null) {
- if (serverHtml === null) {
- throw new Error(
- `serverVerifiedAt is set but serverHtml is missing for platform ${platform} — violates DB invariant (serverVerifiedAt IS NOT NULL → serverHtmlBlobId IS NOT NULL)`,
- );
- }
- return { serverVerifiedAt: postVersion.serverVerifiedAt, serverHtml, clientHtml };
- }
- return { serverVerifiedAt: null, serverHtml, clientHtml };
-}
-
-async function loadInvestigationInputSnapshot(
- prisma: PrismaClient,
- postVersionId: string,
-): Promise {
- const postVersion = await prisma.postVersion.findUnique({
- where: { id: postVersionId },
- select: postVersionForInputSnapshotSelect,
- });
- if (postVersion === null) {
- throw new Error(`PostVersion ${postVersionId} not found`);
- }
-
- const htmlSnapshots = resolveHtmlSnapshotsFromPostVersion(postVersion);
- const markdownResolution = resolveMarkdownForInvestigation({
- platform: postVersion.post.platform,
- snapshots: htmlSnapshots,
- });
- const provenance =
- htmlSnapshots.serverVerifiedAt !== null
- ? ("SERVER_VERIFIED" as const)
- : ("CLIENT_FALLBACK" as const);
-
- if (markdownResolution.source === "NONE") {
- return {
- provenance,
- contentHash: postVersion.contentBlob.contentHash,
- markdownSource: "NONE",
- };
- }
-
- return {
- provenance,
- contentHash: postVersion.contentBlob.contentHash,
- markdownSource: markdownResolution.source,
- markdown: markdownResolution.markdown,
- markdownRendererVersion: markdownResolution.rendererVersion,
- };
-}
-
-async function createInvestigation(
- prisma: PrismaClient,
- input: {
- postVersionId: string;
- promptId: string;
- snapshot: InvestigationInputSnapshot;
- parentInvestigationId?: string;
- contentDiff?: string;
- },
-): Promise {
- const investigationId = randomUUID();
- const now = new Date();
- return prisma.$transaction(async (tx) => {
- await tx.investigationInput.create({
- data: {
- investigationId,
- provenance: input.snapshot.provenance,
- contentHash: input.snapshot.contentHash,
- markdownSource: input.snapshot.markdownSource,
- ...(input.snapshot.markdownSource === "NONE"
- ? {}
- : {
- markdown: input.snapshot.markdown,
- markdownRendererVersion: input.snapshot.markdownRendererVersion,
- }),
- },
- });
-
- return tx.investigation.create({
- data: {
- id: investigationId,
- inputId: investigationId,
- postVersionId: input.postVersionId,
- status: "PENDING",
- parentInvestigationId: input.parentInvestigationId ?? null,
- contentDiff: input.contentDiff ?? null,
- promptId: input.promptId,
- provider: DEFAULT_INVESTIGATION_PROVIDER,
- model: DEFAULT_INVESTIGATION_MODEL,
- queuedAt: now,
- },
- });
- });
-}
-
-/**
- * Recover a stale PROCESSING investigation whose lease has expired.
- * Deletes the expired InvestigationLease row and transitions
- * PROCESSING → PENDING.
- */
-async function tryRecoverExpiredProcessingInvestigation(
- prisma: PrismaClient,
- investigationId: string,
-): Promise {
- const now = new Date();
-
- return prisma.$transaction(async (tx) => {
- // Try to delete an expired lease row.
- const deleted = await tx.investigationLease.deleteMany({
- where: { investigationId, leaseExpiresAt: { lte: now } },
- });
-
- if (deleted.count === 0) {
- // No expired lease was deleted. Check whether a non-expired lease exists.
- const activeLease = await tx.investigationLease.findUnique({
- where: { investigationId },
- select: { investigationId: true },
- });
- if (activeLease) return null; // Active lease, can't recover
-
- const investigationStatus = await tx.investigation.findUnique({
- where: { id: investigationId },
- select: { status: true },
- });
- if (!investigationStatus) return null;
- if (investigationStatus.status !== "PROCESSING") {
- // Another concurrent caller likely recovered/transitioned this row
- // between our candidate selection and recovery attempt.
- return null;
- }
-
- // PROCESSING with no lease row at all is an invariant violation:
- // the InvestigationLease row's existence IS the PROCESSING state.
- // The migration cleans these up, so hitting this in production
- // indicates a bug in lease lifecycle management.
- throw new Error(
- `Invariant violation: PROCESSING investigation ${investigationId} has no InvestigationLease row. ` +
- `This state should not be reachable — lease row existence is required for PROCESSING status.`,
- );
- }
-
- // Expired lease deleted — transition PROCESSING → PENDING
- const recovered = await tx.investigation.updateMany({
- where: { id: investigationId, status: "PROCESSING" },
- data: { status: "PENDING", queuedAt: now },
- });
-
- if (recovered.count === 0) return null;
-
- const investigation = await tx.investigation.findUnique({
- where: { id: investigationId },
- });
-
- if (!investigation) {
- throw new Error(
- `Missing investigation during stale-run recovery (investigationId=${investigationId})`,
- );
- }
-
- return investigation;
- });
-}
-
-interface EnsureInvestigationInput {
- prisma: PrismaClient;
- postVersionId: string;
- promptId: string;
- parentInvestigationId?: string;
- contentDiff?: string;
- rejectOverWordLimitOnCreate?: boolean;
- allowRequeueFailed?: boolean;
- enqueue?: boolean;
- onPendingInvestigation?: (input: {
- prisma: PrismaClient;
- investigation: Investigation;
- }) => Promise;
-}
-
-async function ensureInvestigationRecord(input: EnsureInvestigationInput): Promise<{
- investigation: Investigation;
- created: boolean;
-}> {
- const rejectOverWordLimitOnCreate = input.rejectOverWordLimitOnCreate ?? true;
- const allowRequeueFailed = input.allowRequeueFailed ?? false;
-
- let investigation = await findInvestigation(input.prisma, input.postVersionId);
- let created = false;
-
- if (!investigation) {
- if (rejectOverWordLimitOnCreate) {
- const observedWordCount = await loadPostVersionWordCount(input.prisma, input.postVersionId);
- if (observedWordCount > WORD_COUNT_LIMIT) {
- throw new InvestigationWordLimitError(observedWordCount, WORD_COUNT_LIMIT);
- }
- }
-
- try {
- const snapshot = await loadInvestigationInputSnapshot(input.prisma, input.postVersionId);
- const createInput: Parameters[1] = {
- postVersionId: input.postVersionId,
- promptId: input.promptId,
- snapshot,
- };
- if (input.parentInvestigationId !== undefined) {
- createInput.parentInvestigationId = input.parentInvestigationId;
- }
- if (input.contentDiff !== undefined) {
- createInput.contentDiff = input.contentDiff;
- }
- investigation = await createInvestigation(input.prisma, createInput);
- created = true;
- } catch (error) {
- if (!isUniqueConstraintError(error)) throw error;
- investigation = await findInvestigation(input.prisma, input.postVersionId);
- if (!investigation) throw error;
- }
- }
-
- if (allowRequeueFailed && investigation.status === "FAILED") {
- const failedInvestigation = investigation; // capture narrow type for async callback
- investigation = await input.prisma.$transaction(async (tx) => {
- // Defensive cleanup for any leftover lease row from prior failures.
- await tx.investigationLease.deleteMany({
- where: { investigationId: failedInvestigation.id },
- });
-
- return tx.investigation.update({
- where: { id: failedInvestigation.id },
- data: {
- parentInvestigationId: input.parentInvestigationId ?? null,
- contentDiff: input.contentDiff ?? null,
- status: "PENDING",
- checkedAt: null,
- queuedAt: new Date(),
- attemptCount: 0,
- retryAfter: null,
- },
- });
- });
- }
-
- return { investigation, created };
-}
-
-export async function ensureInvestigationQueued(input: EnsureInvestigationInput): Promise<{
- investigation: Investigation;
- created: boolean;
- enqueued: boolean;
-}> {
- const { investigation: initialInvestigation, created } = await ensureInvestigationRecord(input);
- let investigation = initialInvestigation;
-
- // Recover stale PROCESSING investigations with expired leases
- if (investigation.status === "PROCESSING") {
- const recovered = await tryRecoverExpiredProcessingInvestigation(
- input.prisma,
- investigation.id,
- );
- if (recovered) {
- investigation = recovered;
- }
- }
-
- const shouldEnqueue = input.enqueue ?? true;
- let enqueued = false;
- if (shouldEnqueue && investigation.status === "PENDING") {
- if (input.onPendingInvestigation) {
- await input.onPendingInvestigation({
- prisma: input.prisma,
- investigation,
- });
- }
- await enqueueInvestigation(investigation.id);
- enqueued = true;
- }
-
- return { investigation, created, enqueued };
-}
diff --git a/src/typescript/api/src/lib/services/markdown-resolution.ts b/src/typescript/api/src/lib/services/markdown-resolution.ts
index 02354da..b07097b 100644
--- a/src/typescript/api/src/lib/services/markdown-resolution.ts
+++ b/src/typescript/api/src/lib/services/markdown-resolution.ts
@@ -12,8 +12,6 @@
*/
import type { Platform } from "@openerrata/shared";
-import type { ImagePlaceholder } from "$lib/investigators/interface.js";
-import type { HtmlSnapshots } from "./prompt-context.js";
import {
lesswrongHtmlToContentMarkdown,
substackHtmlToContentMarkdown,
@@ -21,18 +19,24 @@ import {
MARKDOWN_RENDERER_VERSION,
} from "./html-to-markdown.js";
+/**
+ * Source-scoped HTML snapshots with the serverVerifiedAt latch bundled in.
+ *
+ * The discriminated union encodes the DB invariant:
+ * serverVerifiedAt IS NOT NULL → serverHtmlBlobId IS NOT NULL
+ * When server-verified, serverHtml is guaranteed non-null at the type level.
+ */
+export type HtmlSnapshots =
+ | { serverVerifiedAt: Date; serverHtml: string; clientHtml: string | null }
+ | { serverVerifiedAt: null; serverHtml: string | null; clientHtml: string | null };
+
type MarkdownResolution =
| {
- source: "SERVER_HTML";
+ source: "SERVER_HTML" | "CLIENT_HTML";
markdown: string;
rendererVersion: string;
- imagePlaceholders: ImagePlaceholder[];
- }
- | {
- source: "CLIENT_HTML";
- markdown: string;
- rendererVersion: string;
- imagePlaceholders: ImagePlaceholder[];
+ /** Source URL of the image behind `[IMAGE:N]`, indexed by N. */
+ imageSourceUrls: string[];
}
| { source: "NONE" };
@@ -44,34 +48,27 @@ type MarkdownResolution =
* - serverVerifiedAt non-null branch → serverHtml: string guaranteed by type
* - serverVerifiedAt null + clientHtml non-null → CLIENT_HTML
* - otherwise → NONE (X posts, or versions without HTML snapshots)
+ *
+ * `postUrl` is the base for resolving relative image sources.
*/
export function resolveMarkdownForInvestigation(input: {
platform: Platform;
snapshots: HtmlSnapshots;
+ postUrl: string;
}): MarkdownResolution {
if (input.snapshots.serverVerifiedAt !== null) {
- const { markdown, imagePlaceholders } = platformMarkdown(
- input.platform,
- input.snapshots.serverHtml,
- );
return {
source: "SERVER_HTML",
- markdown,
rendererVersion: MARKDOWN_RENDERER_VERSION,
- imagePlaceholders,
+ ...platformMarkdown(input.platform, input.snapshots.serverHtml, input.postUrl),
};
}
if (input.snapshots.clientHtml !== null) {
- const { markdown, imagePlaceholders } = platformMarkdown(
- input.platform,
- input.snapshots.clientHtml,
- );
return {
source: "CLIENT_HTML",
- markdown,
rendererVersion: MARKDOWN_RENDERER_VERSION,
- imagePlaceholders,
+ ...platformMarkdown(input.platform, input.snapshots.clientHtml, input.postUrl),
};
}
@@ -81,40 +78,18 @@ export function resolveMarkdownForInvestigation(input: {
function platformMarkdown(
platform: Platform,
html: string,
-): { markdown: string; imagePlaceholders: ImagePlaceholder[] } {
+ postUrl: string,
+): { markdown: string; imageSourceUrls: string[] } {
switch (platform) {
case "LESSWRONG":
- return lesswrongHtmlToContentMarkdown(html);
+ return lesswrongHtmlToContentMarkdown(html, postUrl);
case "SUBSTACK":
- return substackHtmlToContentMarkdown(html);
+ return substackHtmlToContentMarkdown(html, postUrl);
case "WIKIPEDIA":
- return wikipediaHtmlToContentMarkdown(html);
+ return wikipediaHtmlToContentMarkdown(html, postUrl);
case "X":
// X has no HTML; resolveMarkdownForInvestigation returns NONE before
// reaching here. If this fires, the caller has a bug.
throw new Error("platformMarkdown called for X, which has no HTML content");
}
}
-
-/**
- * Extract image placeholders from stored markdown by parsing `[IMAGE:N]` patterns.
- *
- * Used on retry to reconstruct placeholders from the InvestigationInput snapshot
- * without re-resolving from HTML.
- */
-export function extractImagePlaceholdersFromMarkdown(markdown: string): ImagePlaceholder[] {
- const placeholders: ImagePlaceholder[] = [];
- const pattern = /\[IMAGE:(\d+)\]/g;
- let match: RegExpExecArray | null;
-
- while ((match = pattern.exec(markdown)) !== null) {
- placeholders.push({
- index: parseInt(match[1] ?? "0", 10),
- // sourceUrl is not recoverable from markdown alone; retries must match
- // placeholders to image occurrences by originalIndex.
- matchBy: "ORIGINAL_INDEX",
- });
- }
-
- return placeholders;
-}
diff --git a/src/typescript/api/src/lib/services/openai-key-validation-core.ts b/src/typescript/api/src/lib/services/openai-key-validation-core.ts
index f7f58a9..096bd8c 100644
--- a/src/typescript/api/src/lib/services/openai-key-validation-core.ts
+++ b/src/typescript/api/src/lib/services/openai-key-validation-core.ts
@@ -1,25 +1,66 @@
+import OpenAI from "openai";
import { openaiApiKeyFormatSchema } from "@openerrata/shared";
-import {
- classifyOpenAiKeyValidationStatus,
- readOpenAiStatusCode,
- type OpenAiKeyValidationStatusOutcome,
-} from "$lib/openai/errors.js";
+import { INVESTIGATION_REQUEST_CONFIG } from "$lib/investigators/openai-request-config.js";
+import { probeInvestigationRequest } from "$lib/investigators/openai-probe.js";
-export type { OpenAiKeyValidationStatusOutcome };
-
-type ValidateOpenAiKeyReachability = (openAiApiKey: string) => Promise;
-
-function isAbortError(error: unknown): boolean {
- return error instanceof Error && error.name === "AbortError";
-}
+export type OpenAiKeyValidationStatusOutcome =
+ | { openaiApiKeyStatus: "missing" }
+ | { openaiApiKeyStatus: "valid" }
+ | {
+ openaiApiKeyStatus: "format_invalid";
+ openaiApiKeyMessage: string;
+ }
+ | {
+ openaiApiKeyStatus: "authenticated_restricted";
+ openaiApiKeyMessage: string;
+ }
+ | {
+ openaiApiKeyStatus: "invalid";
+ openaiApiKeyMessage: string;
+ }
+ | {
+ openaiApiKeyStatus: "error";
+ openaiApiKeyMessage: string;
+ };
-function readErrorMessage(error: unknown): string | null {
- return error instanceof Error && error.message.trim().length > 0 ? error.message.trim() : null;
+function describeProbeFailure(error: unknown): OpenAiKeyValidationStatusOutcome {
+ if (error instanceof OpenAI.AuthenticationError) {
+ return { openaiApiKeyStatus: "invalid", openaiApiKeyMessage: "OpenAI rejected this API key." };
+ }
+ if (error instanceof OpenAI.PermissionDeniedError) {
+ return {
+ openaiApiKeyStatus: "authenticated_restricted",
+ openaiApiKeyMessage:
+ "OpenAI authenticated this key, but access is restricted for validation checks.",
+ };
+ }
+ if (error instanceof OpenAI.APIConnectionTimeoutError) {
+ return {
+ openaiApiKeyStatus: "error",
+ openaiApiKeyMessage:
+ "OpenAI key validation timed out. Confirm outbound network access and retry.",
+ };
+ }
+ // Anything else (no model access, rejected request shape, rate limit,
+ // network failure) is reported with the provider's own explanation.
+ const message = error instanceof Error ? error.message.trim() : "";
+ return {
+ openaiApiKeyStatus: "error",
+ openaiApiKeyMessage:
+ message.length > 0
+ ? message
+ : "Could not validate this key with OpenAI. Check outbound network access and retry.",
+ };
}
-export async function validateOpenAiApiKeyForSettingsWithReachability(
+/**
+ * Settings-page key check: the key must be able to make the investigation
+ * request itself (same probe the worker runs at startup), so a key without
+ * access to the investigation model is not reported as valid.
+ */
+export async function validateOpenAiApiKeyForSettingsWithClient(
openaiApiKey: string | null,
- validateOpenAiApiKeyReachability: ValidateOpenAiKeyReachability,
+ createClient: (apiKey: string) => OpenAI,
): Promise {
const normalizedOpenAiApiKey = openaiApiKey?.trim() ?? "";
if (normalizedOpenAiApiKey.length === 0) {
@@ -35,34 +76,12 @@ export async function validateOpenAiApiKeyForSettingsWithReachability(
}
try {
- await validateOpenAiApiKeyReachability(normalizedOpenAiApiKey);
+ await probeInvestigationRequest(
+ createClient(normalizedOpenAiApiKey),
+ INVESTIGATION_REQUEST_CONFIG,
+ );
return { openaiApiKeyStatus: "valid" };
} catch (error) {
- if (isAbortError(error)) {
- return {
- openaiApiKeyStatus: "error",
- openaiApiKeyMessage:
- "OpenAI key validation timed out. Confirm outbound network access and retry.",
- };
- }
-
- const statusOutcome = classifyOpenAiKeyValidationStatus(readOpenAiStatusCode(error));
- if (statusOutcome && statusOutcome.openaiApiKeyStatus !== "error") {
- return statusOutcome;
- }
-
- const specificMessage = readErrorMessage(error);
- if (specificMessage !== null) {
- return {
- openaiApiKeyStatus: "error",
- openaiApiKeyMessage: specificMessage,
- };
- }
-
- return {
- openaiApiKeyStatus: "error",
- openaiApiKeyMessage:
- "Could not validate this key with OpenAI. Check outbound network access and retry.",
- };
+ return describeProbeFailure(error);
}
}
diff --git a/src/typescript/api/src/lib/services/openai-key-validation.ts b/src/typescript/api/src/lib/services/openai-key-validation.ts
index c17b3a8..ac56981 100644
--- a/src/typescript/api/src/lib/services/openai-key-validation.ts
+++ b/src/typescript/api/src/lib/services/openai-key-validation.ts
@@ -1,37 +1,17 @@
import { OPENAI_KEY_VALIDATION_TIMEOUT_MS } from "@openerrata/shared";
import OpenAI from "openai";
-import { getEnv } from "$lib/config/env.js";
import {
- validateOpenAiApiKeyForSettingsWithReachability,
+ validateOpenAiApiKeyForSettingsWithClient,
type OpenAiKeyValidationStatusOutcome,
} from "./openai-key-validation-core.js";
-async function validateOpenAiApiKeyReachability(openAiApiKey: string): Promise {
- const client = new OpenAI({ apiKey: openAiApiKey });
- const abortController = new AbortController();
- const timeoutId = setTimeout(() => {
- abortController.abort();
- }, OPENAI_KEY_VALIDATION_TIMEOUT_MS);
-
- try {
- await client.responses.create(
- {
- model: getEnv().OPENAI_MODEL_ID,
- input: "Reply with the single word pong.",
- max_output_tokens: 16,
- },
- { signal: abortController.signal },
- );
- } finally {
- clearTimeout(timeoutId);
- }
-}
-
export async function validateOpenAiApiKeyForSettings(
openaiApiKey: string | null,
): Promise {
- return validateOpenAiApiKeyForSettingsWithReachability(
+ return validateOpenAiApiKeyForSettingsWithClient(
openaiApiKey,
- validateOpenAiApiKeyReachability,
+ // One bounded try: SDK retries would stack further timeouts behind a
+ // settings request the user is waiting on.
+ (apiKey) => new OpenAI({ apiKey, timeout: OPENAI_KEY_VALIDATION_TIMEOUT_MS, maxRetries: 0 }),
);
}
diff --git a/src/typescript/api/src/lib/services/orchestrator-errors.ts b/src/typescript/api/src/lib/services/orchestrator-errors.ts
index 3e9c432..953a4f3 100644
--- a/src/typescript/api/src/lib/services/orchestrator-errors.ts
+++ b/src/typescript/api/src/lib/services/orchestrator-errors.ts
@@ -1,11 +1,17 @@
import { ZodError } from "zod";
-import { isNonRetryableOpenAiStatusCode, readOpenAiStatusCode } from "$lib/openai/errors.js";
+import { readOpenAiStatusCode } from "$lib/openai/errors.js";
import {
InvestigatorExecutionError,
+ InvestigatorIncompleteResponseError,
+ InvestigatorInputError,
InvestigatorStructuredOutputError,
-} from "$lib/investigators/openai.js";
+} from "$lib/investigators/errors.js";
import { ExpiredOpenAiKeySourceError, InvalidOpenAiKeySourceError } from "./user-key-source.js";
+// Provider statuses that a retry of the same request cannot fix (SPEC §3.7):
+// malformed request, auth, missing model/resource, unprocessable input.
+const NON_RETRYABLE_OPENAI_STATUS_CODES = new Set([400, 401, 403, 404, 422]);
+
type UnwrappedError = Error | Record | string;
export function unwrapError(error: unknown): UnwrappedError {
@@ -20,9 +26,7 @@ export function unwrapError(error: unknown): UnwrappedError {
}
export function getErrorStatus(error: unknown): number | null {
- const root = unwrapError(error);
- if (typeof root === "string") return null;
- return readOpenAiStatusCode(root);
+ return readOpenAiStatusCode(unwrapError(error));
}
export function formatErrorForLog(error: unknown): string {
@@ -34,9 +38,13 @@ export function formatErrorForLog(error: unknown): string {
if (typeof root === "string") {
return root;
}
- return status === null ? "unknown object error" : `status=${status}`;
+ return "unknown object error";
}
+/**
+ * NON_RETRYABLE and PARTIAL failures of SPEC §3.7: the investigation is marked
+ * FAILED immediately. Everything else is TRANSIENT and retried with backoff.
+ */
export function isNonRetryableProviderError(error: unknown): boolean {
const root = unwrapError(error);
if (root instanceof ExpiredOpenAiKeySourceError) return true;
@@ -44,7 +52,9 @@ export function isNonRetryableProviderError(error: unknown): boolean {
if (root instanceof SyntaxError) return true;
if (root instanceof ZodError) return true;
if (root instanceof InvestigatorStructuredOutputError) return true;
+ if (root instanceof InvestigatorIncompleteResponseError) return true;
+ if (root instanceof InvestigatorInputError) return true;
const status = getErrorStatus(root);
- return isNonRetryableOpenAiStatusCode(status);
+ return status !== null && NON_RETRYABLE_OPENAI_STATUS_CODES.has(status);
}
diff --git a/src/typescript/api/src/lib/services/orchestrator.ts b/src/typescript/api/src/lib/services/orchestrator.ts
index f82d29b..dda2515 100644
--- a/src/typescript/api/src/lib/services/orchestrator.ts
+++ b/src/typescript/api/src/lib/services/orchestrator.ts
@@ -2,12 +2,21 @@ import { getPrisma } from "$lib/db/client";
import { requireOpenAiApiKey } from "$lib/config/env.js";
import { isRecordNotFoundError } from "$lib/db/errors.js";
import { downloadAndStoreImages, type ResolvedDownloadedImage } from "./image-downloader.js";
-import { consumeOpenAiKeySource, resolveInvestigationKey } from "./user-key-source.js";
-import { InvestigatorExecutionError, OpenAIInvestigator } from "$lib/investigators/openai.js";
+import {
+ consumeOpenAiKeySource,
+ ExpiredOpenAiKeySourceError,
+ InvalidOpenAiKeySourceError,
+ resolveInvestigationKey,
+ type InvestigationKeyResolution,
+} from "./user-key-source.js";
+import { InvestigatorExecutionError } from "$lib/investigators/errors.js";
import type {
+ ImagePlaceholder,
InvestigationProgressCallbacks,
- InvestigatorAttemptAudit,
+ InvestigatorFactory,
InvestigatorImageOccurrence,
+ InvestigatorInput,
+ InvestigatorSucceededAttemptAudit,
} from "$lib/investigators/interface.js";
import {
claimIdSchema,
@@ -16,16 +25,21 @@ import {
type SupportedImageMimeType,
} from "@openerrata/shared";
import type { ImageBlob, Prisma } from "$lib/db/prisma-client";
-import { createHash } from "node:crypto";
-import { formatErrorForLog, isNonRetryableProviderError } from "./orchestrator-errors.js";
-import { toPromptPostContext, type PromptImageOccurrence } from "./prompt-context.js";
+import {
+ formatErrorForLog,
+ getErrorStatus,
+ isNonRetryableProviderError,
+} from "./orchestrator-errors.js";
import {
tryClaimLease,
loadClaimedInvestigation,
- startHeartbeat,
+ startLeaseHeartbeat,
+ releaseLeaseDroppingUserKey,
+ retryBackoffMs,
+ LeaseLostError,
MAX_INVESTIGATION_ATTEMPTS,
- BASE_BACKOFF_MS,
+ type InvestigationForRun,
type Logger,
} from "./investigation-lease.js";
import {
@@ -33,31 +47,55 @@ import {
persistFailedAttemptAndMarkInvestigationFailed,
persistFailedAttemptAndReleaseLease,
} from "./attempt-audit.js";
-import { extractImagePlaceholdersFromMarkdown } from "./markdown-resolution.js";
import { enqueueInvestigation } from "./queue.js";
-let serverInvestigator: OpenAIInvestigator | null = null;
-
-function getServerInvestigator(): OpenAIInvestigator {
- if (serverInvestigator) {
- return serverInvestigator;
+/**
+ * OpenAI statuses that say "this user key cannot pay for this run" rather than
+ * anything about the post: rejected (401), not permitted or no model access
+ * (403, 404), rate-limited or out of quota (429). Retrying on the same key
+ * cannot help, and letting such failures exhaust attempts or mark the
+ * investigation FAILED would let a bad key block a post from ever being
+ * checked — so the key is dropped instead.
+ */
+const USER_KEY_ATTRIBUTABLE_STATUS_CODES: ReadonlySet = new Set([401, 403, 404, 429]);
+
+/** Whether `error`, raised while running on `keyType`, means the user key is unusable. */
+function isUserKeyFailure(
+ error: unknown,
+ keyType: InvestigationKeyResolution["type"] | null,
+): boolean {
+ if (
+ error instanceof ExpiredOpenAiKeySourceError ||
+ error instanceof InvalidOpenAiKeySourceError
+ ) {
+ return true;
}
-
- serverInvestigator = new OpenAIInvestigator(requireOpenAiApiKey());
- return serverInvestigator;
-}
-
-function hashSnapshotText(snapshotText: string): string {
- return createHash("sha256").update(snapshotText).digest("hex");
+ if (keyType !== "USER_OPENAI_KEY") {
+ return false;
+ }
+ const status = getErrorStatus(error);
+ return status !== null && USER_KEY_ATTRIBUTABLE_STATUS_CODES.has(status);
}
+/** Replace the investigation's image set, provided this worker still holds the lease. */
async function replaceInvestigationImages(
- investigationId: string,
+ lease: { investigationId: string; workerIdentity: string; leaseLostSignal: AbortSignal },
imageBlobs: ImageBlob[],
): Promise {
+ const { investigationId } = lease;
const uniqueBlobs = [...new Map(imageBlobs.map((b) => [b.id, b])).values()];
+ lease.leaseLostSignal.throwIfAborted();
await getPrisma().$transaction(async (tx) => {
+ // Lock and verify our lease row so a reclaimed run cannot clobber images.
+ const held = await tx.investigationLease.updateMany({
+ where: { investigationId, leaseOwner: lease.workerIdentity },
+ data: { heartbeatAt: new Date() },
+ });
+ if (held.count === 0) {
+ throw new LeaseLostError(investigationId, "lease not held when writing images");
+ }
+
await tx.investigationImage.deleteMany({
where: { investigationId },
});
@@ -78,35 +116,51 @@ function toDataUri(bytes: Uint8Array, mimeType: SupportedImageMimeType): string
return `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`;
}
-function uniqueUrlsInOrder(urls: string[]): string[] {
- const seen = new Set();
- const unique: string[] = [];
-
- for (const url of urls) {
- if (seen.has(url)) continue;
- seen.add(url);
- unique.push(url);
+/**
+ * Canonical form of an image URL, shared by image occurrences, markdown
+ * placeholders and downloads so they match each other. Null for URLs that can
+ * never be fetched.
+ */
+function canonicalImageUrl(url: string): string | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return null;
}
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null;
+}
- return unique;
+interface StoredImageOccurrence {
+ originalIndex: number;
+ normalizedTextOffset: number;
+ sourceUrl: string;
+ captionText: string | null;
}
async function resolvePromptImageOccurrences(
- investigationId: string,
- imageOccurrences: PromptImageOccurrence[],
+ lease: { investigationId: string; workerIdentity: string; leaseLostSignal: AbortSignal },
+ storedOccurrences: StoredImageOccurrence[],
): Promise {
- if (imageOccurrences.length === 0) {
- await replaceInvestigationImages(investigationId, []);
- return [];
- }
-
- const uniqueSourceUrls = uniqueUrlsInOrder(
- imageOccurrences.map((occurrence) => occurrence.sourceUrl),
- );
+ const imageOccurrences = storedOccurrences.map((occurrence) => ({
+ originalIndex: occurrence.originalIndex,
+ normalizedTextOffset: occurrence.normalizedTextOffset,
+ sourceUrl: canonicalImageUrl(occurrence.sourceUrl) ?? occurrence.sourceUrl,
+ fetchable: canonicalImageUrl(occurrence.sourceUrl) !== null,
+ ...(occurrence.captionText === null ? {} : { captionText: occurrence.captionText }),
+ }));
+
+ const uniqueSourceUrls = [
+ ...new Set(
+ imageOccurrences
+ .filter((occurrence) => occurrence.fetchable)
+ .map((occurrence) => occurrence.sourceUrl),
+ ),
+ ];
const urlsWithinBudget = uniqueSourceUrls.slice(0, MAX_IMAGES_PER_INVESTIGATION);
const omittedSourceUrls = new Set(uniqueSourceUrls.slice(MAX_IMAGES_PER_INVESTIGATION));
- const resolutions = await downloadAndStoreImages(urlsWithinBudget, MAX_IMAGES_PER_INVESTIGATION);
+ const resolutions = await downloadAndStoreImages(urlsWithinBudget, lease.leaseLostSignal);
const resolvedBySourceUrl = new Map();
const uniqueResolvedBlobs = new Map();
@@ -118,11 +172,11 @@ async function resolvePromptImageOccurrences(
}
await replaceInvestigationImages(
- investigationId,
+ lease,
Array.from(uniqueResolvedBlobs.values()).map((image) => image.blob),
);
- return imageOccurrences.map((occurrence) => {
+ return imageOccurrences.map(({ fetchable: _fetchable, ...occurrence }) => {
if (omittedSourceUrls.has(occurrence.sourceUrl)) {
return {
...occurrence,
@@ -147,6 +201,56 @@ async function resolvePromptImageOccurrences(
});
}
+/**
+ * Investigator input for a claimed run, built from the immutable
+ * InvestigationInput snapshot plus the version's text and resolved images.
+ */
+function buildInvestigatorInput(
+ investigation: InvestigationForRun,
+ imageOccurrences: InvestigatorImageOccurrence[],
+): InvestigatorInput {
+ const { input } = investigation;
+ const imagePlaceholders: ImagePlaceholder[] = input.imagePlaceholderSourceUrls.map(
+ (sourceUrl, index) => ({ index, matchBy: "SOURCE_URL", sourceUrl }),
+ );
+ const base = {
+ contentText: investigation.postVersion.contentBlob.contentText,
+ ...(input.markdown === null ? {} : { contentMarkdown: input.markdown, imagePlaceholders }),
+ platform: investigation.postVersion.post.platform,
+ url: input.postUrl,
+ ...(input.authorName === null ? {} : { authorName: input.authorName }),
+ ...(input.postPublishedAt === null
+ ? {}
+ : { postPublishedAt: input.postPublishedAt.toISOString() }),
+ imageOccurrences,
+ ...(input.hasVideo ? { hasVideo: true } : {}),
+ };
+
+ if (investigation.parentInvestigationId === null) {
+ return base;
+ }
+ if (investigation.parentInvestigation === null) {
+ throw new Error(`Update investigation ${investigation.id} is missing parent investigation`);
+ }
+ return {
+ ...base,
+ isUpdate: true,
+ ...(investigation.contentDiff === null ? {} : { contentDiff: investigation.contentDiff }),
+ oldClaims: investigation.parentInvestigation.claims.map((claim) => ({
+ id: claimIdSchema.parse(claim.id),
+ text: claim.text,
+ context: claim.context,
+ summary: claim.summary,
+ reasoning: claim.reasoning,
+ sources: claim.sources.map((source) => ({
+ url: source.url,
+ title: source.title,
+ snippet: source.snippet,
+ })),
+ })),
+ };
+}
+
/**
* Guard-first persist: atomically transition PROCESSING → COMPLETE.
*
@@ -169,8 +273,10 @@ export async function persistCompletedInvestigation(
workerIdentity: string;
claims: InvestigationResult["claims"];
attemptNumber: number;
- attemptAudit: InvestigatorAttemptAudit;
- modelVersion: string | null;
+ attemptAudit: InvestigatorSucceededAttemptAudit;
+ /** Provider model id the fact-check ran on (INV-INV-MODEL-AT-COMPLETION). */
+ model: string;
+ modelVersion: string;
},
): Promise {
const released = await tx.investigationLease.deleteMany({
@@ -189,6 +295,7 @@ export async function persistCompletedInvestigation(
data: {
status: "COMPLETE",
checkedAt: new Date(),
+ model: params.model,
modelVersion: params.modelVersion,
},
});
@@ -218,9 +325,6 @@ export async function persistCompletedInvestigation(
url: s.url,
title: s.title,
snippet: s.snippet,
- snapshotText: s.snippet,
- snapshotHash: hashSnapshotText(s.snippet),
- retrievedAt: new Date(),
})),
},
},
@@ -236,6 +340,7 @@ export async function orchestrateInvestigation(
logger: Logger,
options: {
workerIdentity: string;
+ createInvestigator: InvestigatorFactory;
},
): Promise {
const inFlightProgressWrites = new Set>();
@@ -256,48 +361,23 @@ export async function orchestrateInvestigation(
}
const claimResult = await tryClaimLease(investigationId, options.workerIdentity);
- if (claimResult.outcome === "MISSING") {
- logger.info(`Investigation ${investigationId} no longer exists; skipping stale job`);
- return;
- }
- if (claimResult.outcome === "TERMINAL") {
- logger.info(`Investigation ${investigationId} already terminal, skipping`);
- return;
- }
- if (claimResult.outcome === "LEASE_HELD") {
- logger.info(`Investigation ${investigationId} already leased, skipping`);
- return;
- }
- if (claimResult.outcome === "ATTEMPTS_EXHAUSTED") {
- logger.error(
- `Investigation ${investigationId} exhausted ${MAX_INVESTIGATION_ATTEMPTS.toString()} attempts; marking FAILED`,
- );
- const prismaForExhausted = getPrisma();
- await prismaForExhausted.$transaction(async (tx) => {
- const now = new Date();
- // Defensive cleanup for stale/expired leases. Active leases are preserved.
- await tx.investigationLease.deleteMany({
- where: {
- investigationId,
- leaseExpiresAt: { lte: now },
- },
- });
- // Match both PENDING (normal exhaustion path) and PROCESSING with no
- // active lease row (stale reclaim rollback path). Avoid marking FAILED
- // while another worker still holds an active lease.
- const transitioned = await tx.investigation.updateMany({
- where: {
- id: investigationId,
- attemptCount: { gte: MAX_INVESTIGATION_ATTEMPTS },
- OR: [{ status: "PENDING" }, { status: "PROCESSING", lease: { is: null } }],
- },
- data: { status: "FAILED" },
- });
- if (transitioned.count > 0) {
- await consumeOpenAiKeySource(tx, investigationId);
- }
- });
- return;
+ switch (claimResult.outcome) {
+ case "MISSING":
+ logger.info(`Investigation ${investigationId} no longer exists; skipping stale job`);
+ return;
+ case "TERMINAL":
+ logger.info(`Investigation ${investigationId} already terminal, skipping`);
+ return;
+ case "LEASE_HELD":
+ logger.info(`Investigation ${investigationId} already leased, skipping`);
+ return;
+ case "UNFUNDED":
+ logger.info(
+ `Investigation ${investigationId} has no funding since its user key was dropped; skipping`,
+ );
+ return;
+ case "CLAIMED":
+ break;
}
const { attemptNumber } = claimResult;
@@ -310,39 +390,42 @@ export async function orchestrateInvestigation(
const prisma = getPrisma();
- const heartbeat = startHeartbeat(investigationId, options.workerIdentity, logger);
-
+ const heartbeat = startLeaseHeartbeat(
+ {
+ investigationId,
+ workerIdentity: options.workerIdentity,
+ leaseExpiresAt: claimResult.leaseExpiresAt,
+ },
+ logger,
+ );
+ // Aborts when this worker loses the lease, which stops every in-flight
+ // provider request, tool fetch and image download of this run.
+ const { leaseLostSignal } = heartbeat;
+ const lease = {
+ investigationId: investigation.id,
+ workerIdentity: options.workerIdentity,
+ leaseLostSignal,
+ };
+
+ let investigationKeyType: InvestigationKeyResolution["type"] | null = null;
try {
- const investigationKey = await resolveInvestigationKey(prisma, investigationId);
- const investigator =
- investigationKey.type === "SERVER_KEY"
- ? getServerInvestigator()
- : new OpenAIInvestigator(investigationKey.apiKey);
- const promptPostContext = toPromptPostContext(investigation.postVersion);
-
- // ── Resolve or restore InvestigationInput snapshot ──
- // All executions (first attempt and retries) must use the immutable
- // InvestigationInput snapshot persisted at queue-time.
- const contentMarkdown = investigation.input.markdown ?? undefined;
- const imagePlaceholders =
- contentMarkdown !== undefined
- ? extractImagePlaceholdersFromMarkdown(contentMarkdown)
- : undefined;
+ // Resolve the key before touching any attacker-chosen image URL: a
+ // user-key run whose key is unusable stops here.
+ const investigationKey = await resolveInvestigationKey(prisma, investigation);
+ investigationKeyType = investigationKey.type;
+ const investigator = options.createInvestigator(
+ investigationKey.type === "SERVER_KEY" ? requireOpenAiApiKey() : investigationKey.apiKey,
+ );
const resolvedImageOccurrences = await resolvePromptImageOccurrences(
- investigation.id,
- promptPostContext.imageOccurrences,
+ lease,
+ investigation.postVersion.imageOccurrenceSet.occurrences,
);
-
- if (
- investigation.parentInvestigationId !== null &&
- investigation.parentInvestigation === null
- ) {
- throw new Error(`Update investigation ${investigation.id} is missing parent investigation`);
- }
+ const investigatorInput = buildInvestigatorInput(investigation, resolvedImageOccurrences);
const progressCallbacks: InvestigationProgressCallbacks = {
onProgressUpdate: (pending, confirmed) => {
+ if (leaseLostSignal.aborted) return;
// Guard on leaseOwner to avoid writing progressClaims after a
// terminal transition or lease reclaim (the lease row won't exist).
const write = prisma.investigationLease
@@ -362,37 +445,15 @@ export async function orchestrateInvestigation(
},
};
- const output = await investigator.investigate(
- {
- contentText: investigation.postVersion.contentBlob.contentText,
- ...promptPostContext,
- ...(contentMarkdown !== undefined && { contentMarkdown }),
- ...(imagePlaceholders !== undefined && { imagePlaceholders }),
- imageOccurrences: resolvedImageOccurrences,
- ...(promptPostContext.hasVideo ? { hasVideo: true } : {}),
- ...(investigation.parentInvestigation !== null && {
- isUpdate: true,
- ...(investigation.contentDiff === null ? {} : { contentDiff: investigation.contentDiff }),
- oldClaims: investigation.parentInvestigation.claims.map((claim) => ({
- id: claimIdSchema.parse(claim.id),
- text: claim.text,
- context: claim.context,
- summary: claim.summary,
- reasoning: claim.reasoning,
- sources: claim.sources.map((source) => ({
- url: source.url,
- title: source.title,
- snippet: source.snippet,
- })),
- })),
- }),
- },
- progressCallbacks,
- );
+ const output = await investigator.investigate(investigatorInput, {
+ signal: leaseLostSignal,
+ callbacks: progressCallbacks,
+ });
// Ensure all progress writes settle before terminal transition.
await flushProgressWrites();
+ leaseLostSignal.throwIfAborted();
const completed = await prisma.$transaction((tx) =>
persistCompletedInvestigation(tx, {
investigationId: investigation.id,
@@ -400,7 +461,8 @@ export async function orchestrateInvestigation(
claims: output.result.claims,
attemptNumber,
attemptAudit: output.attemptAudit,
- modelVersion: output.modelVersion ?? null,
+ model: output.model,
+ modelVersion: output.modelVersion,
}),
);
@@ -417,6 +479,13 @@ export async function orchestrateInvestigation(
// Drain callback writes so FAILED/lease-release transition is the final state.
await flushProgressWrites();
+ if (leaseLostSignal.aborted || error instanceof LeaseLostError) {
+ logger.warn(
+ `Investigation ${investigation.id} attempt ${attemptNumber.toString()} abandoned: ${formatErrorForLog(leaseLostSignal.aborted ? leaseLostSignal.reason : error)}`,
+ );
+ return;
+ }
+
if (isRecordNotFoundError(error)) {
logger.info(
`Investigation ${investigation.id} disappeared during processing; skipping stale job`,
@@ -426,6 +495,27 @@ export async function orchestrateInvestigation(
const attemptAudit = error instanceof InvestigatorExecutionError ? error.attemptAudit : null;
+ // USER KEY UNUSABLE: drop the key; the investigation waits, unfunded, for
+ // the selector or a new request instead of failing.
+ if (isUserKeyFailure(error, investigationKeyType)) {
+ const released = await releaseLeaseDroppingUserKey({
+ investigationId: investigation.id,
+ workerIdentity: options.workerIdentity,
+ attemptNumber,
+ attemptAudit,
+ });
+ if (released) {
+ logger.warn(
+ `Investigation ${investigation.id} dropped its user OpenAI key and is unfunded: ${formatErrorForLog(error)}`,
+ );
+ } else {
+ logger.info(
+ `Investigation ${investigation.id} no longer PROCESSING; ignoring user key failure`,
+ );
+ }
+ return;
+ }
+
// NON_RETRYABLE: deterministic provider or parsing failures.
if (isNonRetryableProviderError(error)) {
const marked = await persistFailedAttemptAndMarkInvestigationFailed({
@@ -469,7 +559,7 @@ export async function orchestrateInvestigation(
// Not last attempt — reclaim to PENDING and explicitly re-enqueue.
// Do NOT rethrow to graphile-worker — we control retry timing ourselves.
- const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attemptNumber - 1);
+ const backoffMs = retryBackoffMs(attemptNumber);
const retryAfter = new Date(Date.now() + backoffMs);
const released = await persistFailedAttemptAndReleaseLease({
diff --git a/src/typescript/api/src/lib/services/prompt-context.ts b/src/typescript/api/src/lib/services/prompt-context.ts
deleted file mode 100644
index ec5c914..0000000
--- a/src/typescript/api/src/lib/services/prompt-context.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import type { Platform } from "@openerrata/shared";
-import type { Prisma } from "$lib/db/prisma-client";
-
-export interface PromptImageOccurrence {
- originalIndex: number;
- normalizedTextOffset: number;
- sourceUrl: string;
- captionText?: string;
-}
-
-interface PromptPostContext {
- platform: Platform;
- url: string;
- authorName?: string;
- postPublishedAt?: string;
- imageOccurrences: PromptImageOccurrence[];
- hasVideo?: boolean;
-}
-
-/** Prisma include fragment that loads everything needed by `toPromptPostContext`. */
-export const investigationContextInclude = {
- postVersion: {
- select: {
- serverVerifiedAt: true,
- contentBlob: {
- select: {
- contentText: true,
- contentHash: true,
- },
- },
- imageOccurrenceSet: {
- select: {
- occurrences: {
- orderBy: [{ originalIndex: "asc" }],
- select: {
- originalIndex: true,
- normalizedTextOffset: true,
- sourceUrl: true,
- captionText: true,
- },
- },
- },
- },
- lesswrongVersionMeta: {
- select: {
- publishedAt: true,
- serverHtmlBlob: { select: { htmlContent: true } },
- clientHtmlBlob: { select: { htmlContent: true } },
- },
- },
- xVersionMeta: {
- select: {
- postedAt: true,
- mediaUrls: true,
- },
- },
- substackVersionMeta: {
- select: {
- publishedAt: true,
- serverHtmlBlob: { select: { htmlContent: true } },
- clientHtmlBlob: { select: { htmlContent: true } },
- },
- },
- wikipediaVersionMeta: {
- select: {
- lastModifiedAt: true,
- serverHtmlBlob: { select: { htmlContent: true } },
- clientHtmlBlob: { select: { htmlContent: true } },
- },
- },
- post: {
- select: {
- platform: true,
- url: true,
- author: { select: { displayName: true } },
- },
- },
- },
- },
-} satisfies Prisma.InvestigationInclude;
-
-type InvestigationWithContext = Prisma.InvestigationGetPayload<{
- include: typeof investigationContextInclude;
-}>;
-type InvestigationVersionContext = InvestigationWithContext["postVersion"];
-
-function unreachablePlatform(platform: never): never {
- throw new Error(`Unsupported post platform: ${String(platform)}`);
-}
-
-export function isLikelyVideoUrl(url: string): boolean {
- let pathname = url.toLowerCase();
- try {
- pathname = new URL(url).pathname.toLowerCase();
- } catch {
- // Keep best-effort behavior for malformed values already stored in metadata.
- }
-
- return (
- pathname.endsWith(".mp4") ||
- pathname.endsWith(".webm") ||
- pathname.endsWith(".m3u8") ||
- pathname.endsWith(".mov") ||
- pathname.endsWith(".m4v")
- );
-}
-
-export function hasXVideoMedia(mediaUrls: string[]): boolean {
- for (const mediaUrl of mediaUrls) {
- if (isLikelyVideoUrl(mediaUrl)) {
- return true;
- }
- }
- return false;
-}
-
-/**
- * Source-scoped HTML snapshots with the serverVerifiedAt latch bundled in.
- *
- * The discriminated union encodes the DB invariant:
- * serverVerifiedAt IS NOT NULL → serverHtmlBlobId IS NOT NULL
- * When server-verified, serverHtml is guaranteed non-null at the type level.
- */
-export type HtmlSnapshots =
- | { serverVerifiedAt: Date; serverHtml: string; clientHtml: string | null }
- | { serverVerifiedAt: null; serverHtml: string | null; clientHtml: string | null };
-
-/**
- * Resolve source-scoped HTML snapshots from version metadata.
- *
- * Throws if serverVerifiedAt is set but serverHtml is absent — that state
- * violates the DB trigger that enforces the invariant, so it represents data
- * corruption and should surface immediately rather than silently falling back.
- */
-export function resolveHtmlSnapshotsFromVersionMeta(
- postVersion: InvestigationVersionContext,
-): HtmlSnapshots {
- const post = postVersion.post;
- let serverHtml: string | null;
- let clientHtml: string | null;
- switch (post.platform) {
- case "LESSWRONG":
- serverHtml = postVersion.lesswrongVersionMeta?.serverHtmlBlob?.htmlContent ?? null;
- clientHtml = postVersion.lesswrongVersionMeta?.clientHtmlBlob?.htmlContent ?? null;
- break;
- case "SUBSTACK":
- serverHtml = postVersion.substackVersionMeta?.serverHtmlBlob?.htmlContent ?? null;
- clientHtml = postVersion.substackVersionMeta?.clientHtmlBlob?.htmlContent ?? null;
- break;
- case "WIKIPEDIA":
- serverHtml = postVersion.wikipediaVersionMeta?.serverHtmlBlob?.htmlContent ?? null;
- clientHtml = postVersion.wikipediaVersionMeta?.clientHtmlBlob?.htmlContent ?? null;
- break;
- case "X":
- serverHtml = null;
- clientHtml = null;
- break;
- default:
- return unreachablePlatform(post.platform);
- }
-
- if (postVersion.serverVerifiedAt !== null) {
- if (serverHtml === null) {
- throw new Error(
- `serverVerifiedAt is set but serverHtml is missing for platform ${post.platform} — violates DB invariant (serverVerifiedAt IS NOT NULL → serverHtmlBlobId IS NOT NULL)`,
- );
- }
- return { serverVerifiedAt: postVersion.serverVerifiedAt, serverHtml, clientHtml };
- }
- return { serverVerifiedAt: null, serverHtml, clientHtml };
-}
-
-export function toPromptPostContext(postVersion: InvestigationVersionContext): PromptPostContext {
- const post = postVersion.post;
- const authorName = post.author?.displayName;
- const imageOccurrences = postVersion.imageOccurrenceSet.occurrences.map((occurrence) => ({
- originalIndex: occurrence.originalIndex,
- normalizedTextOffset: occurrence.normalizedTextOffset,
- sourceUrl: occurrence.sourceUrl,
- ...(occurrence.captionText === null ? {} : { captionText: occurrence.captionText }),
- }));
-
- switch (post.platform) {
- case "LESSWRONG": {
- const publishedAt = postVersion.lesswrongVersionMeta?.publishedAt;
- return {
- platform: "LESSWRONG",
- url: post.url,
- ...(authorName != null && { authorName }),
- ...(publishedAt != null && { postPublishedAt: publishedAt.toISOString() }),
- imageOccurrences,
- hasVideo: false,
- };
- }
- case "X": {
- const postedAt = postVersion.xVersionMeta?.postedAt;
- const mediaUrls = postVersion.xVersionMeta?.mediaUrls ?? [];
- const hasVideo = hasXVideoMedia(mediaUrls);
- return {
- platform: "X",
- url: post.url,
- ...(authorName != null && { authorName }),
- ...(postedAt != null && { postPublishedAt: postedAt.toISOString() }),
- imageOccurrences,
- hasVideo,
- };
- }
- case "SUBSTACK": {
- const publishedAt = postVersion.substackVersionMeta?.publishedAt;
- return {
- platform: "SUBSTACK",
- url: post.url,
- ...(authorName != null && { authorName }),
- ...(publishedAt != null && { postPublishedAt: publishedAt.toISOString() }),
- imageOccurrences,
- hasVideo: false,
- };
- }
- case "WIKIPEDIA": {
- const lastModifiedAt = postVersion.wikipediaVersionMeta?.lastModifiedAt;
- return {
- platform: "WIKIPEDIA",
- url: post.url,
- ...(authorName != null && { authorName }),
- ...(lastModifiedAt != null && {
- postPublishedAt: lastModifiedAt.toISOString(),
- }),
- imageOccurrences,
- hasVideo: false,
- };
- }
- default:
- return unreachablePlatform(post.platform);
- }
-}
diff --git a/src/typescript/api/src/lib/services/public-read-model.ts b/src/typescript/api/src/lib/services/public-read-model.ts
index 6c798f8..ba962bb 100644
--- a/src/typescript/api/src/lib/services/public-read-model.ts
+++ b/src/typescript/api/src/lib/services/public-read-model.ts
@@ -1,10 +1,12 @@
import { Prisma, type PrismaClient } from "$lib/db/prisma-client";
-import { platformSchema, type Platform } from "@openerrata/shared";
+import type { ContentProvenance, InvestigationProvider, Platform } from "@openerrata/shared";
-interface PublicInvestigationOrigin {
- provenance: "SERVER_VERIFIED" | "CLIENT_FALLBACK";
- serverVerifiedAt: Date | null;
-}
+// A SERVER_VERIFIED investigation ran on verified content, so its post
+// version's verification latch is always set; a CLIENT_FALLBACK one's may be
+// set later by a subsequent server fetch of the same content.
+type PublicInvestigationOrigin =
+ | { provenance: Extract; serverVerifiedAt: Date }
+ | { provenance: Extract; serverVerifiedAt: Date | null };
interface PublicTrustSignals {
origin: PublicInvestigationOrigin;
@@ -30,7 +32,7 @@ type PublicInvestigation = PublicTrustSignals & {
id: string;
checkedAt: Date;
promptVersion: string;
- provider: string;
+ provider: InvestigationProvider;
model: string;
};
@@ -87,7 +89,8 @@ interface SearchInvestigationPageRow {
interface PublicMetricsResult {
totalInvestigatedPosts: number;
investigatedPostsWithFlags: number;
- factCheckIncidence: number;
+ /** investigatedPostsWithFlags / totalInvestigatedPosts; null when nothing was investigated. */
+ factCheckIncidence: number | null;
}
interface PublicMetricsInput {
@@ -105,10 +108,6 @@ interface PublicSearchInvestigationsInput {
offset: number;
}
-function parsePlatform(value: string): Platform {
- return platformSchema.parse(value);
-}
-
function escapeLikePattern(query: string): string {
return query.replace(/[\\%_]/g, (char) => `\\${char}`);
}
@@ -124,28 +123,6 @@ function invariantViolation(message: string): never {
throw new PublicReadModelInvariantError(`Public read-model invariant violation: ${message}`);
}
-function parsePublicOrigin(input: {
- investigationId: string;
- provenance: string | undefined;
- serverVerifiedAt: Date | null;
-}): PublicInvestigationOrigin {
- // provenance lives on InvestigationInput (1:1); older investigations may lack it.
- if (input.provenance === undefined) {
- invariantViolation(
- `Investigation ${input.investigationId} has no InvestigationInput (missing provenance)`,
- );
- }
- if (input.provenance !== "SERVER_VERIFIED" && input.provenance !== "CLIENT_FALLBACK") {
- invariantViolation(
- `Investigation ${input.investigationId} has invalid provenance "${input.provenance}"`,
- );
- }
- return {
- provenance: input.provenance,
- serverVerifiedAt: input.serverVerifiedAt,
- };
-}
-
function requireCompleteCheckedAt(input: {
investigationId: string;
checkedAt: Date | null;
@@ -156,26 +133,44 @@ function requireCompleteCheckedAt(input: {
return input.checkedAt;
}
-function parsePublicLifecycle(input: {
- investigationId: string;
- provenance: string | undefined;
- serverVerifiedAt: Date | null;
- checkedAt: Date | null;
-}): { origin: PublicInvestigationOrigin; checkedAt: Date } {
- const origin = parsePublicOrigin({
- investigationId: input.investigationId,
- provenance: input.provenance,
- serverVerifiedAt: input.serverVerifiedAt,
- });
+function requireCompleteModel(input: { investigationId: string; model: string | null }): string {
+ if (input.model === null) {
+ invariantViolation(`Investigation ${input.investigationId} is COMPLETE with null model`);
+ }
+ return input.model;
+}
- const checkedAt = requireCompleteCheckedAt({
- investigationId: input.investigationId,
- checkedAt: input.checkedAt,
- });
+function publicOrigin(investigation: {
+ id: string;
+ input: { provenance: ContentProvenance };
+ postVersion: { serverVerifiedAt: Date | null };
+}): PublicInvestigationOrigin {
+ const { serverVerifiedAt } = investigation.postVersion;
+ switch (investigation.input.provenance) {
+ case "SERVER_VERIFIED":
+ if (serverVerifiedAt === null) {
+ invariantViolation(
+ `Investigation ${investigation.id} is SERVER_VERIFIED but its post version has no serverVerifiedAt`,
+ );
+ }
+ return { provenance: "SERVER_VERIFIED", serverVerifiedAt };
+ case "CLIENT_FALLBACK":
+ return { provenance: "CLIENT_FALLBACK", serverVerifiedAt };
+ }
+}
+function publicLifecycle(investigation: {
+ id: string;
+ input: { provenance: ContentProvenance };
+ postVersion: { serverVerifiedAt: Date | null };
+ checkedAt: Date | null;
+}): { origin: PublicInvestigationOrigin; checkedAt: Date } {
return {
- origin,
- checkedAt,
+ origin: publicOrigin(investigation),
+ checkedAt: requireCompleteCheckedAt({
+ investigationId: investigation.id,
+ checkedAt: investigation.checkedAt,
+ }),
};
}
@@ -259,12 +254,7 @@ export async function getPublicInvestigationById(
return null;
}
- const lifecycle = parsePublicLifecycle({
- investigationId: investigation.id,
- provenance: investigation.input.provenance,
- serverVerifiedAt: investigation.postVersion.serverVerifiedAt,
- checkedAt: investigation.checkedAt,
- });
+ const lifecycle = publicLifecycle(investigation);
return {
investigation: {
@@ -274,10 +264,13 @@ export async function getPublicInvestigationById(
checkedAt: lifecycle.checkedAt,
promptVersion: investigation.prompt.version,
provider: investigation.provider,
- model: investigation.model,
+ model: requireCompleteModel({
+ investigationId: investigation.id,
+ model: investigation.model,
+ }),
},
post: {
- platform: parsePlatform(investigation.postVersion.post.platform),
+ platform: investigation.postVersion.post.platform,
externalId: investigation.postVersion.post.externalId,
url: investigation.postVersion.post.url,
},
@@ -361,17 +354,12 @@ export async function getPublicPostInvestigations(
return {
post: {
- platform: parsePlatform(post.platform),
+ platform: post.platform,
externalId: post.externalId,
url: post.url,
},
investigations: investigations.map((investigation) => {
- const lifecycle = parsePublicLifecycle({
- investigationId: investigation.id,
- provenance: investigation.input.provenance,
- serverVerifiedAt: investigation.postVersion.serverVerifiedAt,
- checkedAt: investigation.checkedAt,
- });
+ const lifecycle = publicLifecycle(investigation);
return {
id: investigation.id,
contentHash: investigation.postVersion.contentBlob.contentHash,
@@ -459,17 +447,12 @@ export async function searchPublicInvestigations(
);
}
- const lifecycle = parsePublicLifecycle({
- investigationId: investigation.id,
- provenance: investigation.input.provenance,
- serverVerifiedAt: investigation.postVersion.serverVerifiedAt,
- checkedAt: investigation.checkedAt,
- });
+ const lifecycle = publicLifecycle(investigation);
return {
id: investigation.id,
contentHash: investigation.postVersion.contentBlob.contentHash,
checkedAt: lifecycle.checkedAt,
- platform: parsePlatform(investigation.postVersion.post.platform),
+ platform: investigation.postVersion.post.platform,
externalId: investigation.postVersion.post.externalId,
url: investigation.postVersion.post.url,
origin: lifecycle.origin,
@@ -509,14 +492,15 @@ export async function getPublicMetrics(
WHERE ${Prisma.join(conditions, " AND ")}
`;
- const { total_investigated, with_flags } = result[0] ?? {
- total_investigated: 0,
- with_flags: 0,
- };
+ const [counts] = result;
+ if (counts === undefined) {
+ invariantViolation("public metrics aggregate query returned no row");
+ }
+ const { total_investigated, with_flags } = counts;
return {
totalInvestigatedPosts: total_investigated,
investigatedPostsWithFlags: with_flags,
- factCheckIncidence: total_investigated > 0 ? with_flags / total_investigated : 0,
+ factCheckIncidence: total_investigated > 0 ? with_flags / total_investigated : null,
};
}
diff --git a/src/typescript/api/src/lib/services/queue-lifecycle.ts b/src/typescript/api/src/lib/services/queue-lifecycle.ts
deleted file mode 100644
index 4c33968..0000000
--- a/src/typescript/api/src/lib/services/queue-lifecycle.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-// ── Queue lifecycle state machine ────────────────────────────────────────
-//
-// Manages a lazily-initialized, closeable resource pool. The state machine
-// ensures that initialization, usage, and shutdown are properly serialized
-// even under concurrent access.
-//
-// Transitions:
-// idle → initializing acquire starts connection
-// idle → closed close requested with nothing to release
-// initializing → ready connection succeeds
-// initializing → idle connection fails (retry allowed)
-// initializing → closing close requested during connection
-// ready → closing close requested
-// closing → closed release succeeds
-// closing → idle release fails (retry allowed)
-// closed → (terminal) acquire throws
-
-export interface Releasable {
- release(): void | Promise;
-}
-
-export const QUEUE_ERROR_CODES = {
- CLOSED: "QUEUE_CLOSED",
- CONNECT_FAILED: "QUEUE_CONNECT_FAILED",
- RELEASE_FAILED: "QUEUE_RELEASE_FAILED",
-} as const;
-
-export type QueueErrorCode = (typeof QUEUE_ERROR_CODES)[keyof typeof QUEUE_ERROR_CODES];
-
-class QueueLifecycleError extends Error {
- constructor(
- readonly code: QueueErrorCode,
- message: string,
- options?: ErrorOptions,
- ) {
- super(message, options);
- this.name = new.target.name;
- }
-}
-
-export class QueueClosedError extends QueueLifecycleError {
- constructor() {
- super(QUEUE_ERROR_CODES.CLOSED, "Queue utilities are closed");
- }
-}
-
-export class QueueConnectError extends QueueLifecycleError {
- constructor(cause: unknown) {
- super(QUEUE_ERROR_CODES.CONNECT_FAILED, "Queue utilities failed to initialize", { cause });
- }
-}
-
-export class QueueReleaseError extends QueueLifecycleError {
- constructor(cause: unknown) {
- super(QUEUE_ERROR_CODES.RELEASE_FAILED, "Queue utilities failed to release", { cause });
- }
-}
-
-interface QueueManager {
- acquire(): Promise;
- close(): Promise;
-}
-
-type QueueState =
- | { phase: "idle" }
- | { phase: "initializing"; promise: Promise }
- | { phase: "ready"; utils: T }
- | { phase: "closing"; promise: Promise }
- | { phase: "closed" };
-
-export function createQueueManager(
- connect: () => Promise,
-): QueueManager {
- let state: QueueState = { phase: "idle" };
-
- /**
- * Checks whether the given promise is still the active initialization
- * attempt. State can change across await boundaries (e.g. close requested
- * during init), so callers must re-check after any suspension. This is
- * extracted as a function so TypeScript reads `state` without the enclosing
- * switch-case narrowing that would make the check look redundant to the
- * linter.
- */
- function isActiveInit(promise: Promise): boolean {
- return state.phase === "initializing" && state.promise === promise;
- }
-
- async function acquire(): Promise {
- while (true) {
- switch (state.phase) {
- case "closed":
- throw new QueueClosedError();
-
- case "closing":
- // Wait for close to finish, then re-check. Swallow close errors —
- // an acquire caller should not see release failures.
- try {
- await state.promise;
- } catch {
- // Close failed; state is now idle. Loop will retry initialization.
- }
- continue;
-
- case "ready":
- return state.utils;
-
- case "idle": {
- const promise = connect();
- state = { phase: "initializing", promise };
- continue;
- }
-
- case "initializing": {
- const { promise } = state;
- let utils: T;
- try {
- utils = await promise;
- } catch (error) {
- if (isActiveInit(promise)) {
- state = { phase: "idle" };
- throw new QueueConnectError(error);
- }
- // State changed during init (e.g. close was requested).
- // Swallow the connection error and re-check — callers should
- // see "closed", not a transient connection failure.
- continue;
- }
- if (isActiveInit(promise)) {
- state = { phase: "ready", utils };
- return utils;
- }
- // State changed during init (e.g. close was requested). Re-check.
- continue;
- }
- }
- }
- }
-
- async function releaseAndClose(utils: T): Promise {
- try {
- await utils.release();
- state = { phase: "closed" };
- } catch (error) {
- // Release failed — revert to idle so close can be retried.
- state = { phase: "idle" };
- throw new QueueReleaseError(error);
- }
- }
-
- async function awaitInitThenClose(initPromise: Promise): Promise {
- let utils: T;
- try {
- utils = await initPromise;
- } catch {
- // Init failed — nothing to release. Close succeeds.
- state = { phase: "closed" };
- return;
- }
- await releaseAndClose(utils);
- }
-
- async function close(): Promise {
- switch (state.phase) {
- case "closed":
- return;
-
- case "closing":
- await state.promise;
- return;
-
- case "idle": {
- state = { phase: "closed" };
- return;
- }
-
- case "ready": {
- const { utils } = state;
- const promise = releaseAndClose(utils);
- state = { phase: "closing", promise };
- await promise;
- return;
- }
-
- case "initializing": {
- const { promise: initPromise } = state;
- const promise = awaitInitThenClose(initPromise);
- state = { phase: "closing", promise };
- await promise;
- return;
- }
- }
- }
-
- return { acquire, close };
-}
diff --git a/src/typescript/api/src/lib/services/queue.ts b/src/typescript/api/src/lib/services/queue.ts
index b668afa..d4bcacf 100644
--- a/src/typescript/api/src/lib/services/queue.ts
+++ b/src/typescript/api/src/lib/services/queue.ts
@@ -1,27 +1,47 @@
-import { makeWorkerUtils } from "graphile-worker";
+import { makeWorkerUtils, type WorkerUtils } from "graphile-worker";
import { getEnv } from "$lib/config/env.js";
import { normalizePgConnectionStringForNode } from "$lib/db/connection-string.js";
-import { createQueueManager } from "./queue-lifecycle.js";
-const manager = createQueueManager(() =>
- makeWorkerUtils({
+let workerUtils: Promise | null = null;
+
+/** Lazily connect once per process; a failed connection is retried on the next call. */
+function getWorkerUtils(): Promise {
+ workerUtils ??= makeWorkerUtils({
connectionString: normalizePgConnectionStringForNode(getEnv().DATABASE_URL),
- }),
-);
+ }).catch((error: unknown) => {
+ workerUtils = null;
+ throw error;
+ });
+ return workerUtils;
+}
+/**
+ * Enqueue (or replace) the single graphile-worker job for an investigation.
+ * The per-investigation jobKey collapses concurrent enqueues from
+ * investigateNow, the selector and retry scheduling into one job; retries are
+ * application-controlled, so graphile-worker never retries a job itself.
+ */
export async function enqueueInvestigation(
investigationId: string,
options?: { runAt?: Date },
): Promise {
- const utils = await manager.acquire();
- const spec = {
- maxAttempts: 1,
- jobKey: `investigate:${investigationId}`,
- ...(options?.runAt !== undefined && { runAt: options.runAt }),
- };
- await utils.addJob("investigate", { investigationId }, spec);
+ const utils = await getWorkerUtils();
+ await utils.addJob(
+ "investigate",
+ { investigationId },
+ {
+ maxAttempts: 1,
+ jobKey: `investigate:${investigationId}`,
+ ...(options?.runAt !== undefined && { runAt: options.runAt }),
+ },
+ );
}
+/** Release the queue's database pool (lets short-lived processes such as tests exit). */
export async function closeQueueUtils(): Promise {
- await manager.close();
+ const pending = workerUtils;
+ workerUtils = null;
+ if (pending !== null) {
+ await (await pending).release();
+ }
}
diff --git a/src/typescript/api/src/lib/services/request-identity.ts b/src/typescript/api/src/lib/services/request-identity.ts
index 517af29..f84a624 100644
--- a/src/typescript/api/src/lib/services/request-identity.ts
+++ b/src/typescript/api/src/lib/services/request-identity.ts
@@ -3,25 +3,23 @@ interface RequestIdentityInput {
userAgent: string;
instanceApiKey: string | null | undefined;
userOpenAiApiKey: string | null | undefined;
- attestationSignature: string | null | undefined;
- attestationBody: string | null;
}
interface RequestIdentityDependencies {
hashContent: (value: string) => Promise;
findActiveInstanceApiKeyHash: (apiKey: string) => Promise;
deriveIpRangePrefix: (ipAddress: string) => string;
- verifyHmac: (body: string, signature: string) => Promise;
}
interface RequestIdentity {
- authenticatedApiKeyHash: string | null;
+ /** Stable hashed viewer: the instance API key when authenticated, else address + user agent. */
viewerKey: string;
+ /** Stable hashed /24 (IPv4) or /48 (IPv6) of the client address. */
ipRangeKey: string;
- userOpenAiApiKey: string | null;
+ /** Whether the request carries an active instance API key. */
isAuthenticated: boolean;
- canInvestigate: boolean;
- hasValidAttestation: boolean;
+ /** The request-scoped user OpenAI key, as sent; unverified. */
+ userOpenAiApiKey: string | null;
}
function trimToOptional(value: string | null | undefined): string | null {
@@ -29,84 +27,28 @@ function trimToOptional(value: string | null | undefined): string | null {
return trimmed !== undefined && trimmed.length > 0 ? trimmed : null;
}
-async function resolveAuthenticatedApiKeyHash(input: {
- instanceApiKey: string | null;
- findActiveInstanceApiKeyHash: RequestIdentityDependencies["findActiveInstanceApiKeyHash"];
-}): Promise {
- if (input.instanceApiKey === null) {
- return null;
- }
- return input.findActiveInstanceApiKeyHash(input.instanceApiKey);
-}
-
-async function resolveViewerKey(input: {
- authenticatedApiKeyHash: string | null;
- clientAddress: string;
- userAgent: string;
- hashContent: RequestIdentityDependencies["hashContent"];
-}): Promise {
- if (input.authenticatedApiKeyHash !== null) {
- return input.hashContent(`apikey:${input.authenticatedApiKeyHash}`);
- }
- return input.hashContent(`anon:${input.clientAddress}:${input.userAgent}`);
-}
-
-async function resolveHasValidAttestation(input: {
- attestationSignature: string | null;
- attestationBody: string | null;
- verifyHmac: RequestIdentityDependencies["verifyHmac"];
-}): Promise {
- if (
- input.attestationSignature === null ||
- input.attestationBody === null ||
- input.attestationBody.length === 0
- ) {
- return false;
- }
-
- try {
- return await input.verifyHmac(input.attestationBody, input.attestationSignature);
- } catch {
- return false;
- }
-}
-
export async function deriveRequestIdentity(
input: RequestIdentityInput,
dependencies: RequestIdentityDependencies,
): Promise {
const instanceApiKey = trimToOptional(input.instanceApiKey);
- const userOpenAiApiKey = trimToOptional(input.userOpenAiApiKey);
- const attestationSignature = trimToOptional(input.attestationSignature);
-
- const authenticatedApiKeyHash = await resolveAuthenticatedApiKeyHash({
- instanceApiKey,
- findActiveInstanceApiKeyHash: dependencies.findActiveInstanceApiKeyHash,
- });
- const viewerKey = await resolveViewerKey({
- authenticatedApiKeyHash,
- clientAddress: input.clientAddress,
- userAgent: input.userAgent,
- hashContent: dependencies.hashContent,
- });
+ const authenticatedApiKeyHash =
+ instanceApiKey === null
+ ? null
+ : await dependencies.findActiveInstanceApiKeyHash(instanceApiKey);
+
+ const viewerKey = await dependencies.hashContent(
+ authenticatedApiKeyHash === null
+ ? `anon:${input.clientAddress}:${input.userAgent}`
+ : `apikey:${authenticatedApiKeyHash}`,
+ );
const ipRangePrefix = dependencies.deriveIpRangePrefix(input.clientAddress);
const ipRangeKey = await dependencies.hashContent(`iprange:${ipRangePrefix}`);
- const hasValidAttestation = await resolveHasValidAttestation({
- attestationSignature,
- attestationBody: input.attestationBody,
- verifyHmac: dependencies.verifyHmac,
- });
-
- const isAuthenticated = authenticatedApiKeyHash !== null;
- const canInvestigate = isAuthenticated || userOpenAiApiKey !== null;
return {
- authenticatedApiKeyHash,
viewerKey,
ipRangeKey,
- userOpenAiApiKey,
- isAuthenticated,
- canInvestigate,
- hasValidAttestation,
+ isAuthenticated: authenticatedApiKeyHash !== null,
+ userOpenAiApiKey: trimToOptional(input.userOpenAiApiKey),
};
}
diff --git a/src/typescript/api/src/lib/services/selector-entrypoint.ts b/src/typescript/api/src/lib/services/selector-entrypoint.ts
index d8f30a1..a0568b5 100644
--- a/src/typescript/api/src/lib/services/selector-entrypoint.ts
+++ b/src/typescript/api/src/lib/services/selector-entrypoint.ts
@@ -1,3 +1,4 @@
+import { getSelectorDailyBudget } from "$lib/config/runtime.js";
import { runStartupChecks } from "$lib/config/startup.js";
import { getPrisma } from "$lib/db/client";
import { runSelector } from "./selector.js";
@@ -6,8 +7,16 @@ async function runOnce(): Promise {
let exitCode = 0;
try {
await runStartupChecks("selector");
- const count = await runSelector();
- console.log(`Selector: enqueued ${count} investigations`);
+ const summary = await runSelector({ dailyBudget: getSelectorDailyBudget() });
+ console.log(
+ `Selector: admitted ${summary.admitted.toString()} (budget left today: ${summary.budgetRemaining.toString()}), re-enqueued ${summary.requeued.toString()}, recovered ${summary.recovered.toString()} expired lease(s)`,
+ );
+ for (const failure of summary.failures) {
+ console.error(`Selector ${failure.stage} failed for ${failure.subjectId}:`, failure.error);
+ }
+ if (summary.failures.length > 0) {
+ exitCode = 1;
+ }
} catch (err) {
console.error("Selector error:", err);
exitCode = 1;
diff --git a/src/typescript/api/src/lib/services/selector.ts b/src/typescript/api/src/lib/services/selector.ts
index 396d683..b1a08e6 100644
--- a/src/typescript/api/src/lib/services/selector.ts
+++ b/src/typescript/api/src/lib/services/selector.ts
@@ -1,71 +1,270 @@
-import { getPrisma } from "$lib/db/client";
-import { getOrCreateCurrentPrompt } from "./prompt.js";
-import { ensureInvestigationQueued } from "./investigation-lifecycle.js";
+/**
+ * Investigation selector (SPEC §2.10, §3.6).
+ *
+ * Each run, in order:
+ * 1. Recovers every investigation whose lease expired (dead or stalled worker).
+ * 2. Re-enqueues every funded PENDING investigation that is due, so a lost
+ * queue job never strands one. This is not new spending and is unbudgeted.
+ * 3. Admits new work, highest capped unique-view score first: latest post
+ * versions with no investigation, and unfunded investigations (whose user
+ * key was dropped). Admissions are SELECTOR-funded and capped at
+ * SELECTOR_DAILY_BUDGET per UTC day, however often the cron runs.
+ *
+ * A failure on one candidate is recorded and the run moves on; the run's
+ * summary carries the failures so the entrypoint can exit non-zero.
+ */
+
+import { getPrisma, type PrismaClient } from "$lib/db/client";
+import { startOfUtcDay } from "$lib/date.js";
+import { isUniqueConstraintError } from "$lib/db/errors.js";
+import type { Prisma } from "$lib/db/prisma-client";
import { WORD_COUNT_LIMIT } from "@openerrata/shared";
-import { getSelectorBudget } from "$lib/config/runtime.js";
+import { getOrCreateCurrentPrompt } from "./prompt.js";
+import {
+ fundUnfundedInvestigation,
+ insertAdmittedInvestigation,
+ unfundedInvestigationWhere,
+} from "./investigation-admission.js";
+import { buildInvestigationInputSnapshot } from "./investigation-input.js";
+import { recoverExpiredLease } from "./investigation-lease.js";
+import { enqueueInvestigation } from "./queue.js";
+import { resolveUpdateLineage } from "./update-lineage.js";
-export async function runSelector(): Promise {
- const prisma = getPrisma();
- const budget = getSelectorBudget();
- const prompt = await getOrCreateCurrentPrompt();
-
- // Consider the most recently seen version for each post and enqueue
- // investigations that are missing or in recoverable pending/processing states.
- const candidates = await prisma.$queryRaw<
- {
- postVersionId: string;
- investigationId: string | null;
- investigationStatus: "PENDING" | "PROCESSING" | "COMPLETE" | "FAILED" | null;
- }[]
+interface SelectorFailure {
+ stage: "RECOVER" | "REQUEUE" | "ADMIT";
+ /** Investigation id for RECOVER/REQUEUE, post version id for ADMIT. */
+ subjectId: string;
+ error: unknown;
+}
+
+interface SelectorRunSummary {
+ recovered: number;
+ requeued: number;
+ admitted: number;
+ /** SELECTOR admissions still allowed today after this run. */
+ budgetRemaining: number;
+ failures: SelectorFailure[];
+}
+
+type AdmissionCandidate =
+ | { kind: "NEW"; postVersionId: string }
+ | { kind: "UNFUNDED"; postVersionId: string; investigationId: string };
+
+/** Serializes budget checks across concurrent selector runs. */
+async function lockSelectorBudget(tx: Prisma.TransactionClient): Promise {
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('openerrata.selector_daily_budget')::bigint)`;
+}
+
+async function countSelectorAdmissionsSince(
+ db: PrismaClient | Prisma.TransactionClient,
+ dayStart: Date,
+): Promise {
+ return db.investigation.count({
+ where: { origin: "SELECTOR", admittedAt: { gte: dayStart } },
+ });
+}
+
+async function recoverExpiredLeases(
+ prisma: PrismaClient,
+ failures: SelectorFailure[],
+): Promise {
+ const expired = await prisma.investigationLease.findMany({
+ where: { leaseExpiresAt: { lte: new Date() } },
+ select: { investigationId: true },
+ });
+
+ let recovered = 0;
+ for (const { investigationId } of expired) {
+ try {
+ if (await recoverExpiredLease(prisma, investigationId)) {
+ recovered += 1;
+ }
+ } catch (error) {
+ failures.push({ stage: "RECOVER", subjectId: investigationId, error });
+ }
+ }
+ return recovered;
+}
+
+async function requeueDueFundedInvestigations(
+ prisma: PrismaClient,
+ failures: SelectorFailure[],
+): Promise {
+ const due = await prisma.investigation.findMany({
+ where: {
+ status: "PENDING",
+ NOT: unfundedInvestigationWhere,
+ OR: [{ retryAfter: null }, { retryAfter: { lte: new Date() } }],
+ },
+ select: { id: true },
+ });
+
+ let requeued = 0;
+ for (const { id } of due) {
+ try {
+ await enqueueInvestigation(id);
+ requeued += 1;
+ } catch (error) {
+ failures.push({ stage: "REQUEUE", subjectId: id, error });
+ }
+ }
+ return requeued;
+}
+
+async function loadAdmissionCandidates(
+ prisma: PrismaClient,
+ limit: number,
+): Promise {
+ const rows = await prisma.$queryRaw<
+ { postVersionId: string; unfundedInvestigationId: string | null }[]
>`
WITH latest_versions AS (
SELECT DISTINCT ON (pv."postId")
pv."id" AS "postVersionId",
pv."postId",
- pv."contentBlobId",
- pv."lastSeenAt"
+ pv."contentBlobId"
FROM "PostVersion" pv
ORDER BY pv."postId", pv."lastSeenAt" DESC, pv."id" DESC
)
SELECT
lv."postVersionId",
- i."id" AS "investigationId",
- i."status" AS "investigationStatus"
+ i."id" AS "unfundedInvestigationId"
FROM latest_versions lv
- JOIN "Post" p
- ON p."id" = lv."postId"
- JOIN "ContentBlob" cb
- ON cb."id" = lv."contentBlobId"
- LEFT JOIN "Investigation" i
- ON i."postVersionId" = lv."postVersionId"
- LEFT JOIN "InvestigationLease" il
- ON il."investigationId" = i."id"
+ JOIN "Post" p ON p."id" = lv."postId"
+ JOIN "ContentBlob" cb ON cb."id" = lv."contentBlobId"
+ LEFT JOIN "Investigation" i ON i."postVersionId" = lv."postVersionId"
WHERE cb."wordCount" <= ${WORD_COUNT_LIMIT}
AND (
- i."id" IS NULL
- OR (i."status" = 'PENDING' AND (i."retryAfter" IS NULL OR i."retryAfter" <= NOW()))
- OR (
- i."status" = 'PROCESSING'
- AND (il."investigationId" IS NULL OR il."leaseExpiresAt" <= NOW())
+ i."id" IS NULL
+ OR (
+ i."status" = 'PENDING'
+ AND i."origin" = 'USER_KEY_REQUEST'
+ AND NOT EXISTS (
+ SELECT 1 FROM "InvestigationOpenAiKeySource" ks WHERE ks."investigationId" = i."id"
+ )
+ )
)
- )
- ORDER BY p."uniqueViewScore" DESC
- LIMIT ${budget}
+ ORDER BY p."uniqueViewScore" DESC, lv."postVersionId"
+ LIMIT ${limit}
`;
- let enqueued = 0;
- for (const candidate of candidates) {
- const { enqueued: wasEnqueued } = await ensureInvestigationQueued({
- prisma,
- postVersionId: candidate.postVersionId,
- promptId: prompt.id,
- rejectOverWordLimitOnCreate: false,
+ return rows.map((row) =>
+ row.unfundedInvestigationId === null
+ ? { kind: "NEW", postVersionId: row.postVersionId }
+ : {
+ kind: "UNFUNDED",
+ postVersionId: row.postVersionId,
+ investigationId: row.unfundedInvestigationId,
+ },
+ );
+}
+
+/**
+ * Run `admit` in a transaction holding the selector budget lock, but only if
+ * fewer than `dailyBudget` SELECTOR admissions happened since `dayStart`.
+ * Returns null when the budget is spent or `admit` declined; a unique
+ * violation means a request or concurrent run admitted the version first.
+ */
+async function admitWithinDailyBudget(
+ prisma: PrismaClient,
+ budget: { dailyBudget: number; dayStart: Date },
+ admit: (tx: Prisma.TransactionClient, now: Date) => Promise,
+): Promise {
+ try {
+ return await prisma.$transaction(async (tx) => {
+ await lockSelectorBudget(tx);
+ if ((await countSelectorAdmissionsSince(tx, budget.dayStart)) >= budget.dailyBudget) {
+ return null;
+ }
+ return admit(tx, new Date());
});
+ } catch (error) {
+ if (isUniqueConstraintError(error)) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+/** Admit one candidate under the daily budget; returns the admitted investigation id. */
+async function admitCandidate(
+ prisma: PrismaClient,
+ input: { candidate: AdmissionCandidate; promptId: string; dailyBudget: number; dayStart: Date },
+): Promise {
+ const { candidate } = input;
+ switch (candidate.kind) {
+ case "UNFUNDED":
+ return admitWithinDailyBudget(prisma, input, async (tx, now) =>
+ (await fundUnfundedInvestigation(tx, {
+ investigationId: candidate.investigationId,
+ funding: { origin: "SELECTOR" },
+ now,
+ }))
+ ? candidate.investigationId
+ : null,
+ );
+ case "NEW": {
+ const postVersion = await prisma.postVersion.findUniqueOrThrow({
+ where: { id: candidate.postVersionId },
+ select: { id: true, postId: true, contentBlob: { select: { contentText: true } } },
+ });
+ const lineage = await resolveUpdateLineage(prisma, {
+ id: postVersion.id,
+ postId: postVersion.postId,
+ contentText: postVersion.contentBlob.contentText,
+ });
+ const snapshot = await buildInvestigationInputSnapshot(prisma, postVersion.id);
+ return admitWithinDailyBudget(prisma, input, async (tx, now) => {
+ const created = await insertAdmittedInvestigation(tx, {
+ postVersionId: postVersion.id,
+ promptId: input.promptId,
+ funding: { origin: "SELECTOR" },
+ lineage,
+ snapshot,
+ now,
+ });
+ return created.id;
+ });
+ }
+ }
+}
+
+/** One selector pass; `dailyBudget` caps SELECTOR admissions per UTC day. */
+export async function runSelector(input: { dailyBudget: number }): Promise