Squashed commit of the following:

commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
This commit is contained in:
Amruth Pillai
2026-05-19 13:14:21 +02:00
parent 5b1297fa2b
commit 62f8270b3e
518 changed files with 29398 additions and 26871 deletions
+281
View File
@@ -0,0 +1,281 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ModelMessage, UIMessage } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamToEventIterator } from "@orpc/server";
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
import { createOllama } from "ollama-ai-provider-v2";
import { match } from "ts-pattern";
import { z } from "zod";
import {
analyzeResumeSystemPrompt as analyzeResumeSystemPromptTemplate,
chatSystemPromptTemplate,
docxParserSystemPrompt,
docxParserUserPrompt,
pdfParserSystemPrompt,
pdfParserUserPrompt,
} from "@reactive-resume/ai/prompts";
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
import {
normalizeResumePatchProposals,
resumePatchProposalToolInputSchema,
resumePatchProposalToolOutputSchema,
} from "@reactive-resume/ai/tools/patch-proposal";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { applyResumePatches } from "@reactive-resume/resume/patch";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { supportsProviderNativeWebSearch } from "./capabilities";
import { resolveAiBaseUrl } from "./url-policy";
const aiExtractionTemplate = buildAiExtractionTemplate();
function logAndRethrow(context: string, error: unknown): never {
if (error instanceof Error) {
console.error(`${context}:`, error);
throw error;
}
console.error(`${context}:`, error);
throw new Error(`An unknown error occurred during ${context}.`);
}
function parseAndValidateResumeJson(resultText: string): ResumeData {
const { data, diagnostics } = sanitizeAndParseResumeJson(resultText);
if (diagnostics.coercions.length === 0 && diagnostics.droppedSectionItems.length === 0) return data;
const droppedBySection = diagnostics.droppedSectionItems.reduce<Record<string, number>>((acc, item) => {
acc[item.section] = (acc[item.section] ?? 0) + 1;
return acc;
}, {});
console.info("AI resume sanitization diagnostics", {
coercions: diagnostics.coercions.length,
droppedBySection,
salvageApplied: diagnostics.salvageApplied,
});
return data;
}
type GetModelInput = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string;
};
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
export function getModel(input: GetModelInput) {
const { provider, model, apiKey } = input;
const baseURL = resolveAiBaseUrl(input);
return match(provider)
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
.with("openai-compatible", () =>
createOpenAICompatible({ name: "openai-compatible", apiKey, baseURL }).languageModel(model),
)
.with("ollama", () => {
const ollama = createOllama({
name: "ollama",
baseURL,
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
});
return ollama.languageModel(model);
})
.exhaustive();
}
export function getAgentModel(input: GetModelInput) {
if (!supportsProviderNativeWebSearch(input)) return getModel(input);
return createOpenAI({ apiKey: input.apiKey, baseURL: resolveAiBaseUrl(input) }).responses(input.model);
}
const aiCredentialsSchema = z.object({
provider: aiProviderSchema,
model: z.string().trim().min(1),
apiKey: z.string().trim().min(1),
baseURL: z.string().optional().default(""),
});
export const fileInputSchema = z.object({
name: z.string(),
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."),
});
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
export async function testConnection(input: TestConnectionInput): Promise<boolean> {
const RESPONSE_OK = "1";
const result = await generateText({
model: getModel(input),
output: Output.choice({ options: [RESPONSE_OK] }),
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
});
return result.output === RESPONSE_OK;
}
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
};
type BuildResumeParsingMessagesInput = {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
};
function buildResumeParsingMessages({
systemPrompt,
userPrompt,
file,
mediaType,
}: BuildResumeParsingMessagesInput): ModelMessage[] {
return [
{
role: "system",
content: `${systemPrompt}\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n${JSON.stringify(aiExtractionTemplate, null, 2)}`,
},
{
role: "user",
content: [
{ type: "text", text: userPrompt },
{ type: "file", data: file.data, mediaType, filename: file.name },
],
},
];
}
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: pdfParserSystemPrompt,
userPrompt: pdfParserUserPrompt,
file: input.file,
mediaType: "application/pdf",
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
};
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: docxParserSystemPrompt,
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
function buildChatSystemPrompt(resumeData: ResumeData): string {
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
}
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
messages: UIMessage[];
resumeData: ResumeData;
resumeUpdatedAt: Date;
};
async function chat(input: ChatInput) {
const model = getModel(input);
const systemPrompt = buildChatSystemPrompt(input.resumeData);
const result = streamText({
model,
system: systemPrompt,
messages: await convertToModelMessages(input.messages),
tools: {
propose_resume_patches: tool({
description:
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
inputSchema: resumePatchProposalToolInputSchema,
outputSchema: resumePatchProposalToolOutputSchema,
execute: async (toolInput) => {
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
for (const proposal of proposals) {
applyResumePatches(input.resumeData, proposal.operations);
}
return { proposals };
},
}),
},
stopWhen: stepCountIs(3),
});
return streamToEventIterator(result.toUIMessageStream());
}
type AnalyzeResumeInput = z.infer<typeof aiCredentialsSchema> & {
resumeData: ResumeData;
};
function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
}
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
const model = getModel(input);
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
const result = await generateText({
model,
output: Output.object({ schema: resumeAnalysisOutputSchema }),
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content:
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
},
],
});
if (result.output == null) {
throw new Error("AI returned no structured analysis output.");
}
return resumeAnalysisSchema.parse(result.output);
}
export const aiService = {
analyzeResume,
chat,
parseDocx,
parsePdf,
testConnection,
};