Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/scripts/pr-quality-command-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export function createPrQualityCommandPlan({
if (runMode === "affected") {
return {
should_run: true,
command: "turbo run lint --affected && turbo run typecheck --affected",
command:
"pnpm exec turbo run lint --affected && pnpm exec turbo run typecheck --affected",
uses_remote_cache: canUseRemoteCache,
reason: "affected-static-gate",
};
Expand All @@ -50,7 +51,7 @@ export function createPrQualityCommandPlan({
if (runMode === "affected") {
return {
should_run: true,
command: "pnpm test:root && turbo run test --affected",
command: "pnpm test:root && pnpm exec turbo run test --affected",
uses_remote_cache: canUseRemoteCache,
reason: "affected-test-gate",
};
Expand Down
7 changes: 5 additions & 2 deletions .github/scripts/pr-quality-command-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test("affected static plan uses turbo affected commands", () => {

assert.equal(
result.command,
"turbo run lint --affected && turbo run typecheck --affected",
"pnpm exec turbo run lint --affected && pnpm exec turbo run typecheck --affected",
);
assert.equal(result.uses_remote_cache, true);
assert.equal(result.reason, "affected-static-gate");
Expand All @@ -56,7 +56,10 @@ test("affected test plan preserves root-owned tests and affected workspace tests
canUseRemoteCache: true,
});

assert.equal(result.command, "pnpm test:root && turbo run test --affected");
assert.equal(
result.command,
"pnpm test:root && pnpm exec turbo run test --affected",
);
assert.equal(result.reason, "affected-test-gate");
});

Expand Down
56 changes: 51 additions & 5 deletions apps/api/e2e/articles.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("Articles endpoints list and detail", () => {
"publishedAt",
"sourceTitle",
"summary",
"summaryErrorReason",
"summaryError",
"title",
"translatedTitle",
]);
Expand All @@ -159,11 +159,11 @@ describe("Articles endpoints list and detail", () => {
const detail = asArticleDetail(response.body);

expect(detail.summary).toBe("");
expect(detail.summaryErrorReason).toBe("");
expect(detail.summaryError).toBeNull();
expect(detail.translatedTitle).toBe("");
});

it("GET /articles/:id returns persisted summary failure reasons", async () => {
it("GET /articles/:id returns a structured safe summary error payload", async () => {
const failed = await prisma.article.create({
data: {
feedId: (await prisma.feed.findFirstOrThrow()).id,
Expand All @@ -175,7 +175,7 @@ describe("Articles endpoints list and detail", () => {
publishedAt: new Date("2026-04-15T12:00:00.000Z"),
sourceId: "guid-3",
summary: "",
summaryErrorReason: "gateway_timeout",
summaryErrorReason: "LLM_TIMEOUT",
title: "Article 3",
translatedTitle: "",
},
Expand All @@ -187,7 +187,53 @@ describe("Articles endpoints list and detail", () => {
const detail = asArticleDetail(response.body);

expect(detail.summary).toBe("");
expect(detail.summaryErrorReason).toBe("gateway_timeout");
expect(detail.summaryError).toEqual({
action:
"Refresh later. If timeouts keep happening, send the support note to the maintainer.",
code: "LLM_TIMEOUT",
copyText:
"Summary unavailable (LLM_TIMEOUT). The provider did not finish before the summary request timed out.",
message:
"The summary provider did not finish before the request timed out.",
title: "Summary request timed out",
});
});

it("GET /articles/:id sanitizes unknown persisted failure strings", async () => {
const failed = await prisma.article.create({
data: {
feedId: (await prisma.feed.findFirstOrThrow()).id,
identityHash: "hash-4",
identitySourceType: "SOURCE_ID",
identitySourceValue: "guid-4",
ingestedAt: new Date("2026-04-15T13:00:00.000Z"),
originalUrl: "https://example.com/articles/4",
publishedAt: new Date("2026-04-15T13:00:00.000Z"),
sourceId: "guid-4",
summary: "",
summaryErrorReason: "Authorization: Bearer secret-token",
title: "Article 4",
translatedTitle: "",
},
});
const server = app.getHttpServer() as Parameters<typeof request>[0];
const response = await request(server)
.get(`/articles/${failed.id}`)
.expect(200);
const detail = asArticleDetail(response.body);

expect(detail.summary).toBe("");
expect(detail.summaryError).toEqual({
action:
"Refresh later. If the same article keeps failing, send the support note to the maintainer.",
code: "LLM_PROVIDER_FAILED",
copyText:
"Summary unavailable (LLM_PROVIDER_FAILED). The provider failed before a safe summary could be prepared.",
message:
"The summary provider failed before a safe summary could be prepared.",
title: "Summary provider failed",
});
expect(JSON.stringify(detail)).not.toContain("secret-token");
});
});

Expand Down
136 changes: 136 additions & 0 deletions apps/api/src/article-summary/article-summary.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
export const ARTICLE_SUMMARY_ERROR_CODES = [
"LLM_AUTH_FAILED",
"LLM_RATE_LIMITED",
"LLM_TIMEOUT",
"LLM_CONNECTION_FAILED",
"LLM_BAD_RESPONSE",
"LLM_PROVIDER_FAILED",
"LLM_CONFIG_UNAVAILABLE",
] as const;

export type ArticleSummaryErrorCode =
(typeof ARTICLE_SUMMARY_ERROR_CODES)[number];

export type ArticleSummaryFailureDiagnostics = {
httpStatus?: number;
provider?: "openai_compatible";
providerRequestId?: string;
sdkErrorName?: string;
};

export type ArticleSummaryFailure = {
diagnostics: ArticleSummaryFailureDiagnostics;
errorCode: ArticleSummaryErrorCode;
retryable: boolean;
};

export type ArticleSummarySafeError = {
action: string;
code: ArticleSummaryErrorCode;
copyText: string;
message: string;
title: string;
};

const SAFE_ERROR_COPY: Record<
ArticleSummaryErrorCode,
Omit<ArticleSummarySafeError, "code">
> = {
LLM_AUTH_FAILED: {
action: "Please report this article to the maintainer and try again later.",
copyText:
"Summary unavailable (LLM_AUTH_FAILED). Ask the maintainer to verify the LLM provider credentials and access policy.",
message:
"The summary provider rejected this request before a summary could be prepared.",
title: "Summary service needs attention",
},
LLM_BAD_RESPONSE: {
action:
"Refresh later. If the same article keeps failing, send the support note to the maintainer.",
copyText:
"Summary unavailable (LLM_BAD_RESPONSE). The provider response could not be safely converted into a reader summary.",
message:
"The summary provider returned a response that could not be safely used.",
title: "Summary response was unusable",
},
LLM_CONFIG_UNAVAILABLE: {
action: "Please report this article to the maintainer and try again later.",
copyText:
"Summary unavailable (LLM_CONFIG_UNAVAILABLE). The summary service is not configured for this environment.",
message:
"This environment does not currently have a summary provider configured.",
title: "Summary service is unavailable",
},
LLM_CONNECTION_FAILED: {
action:
"Refresh later. If the problem continues, send the support note to the maintainer.",
copyText:
"Summary unavailable (LLM_CONNECTION_FAILED). The service could not reach the summary provider.",
message:
"The app could not establish a stable connection to the summary provider.",
title: "Summary provider connection failed",
},
LLM_PROVIDER_FAILED: {
action:
"Refresh later. If the same article keeps failing, send the support note to the maintainer.",
copyText:
"Summary unavailable (LLM_PROVIDER_FAILED). The provider failed before a safe summary could be prepared.",
message:
"The summary provider failed before a safe summary could be prepared.",
title: "Summary provider failed",
},
LLM_RATE_LIMITED: {
action:
"Please wait a moment and refresh later. Report it if the delay does not clear.",
copyText:
"Summary unavailable (LLM_RATE_LIMITED). The summary provider asked this app to slow down and retry later.",
message: "The summary provider is rate limiting requests right now.",
title: "Summary is temporarily rate limited",
},
LLM_TIMEOUT: {
action:
"Refresh later. If timeouts keep happening, send the support note to the maintainer.",
copyText:
"Summary unavailable (LLM_TIMEOUT). The provider did not finish before the summary request timed out.",
message:
"The summary provider did not finish before the request timed out.",
title: "Summary request timed out",
},
};

export function createArticleSummaryFailure(input: {
diagnostics?: ArticleSummaryFailureDiagnostics;
errorCode: ArticleSummaryErrorCode;
retryable: boolean;
}): ArticleSummaryFailure {
return {
diagnostics: input.diagnostics ?? {},
errorCode: input.errorCode,
retryable: input.retryable,
};
}

export function isArticleSummaryErrorCode(
value: string,
): value is ArticleSummaryErrorCode {
return (ARTICLE_SUMMARY_ERROR_CODES as readonly string[]).includes(value);
}

export function toArticleSummarySafeError(
storedReason: string | null | undefined,
): ArticleSummarySafeError | null {
const normalizedReason = storedReason?.trim();

if (!normalizedReason) {
return null;
}

const code = isArticleSummaryErrorCode(normalizedReason)
? normalizedReason
: "LLM_PROVIDER_FAILED";

return {
code,
...SAFE_ERROR_COPY[code],
};
}
Loading