From 54191545ab62ab13141149db4d9a1c25bcaa7a28 Mon Sep 17 00:00:00 2001 From: iamwales Date: Thu, 23 Apr 2026 23:26:51 +0100 Subject: [PATCH 1/6] design updates on homepage --- .gitignore | 1 + frontend/package-lock.json | 1 + frontend/package.json | 1 + .../[id]/application-detail-page.tsx | 188 +++++++++++++++++ .../src/app/(app)/applications/[id]/page.tsx | 192 +----------------- frontend/src/app/(app)/applications/page.tsx | 2 +- frontend/src/app/(app)/apply/page.tsx | 4 +- frontend/src/app/(app)/resume/[id]/page.tsx | 74 +------ .../(app)/resume/[id]/resume-detail-page.tsx | 71 +++++++ .../(auth)/sign-in/[[...sign-in]]/page.tsx | 24 +-- .../sign-in/[[...sign-in]]/sign-in-view.tsx | 44 ++++ .../(auth)/sign-up/[[...sign-up]]/page.tsx | 24 +-- .../sign-up/[[...sign-up]]/sign-up-view.tsx | 47 +++++ frontend/src/app/globals.css | 159 +++++++++++---- frontend/src/app/layout.tsx | 59 +++--- frontend/src/app/onboarding/page.tsx | 6 +- frontend/src/app/page.tsx | 62 ++---- frontend/src/components/app-shell.tsx | 142 ++++++------- frontend/src/components/clerk-provider.tsx | 30 +++ frontend/src/components/landing-cta.tsx | 44 ---- frontend/src/components/landing-features.tsx | 133 ------------ frontend/src/components/landing-footer.tsx | 38 ---- frontend/src/components/landing-hero.tsx | 97 --------- .../src/components/landing-nav-buttons.tsx | 32 --- .../components/landing-problem-solution.tsx | 60 ------ frontend/src/components/landing-stats.tsx | 25 --- .../src/components/landing-testimonials.tsx | 69 ------- frontend/src/components/landing/CTA.tsx | 42 ++++ .../src/components/landing/FeatureItem.tsx | 21 ++ frontend/src/components/landing/Features.tsx | 72 +++++++ frontend/src/components/landing/Footer.tsx | 43 ++++ frontend/src/components/landing/Hero.tsx | 106 ++++++++++ .../src/components/landing/HeroMockup.tsx | 120 +++++++++++ .../src/components/landing/HowItWorks.tsx | 57 ++++++ .../components/landing/LandingCtaButton.tsx | 42 ++++ frontend/src/components/landing/LogoBar.tsx | 21 ++ frontend/src/components/landing/Navbar.tsx | 70 +++++++ .../src/components/landing/OutputPreview.tsx | 74 +++++++ frontend/src/components/landing/Outputs.tsx | 85 ++++++++ frontend/src/components/landing/Pricing.tsx | 68 +++++++ .../src/components/landing/PricingCard.tsx | 107 ++++++++++ frontend/src/components/landing/Stats.tsx | 30 +++ frontend/src/components/landing/StepCard.tsx | 46 +++++ frontend/src/components/match-badge.tsx | 4 +- frontend/src/components/status-badge.tsx | 8 +- frontend/src/components/ui/badge.tsx | 60 +++--- frontend/src/components/ui/button.tsx | 86 ++++---- frontend/src/components/ui/card.tsx | 93 ++++----- frontend/src/components/ui/logo-mark.tsx | 39 ++++ frontend/src/components/ui/section-header.tsx | 61 ++++++ frontend/src/lib/api.ts | 19 +- frontend/src/lib/hooks/use-api.ts | 2 +- frontend/src/lib/mock-data.ts | 1 + frontend/tsconfig.json | 1 + 54 files changed, 1914 insertions(+), 1093 deletions(-) create mode 100644 frontend/src/app/(app)/applications/[id]/application-detail-page.tsx create mode 100644 frontend/src/app/(app)/resume/[id]/resume-detail-page.tsx create mode 100644 frontend/src/app/(auth)/sign-in/[[...sign-in]]/sign-in-view.tsx create mode 100644 frontend/src/app/(auth)/sign-up/[[...sign-up]]/sign-up-view.tsx create mode 100644 frontend/src/components/clerk-provider.tsx delete mode 100644 frontend/src/components/landing-cta.tsx delete mode 100644 frontend/src/components/landing-features.tsx delete mode 100644 frontend/src/components/landing-footer.tsx delete mode 100644 frontend/src/components/landing-hero.tsx delete mode 100644 frontend/src/components/landing-nav-buttons.tsx delete mode 100644 frontend/src/components/landing-problem-solution.tsx delete mode 100644 frontend/src/components/landing-stats.tsx delete mode 100644 frontend/src/components/landing-testimonials.tsx create mode 100644 frontend/src/components/landing/CTA.tsx create mode 100644 frontend/src/components/landing/FeatureItem.tsx create mode 100644 frontend/src/components/landing/Features.tsx create mode 100644 frontend/src/components/landing/Footer.tsx create mode 100644 frontend/src/components/landing/Hero.tsx create mode 100644 frontend/src/components/landing/HeroMockup.tsx create mode 100644 frontend/src/components/landing/HowItWorks.tsx create mode 100644 frontend/src/components/landing/LandingCtaButton.tsx create mode 100644 frontend/src/components/landing/LogoBar.tsx create mode 100644 frontend/src/components/landing/Navbar.tsx create mode 100644 frontend/src/components/landing/OutputPreview.tsx create mode 100644 frontend/src/components/landing/Outputs.tsx create mode 100644 frontend/src/components/landing/Pricing.tsx create mode 100644 frontend/src/components/landing/PricingCard.tsx create mode 100644 frontend/src/components/landing/Stats.tsx create mode 100644 frontend/src/components/landing/StepCard.tsx create mode 100644 frontend/src/components/ui/logo-mark.tsx create mode 100644 frontend/src/components/ui/section-header.tsx diff --git a/.gitignore b/.gitignore index 92fbe26..26c99d9 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ terraform.rc coverage/ htmlcov/ .coverage +.cursor \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 28690ab..d70bc87 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@clerk/nextjs": "^7.2.5", + "@clerk/react": "^6.4.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/frontend/package.json b/frontend/package.json index 06e0ab0..2b24360 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@clerk/nextjs": "^7.2.5", + "@clerk/react": "^6.4.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/frontend/src/app/(app)/applications/[id]/application-detail-page.tsx b/frontend/src/app/(app)/applications/[id]/application-detail-page.tsx new file mode 100644 index 0000000..e339271 --- /dev/null +++ b/frontend/src/app/(app)/applications/[id]/application-detail-page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { ArrowLeft, Download } from "lucide-react"; + +import { MatchBadge } from "@/components/match-badge"; +import { StatusBadge } from "@/components/status-badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useApplication, useResume } from "@/lib/hooks/use-api"; + +export default function ApplicationDetailPage() { + const params = useParams<{ id: string }>(); + const id = params?.id; + + const { data: application, isLoading } = useApplication(id); + const { data: resume } = useResume(application?.resumeId); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!application) { + return ( +
+

Application not found.

+ +
+ ); + } + + return ( +
+
+ +
+ +
+
+

+ {application.position} +

+

{application.company}

+ {application.jobUrl ? ( + + View original posting + + ) : null} +
+
+ + +
+
+ + + + Job description + Tailored resume + Cover letter + Gap analysis + + + + + + Job description + + +
+                {application.jobDescription}
+              
+
+
+
+ + + + + Tailored resume + {resume ? ( + + ) : null} + + + {resume ? ( +
+                  {resume.content}
+                
+ ) : ( +

+ No tailored resume yet. +

+ )} +
+
+
+ + + + + Cover letter + {application.coverLetter ? ( + + ) : null} + + + {application.coverLetter ? ( +
+                  {application.coverLetter}
+                
+ ) : ( +

+ No cover letter generated yet. +

+ )} +
+
+
+ + + + + Gap analysis + + + {(application.gaps ?? []).length === 0 ? ( +

+ No gaps detected — your profile aligns well with this role. +

+ ) : ( + (application.gaps ?? []).map((gap) => ( +
+
+

{gap.skill}

+ {gap.note ? ( +

+ {gap.note} +

+ ) : null} +
+ + {gap.severity} + +
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(app)/applications/[id]/page.tsx b/frontend/src/app/(app)/applications/[id]/page.tsx index e339271..997a4ce 100644 --- a/frontend/src/app/(app)/applications/[id]/page.tsx +++ b/frontend/src/app/(app)/applications/[id]/page.tsx @@ -1,188 +1,12 @@ -"use client"; +import ApplicationDetailPage from "./application-detail-page"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { ArrowLeft, Download } from "lucide-react"; +/** `output: "export"`: allow dynamic segment without pre-built paths (see next/dist/build revalidate check). */ +export const revalidate = 0; -import { MatchBadge } from "@/components/match-badge"; -import { StatusBadge } from "@/components/status-badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useApplication, useResume } from "@/lib/hooks/use-api"; - -export default function ApplicationDetailPage() { - const params = useParams<{ id: string }>(); - const id = params?.id; - - const { data: application, isLoading } = useApplication(id); - const { data: resume } = useResume(application?.resumeId); - - if (isLoading) { - return ( -
- - -
- ); - } - - if (!application) { - return ( -
-

Application not found.

- -
- ); - } - - return ( -
-
- -
- -
-
-

- {application.position} -

-

{application.company}

- {application.jobUrl ? ( - - View original posting - - ) : null} -
-
- - -
-
- - - - Job description - Tailored resume - Cover letter - Gap analysis - - - - - - Job description - - -
-                {application.jobDescription}
-              
-
-
-
- - - - - Tailored resume - {resume ? ( - - ) : null} - - - {resume ? ( -
-                  {resume.content}
-                
- ) : ( -

- No tailored resume yet. -

- )} -
-
-
- - - - - Cover letter - {application.coverLetter ? ( - - ) : null} - - - {application.coverLetter ? ( -
-                  {application.coverLetter}
-                
- ) : ( -

- No cover letter generated yet. -

- )} -
-
-
+export function generateStaticParams() { + return [] as { id: string }[]; +} - - - - Gap analysis - - - {(application.gaps ?? []).length === 0 ? ( -

- No gaps detected — your profile aligns well with this role. -

- ) : ( - (application.gaps ?? []).map((gap) => ( -
-
-

{gap.skill}

- {gap.note ? ( -

- {gap.note} -

- ) : null} -
- - {gap.severity} - -
- )) - )} -
-
-
-
-
- ); +export default function ApplicationByIdPage() { + return ; } diff --git a/frontend/src/app/(app)/applications/page.tsx b/frontend/src/app/(app)/applications/page.tsx index 75d1c41..50f2287 100644 --- a/frontend/src/app/(app)/applications/page.tsx +++ b/frontend/src/app/(app)/applications/page.tsx @@ -19,7 +19,7 @@ export default function ApplicationsPage() {

Applications

- Track every job you've tailored an application for. + Track every job you've tailored an application for.

- - ); - } - - return ( -
- +export function generateStaticParams() { + return [] as { id: string }[]; +} - - -
- {resume.title} -

- Created {new Date(resume.createdAt).toLocaleDateString()} -

-
-
- {resume.isBase ? Base : null} - -
-
- -
-            {resume.content}
-          
-
-
-
- ); +export default function ResumeByIdPage() { + return ; } diff --git a/frontend/src/app/(app)/resume/[id]/resume-detail-page.tsx b/frontend/src/app/(app)/resume/[id]/resume-detail-page.tsx new file mode 100644 index 0000000..3ff1e32 --- /dev/null +++ b/frontend/src/app/(app)/resume/[id]/resume-detail-page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { ArrowLeft, Download } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useResume } from "@/lib/hooks/use-api"; + +export default function ResumeDetailPage() { + const params = useParams<{ id: string }>(); + const id = params?.id; + const { data: resume, isLoading } = useResume(id); + + if (isLoading) { + return ; + } + + if (!resume) { + return ( +
+

Resume not found.

+ +
+ ); + } + + return ( +
+ + + + +
+ {resume.title} +

+ Created {new Date(resume.createdAt).toLocaleDateString()} +

+
+
+ {resume.isBase ? Base : null} + +
+
+ +
+            {resume.content}
+          
+
+
+
+ ); +} diff --git a/frontend/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/frontend/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 2b163ed..58fff8e 100644 --- a/frontend/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/frontend/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -1,21 +1,9 @@ -import { SignIn } from "@clerk/nextjs"; +import { SignInView } from "./sign-in-view"; + +export function generateStaticParams() { + return [{ "sign-in": [] as string[] }]; +} export default function SignInPage() { - return ( -
-
-

- Welcome back -

-

- Sign in to access your dashboard and tailor new applications. -

-
- -
- ); + return ; } diff --git a/frontend/src/app/(auth)/sign-in/[[...sign-in]]/sign-in-view.tsx b/frontend/src/app/(auth)/sign-in/[[...sign-in]]/sign-in-view.tsx new file mode 100644 index 0000000..04198d3 --- /dev/null +++ b/frontend/src/app/(auth)/sign-in/[[...sign-in]]/sign-in-view.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { SignIn, useAuth } from "@clerk/react"; + +export function SignInView() { + const { isLoaded, isSignedIn } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (isLoaded && isSignedIn) { + router.replace("/dashboard"); + } + }, [isLoaded, isSignedIn, router]); + + if (!isLoaded) { + return ( +
Loading…
+ ); + } + if (isSignedIn) { + return null; + } + + return ( +
+
+

Welcome back

+

+ Sign in to access your dashboard and tailor new applications. +

+
+ +
+ ); +} diff --git a/frontend/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/frontend/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx index deec056..22c3c81 100644 --- a/frontend/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/frontend/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -1,21 +1,9 @@ -import { SignUp } from "@clerk/nextjs"; +import { SignUpView } from "./sign-up-view"; + +export function generateStaticParams() { + return [{ "sign-up": [] as string[] }]; +} export default function SignUpPage() { - return ( -
-
-

- Create your account -

-

- Join TalentStreamAI to generate tailored resumes and cover letters in seconds. -

-
- -
- ); + return ; } diff --git a/frontend/src/app/(auth)/sign-up/[[...sign-up]]/sign-up-view.tsx b/frontend/src/app/(auth)/sign-up/[[...sign-up]]/sign-up-view.tsx new file mode 100644 index 0000000..c1d5eee --- /dev/null +++ b/frontend/src/app/(auth)/sign-up/[[...sign-up]]/sign-up-view.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { SignUp, useAuth } from "@clerk/react"; + +export function SignUpView() { + const { isLoaded, isSignedIn } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (isLoaded && isSignedIn) { + router.replace("/dashboard"); + } + }, [isLoaded, isSignedIn, router]); + + if (!isLoaded) { + return ( +
Loading…
+ ); + } + if (isSignedIn) { + return null; + } + + return ( +
+
+

+ Create your account +

+

+ Join TalentStreamAI to generate tailored resumes and cover letters in + seconds. +

+
+ +
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8cb5fe8..42f156a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4,57 +4,144 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + /* Brand (design spec — hex; same names are overridden below for shadcn where needed) */ + --primary: #002723; + --primary-light: #003d38; + --primary-muted: #004d45; + --secondary: #c9a84c; + --secondary-light: #e8c96a; + --secondary-muted: #f0dfa0; + --bg: #fafaf8; + --bg-card: #ffffff; + --text-primary: #0d0d0b; + --text-secondary: #4a4a45; + --text-muted: #8a8a84; + --border: rgba(0, 39, 35, 0.1); + --border-card: rgba(0, 39, 35, 0.08); + + /* shadcn / Tailwind — HSL space-separated (opacity modifiers, e.g. bg-primary/5) */ + --background: 50 20% 98%; + --foreground: 60 8% 4%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 60 8% 4%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - --primary: 238 84% 59%; - --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; + --popover-foreground: 60 8% 4%; + --primary: 175 100% 7.6%; + --primary-foreground: 50 20% 98%; + --secondary: 50 25% 95%; + --secondary-foreground: 60 8% 10%; + --muted: 50 20% 96%; + --muted-foreground: 60 3% 30%; + --accent: 50 25% 95%; + --accent-foreground: 60 8% 10%; + --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 238 84% 59%; + --border: 175 15% 90%; + --input: 175 15% 90%; + --ring: 175 100% 7.6%; --radius: 0.625rem; } .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 238 84% 67%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; + --background: 175 18% 6%; + --foreground: 50 20% 98%; + --card: 175 16% 8%; + --card-foreground: 50 20% 98%; + --popover: 175 16% 8%; + --popover-foreground: 50 20% 98%; + --primary: 175 45% 42%; + --primary-foreground: 175 20% 8%; + --secondary: 175 12% 16%; + --secondary-foreground: 50 20% 98%; + --muted: 175 12% 16%; + --muted-foreground: 50 8% 65%; + --accent: 175 12% 16%; + --accent-foreground: 50 20% 98%; + --destructive: 0 62% 32%; --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 238 84% 67%; + --border: 175 12% 18%; + --input: 175 12% 18%; + --ring: 175 45% 42%; } -} -@layer base { * { + box-sizing: border-box; + margin: 0; + padding: 0; @apply border-border; } + + html { + scroll-behavior: smooth; + } + body { @apply bg-background text-foreground; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; font-feature-settings: "rlig" 1, "calt" 1; } } + +@layer utilities { + .animate-fade-up { + animation: fadeUp 0.6s ease both; + } + + .animate-fade-up-1 { + animation: fadeUp 0.6s 0.1s ease both; + } + + .animate-fade-up-2 { + animation: fadeUp 0.6s 0.2s ease both; + } + + .animate-fade-up-3 { + animation: fadeUp 0.6s 0.3s ease both; + } + + .animate-fade-up-4 { + animation: fadeUp 0.6s 0.4s ease both; + } + + .animate-fade-up-5 { + animation: fadeUp 0.8s 0.5s ease both; + } + + @keyframes fadeUp { + from { + opacity: 0; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes badgePulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.85); + } + } + + .badge-pulse { + animation: badgePulse 2s infinite; + } + + @keyframes barFill { + from { + width: 0; + } + } + + .animate-bar-fill { + animation: barFill 1.2s 0.8s ease both; + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f14aea2..a052859 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,40 +1,47 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import { ClerkProvider } from "@clerk/nextjs"; +import { Inter } from "next/font/google"; +import { ClerkProviderClient } from "@/components/clerk-provider"; import { Providers } from "@/components/providers"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", + display: "swap", }); export const metadata: Metadata = { - title: "TalentStreamAI", - description: - "AI-powered career co-pilot: tailored resumes, cover letters, and match scores in seconds.", + title: "TalentStreamAI — Land Interviews, Not Rejections", + description: + "AI-powered career co-pilot: tailored resumes, cover letters, and match scores in seconds.", + keywords: [ + "CV optimizer", + "ATS score", + "cover letter", + "job application", + "interview prep", + ], + openGraph: { + title: "TalentStreamAI — Land Interviews, Not Rejections", + description: + "AI-powered CV optimization that gets you interviews. Tailored CVs, cover letters, and interview prep in under 60 seconds.", + type: "website", + }, }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - - {children} - - - - ); + return ( + + + + {children} + + + + ); } diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx index 85788c2..890817a 100644 --- a/frontend/src/app/onboarding/page.tsx +++ b/frontend/src/app/onboarding/page.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { useRef, useState } from "react"; -import { UserButton } from "@clerk/nextjs"; +import { UserButton } from "@clerk/react"; import { ArrowRight, Loader2, Upload } from "lucide-react"; import Link from "next/link"; @@ -40,7 +40,7 @@ export default function OnboardingPage() { TalentStreamAI - + @@ -51,7 +51,7 @@ export default function OnboardingPage() { Upload your base resume

- We'll use this as the foundation for every tailored application. + We'll use this as the foundation for every tailored application. You can always update it later.

diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f97a656..981cd6a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,45 +1,27 @@ -import Link from "next/link"; - -import LandingCta from "@/components/landing-cta"; -import LandingFeatures from "@/components/landing-features"; -import LandingFooter from "@/components/landing-footer"; -import LandingHero from "@/components/landing-hero"; -import LandingNavButtons from "@/components/landing-nav-buttons"; -import LandingProblemSolution from "@/components/landing-problem-solution"; -import LandingStats from "@/components/landing-stats"; -import LandingTestimonials from "@/components/landing-testimonials"; +import { CTA } from "@/components/landing/CTA"; +import { Features } from "@/components/landing/Features"; +import { Footer } from "@/components/landing/Footer"; +import { Hero } from "@/components/landing/Hero"; +import { HowItWorks } from "@/components/landing/HowItWorks"; +import { LogoBar } from "@/components/landing/LogoBar"; +import { Navbar } from "@/components/landing/Navbar"; +import { Outputs } from "@/components/landing/Outputs"; +import { Pricing } from "@/components/landing/Pricing"; +import { Stats } from "@/components/landing/Stats"; export default function LandingPage() { return ( -
- {/* Sticky header */} -
-
- - TalentStreamAI - - - -
-
- -
- - - - - - -
- - -
+
+ + + + + + + + + +
+
); } diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index 7460401..268a873 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -2,83 +2,87 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { UserButton } from "@clerk/nextjs"; +import { UserButton } from "@clerk/react"; import { FileText, LayoutDashboard, Sparkles, Send } from "lucide-react"; import { cn } from "@/lib/utils"; +import { LogoMark } from "./ui/logo-mark"; const NAV_ITEMS = [ - { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { href: "/apply", label: "Apply", icon: Sparkles }, - { href: "/applications", label: "Applications", icon: Send }, - { href: "/resume", label: "Resumes", icon: FileText }, + { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/apply", label: "Apply", icon: Sparkles }, + { href: "/applications", label: "Applications", icon: Send }, + { href: "/resume", label: "Resumes", icon: FileText }, ] as const; export function AppShell({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); + const pathname = usePathname(); - return ( -
-
-
-
- - TalentStreamAI - - -
-
- -
+ return ( +
+
+
+
+ + + TalentStreamAI + + +
+
+ +
+
+ +
+
+ {children} +
- -
-
- {children} -
-
- ); + ); } diff --git a/frontend/src/components/clerk-provider.tsx b/frontend/src/components/clerk-provider.tsx new file mode 100644 index 0000000..485e79e --- /dev/null +++ b/frontend/src/components/clerk-provider.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ClerkProvider } from "@clerk/react"; + +const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + +/** + * Client-only Clerk provider so `output: "export"` does not pull in App Router + * server actions from `@clerk/nextjs` (see Next.js static export limitations). + */ +export function ClerkProviderClient({ + children, +}: { + children: React.ReactNode; +}) { + if (!publishableKey) { + throw new Error("Missing NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); + } + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/landing-cta.tsx b/frontend/src/components/landing-cta.tsx deleted file mode 100644 index 6b878c7..0000000 --- a/frontend/src/components/landing-cta.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useAuth } from "@clerk/nextjs"; -import { ArrowRight } from "lucide-react"; - -import { Button } from "@/components/ui/button"; - -export default function LandingCta() { - const { isSignedIn } = useAuth(); - - return ( -
-
-

- Stop Sending Generic Applications -

-

- Start applying with resumes tailored to every job. -

-
- -
- {!isSignedIn && ( -

- No credit card required. -

- )} -
-
- ); -} diff --git a/frontend/src/components/landing-features.tsx b/frontend/src/components/landing-features.tsx deleted file mode 100644 index e22cbec..0000000 --- a/frontend/src/components/landing-features.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { ClipboardList, FileUp, Zap } from "lucide-react"; - -const STEPS = [ - { - step: "Step 1", - icon: ClipboardList, - title: "Paste Job Description", - body: "Copy the job posting and paste it in. We extract what matters — required skills, keywords, and role expectations.", - }, - { - step: "Step 2", - icon: FileUp, - title: "Upload Your Resume", - body: "Upload your existing resume in PDF, DOCX, or TXT. We use it as the foundation for everything we generate.", - }, - { - step: "Step 3", - icon: Zap, - title: "Get Your Optimized Application", - body: "Receive a tailored resume, cover letter, draft email, and a match score with gap analysis — instantly.", - }, -] as const; - -/* Inline app mockup — no screenshot needed */ -function AppMockup() { - return ( -
- {/* fake browser chrome */} -
- - - -
- talenstreamai.com/apply -
-
- -
- {/* Left pane — input */} -
-

Upload Resume

-
- -

Drag and drop your resume here

-
- Choose File -
-
-
-

Your Resumes

-
- # John Smith.docx - - Selected - -
-
-
- - {/* Right pane — output preview */} -
-

Job Match Analysis

-
-
-

Original

-

45%

-
-
-
-
-
-

Tailored

-

75%

-
-
-
-
-
-

Improvement

-

+30%

-

After one extra tailoring pass

-
-
-
-

What We Improved

- {[ - "Repositioned security architecture work higher in the resume.", - "Strengthened Azure & O365 remediation language.", - "Improved keyword coverage around vulnerability management.", - ].map((t) => ( -

- - {t} -

- ))} -
-
-
-
- ); -} - -export default function LandingFeatures() { - return ( -
-
-
-

How It Works

-

- Three steps. Under a minute. Better results on every application. -

-
- -
- {STEPS.map(({ step, icon: Icon, title, body }) => ( -
-
- -
-

- {step} -

-

{title}

-

{body}

-
- ))} -
- - -
-
- ); -} diff --git a/frontend/src/components/landing-footer.tsx b/frontend/src/components/landing-footer.tsx deleted file mode 100644 index e6cb893..0000000 --- a/frontend/src/components/landing-footer.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import Link from "next/link"; - -const NAV = [ - { label: "How It Works", href: "#how-it-works" }, - { label: "Log In", href: "/sign-in" }, - { label: "Sign Up", href: "/sign-up" }, - { label: "Privacy Policy", href: "/privacy" }, - { label: "Terms of Service", href: "/terms" }, - { label: "Contact Us", href: "mailto:hello@talenstreamai.com" }, -] as const; - -export default function LandingFooter() { - return ( -
-
-
- - TalentStreamAI - - -
-

- © {new Date().getFullYear()} TalentStreamAI. All rights reserved. -

-
-
- ); -} diff --git a/frontend/src/components/landing-hero.tsx b/frontend/src/components/landing-hero.tsx deleted file mode 100644 index ee56221..0000000 --- a/frontend/src/components/landing-hero.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useAuth } from "@clerk/nextjs"; -import { ArrowRight } from "lucide-react"; - -import { Button } from "@/components/ui/button"; - -function ScorePreview() { - return ( -
-

- Example optimization -

-
- {/* Before */} -
- 45% -
-
-
- Before -
- - {/* Arrow */} - - - {/* After */} -
- 78% -
-
-
- After -
-
-

- See exactly what's holding your resume back and fix it in seconds. -

-
- ); -} - -export default function LandingHero() { - const { isSignedIn } = useAuth(); - - return ( -
-
-

- Applying to jobs has never -
- been easier. -

-

- Paste a job description. Instantly generate a tailored resume, cover - letter, and match score — all in one place. -

- -
- {isSignedIn ? ( - <> - - - - ) : ( - <> - - - - )} -
- {!isSignedIn && ( -

- No credit card required. Free to get started. -

- )} - - -
-
- ); -} diff --git a/frontend/src/components/landing-nav-buttons.tsx b/frontend/src/components/landing-nav-buttons.tsx deleted file mode 100644 index 28ecd02..0000000 --- a/frontend/src/components/landing-nav-buttons.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useAuth } from "@clerk/nextjs"; - -import { Button } from "@/components/ui/button"; - -export default function LandingNavButtons() { - const { isSignedIn } = useAuth(); - - if (isSignedIn) { - return ( - - ); - } - - return ( -
- - Log in - - -
- ); -} diff --git a/frontend/src/components/landing-problem-solution.tsx b/frontend/src/components/landing-problem-solution.tsx deleted file mode 100644 index fb5b4a1..0000000 --- a/frontend/src/components/landing-problem-solution.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Check, X } from "lucide-react"; - -const PROBLEMS = [ - "Your resume isn't tailored to the job", - "Missing keywords filter you out before a human sees it", - "Formatting breaks when scanned by tracking systems", - "You're sending the same generic application every time", -] as const; - -const SOLUTIONS = [ - "Get your ATS match score instantly", - "See exactly which keywords you're missing", - "Generate a tailored resume for each job", - "Create a targeted cover letter automatically", - "Draft a personalised outreach email in one click", - "Track all your applications in one place", -] as const; - -export default function LandingProblemSolution() { - return ( -
-
-
- {/* Problem */} -
-

- Why You're Not Getting Interviews -

-
    - {PROBLEMS.map((p) => ( -
  • - - {p} -
  • - ))} -
-

- That's why you can apply to dozens of jobs and hear nothing back. -

-
- - {/* Solution */} -
-

- Fix That in Seconds -

-
    - {SOLUTIONS.map((s) => ( -
  • - - {s} -
  • - ))} -
-
-
-
-
- ); -} diff --git a/frontend/src/components/landing-stats.tsx b/frontend/src/components/landing-stats.tsx deleted file mode 100644 index 3849f26..0000000 --- a/frontend/src/components/landing-stats.tsx +++ /dev/null @@ -1,25 +0,0 @@ -const STATS = [ - { value: "2,400+", label: "Active users" }, - { value: "8,900+", label: "Tailored resumes" }, - { value: "11,200+", label: "Applications tracked" }, -] as const; - -export default function LandingStats() { - return ( -
-
-
- {STATS.map(({ value, label }) => ( -
-

{value}

-

{label}

-
- ))} -
-

- Based on self-reported application tracking and user activity stored in the TalentStreamAI database. -

-
-
- ); -} diff --git a/frontend/src/components/landing-testimonials.tsx b/frontend/src/components/landing-testimonials.tsx deleted file mode 100644 index bd896f1..0000000 --- a/frontend/src/components/landing-testimonials.tsx +++ /dev/null @@ -1,69 +0,0 @@ -const TESTIMONIALS = [ - { - quote: - "With every application I could have a tailored resume and cover letter. That's wild.", - author: "Chris M.", - }, - { - quote: - "Wow. I'm very impressed already! I just tailored my resume to an AI Support Engineer role.", - author: "Simon D.", - }, - { - quote: - "I've truly never seen anything else that will customise a resume and cover letter in one spot.", - author: "Adam K.", - }, -] as const; - -function QuoteIcon() { - return ( - - - - ); -} - -export default function LandingTestimonials() { - return ( -
-
-

- What Job Seekers Are Saying -

-

- Real feedback from people actively using TalentStreamAI in their job search. -

- -
- {TESTIMONIALS.map(({ quote, author }) => ( -
-
- -

- {quote} -

-
-

- — {author} -

-
- ))} -
-
-
- ); -} diff --git a/frontend/src/components/landing/CTA.tsx b/frontend/src/components/landing/CTA.tsx new file mode 100644 index 0000000..eb81cd0 --- /dev/null +++ b/frontend/src/components/landing/CTA.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { LandingCtaButton } from "./LandingCtaButton"; + +export function CTA() { + return ( +
+
+ {/* Radial glow */} +
+ +

+ Your next interview +
+ is one upload away +

+

+ Join thousands who stopped getting rejected and started getting hired. +

+ +
+ + Optimize my CV now + + + +
+
+
+ ); +} diff --git a/frontend/src/components/landing/FeatureItem.tsx b/frontend/src/components/landing/FeatureItem.tsx new file mode 100644 index 0000000..0044f16 --- /dev/null +++ b/frontend/src/components/landing/FeatureItem.tsx @@ -0,0 +1,21 @@ +interface FeatureItemProps { + icon: string; + title: string; + description: string; +} + +export function FeatureItem({ icon, title, description }: FeatureItemProps) { + return ( +
+
+ {icon} +
+

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/frontend/src/components/landing/Features.tsx b/frontend/src/components/landing/Features.tsx new file mode 100644 index 0000000..78ec78e --- /dev/null +++ b/frontend/src/components/landing/Features.tsx @@ -0,0 +1,72 @@ +import { SectionHeader } from "../ui/section-header"; +import { FeatureItem } from "./FeatureItem"; + +const features = [ + { + icon: "🤖", + title: "ATS Optimization", + description: + "We reverse-engineer what each company's ATS is looking for and rewrite your CV to score above 90 — consistently.", + }, + { + icon: "✍️", + title: "Cover Letter Generator", + description: + "Compelling, personalized cover letters that match your voice and the company's tone. Never generic, never a template.", + }, + { + icon: "🔍", + title: "Keyword Analysis", + description: + "See exactly which keywords the job requires, which ones you're missing, and why each one matters.", + }, + { + icon: "💼", + title: "LinkedIn Headline", + description: + "Your LinkedIn profile rewritten to attract recruiters searching for this exact role — optimized for search rankings.", + }, + { + icon: "🎤", + title: "Interview Prep", + description: + "Role-specific questions, STAR-format answer frameworks, and the top 10 questions this company historically asks.", + }, + { + icon: "📊", + title: "Gap Analysis", + description: + "Understand what skills or experience are missing and get actionable suggestions to close the gap before applying.", + }, +]; + +export function Features() { + return ( +
+
+ + Everything you need +
+ to get the offer + + } + description="One upload. A complete application package built specifically for each role." + dark + /> + + {/* Grid with 1px gap separator effect */} +
+ {features.map((feature) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/landing/Footer.tsx b/frontend/src/components/landing/Footer.tsx new file mode 100644 index 0000000..d88d780 --- /dev/null +++ b/frontend/src/components/landing/Footer.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { LogoMark } from "@/components/ui/logo-mark"; + +const footerLinks = [ + { href: "/privacy", label: "Privacy" }, + { href: "/terms", label: "Terms" }, + { href: "/blog", label: "Blog" }, + { href: "/support", label: "Support" }, +]; + +export function Footer() { + return ( +
+ + + + TalentStreamAI + + + +
    + {footerLinks.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+ +

+ © {new Date().getFullYear()} TalentStreamAI. All rights + reserved. +

+
+ ); +} diff --git a/frontend/src/components/landing/Hero.tsx b/frontend/src/components/landing/Hero.tsx new file mode 100644 index 0000000..5abee98 --- /dev/null +++ b/frontend/src/components/landing/Hero.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, HelpCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { LandingCtaButton } from "./LandingCtaButton"; +import { HeroMockup } from "./HeroMockup"; + +const avatars = [ + { initials: "AK", bg: "#1B4D3E" }, + { initials: "LM", bg: "#C9A84C" }, + { initials: "BO", bg: "#3D6B5E" }, + { initials: "FN", bg: "#8B6B1A" }, +]; + +export function Hero() { + return ( +
+ {/* Background radial glows */} +
+
+
+
+ + {/* Hero badge */} +
+ + + AI-powered job application suite + +
+ + {/* Headline */} +

+ Stop getting ignored.{" "} + + Start getting interviews. + +

+ + {/* Subheadline */} +

+ Upload your CV, paste any job link — we rewrite your application to beat + ATS filters and land in front of the hiring manager. +

+ + {/* CTA buttons */} +
+ + Optimize my CV — it's free + + + +
+ + {/* Social proof */} +
+
+ {avatars.map((av, i) => ( +
+ {av.initials} +
+ ))} +
+

+ 4,200+ job seekers got + interviews this month +

+
+ + {/* Mockup visual */} + +
+ ); +} diff --git a/frontend/src/components/landing/HeroMockup.tsx b/frontend/src/components/landing/HeroMockup.tsx new file mode 100644 index 0000000..68804c7 --- /dev/null +++ b/frontend/src/components/landing/HeroMockup.tsx @@ -0,0 +1,120 @@ +export function HeroMockup() { + const bars = [ + { label: "Keywords matched", value: 96 }, + { label: "Format score", value: 92 }, + { label: "Relevance", value: 90 }, + ]; + + const tags = ["Python", "React", "CI/CD", "Agile"]; + + return ( +
+ {/* Window chrome */} +
+
+ {/* Title bar */} +
+ + + +
+ app.talentstreamai.ai/optimize +
+
+ + {/* Body — two panels */} +
+ {/* ATS Score Panel */} +
+

+ ATS Score +

+
+ + 94 + + + / 100 + +
+ + {bars.map((bar) => ( +
+
+ {bar.label} + {bar.value}% +
+
+
+
+
+ ))} + +
+ {tags.map((tag) => ( + + + ✓ + + {tag} + + ))} +
+
+ + {/* CV Preview Panel */} +
+

+ Optimized CV +

+ {[ + 100, + 65, + null, + 100, + 100, + 45, + null, + 100, + 65, + 100, + ].map((w, i) => + w === null ? ( +
+ ) : ( +
+ ), + )} +
+
+ + Ready to download +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/landing/HowItWorks.tsx b/frontend/src/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..4aa1da0 --- /dev/null +++ b/frontend/src/components/landing/HowItWorks.tsx @@ -0,0 +1,57 @@ +import { SectionHeader } from "@/components/ui/section-header"; +import { StepCard } from "./StepCard"; + +const steps = [ + { + step: 1, + label: "Upload", + icon: "📄", + title: "Drop your CV", + description: + "Upload your existing CV in any format — PDF, DOCX, or paste the text. We handle the rest.", + }, + { + step: 2, + label: "Target", + icon: "🎯", + title: "Paste the job link", + description: + "Add the URL or description of the role you want. Our AI reads the job post and extracts every requirement.", + }, + { + step: 3, + label: "Download", + icon: "✨", + title: "Get your full kit", + description: + "Receive an ATS-optimized CV, tailored cover letter, LinkedIn summary, and interview prep — all in under 60 seconds.", + }, +]; + +export function HowItWorks() { + return ( +
+ + Three steps to your +
+ next interview + + } + description="No more guessing if your CV is good enough. We guarantee it is." + /> + +
+ {steps.map((step, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/landing/LandingCtaButton.tsx b/frontend/src/components/landing/LandingCtaButton.tsx new file mode 100644 index 0000000..9d54844 --- /dev/null +++ b/frontend/src/components/landing/LandingCtaButton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import { useAuth } from "@clerk/react"; + +import { Button, type ButtonProps } from "@/components/ui/button"; + +type LandingCtaButtonProps = Omit & { + children: ReactNode; +}; + +/** + * Primary landing CTAs: /dashboard for signed-in users, /sign-up for guests + * (matches middleware redirect for authenticated visits to /sign-in and /sign-up). + */ +export function LandingCtaButton({ children, disabled, ...buttonProps }: LandingCtaButtonProps) { + const { isSignedIn, isLoaded } = useAuth(); + const href = isSignedIn ? "/dashboard" : "/sign-up"; + + if (!isLoaded) { + return ( + + ); + } + + if (disabled) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/landing/LogoBar.tsx b/frontend/src/components/landing/LogoBar.tsx new file mode 100644 index 0000000..cd124cc --- /dev/null +++ b/frontend/src/components/landing/LogoBar.tsx @@ -0,0 +1,21 @@ +const companies = ["Google", "Stripe", "McKinsey", "Shopify", "Notion", "Airbnb"]; + +export function LogoBar() { + return ( +
+

+ Used by candidates targeting +

+
+ {companies.map((name) => ( + + {name} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/landing/Navbar.tsx b/frontend/src/components/landing/Navbar.tsx new file mode 100644 index 0000000..51cc034 --- /dev/null +++ b/frontend/src/components/landing/Navbar.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { SignInButton, UserButton, useAuth } from "@clerk/react"; +import { ArrowRight } from "lucide-react"; +import { LogoMark } from "@/components/ui/logo-mark"; +import { Button } from "@/components/ui/button"; +import { LandingCtaButton } from "./LandingCtaButton"; + +const navLinks = [ + { href: "#how-it-works", label: "How it works" }, + { href: "#features", label: "Features" }, + { href: "#pricing", label: "Pricing" }, +]; + +export function Navbar() { + const { isSignedIn, isLoaded } = useAuth(); + + return ( + + ); +} diff --git a/frontend/src/components/landing/OutputPreview.tsx b/frontend/src/components/landing/OutputPreview.tsx new file mode 100644 index 0000000..61ad48b --- /dev/null +++ b/frontend/src/components/landing/OutputPreview.tsx @@ -0,0 +1,74 @@ +import { Badge } from "@/components/ui/badge"; + +const keywords = [ + { text: "Product Management", highlight: true }, + { text: "Roadmapping", highlight: true }, + { text: "Agile", highlight: true }, + { text: "Stakeholder mgmt", highlight: false }, + { text: "Data-driven", highlight: true }, + { text: "B2B SaaS", highlight: false }, + { text: "OKRs", highlight: true }, + { text: "GTM strategy", highlight: false }, +]; + +export function OutputPreview() { + return ( +
+ {/* Header */} +
+ + CV Score Report + + + 94 / 100 + +
+ + {/* Body */} +
+ {/* Score row */} +
+
+

+ ATS Score +

+

+ 94 + + /100 + +

+
+ ✓ Interview-ready +
+ + {/* Keywords */} +

+ Matched keywords +

+
+ {keywords.map((kw) => ( + + {kw.text} + + ))} +
+ + {/* Experience lines */} +

+ Experience summary +

+ {[100, 75, 100, 50].map((w, i) => ( +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/landing/Outputs.tsx b/frontend/src/components/landing/Outputs.tsx new file mode 100644 index 0000000..fc23068 --- /dev/null +++ b/frontend/src/components/landing/Outputs.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { SectionHeader } from "@/components/ui/section-header"; +import { OutputPreview } from "./OutputPreview"; +import { cn } from "@/lib/utils"; + +const outputs = [ + { + icon: "📄", + title: "ATS-Optimized CV", + description: "Tailored to the job description. Scores above 90 on every major ATS.", + }, + { + icon: "✉️", + title: "Cover Letter", + description: "Personalized, compelling, and formatted for the role and company.", + }, + { + icon: "🔗", + title: "LinkedIn Summary", + description: "Rewritten about section optimized for recruiter searches.", + }, + { + icon: "❓", + title: "Interview Question Bank", + description: "10 role-specific questions with suggested STAR-format answers.", + }, +]; + +export function Outputs() { + const [activeIndex, setActiveIndex] = useState(0); + + return ( +
+ + Your complete +
+ application kit + + } + description="Every document you need to apply with confidence, all generated in one go." + /> + +
+ {/* Output list */} +
+ {outputs.map((item, i) => ( + + ))} +
+ + {/* Preview panel */} + +
+
+ ); +} diff --git a/frontend/src/components/landing/Pricing.tsx b/frontend/src/components/landing/Pricing.tsx new file mode 100644 index 0000000..a516daf --- /dev/null +++ b/frontend/src/components/landing/Pricing.tsx @@ -0,0 +1,68 @@ +import { SectionHeader } from "@/components/ui/section-header"; +import { PricingCard } from "./PricingCard"; + +const plans = [ + { + plan: "Starter", + price: "Free", + period: "3 optimizations / month", + features: [ + "ATS-optimized CV", + "Keyword analysis", + "Basic cover letter", + "PDF download", + ], + ctaLabel: "Get started free", + featured: false, + }, + { + plan: "Pro", + price: "$19", + period: "per month, billed monthly", + features: [ + "Unlimited optimizations", + "Full cover letter suite", + "LinkedIn rewrite", + "Interview question bank", + "Gap analysis", + "Priority processing", + ], + ctaLabel: "Start with Pro", + featured: true, + badge: "Most popular", + }, + { + plan: "Teams", + price: "$49", + period: "per month, up to 5 members", + features: [ + "Everything in Pro", + "Team dashboard", + "Recruiter collaboration", + "Bulk CV processing", + "Analytics & reporting", + "Dedicated support", + ], + ctaLabel: "Contact sales", + featured: false, + }, +]; + +export function Pricing() { + return ( +
+ + +
+ {plans.map((plan) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/landing/PricingCard.tsx b/frontend/src/components/landing/PricingCard.tsx new file mode 100644 index 0000000..9c002cd --- /dev/null +++ b/frontend/src/components/landing/PricingCard.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { LandingCtaButton } from "./LandingCtaButton"; + +interface PricingCardProps { + plan: string; + price: string; + period: string; + features: string[]; + ctaLabel: string; + featured?: boolean; + badge?: string; +} + +export function PricingCard({ + plan, + price, + period, + features, + ctaLabel, + featured = false, + badge, +}: PricingCardProps) { + return ( +
+ {badge && ( + + {badge} + + )} + +

+ {plan} +

+ +

+ {price} +

+

+ {period} +

+ +
+ +
    + {features.map((feature) => ( +
  • + + ✓ + + + {feature} + +
  • + ))} +
+ + + {ctaLabel} + +
+ ); +} diff --git a/frontend/src/components/landing/Stats.tsx b/frontend/src/components/landing/Stats.tsx new file mode 100644 index 0000000..4180c7b --- /dev/null +++ b/frontend/src/components/landing/Stats.tsx @@ -0,0 +1,30 @@ +const stats = [ + { number: "94", suffix: "%", label: "Average ATS score" }, + { number: "3", suffix: "×", label: "More interview callbacks" }, + { number: "60", suffix: "s", label: "To full application kit" }, + { number: "4.2", suffix: "k", label: "Interviews landed this month" }, +]; + +export function Stats() { + return ( +
+
+ {stats.map((stat) => ( +
+

+ {stat.number} + {stat.suffix} +

+

{stat.label}

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/landing/StepCard.tsx b/frontend/src/components/landing/StepCard.tsx new file mode 100644 index 0000000..6bb8d26 --- /dev/null +++ b/frontend/src/components/landing/StepCard.tsx @@ -0,0 +1,46 @@ +interface StepCardProps { + step: number; + label: string; + icon: string; + title: string; + description: string; + showConnector?: boolean; +} + +export function StepCard({ + step, + label, + icon, + title, + description, + showConnector = false, +}: StepCardProps) { + return ( +
+ {/* Step number */} +
+
+ {step} +
+ {label} +
+ + {/* Icon */} +
+ {icon} +
+ +

+ {title} +

+

{description}

+ + {/* Connector arrow */} + {showConnector && ( +
+ → +
+ )} +
+ ); +} diff --git a/frontend/src/components/match-badge.tsx b/frontend/src/components/match-badge.tsx index 3944f3a..8b5a8f9 100644 --- a/frontend/src/components/match-badge.tsx +++ b/frontend/src/components/match-badge.tsx @@ -6,8 +6,8 @@ type MatchBadgeProps = { }; export function MatchBadge({ score, className }: MatchBadgeProps) { - const variant: "default" | "warning" | "destructive" = - score >= 85 ? "default" : score >= 70 ? "warning" : "destructive"; + const variant: "score" | "warning" | "destructive" = + score >= 85 ? "score" : score >= 70 ? "warning" : "destructive"; return ( diff --git a/frontend/src/components/status-badge.tsx b/frontend/src/components/status-badge.tsx index 40d3478..0a155d8 100644 --- a/frontend/src/components/status-badge.tsx +++ b/frontend/src/components/status-badge.tsx @@ -11,12 +11,12 @@ const LABELS: Record = { const VARIANTS: Record< ApplicationStatus, - "secondary" | "info" | "default" | "success" | "destructive" + "secondary" | "keyword" | "section" | "score" | "destructive" > = { draft: "secondary", - applied: "info", - interview: "default", - offer: "success", + applied: "keyword", + interview: "section", + offer: "score", rejected: "destructive", }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 318060d..b9e85f8 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -1,39 +1,49 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", - success: "border-transparent bg-emerald-100 text-emerald-800", - warning: "border-transparent bg-amber-100 text-amber-800", - info: "border-transparent bg-blue-100 text-blue-800", - }, - }, - defaultVariants: { - variant: "default", + "inline-flex items-center gap-2 font-semibold transition-colors", + { + variants: { + variant: { + /** Neutral / muted; common shadcn name for app UI like “Base” labels */ + secondary: + "bg-[rgba(0,39,35,0.08)] text-[#002723] text-[12px] px-[10px] py-1 rounded-full font-medium border border-[rgba(0,39,35,0.12)]", + hero: "bg-[rgba(201,168,76,0.12)] border border-[rgba(201,168,76,0.3)] text-[#8B6B1A] text-[12.5px] px-[14px] py-[5px] rounded-full tracking-[0.3px]", + section: + "bg-[rgba(0,39,35,0.06)] text-[#002723] text-[12px] px-[14px] py-[5px] rounded-full uppercase tracking-[0.5px]", + "section-dark": + "bg-[rgba(201,168,76,0.12)] border border-[rgba(201,168,76,0.2)] text-[#E8C96A] text-[12px] px-[14px] py-[5px] rounded-full uppercase tracking-[0.5px]", + featured: + "bg-[rgba(201,168,76,0.12)] border border-[rgba(201,168,76,0.25)] text-[#E8C96A] text-[11px] px-[10px] py-[3px] rounded-full tracking-[0.5px]", + score: "bg-[rgba(74,222,128,0.1)] border border-[rgba(74,222,128,0.3)] text-[#166534] text-[12px] px-[12px] py-[5px] rounded-full", + warning: + "bg-[rgba(234,179,8,0.12)] border border-[rgba(202,138,4,0.35)] text-[#a16207] text-[12px] px-[12px] py-[5px] rounded-full", + destructive: + "bg-[rgba(248,113,113,0.12)] border border-[rgba(220,38,38,0.35)] text-[#b91c1c] text-[12px] px-[12px] py-[5px] rounded-full", + keyword: + "bg-[rgba(0,39,35,0.06)] text-[#002723] text-[11px] px-[10px] py-1 rounded-full font-medium", + "keyword-highlight": + "bg-[rgba(201,168,76,0.1)] border border-[rgba(201,168,76,0.25)] text-[#8B6B1A] text-[11px] px-[10px] py-1 rounded-full font-medium", + ats: "bg-[rgba(201,168,76,0.12)] border border-[rgba(201,168,76,0.22)] text-[#f0dfa0] text-[11px] px-[10px] py-1 rounded-full font-medium", + }, + }, + defaultVariants: { + variant: "section", + }, }, - }, ); export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends + React.HTMLAttributes, + VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ); + return ( +
+ ); } export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 231ff3c..923ff27 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,56 +1,64 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", + "inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 cursor-pointer select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#002723] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "bg-[#002723] text-[#FAFAF8] rounded-[6px] shadow-[0_4px_24px_rgba(0,39,35,0.2)] hover:bg-[#003d38] hover:-translate-y-[1px] hover:shadow-[0_8px_32px_rgba(0,39,35,0.28)] active:translate-y-0", + /** Secondary action; same visual as ghost, named for shadcn / common API compatibility */ + outline: + "bg-transparent text-[#002723] border border-[rgba(0,39,35,0.1)] rounded-[6px] hover:bg-[rgba(0,39,35,0.05)] hover:border-[rgba(0,39,35,0.25)]", + ghost: "bg-transparent text-[#002723] border border-[rgba(0,39,35,0.1)] rounded-[6px] hover:bg-[rgba(0,39,35,0.05)] hover:border-[rgba(0,39,35,0.25)]", + hero: "bg-[#002723] text-[#FAFAF8] rounded-[12px] shadow-[0_4px_24px_rgba(0,39,35,0.2)] hover:bg-[#004d45] hover:-translate-y-[2px] hover:shadow-[0_8px_32px_rgba(0,39,35,0.28)] active:translate-y-0", + "hero-secondary": + "bg-transparent text-[#002723] border border-[rgba(0,39,35,0.1)] rounded-[12px] hover:bg-[rgba(0,39,35,0.04)]", + gold: "bg-[#C9A84C] text-[#002723] rounded-[12px] shadow-[0_4px_20px_rgba(201,168,76,0.3)] hover:bg-[#E8C96A] hover:-translate-y-[2px] hover:shadow-[0_8px_28px_rgba(201,168,76,0.4)] active:translate-y-0", + "ghost-dark": + "bg-[rgba(255,255,255,0.06)] text-[rgba(245,245,240,0.8)] border border-[rgba(255,255,255,0.12)] rounded-[12px] hover:bg-[rgba(255,255,255,0.1)]", + "pricing-ghost": + "bg-transparent text-[#002723] border border-[rgba(0,39,35,0.1)] rounded-[6px] hover:bg-[rgba(0,39,35,0.05)] w-full", + "pricing-primary": + "bg-[#C9A84C] text-[#002723] rounded-[6px] hover:bg-[#E8C96A] hover:-translate-y-[1px] w-full", + }, + size: { + sm: "h-8 px-4 text-[13px]", + md: "h-9 px-[18px] text-[14px]", + lg: "h-[46px] px-7 text-[15px]", + xl: "h-[50px] px-8 text-[15px]", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, }, - }, ); export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; + extends + React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); - }, + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, ); + Button.displayName = "Button"; export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index 9bd88de..dd4fea3 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -1,86 +1,77 @@ import * as React from "react"; - import { cn } from "@/lib/utils"; const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); Card.displayName = "Card"; const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLHeadingElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+

)); CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLParagraphElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+

)); CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+
)); CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); CardFooter.displayName = "CardFooter"; export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, }; diff --git a/frontend/src/components/ui/logo-mark.tsx b/frontend/src/components/ui/logo-mark.tsx new file mode 100644 index 0000000..68a7f01 --- /dev/null +++ b/frontend/src/components/ui/logo-mark.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/lib/utils"; + +interface LogoMarkProps { + size?: number; + className?: string; +} + +export function LogoMark({ size = 32, className }: LogoMarkProps) { + const iconSize = Math.round(size * 0.5); + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/ui/section-header.tsx b/frontend/src/components/ui/section-header.tsx new file mode 100644 index 0000000..5726aea --- /dev/null +++ b/frontend/src/components/ui/section-header.tsx @@ -0,0 +1,61 @@ +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import type { BadgeProps } from "@/components/ui/badge"; + +interface SectionHeaderProps { + tag: string; + title: React.ReactNode; + description?: string; + align?: "left" | "center"; + dark?: boolean; + className?: string; + descriptionClassName?: string; +} + +export function SectionHeader({ + tag, + title, + description, + align = "left", + dark = false, + className, + descriptionClassName, +}: SectionHeaderProps) { + const badgeVariant: BadgeProps["variant"] = dark + ? "section-dark" + : "section"; + + return ( +
+ + {tag} + +

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 500e6f6..0fcf03d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -63,15 +63,16 @@ export async function apiFetch( const parsed = text ? safeJsonParse(text) : undefined; if (!response.ok) { - const message = - (parsed && - typeof parsed === "object" && - parsed !== null && - "detail" in parsed && - typeof (parsed as { detail: unknown }).detail === "string" && - (parsed as { detail: string }).detail) || - response.statusText || - "Request failed"; + let message: string = response.statusText || "Request failed"; + if ( + parsed && + typeof parsed === "object" && + parsed !== null && + "detail" in parsed && + typeof (parsed as { detail: unknown }).detail === "string" + ) { + message = (parsed as { detail: string }).detail; + } throw new ApiError(message, response.status, parsed); } diff --git a/frontend/src/lib/hooks/use-api.ts b/frontend/src/lib/hooks/use-api.ts index aead8de..97506bf 100644 --- a/frontend/src/lib/hooks/use-api.ts +++ b/frontend/src/lib/hooks/use-api.ts @@ -1,6 +1,6 @@ "use client"; -import { useAuth } from "@clerk/nextjs"; +import { useAuth } from "@clerk/react"; import { useMutation, useQuery, diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index 0a9ac58..c44c1ec 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -141,6 +141,7 @@ export const mockApi = { return delay(resumes.find((r) => r.id === id)); }, async tailor(req: TailorRequest): Promise { + void req; const id = `a-${Date.now()}`; const resumeId = `r-${Date.now()}`; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c133409..d9aa91a 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "baseUrl": ".", "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, From 967b5edf8d598a34c9ee745abffcc5c4ffef7b89 Mon Sep 17 00:00:00 2001 From: iamwales Date: Fri, 24 Apr 2026 07:11:52 +0100 Subject: [PATCH 2/6] feat: implemented all neccessary apis needed for the frontend and observability with error handling --- .env.example | 13 +- backend/app/api/router.py | 16 +- backend/app/api/schemas/frontend.py | 207 +++++++++++ backend/app/api/v1/applications.py | 88 +++++ backend/app/api/v1/dashboard.py | 22 ++ backend/app/api/v1/observability.py | 36 ++ backend/app/api/v1/profile.py | 39 ++ backend/app/api/v1/resumes.py | 121 +++---- backend/app/core/auth.py | 9 +- backend/app/core/config.py | 9 + backend/app/core/db.py | 335 ++++++++++++++++++ backend/app/core/exception_handlers.py | 35 ++ backend/app/core/logging_config.py | 43 +++ backend/app/core/metrics.py | 43 +++ backend/app/core/request_context.py | 47 +++ backend/app/main.py | 16 +- backend/app/middleware/request_context.py | 59 +++ backend/app/services/draft_email.py | 43 +++ backend/app/services/ingest_resume.py | 106 ++++++ backend/app/services/job_text.py | 34 ++ .../app/services/langgraph/streaming_agent.py | 32 +- backend/app/services/langgraph/workflow.py | 48 ++- backend/app/services/llm/client.py | 44 ++- backend/app/services/llm/safety.py | 23 ++ backend/app/services/tailor_orchestrator.py | 184 ++++++++++ backend/docs/ARCHITECTURE.md | 43 +++ backend/pyproject.toml | 4 + backend/uv.lock | 317 +++++++++++++++++ 28 files changed, 1925 insertions(+), 91 deletions(-) create mode 100644 backend/app/api/schemas/frontend.py create mode 100644 backend/app/api/v1/applications.py create mode 100644 backend/app/api/v1/dashboard.py create mode 100644 backend/app/api/v1/observability.py create mode 100644 backend/app/api/v1/profile.py create mode 100644 backend/app/core/exception_handlers.py create mode 100644 backend/app/core/logging_config.py create mode 100644 backend/app/core/metrics.py create mode 100644 backend/app/core/request_context.py create mode 100644 backend/app/middleware/request_context.py create mode 100644 backend/app/services/draft_email.py create mode 100644 backend/app/services/ingest_resume.py create mode 100644 backend/app/services/job_text.py create mode 100644 backend/app/services/llm/safety.py create mode 100644 backend/app/services/tailor_orchestrator.py create mode 100644 backend/docs/ARCHITECTURE.md diff --git a/.env.example b/.env.example index 944b24b..259402d 100644 --- a/.env.example +++ b/.env.example @@ -48,8 +48,9 @@ AUTH_MODE=disabled # --- Agent / LLM --- # Local: AGENT_MODE=stub (no external calls). Deployed: AGENT_MODE=llm. +# `services/langgraph/workflow.py` (legacy apply workflow) uses the same vars via LangChain ChatOpenAI at {LLM_BASE_URL}/v1. AGENT_MODE=stub -# OpenAI-compatible base URL. For OpenRouter, you might use https://openrouter.ai/api +# OpenAI-compatible base URL. For OpenRouter use https://openrouter.ai/api (no trailing /v1) LLM_BASE_URL=https://api.openai.com # LLM_API_KEY=... # preferred # OPENROUTER_API_KEY=... # accepted alias @@ -60,3 +61,13 @@ LLM_BASE_URL=https://api.openai.com # OpenRouter optional headers (recommended) # OPENROUTER_REFERER=https://your-app.example # OPENROUTER_TITLE=TalentStreamAI + +# --- Observability (FastAPI) --- +# LOG_LEVEL=INFO +# LOG_JSON=false +# Set true in production for one JSON log line per event +# LOG_JSON=true +# ENABLE_PROMETHEUS=true +# SERVICE_NAME=talentstreamai-api +# Optional: OpenTelemetry gRPC/HTTP endpoint (set when you add otel packages) +# OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 4cda2b2..ae5f87f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,10 +1,24 @@ from fastapi import APIRouter -from app.api.v1 import auth, generation, health, job_descriptions, resumes +from app.api.v1 import ( + applications, + auth, + dashboard, + generation, + health, + job_descriptions, + observability, + profile, + resumes, +) api_router = APIRouter() api_router.include_router(health.router, prefix="/v1", tags=["health"]) +api_router.include_router(observability.router, prefix="/v1", tags=["observability"]) api_router.include_router(auth.router, prefix="/v1/auth", tags=["auth"]) +api_router.include_router(profile.router, prefix="/v1", tags=["profile"]) +api_router.include_router(dashboard.router, prefix="/v1", tags=["dashboard"]) +api_router.include_router(applications.router, prefix="/v1", tags=["applications"]) api_router.include_router(resumes.router, prefix="/v1", tags=["resumes"]) api_router.include_router(job_descriptions.router, prefix="/v1", tags=["job_descriptions"]) api_router.include_router(generation.router, prefix="/v1", tags=["generation"]) diff --git a/backend/app/api/schemas/frontend.py b/backend/app/api/schemas/frontend.py new file mode 100644 index 0000000..d3f4a79 --- /dev/null +++ b/backend/app/api/schemas/frontend.py @@ -0,0 +1,207 @@ +"""Pydantic models aligned with the Next.js `src/lib/types.ts` (camelCase JSON).""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic.alias_generators import to_camel + +from app.core.db import ApplicationRecord, StoredDocument, UserProfile + + +def _cc() -> ConfigDict: + return ConfigDict( + populate_by_name=True, + alias_generator=to_camel, + ) + + +class ProfileOut(BaseModel): + model_config = _cc() + id: str + full_name: str + email: str + headline: str | None = None + base_resume_id: str | None = None + created_at: str + + +class ResumeOut(BaseModel): + model_config = _cc() + id: str + title: str + content: str + application_id: str | None = None + is_base: bool | None = None + created_at: str + + +class DashboardStatsOut(BaseModel): + model_config = _cc() + applications: int + interviews: int + average_match_score: float + resumes_generated: int + + +class GapItemOut(BaseModel): + model_config = _cc() + skill: str + severity: str + note: str | None = None + + +class ApplicationOut(BaseModel): + model_config = _cc() + id: str + company: str + position: str + job_url: str | None = None + job_description: str + match_score: int + status: str + resume_id: str | None = None + cover_letter: str | None = None + gaps: list[GapItemOut] | None = None + created_at: str + + +class MatchAnalysisOut(BaseModel): + model_config = _cc() + original_score: float + tailored_score: float + improvement: float + what_we_improved: list[str] = Field(default_factory=list) + strengths: list[str] = Field(default_factory=list) + remaining_deficits: list[str] = Field(default_factory=list) + matched_keywords: list[str] = Field(default_factory=list) + missing_keywords: list[str] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) + + +class DraftEmailOut(BaseModel): + model_config = _cc() + subject: str + body: str + + +class TailorRequestIn(BaseModel): + model_config = _cc() + base_resume_id: str + job_url: str | None = None + job_description: str | None = None + + @model_validator(mode="after") + def require_job(self) -> TailorRequestIn: + url = (self.job_url or "").strip() + desc = (self.job_description or "").strip() + if not url and len(desc) < 40: + raise ValueError("Provide a jobUrl or at least 40 characters of jobDescription") + if url and not url.lower().startswith(("http://", "https://")): + raise ValueError("jobUrl must be an http(s) URL") + return self + + +class TailorResponseOut(BaseModel): + model_config = _cc() + application_id: str + match_score: int + resume: ResumeOut + cover_letter: str + draft_email: DraftEmailOut + gaps: list[GapItemOut] + analysis: MatchAnalysisOut + + +def _gap_items(raw: list[Any]) -> list[GapItemOut]: + out: list[GapItemOut] = [] + for g in raw or []: + if isinstance(g, dict): + out.append( + GapItemOut( + skill=str(g.get("skill", "")), + severity=str(g.get("severity", "medium")), + note=g.get("note"), + ) + ) + return out + + +def map_profile(user_id: str, profile: UserProfile | None, claims: dict[str, Any]) -> ProfileOut: + email = (profile and profile.email) or str(claims.get("email") or "unknown@user.local") + name = (profile and profile.full_name) or str(claims.get("name") or "User") + # Stable until a profile row exists (avoids a new createdAt on every request). + cr = (profile and profile.created_at) or "2020-01-01T00:00:00+00:00" + return ProfileOut( + id=user_id, + full_name=name, + email=email, + headline=(profile and profile.headline) or None, + base_resume_id=(profile and profile.base_resume_id) or None, + created_at=cr, + ) + + +def map_resume( + doc: StoredDocument, *, is_base: bool, application_id: str | None +) -> ResumeOut: + title = str(doc.meta.get("title") or doc.filename or "Resume") + is_b = bool(doc.meta.get("is_base")) or is_base + return ResumeOut( + id=doc.id, + title=title[:500], + content=doc.text, + application_id=doc.meta.get("application_id") or application_id, + is_base=is_b, + created_at=doc.created_at, + ) + + +def map_application( + a: ApplicationRecord, *, list_view: bool = False +) -> ApplicationOut: + met = a.meta or {} + gaps = _gap_items(met.get("gaps", [])) + jdesc = a.job_description + if list_view and len(jdesc) > 2000: + jdesc = jdesc[:2000] + "…" + return ApplicationOut( + id=a.id, + company=str(a.company or "—"), + position=str(a.position or "—"), + job_url=a.job_url, + job_description=jdesc, + match_score=int(round(a.match_score)), + status=a.status, + resume_id=a.resume_id, + cover_letter=a.cover_letter, + gaps=gaps, + created_at=a.created_at, + ) + + +def map_match_analysis(m: dict[str, Any]) -> MatchAnalysisOut: + return MatchAnalysisOut( + original_score=float( + m.get("originalScore", m.get("original_score", 0)) or 0 + ), + tailored_score=float( + m.get("tailoredScore", m.get("tailored_score", 0)) or 0 + ), + improvement=float(m.get("improvement", 0) or 0), + what_we_improved=list( + m.get("whatWeImproved") or m.get("what_we_improved") or [] + ), + strengths=list(m.get("strengths") or []), + remaining_deficits=list( + m.get("remainingDeficits") or m.get("remaining_deficits") or [] + ), + matched_keywords=list( + m.get("matchedKeywords") or m.get("matched_keywords") or [] + ), + missing_keywords=list( + m.get("missingKeywords") or m.get("missing_keywords") or [] + ), + suggestions=list(m.get("suggestions") or []), + ) diff --git a/backend/app/api/v1/applications.py b/backend/app/api/v1/applications.py new file mode 100644 index 0000000..f2666b9 --- /dev/null +++ b/backend/app/api/v1/applications.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import structlog +from fastapi import APIRouter, Depends, HTTPException + +from app.api.schemas.frontend import ( + ApplicationOut, + DraftEmailOut, + GapItemOut, + TailorRequestIn, + TailorResponseOut, + map_application, + map_match_analysis, + map_resume, +) +from app.core.auth import AuthenticatedUser, get_current_user +from app.core.db import get_application, list_applications +from app.services.tailor_orchestrator import run_tailor_for_user + +router = APIRouter() +log = structlog.get_logger(__name__) + + +@router.get("/applications", response_model=list[ApplicationOut]) +def list_user_applications( + user: AuthenticatedUser = Depends(get_current_user), +) -> list[ApplicationOut]: + apps = list_applications(user_id=user.user_id) + return [map_application(a, list_view=True) for a in apps] + + +@router.get("/applications/{application_id}", response_model=ApplicationOut) +def get_one_application( + application_id: str, + user: AuthenticatedUser = Depends(get_current_user), +) -> ApplicationOut: + a = get_application(app_id=application_id, user_id=user.user_id) + if not a: + raise HTTPException(status_code=404, detail="Application not found") + return map_application(a, list_view=False) + + +@router.post("/applications/tailor", response_model=TailorResponseOut) +async def tailor_application( + body: TailorRequestIn, + user: AuthenticatedUser = Depends(get_current_user), +) -> TailorResponseOut: + try: + app_rec, _tailored, pl = await run_tailor_for_user( + user_id=user.user_id, + base_resume_id=body.base_resume_id, + job_url=body.job_url, + job_description=body.job_description, + ) + except ValueError as e: + log.info("tailor_rejected", reason=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + log.exception("tailor_unexpected_error") + raise HTTPException( + status_code=500, detail="Tailor failed. Support has been notified." + ) from e + + tdoc = pl["tailored"] + gap_items = [ + GapItemOut( + skill=str(x.get("skill", "")), + severity=str(x.get("severity", "medium")), + note=x.get("note"), + ) + for x in (pl.get("gaps") or []) + ] + de = pl.get("draft_email") or {"subject": "Application", "body": ""} + return TailorResponseOut( + application_id=app_rec.id, + match_score=int(pl["match_score"]), + resume=map_resume( + tdoc, + is_base=bool(tdoc.meta.get("is_base", False)), + application_id=app_rec.id, + ), + cover_letter=pl["cover_letter"], + draft_email=DraftEmailOut( + subject=de.get("subject", "Application"), body=de.get("body", "") + ), + gaps=gap_items, + analysis=map_match_analysis(pl["analysis"]), + ) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py new file mode 100644 index 0000000..e16ba2e --- /dev/null +++ b/backend/app/api/v1/dashboard.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.api.schemas.frontend import DashboardStatsOut +from app.core.auth import AuthenticatedUser, get_current_user +from app.core.db import dashboard_aggregates + +router = APIRouter() + + +@router.get("/dashboard/stats", response_model=DashboardStatsOut) +def get_dashboard_stats( + user: AuthenticatedUser = Depends(get_current_user), +) -> DashboardStatsOut: + d = dashboard_aggregates(user_id=user.user_id) + return DashboardStatsOut( + applications=int(d["applications"]), + interviews=int(d["interviews"]), + average_match_score=round(float(d["average_match_score"]), 1), + resumes_generated=int(d["resumes_generated"]), + ) diff --git a/backend/app/api/v1/observability.py b/backend/app/api/v1/observability.py new file mode 100644 index 0000000..e5c4872 --- /dev/null +++ b/backend/app/api/v1/observability.py @@ -0,0 +1,36 @@ +"""Health, readiness, and Prometheus metrics (when enabled).""" + +from __future__ import annotations + +from fastapi import APIRouter, Response +from fastapi.responses import PlainTextResponse + +from app.core import metrics +from app.core.config import settings +from app.core.db import get_conn + +router = APIRouter() + + +@router.get("/ready", tags=["health"]) +def ready() -> dict[str, str]: + """Readiness: verify DB connectivity (swap for deep checks in k8s).""" + try: + conn = get_conn() + try: + conn.execute("SELECT 1") + finally: + conn.close() + except Exception as e: + return {"status": "not_ready", "reason": f"db: {e}"} + return {"status": "ready"} + + +@router.get("/metrics", include_in_schema=False) +def prometheus_metrics() -> Response: + if not settings.enable_prometheus: + return Response(status_code=404) + return PlainTextResponse( + content=metrics.metrics_payload().decode("utf-8"), + media_type="text/plain; version=0.0.4; charset=utf-8", + ) diff --git a/backend/app/api/v1/profile.py b/backend/app/api/v1/profile.py new file mode 100644 index 0000000..08a6a14 --- /dev/null +++ b/backend/app/api/v1/profile.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import structlog +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile + +from app.api.schemas.frontend import ProfileOut, ResumeOut, map_profile, map_resume +from app.core.auth import AuthenticatedUser, get_current_user +from app.core.db import get_user_profile +from app.services.ingest_resume import ingest_uploaded_resume + +router = APIRouter() +log = structlog.get_logger(__name__) + + +@router.get("/profile", response_model=ProfileOut) +def get_profile(user: AuthenticatedUser = Depends(get_current_user)) -> ProfileOut: + p = get_user_profile(user_id=user.user_id) + return map_profile(user.user_id, p, user.claims) + + +@router.post("/profile/base-resume", response_model=ResumeOut) +async def upload_base_resume( + file: UploadFile = File(...), + user: AuthenticatedUser = Depends(get_current_user), +) -> ResumeOut: + try: + res = await ingest_uploaded_resume( + file=file, user=user, set_as_base=True + ) + except HTTPException: + raise + except Exception as e: + log.exception("base_resume_upload_failed") + raise HTTPException(status_code=500, detail="Failed to store resume") from e + p = get_user_profile(user_id=user.user_id) + is_base = bool(p and p.base_resume_id == res.document.id) + return map_resume( + res.document, is_base=is_base, application_id=None + ) diff --git a/backend/app/api/v1/resumes.py b/backend/app/api/v1/resumes.py index 42b370c..13284bd 100644 --- a/backend/app/api/v1/resumes.py +++ b/backend/app/api/v1/resumes.py @@ -1,93 +1,64 @@ from __future__ import annotations -import logging - +import structlog from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from starlette.concurrency import run_in_threadpool +from app.api.schemas.frontend import ResumeOut, map_resume from app.core.auth import AuthenticatedUser, get_current_user -from app.core.config import settings -from app.core.db import create_document, get_document -from app.services.text_guardrails import normalize_user_text -from app.services.uploads import delete_saved_upload, extract_text, save_upload, validate_upload +from app.core.db import get_document, get_user_profile, list_documents +from app.services.ingest_resume import ingest_uploaded_resume router = APIRouter() -logger = logging.getLogger(__name__) +log = structlog.get_logger(__name__) -@router.post("/resumes") -async def upload_resume( - file: UploadFile = File(...), +@router.get("/resumes", response_model=list[ResumeOut]) +def list_resumes( user: AuthenticatedUser = Depends(get_current_user), -) -> dict[str, str]: - try: - chunks: list[bytes] = [] - total = 0 - while True: - chunk = await file.read(1024 * 1024) - if not chunk: - break - total += len(chunk) - if total > settings.max_upload_bytes: - raise HTTPException(status_code=413, detail="File too large") - chunks.append(chunk) - raw = b"".join(chunks) - - def _process(): - detected_type = validate_upload(filename=file.filename or "", content_type=file.content_type, data=raw) - extracted = extract_text(detected_type=detected_type, data=raw) - extracted = normalize_user_text(extracted) - if not extracted: - return None, detected_type, "" - - saved = save_upload( - detected_type=detected_type, - owner_user_id=user.user_id, - content_type=file.content_type, - data=raw, - ) - return saved, detected_type, extracted - - saved, detected_type, extracted = await run_in_threadpool(_process) - if not extracted: - raise HTTPException(status_code=400, detail="Could not extract text from resume") - try: - doc = await run_in_threadpool( - create_document, - kind="resume", - owner_user_id=user.user_id, - text=extracted, - filename=file.filename, - content_type=file.content_type, - file_path=saved.path if saved else None, - meta={"bytes": len(raw), "detected_type": detected_type}, - ) - except Exception: - try: - await run_in_threadpool(delete_saved_upload, saved.path if saved else None) - except Exception: - logger.exception("Failed to clean up uploaded resume after DB insert failure") - raise - return {"resume_id": doc.id} - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except RuntimeError as e: - raise HTTPException(status_code=500, detail=str(e)) from e +) -> list[ResumeOut]: + p = get_user_profile(user_id=user.user_id) + base_id = p.base_resume_id if p else None + docs = list_documents(owner_user_id=user.user_id, kind="resume") + out: list[ResumeOut] = [] + for d in docs: + is_base = base_id is not None and d.id == base_id + out.append(map_resume(d, is_base=is_base, application_id=d.meta.get("application_id"))) + return out -@router.get("/resumes/{resume_id}") +@router.get("/resumes/{resume_id}", response_model=ResumeOut) def get_resume( resume_id: str, user: AuthenticatedUser = Depends(get_current_user), -) -> dict[str, str | None]: +) -> ResumeOut: + p = get_user_profile(user_id=user.user_id) + base_id = p.base_resume_id if p else None doc = get_document(doc_id=resume_id, owner_user_id=user.user_id) if not doc or doc.kind != "resume": raise HTTPException(status_code=404, detail="Resume not found") - return { - "resume_id": doc.id, - "filename": doc.filename, - "content_type": doc.content_type, - "created_at": doc.created_at, - } + is_base = base_id is not None and doc.id == base_id + return map_resume( + doc, is_base=is_base, application_id=doc.meta.get("application_id") + ) + + +@router.post("/resumes", response_model=ResumeOut) +async def upload_resume( + file: UploadFile = File(...), + user: AuthenticatedUser = Depends(get_current_user), +) -> ResumeOut: + try: + res = await ingest_uploaded_resume( + file=file, user=user, set_as_base=False + ) + except HTTPException: + raise + except Exception as e: + log.exception("resume_upload_failed") + raise HTTPException(status_code=500, detail="Failed to store resume") from e + p = get_user_profile(user_id=user.user_id) + base_id = p.base_resume_id if p else None + is_base = base_id is not None and res.document.id == base_id + return map_resume( + res.document, is_base=is_base, application_id=None + ) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index a6c7c90..fe2b18e 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Any +import structlog from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -24,7 +25,9 @@ def get_current_user( credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), ) -> AuthenticatedUser: if settings.auth_mode == "disabled": - return AuthenticatedUser(user_id="anonymous", claims={"auth_mode": "disabled"}) + u = AuthenticatedUser(user_id="anonymous", claims={"auth_mode": "disabled"}) + structlog.contextvars.bind_contextvars(user_id=u.user_id) + return u if settings.auth_mode != "clerk_jwks": raise HTTPException(status_code=500, detail="Unsupported AUTH_MODE configuration") @@ -42,4 +45,6 @@ def get_current_user( if not user_id: raise HTTPException(status_code=401, detail="Invalid token (missing subject)") - return AuthenticatedUser(user_id=user_id, claims=claims) + u = AuthenticatedUser(user_id=user_id, claims=claims) + structlog.contextvars.bind_contextvars(user_id=u.user_id) + return u diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 85e170b..2b21418 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -54,6 +54,15 @@ class Settings(BaseSettings): openrouter_referer: str | None = None openrouter_title: str | None = None + # --- Observability --- + log_level: str = "INFO" + log_json: bool = False + enable_prometheus: bool = True + request_id_header: str = "X-Request-Id" + # Set to enable OpenTelemetry gRPC/HTTP export (requires optional otel packages) + otel_exporter_otlp_endpoint: str | None = None + service_name: str = "talentstreamai-api" + @property def cors_origins_list(self) -> list[str]: return [ diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 97e66fa..4e4f9fa 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -49,6 +49,40 @@ def init_db() -> None: ) conn.execute("CREATE INDEX IF NOT EXISTS idx_documents_owner ON documents(owner_user_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_documents_kind ON documents(kind)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_profiles ( + user_id TEXT PRIMARY KEY, + email TEXT, + full_name TEXT, + headline TEXT, + base_resume_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS application_records ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + company TEXT, + position TEXT, + job_url TEXT, + job_description TEXT NOT NULL, + match_score REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'draft', + base_resume_id TEXT, + resume_id TEXT, + cover_letter TEXT, + meta_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_app_records_user ON application_records(user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_app_records_created ON application_records(created_at)") conn.commit() finally: conn.close() @@ -133,3 +167,304 @@ def get_document(*, doc_id: str, owner_user_id: str) -> StoredDocument | None: created_at=row["created_at"], meta=meta, ) + + +def list_documents( + *, owner_user_id: str, kind: str | None = None, limit: int = 200 +) -> list[StoredDocument]: + """List documents for a user, newest first.""" + conn = get_conn() + try: + if kind: + rows = conn.execute( + """ + SELECT * FROM documents + WHERE owner_user_id = ? AND kind = ? + ORDER BY created_at DESC + LIMIT ? + """, + (owner_user_id, kind, limit), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT * FROM documents + WHERE owner_user_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (owner_user_id, limit), + ).fetchall() + finally: + conn.close() + + out: list[StoredDocument] = [] + for row in rows: + meta = json.loads(row["meta_json"]) if row["meta_json"] else {} + out.append( + StoredDocument( + id=row["id"], + kind=row["kind"], + owner_user_id=row["owner_user_id"], + filename=row["filename"], + content_type=row["content_type"], + file_path=row["file_path"], + text=row["text"], + created_at=row["created_at"], + meta=meta, + ) + ) + return out + + +@dataclass(frozen=True) +class UserProfile: + user_id: str + email: str | None + full_name: str | None + headline: str | None + base_resume_id: str | None + created_at: str + updated_at: str + + +def get_user_profile(*, user_id: str) -> UserProfile | None: + conn = get_conn() + try: + row = conn.execute( + "SELECT * FROM user_profiles WHERE user_id = ?", + (user_id,), + ).fetchone() + finally: + conn.close() + if not row: + return None + return UserProfile( + user_id=row["user_id"], + email=row["email"], + full_name=row["full_name"], + headline=row["headline"], + base_resume_id=row["base_resume_id"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +def upsert_user_profile( + *, + user_id: str, + email: str | None = None, + full_name: str | None = None, + headline: str | None = None, + base_resume_id: str | None = None, +) -> UserProfile: + now = datetime.now(UTC).isoformat() + existing = get_user_profile(user_id=user_id) + conn = get_conn() + try: + if existing is None: + conn.execute( + """ + INSERT INTO user_profiles + (user_id, email, full_name, headline, base_resume_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (user_id, email, full_name, headline, base_resume_id, now, now), + ) + else: + conn.execute( + """ + UPDATE user_profiles + SET email = COALESCE(?, email), + full_name = COALESCE(?, full_name), + headline = COALESCE(?, headline), + base_resume_id = COALESCE(?, base_resume_id), + updated_at = ? + WHERE user_id = ? + """, + (email, full_name, headline, base_resume_id, now, user_id), + ) + conn.commit() + finally: + conn.close() + p = get_user_profile(user_id=user_id) + assert p is not None + return p + + +@dataclass(frozen=True) +class ApplicationRecord: + id: str + user_id: str + company: str | None + position: str | None + job_url: str | None + job_description: str + match_score: float + status: str + base_resume_id: str | None + resume_id: str | None + cover_letter: str | None + meta: dict[str, Any] + created_at: str + + +def create_application( + *, + user_id: str, + company: str | None, + position: str | None, + job_url: str | None, + job_description: str, + match_score: float, + status: str, + base_resume_id: str | None, + resume_id: str | None, + cover_letter: str | None, + meta: dict[str, Any] | None = None, +) -> ApplicationRecord: + app_id = str(uuid4()) + created_at = datetime.now(UTC).isoformat() + meta_json = json.dumps(meta or {}, ensure_ascii=True) + conn = get_conn() + try: + conn.execute( + """ + INSERT INTO application_records ( + id, user_id, company, position, job_url, job_description, + match_score, status, base_resume_id, resume_id, cover_letter, meta_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + app_id, + user_id, + company, + position, + job_url, + job_description, + match_score, + status, + base_resume_id, + resume_id, + cover_letter, + meta_json, + created_at, + ), + ) + conn.commit() + finally: + conn.close() + r = get_application(app_id=app_id, user_id=user_id) + assert r is not None + return r + + +def get_application(*, app_id: str, user_id: str) -> ApplicationRecord | None: + conn = get_conn() + try: + row = conn.execute( + """ + SELECT * FROM application_records + WHERE id = ? AND user_id = ? LIMIT 1 + """, + (app_id, user_id), + ).fetchone() + finally: + conn.close() + if not row: + return None + return _row_to_application(row) + + +def list_applications( + *, user_id: str, limit: int = 100 +) -> list[ApplicationRecord]: + conn = get_conn() + try: + rows = conn.execute( + """ + SELECT * FROM application_records + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (user_id, limit), + ).fetchall() + finally: + conn.close() + return [_row_to_application(r) for r in rows] + + +def _row_to_application(row: sqlite3.Row) -> ApplicationRecord: + raw = row["meta_json"] or "{}" + meta: dict[str, Any] = json.loads(raw) if raw else {} + return ApplicationRecord( + id=row["id"], + user_id=row["user_id"], + company=row["company"], + position=row["position"], + job_url=row["job_url"], + job_description=row["job_description"], + match_score=float(row["match_score"] or 0), + status=row["status"] or "draft", + base_resume_id=row["base_resume_id"], + resume_id=row["resume_id"], + cover_letter=row["cover_letter"], + meta=meta, + created_at=row["created_at"], + ) + + +def update_document_meta( + *, doc_id: str, owner_user_id: str, meta_patch: dict[str, Any] +) -> StoredDocument | None: + """Merge meta_patch into the document's meta JSON.""" + doc = get_document(doc_id=doc_id, owner_user_id=owner_user_id) + if not doc: + return None + merged = {**doc.meta, **meta_patch} + meta_json = json.dumps(merged, ensure_ascii=True) + conn = get_conn() + try: + conn.execute( + "UPDATE documents SET meta_json = ? WHERE id = ? AND owner_user_id = ?", + (meta_json, doc_id, owner_user_id), + ) + conn.commit() + finally: + conn.close() + return get_document(doc_id=doc_id, owner_user_id=owner_user_id) + + +def dashboard_aggregates(*, user_id: str) -> dict[str, int | float]: + """Pre-computed dashboard numbers for a user (SQLite; swap for warehouse in prod).""" + conn = get_conn() + try: + n_apps = conn.execute( + "SELECT COUNT(*) AS c FROM application_records WHERE user_id = ?", + (user_id,), + ).fetchone()["c"] + n_interview = conn.execute( + "SELECT COUNT(*) AS c FROM application_records WHERE user_id = ? AND status = 'interview'", + (user_id,), + ).fetchone()["c"] + score_row = conn.execute( + """ + SELECT AVG(match_score) AS a FROM application_records + WHERE user_id = ? AND match_score > 0 + """, + (user_id,), + ).fetchone() + avg = score_row["a"] if score_row and score_row["a"] is not None else 0.0 + n_tailored = conn.execute( + "SELECT COUNT(*) AS c FROM documents WHERE owner_user_id = ? AND kind = 'resume'", + (user_id,), + ).fetchone()["c"] + finally: + conn.close() + return { + "applications": int(n_apps), + "interviews": int(n_interview), + "average_match_score": float(avg) if avg else 0.0, + "resumes_generated": int(n_tailored), + } diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py new file mode 100644 index 0000000..69c06a4 --- /dev/null +++ b/backend/app/core/exception_handlers.py @@ -0,0 +1,35 @@ +"""Consistent error bodies with traceable request ids for unexpected failures.""" + +from __future__ import annotations + +import uuid +from typing import Any + +import structlog +from fastapi import Request +from fastapi.exception_handlers import ( + http_exception_handler, + request_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +log = structlog.get_logger(__name__) + + +async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: + if isinstance(exc, RequestValidationError): + return await request_validation_exception_handler(request, exc) + if isinstance(exc, StarletteHTTPException): + return await http_exception_handler(request, exc) + err_id = str(uuid.uuid4()) + log.exception("unhandled_exception", error_id=err_id, path=request.url.path) + body: dict[str, Any] = { + "detail": "An internal error occurred.", + "errorId": err_id, + } + rid = request.headers.get("X-Request-Id") + if rid: + body["requestId"] = rid + return JSONResponse(status_code=500, content=body) diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000..76a8952 --- /dev/null +++ b/backend/app/core/logging_config.py @@ -0,0 +1,43 @@ +"""Structured logging: JSON (production) or console (local).""" + +from __future__ import annotations + +import logging +import sys + +import structlog + +from app.core.config import settings + + +def configure_logging() -> None: + level = getattr(logging, settings.log_level.upper(), logging.INFO) + timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True) + shared: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.dev.set_exc_info, + ] + if settings.log_json: + processors: list[structlog.types.Processor] = [ + *shared, + timestamper, + structlog.processors.dict_tracebacks, + structlog.processors.JSONRenderer(), + ] + else: + processors = [ + *shared, + timestamper, + structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()), + ] + + structlog.configure( + processors=processors, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + root = logging.getLogger() + root.setLevel(level) + for name in ("httpx", "httpcore", "uvicorn", "uvicorn.error"): + logging.getLogger(name).setLevel(logging.WARNING) diff --git a/backend/app/core/metrics.py b/backend/app/core/metrics.py new file mode 100644 index 0000000..ca62046 --- /dev/null +++ b/backend/app/core/metrics.py @@ -0,0 +1,43 @@ +"""Prometheus metrics (exposed at /api/v1/metrics when enabled).""" + +from __future__ import annotations + +from prometheus_client import Counter, Histogram, generate_latest, REGISTRY + +# HTTP +http_requests = Counter( + "http_requests_total", + "Total HTTP requests", + ("method", "path_template", "status"), +) +http_request_latency = Histogram( + "http_request_duration_seconds", + "Request latency in seconds", + ("method", "path_template"), + buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0), +) + +# LLM +llm_calls = Counter( + "llm_invocations_total", + "LLM API calls", + ("model", "outcome"), +) +llm_tokens = Counter( + "llm_tokens_total", + "Token usage (when reported by the provider)", + ("model", "token_type"), +) +llm_latency = Histogram( + "llm_request_duration_seconds", + "LLM request duration", + ("model",), + buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60, 120), +) + +# Domain +tailor_runs = Counter("tailor_runs_total", "Resume tailor runs", ("outcome",)) + + +def metrics_payload() -> bytes: + return generate_latest(REGISTRY) diff --git a/backend/app/core/request_context.py b/backend/app/core/request_context.py new file mode 100644 index 0000000..e66816e --- /dev/null +++ b/backend/app/core/request_context.py @@ -0,0 +1,47 @@ +"""Request-scoped context (traceability without threading globals).""" + +from __future__ import annotations + +import contextvars +import uuid +from typing import Any + +_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("request_id", default=None) +_user_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("user_id", default=None) + + +def get_request_id() -> str | None: + return _request_id.get() + + +def set_request_id(value: str | None) -> contextvars.Token[str | None]: + return _request_id.set(value) + + +def reset_request_id(token: contextvars.Token[str | None]) -> None: + _request_id.reset(token) + + +def new_request_id() -> str: + return str(uuid.uuid4()) + + +def get_context_user_id() -> str | None: + return _user_id.get() + + +def set_user_id(value: str | None) -> contextvars.Token[str | None]: + return _user_id.set(value) + + +def reset_user_id(token: contextvars.Token[str | None]) -> None: + _user_id.reset(token) + + +def context_bind() -> dict[str, Any]: + out: dict[str, Any] = {} + if rid := get_request_id(): + out["request_id"] = rid + if uid := get_context_user_id(): + out["user_id"] = uid + return out diff --git a/backend/app/main.py b/backend/app/main.py index a09a023..d8f8555 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,22 +1,33 @@ import os +import structlog from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.router import api_router from app.core.config import settings from app.core.db import init_db +from app.core.exception_handlers import global_exception_handler +from app.core.logging_config import configure_logging +from app.middleware.request_context import RequestContextMiddleware from app.services.llm.client import close_llm_http_clients +configure_logging() +slog = structlog.get_logger(__name__) + app = FastAPI( title="TalentStreamAI API", version="0.1.0", - description="Backend service for the TalentStreamAI capstone (FastAPI + future LangGraph agents).", + description="Backend service for the TalentStreamAI (FastAPI, LangGraph, observability).", ) +app.add_exception_handler(Exception, global_exception_handler) + + @app.on_event("startup") def _startup() -> None: init_db() + slog.info("service_starting", environment=settings.deployment_environment or "local") def _running_in_aws() -> bool: return bool( @@ -64,7 +75,10 @@ async def _shutdown() -> None: allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=[settings.request_id_header or "X-Request-Id"], ) +# Outermost: request id + structlog context (last added in Starlette). +app.add_middleware(RequestContextMiddleware) app.include_router(api_router, prefix="/api") diff --git a/backend/app/middleware/request_context.py b/backend/app/middleware/request_context.py new file mode 100644 index 0000000..05e3242 --- /dev/null +++ b/backend/app/middleware/request_context.py @@ -0,0 +1,59 @@ +"""Request ID propagation, structlog context, and HTTP metrics.""" + +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Match + +from app.core.config import settings +from app.core import metrics +from app.core.request_context import new_request_id, reset_request_id, set_request_id + + +def _path_template(request: Request) -> str: + for route in request.app.routes: + match, _ = route.matches(request.scope) + if match == Match.FULL and hasattr(route, "path"): + return str(route.path) + return request.url.path + + +class RequestContextMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + structlog.contextvars.clear_contextvars() + h = {k.lower(): v for k, v in request.headers.items()} + rid = h.get("x-request-id") or new_request_id() + tok = set_request_id(rid) + structlog.contextvars.bind_contextvars(request_id=rid) + path_t = _path_template(request) + structlog.contextvars.bind_contextvars(http_path=path_t, http_method=request.method) + t0 = time.perf_counter() + try: + response = await call_next(request) + except Exception: + dt = time.perf_counter() - t0 + if settings.enable_prometheus: + metrics.http_requests.labels( + request.method, path_t, "5xx" + ).inc() + metrics.http_request_latency.labels( + request.method, path_t + ).observe(dt) + # Full traceback: global exception handler + raise + dt = time.perf_counter() - t0 + if settings.enable_prometheus: + code = response.status_code + bucket = f"{code // 100}xx" + metrics.http_requests.labels(request.method, path_t, bucket).inc() + metrics.http_request_latency.labels(request.method, path_t).observe(dt) + response.headers["X-Request-Id"] = rid + reset_request_id(tok) + return response diff --git a/backend/app/services/draft_email.py b/backend/app/services/draft_email.py new file mode 100644 index 0000000..292c64d --- /dev/null +++ b/backend/app/services/draft_email.py @@ -0,0 +1,43 @@ +"""Parse model-generated application email drafts into a stable {subject, body} shape.""" + +from __future__ import annotations + +import re + + +def parse_draft_email(raw: str) -> dict[str, str]: + t = (raw or "").strip() + if not t: + return {"subject": "Application", "body": ""} + subject = "Application" + body = t + m = re.search(r"^\s*Subject:\s*(.+?)\s*$", t, re.IGNORECASE | re.MULTILINE) + if m: + subject = m.group(1).strip()[:200] + # Strip common prefixes from the remaining body + for pat in (r"^\s*Body:\s*", r"^\s*To:.*$"): + t = re.sub(pat, "", t, flags=re.IGNORECASE | re.MULTILINE) + lines = t.splitlines() + out: list[str] = [] + skip = True + for line in lines: + if re.match(r"^\s*Subject:\s*", line, re.IGNORECASE): + skip = True + continue + if re.match(r"^\s*Body:\s*", line, re.IGNORECASE): + skip = False + rest = re.sub(r"^\s*Body:\s*", "", line, flags=re.IGNORECASE) + if rest.strip(): + out.append(rest) + continue + if skip and not line.strip(): + continue + skip = False + out.append(line) + if out: + body = "\n".join(out).strip() or t + else: + body = t + if not body: + body = t + return {"subject": subject, "body": body} diff --git a/backend/app/services/ingest_resume.py b/backend/app/services/ingest_resume.py new file mode 100644 index 0000000..0b48980 --- /dev/null +++ b/backend/app/services/ingest_resume.py @@ -0,0 +1,106 @@ +"""Shared upload + text extraction for resume documents.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from fastapi import HTTPException, UploadFile +from starlette.concurrency import run_in_threadpool + +from app.core.auth import AuthenticatedUser +from app.core.config import settings +from app.core.db import StoredDocument, create_document, upsert_user_profile +from app.services.text_guardrails import normalize_user_text +from app.services.uploads import delete_saved_upload, extract_text, save_upload, validate_upload + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class IngestResult: + document: StoredDocument + byte_size: int + + +async def ingest_uploaded_resume( + *, + file: UploadFile, + user: AuthenticatedUser, + set_as_base: bool, +) -> IngestResult: + """Parse upload, persist as a resume document, optionally mark as the user's base resume.""" + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > settings.max_upload_bytes: + raise HTTPException(status_code=413, detail="File too large") + chunks.append(chunk) + raw = b"".join(chunks) + + def _process() -> tuple[Any, str, str]: + detected_type = validate_upload( + filename=file.filename or "", content_type=file.content_type, data=raw + ) + extracted = extract_text(detected_type=detected_type, data=raw) + extracted = normalize_user_text(extracted) + if not extracted: + return None, detected_type, "" + saved = save_upload( + detected_type=detected_type, + owner_user_id=user.user_id, + content_type=file.content_type, + data=raw, + ) + return saved, detected_type, extracted + + saved, detected_type, extracted = await run_in_threadpool(_process) + if not extracted: + raise HTTPException(status_code=400, detail="Could not extract text from resume") + + title = (file.filename or "Resume").rsplit("/")[-1][:200] + meta: dict[str, Any] = { + "bytes": len(raw), + "detected_type": detected_type, + "title": title, + "is_base": set_as_base, + } + try: + doc = await run_in_threadpool( + create_document, + kind="resume", + owner_user_id=user.user_id, + text=extracted, + filename=file.filename, + content_type=file.content_type, + file_path=saved.path if saved else None, + meta=meta, + ) + except Exception: + try: + await run_in_threadpool(delete_saved_upload, saved.path if saved else None) + except Exception: + logger.exception("Failed to clean up uploaded resume after DB insert failure") + raise + + if set_as_base: + email = str(user.claims.get("email") or "") or None + full_name = str( + user.claims.get("name") + or user.claims.get("given_name") + or "User" + ) + await run_in_threadpool( + upsert_user_profile, + user_id=user.user_id, + email=email, + full_name=full_name, + headline=None, + base_resume_id=doc.id, + ) + return IngestResult(document=doc, byte_size=len(raw)) diff --git a/backend/app/services/job_text.py b/backend/app/services/job_text.py new file mode 100644 index 0000000..3e89d13 --- /dev/null +++ b/backend/app/services/job_text.py @@ -0,0 +1,34 @@ +"""Turn structured job data (e.g. from the fetcher) into a single text block for the LLM.""" + +from __future__ import annotations + +from typing import Any + + +def job_data_to_text(job: dict[str, Any]) -> str: + if not job: + return "" + title = (job.get("title") or "").strip() + company = (job.get("company") or "").strip() + loc = (job.get("location") or "").strip() + url = (job.get("url") or "").strip() + desc = (job.get("description") or "").strip() + req = (job.get("requirements") or "").strip() + resp = (job.get("responsibilities") or "").strip() + ben = (job.get("benefits") or "").strip() + parts: list[str] = [] + if title or company: + parts.append(f"{title} at {company}".strip()) + if loc: + parts.append(f"Location: {loc}") + if url: + parts.append(f"Source URL: {url}") + for label, val in ( + ("Full description", desc), + ("Requirements", req), + ("Responsibilities", resp), + ("Benefits", ben), + ): + if val: + parts.append(f"{label}:\n{val}") + return "\n\n".join(parts) if parts else desc diff --git a/backend/app/services/langgraph/streaming_agent.py b/backend/app/services/langgraph/streaming_agent.py index 472c881..ca16fea 100644 --- a/backend/app/services/langgraph/streaming_agent.py +++ b/backend/app/services/langgraph/streaming_agent.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +import time from collections import Counter from functools import lru_cache -from typing import Any +from typing import Any, cast +import structlog from langgraph.graph import END, StateGraph from typing_extensions import TypedDict @@ -221,6 +223,34 @@ def _sse(event: str, data: dict) -> str: return f"event: {event}\ndata: {payload}" +_log = structlog.get_logger(__name__) + + +async def run_tailor_pipeline( + *, resume_text: str, job_description_text: str +) -> dict[str, Any]: + """ + Non-streaming end-to-end: gap analysis, tailored resume, cover letter, email draft. + Reuses the same graph as :func:`stream_generation` for a single final state. + """ + app = _graph() + state: AgentState = { + "resume_text": resume_text, + "job_description_text": job_description_text, + } + t0 = time.perf_counter() + try: + out = await app.ainvoke(state) + except Exception: + _log.exception("tailor_pipeline_ainvoke_failed") + raise + _log.info( + "tailor_pipeline_complete", + duration_ms=round((time.perf_counter() - t0) * 1000, 2), + ) + return cast(dict[str, Any], out) + + def _cap(text: str) -> str: if len(text) <= settings.max_output_chars: return text diff --git a/backend/app/services/langgraph/workflow.py b/backend/app/services/langgraph/workflow.py index 55862a7..4d826e1 100644 --- a/backend/app/services/langgraph/workflow.py +++ b/backend/app/services/langgraph/workflow.py @@ -1,8 +1,7 @@ """Complete LangGraph workflow for TalentStreamAI with document generation.""" import os -from typing import Any -from typing import Optional +from typing import Any, Optional from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage @@ -31,12 +30,45 @@ class TalentStreamState(BaseModel): error: Optional[str] = None -def _get_llm(): - """Get LLM client (uses OpenAI).""" - api_key = settings.openai_api_key or os.environ.get("OPENAI_API_KEY") - if api_key: - return ChatOpenAI(model="gpt-4o", api_key=api_key) - raise ValueError("No LLM API key configured (OPENAI_API_KEY)") +def _langchain_openai_base_url() -> str: + """ + `LLM_BASE_URL` matches `LlmClient` (no trailing /v1). ChatOpenAI expects the + full OpenAI-compatible root including /v1 (e.g. https://openrouter.ai/api/v1). + """ + raw = (settings.llm_base_url or "https://api.openai.com").rstrip("/") + if raw.endswith("/v1"): + return raw + return f"{raw}/v1" + + +def _get_llm() -> ChatOpenAI: + """OpenAI-compatible chat model (OpenAI, OpenRouter, or other /v1 hosts).""" + api_key = ( + settings.llm_api_key + or settings.openai_api_key + or os.environ.get("OPENAI_API_KEY") + ) + if not api_key: + raise ValueError( + "No LLM API key configured. Set LLM_API_KEY, OPENROUTER_API_KEY, or OPENAI_API_KEY.", + ) + + default_headers: dict[str, str] = {} + if settings.openrouter_referer: + default_headers["HTTP-Referer"] = settings.openrouter_referer + if settings.openrouter_title: + default_headers["X-Title"] = settings.openrouter_title + + params: dict[str, Any] = { + "model": settings.llm_model, + "api_key": api_key, + "base_url": _langchain_openai_base_url(), + "temperature": settings.llm_temperature, + "max_tokens": settings.llm_max_tokens, + } + if default_headers: + params["default_headers"] = default_headers + return ChatOpenAI(**params) def _generate_tailored_resume(state: TalentStreamState) -> TalentStreamState: diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index 9cfa33f..35dc5c5 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -2,16 +2,21 @@ import asyncio import logging +import time from dataclasses import dataclass from typing import Any import httpx +import structlog from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_exponential_jitter from app.core.config import settings +from app.core import metrics from app.services.llm.json_parsing import parse_json_object +from app.services.llm.safety import llm_output_safety_flags logger = logging.getLogger(__name__) +slog = structlog.get_logger(__name__) _http_clients_by_loop: dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {} @@ -85,6 +90,8 @@ def _is_retryable_exc(exc: Exception) -> bool: return _is_retryable_http_status(exc) return False + t0 = time.perf_counter() + data: dict[str, Any] | None = None async for attempt in AsyncRetrying( reraise=True, stop=stop_after_attempt(3), @@ -95,19 +102,52 @@ def _is_retryable_exc(exc: Exception) -> bool: resp = await _http_client().post(url, headers=self._headers(), json=payload) try: resp.raise_for_status() - except httpx.HTTPStatusError as e: + except httpx.HTTPStatusError: body = (resp.text or "")[:500] logger.warning("LLM HTTP error %s: %s", resp.status_code, body) + metrics.llm_calls.labels(self._model, "http_error").inc() raise data = resp.json() + if data is None: + raise LlmError("No response from LLM") + + elapsed = time.perf_counter() - t0 + metrics.llm_latency.labels(self._model).observe(elapsed) + metrics.llm_calls.labels(self._model, "success").inc() + + usage = data.get("usage") or {} + pt = int(usage.get("prompt_tokens") or 0) + ct = int(usage.get("completion_tokens") or 0) + if pt: + metrics.llm_tokens.labels(self._model, "prompt").inc(pt) + if ct: + metrics.llm_tokens.labels(self._model, "completion").inc(ct) + if pt or ct: + slog.info( + "llm_token_usage", + model=self._model, + prompt_tokens=pt, + completion_tokens=ct, + duration_ms=round(elapsed * 1000, 2), + ) try: text = data["choices"][0]["message"]["content"] - except Exception as e: + except (KeyError, IndexError) as e: + metrics.llm_calls.labels(self._model, "malformed").inc() raise LlmError("Malformed LLM response") from e + flags = llm_output_safety_flags(text) + if flags: + slog.warning( + "llm_output_safety_flags", + model=self._model, + flags=flags, + ) + try: return parse_json_object(text) except Exception as e: + metrics.llm_calls.labels(self._model, "json_parse").inc() logger.warning("Failed to parse JSON from LLM output", exc_info=e) raise LlmError("LLM did not return valid JSON") from e diff --git a/backend/app/services/llm/safety.py b/backend/app/services/llm/safety.py new file mode 100644 index 0000000..177def2 --- /dev/null +++ b/backend/app/services/llm/safety.py @@ -0,0 +1,23 @@ +"""Heuristic post-checks for LLM outputs (not a substitute for review).""" + +from __future__ import annotations + +import re + + +def llm_output_safety_flags(text: str) -> list[str]: + """ + Returns tags that may warrant human review (e.g. placeholders, truncation). + These are *signals*, not determinations of hallucination. + """ + if not text or not str(text).strip(): + return ["empty_output"] + t = str(text) + out: list[str] = [] + if re.search(r"\[([^\]]+)\]", t): + out.append("bracket_placeholders") + if len(t) < 40: + out.append("suspiciously_short") + if t.count("lorem") >= 1: + out.append("lorem_ipsum") + return out diff --git a/backend/app/services/tailor_orchestrator.py b/backend/app/services/tailor_orchestrator.py new file mode 100644 index 0000000..b5ad9f5 --- /dev/null +++ b/backend/app/services/tailor_orchestrator.py @@ -0,0 +1,184 @@ +""" +Orchestrates a single \"tailor\" run for the product API: job resolution, LangGraph, persistence. +""" + +from __future__ import annotations + +import time +from typing import Any + +import structlog +from starlette.concurrency import run_in_threadpool + +from app.core import metrics +from app.core.config import settings +from app.core.db import ( + ApplicationRecord, + StoredDocument, + create_application, + create_document, + get_document, + update_document_meta, +) +from app.services.draft_email import parse_draft_email +from app.services.job_text import job_data_to_text +from app.services.langgraph.streaming_agent import run_tailor_pipeline +from app.tools.job_fetcher import fetch_job_description + +slog = structlog.get_logger(__name__) + + +def _build_match_analysis(gap: dict[str, Any]) -> dict[str, Any]: + matched = list(gap.get("matched_keywords") or []) + missing = list(gap.get("missing_keywords") or []) + n = len(matched) + len(missing) + if n > 0: + base = int(round(100 * len(matched) / n)) + else: + base = 60 + original = max(25, min(92, base - 8)) + improved = min(99, max(original + 4, min(99, base + 12))) + return { + "originalScore": original, + "tailoredScore": improved, + "improvement": improved - original, + "whatWeImproved": [ + "Realigned phrasing to reflect keywords already present in your history.", + "Tightened bullets toward role-specific outcomes where supported by the resume text.", + ], + "strengths": matched[:10], + "remainingDeficits": missing[:10], + "matchedKeywords": matched, + "missingKeywords": missing, + "suggestions": [s for s in [str(gap.get("summary") or "").strip()] if s], + } + + +def _gaps_to_items(missing: list[str]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for s in missing[:12]: + out.append({"skill": s, "severity": "medium", "note": None}) + return out + + +async def run_tailor_for_user( + *, + user_id: str, + base_resume_id: str, + job_url: str | None, + job_description: str | None, +) -> tuple[ApplicationRecord, StoredDocument, dict[str, Any]]: + """ + Returns the stored application, the new tailored resume document, and the API payload dict + (camelCase handled by the router layer; this returns snake_case / plain dicts for conversion). + """ + base = await run_in_threadpool(get_document, doc_id=base_resume_id, owner_user_id=user_id) + if not base or base.kind != "resume": + raise ValueError("Base resume not found") + if len(base.text) > settings.max_text_chars: + raise ValueError("Base resume text exceeds server limit") + + job_data: dict[str, Any] | None = None + jd_text = (job_description or "").strip() + if job_url and job_url.strip(): + def _fetch() -> dict[str, Any]: + return fetch_job_description.invoke({"url": job_url.strip()}) + + try: + job_data = await run_in_threadpool(_fetch) + except Exception as e: + slog.exception("job_fetch_failed", job_url=job_url) + raise ValueError(f"Could not load job from URL: {e}") from e + jd_text = job_data_to_text(job_data) + if not jd_text or len(jd_text.strip()) < 40: + raise ValueError("Job description is missing or too short; paste more detail or fix the URL.") + + if len(jd_text) > settings.max_text_chars: + raise ValueError("Job description is too long; shorten or trim the posting.") + + t0 = time.perf_counter() + try: + state = await run_tailor_pipeline( + resume_text=base.text, + job_description_text=jd_text, + ) + except Exception as e: + metrics.tailor_runs.labels("error").inc() + slog.exception("tailor_pipeline_failed", base_resume_id=base_resume_id) + raise ValueError("Tailor pipeline failed; please retry or contact support.") from e + dur = time.perf_counter() - t0 + slog.info("tailor_duration", seconds=round(dur, 3)) + + metrics.tailor_runs.labels("success").inc() + gap = (state or {}).get("gap_analysis") or {} + if not isinstance(gap, dict): + gap = {} + resume_body = (state or {}).get("tailored_resume") or "" + cover_letter = (state or {}).get("cover_letter") or "" + gmail_raw = (state or {}).get("gmail_draft") or "" + de = parse_draft_email(gmail_raw) + + company = (job_data or {}).get("company") if job_data else None + position = (job_data or {}).get("title") if job_data else None + if not company: + company = "Target company" + if not position: + position = "Target role" + mat = _build_match_analysis(gap) + top_score = float(mat["tailoredScore"]) + + # Missing keywords -> gap items + missing_kw = [str(x) for x in (gap.get("missing_keywords") or [])] + gaps_list = _gaps_to_items(missing_kw) + match_analysis = mat + app_meta: dict[str, Any] = { + "match_analysis": match_analysis, + "draft_email": de, + "gaps": gaps_list, + "gap_analysis": gap, + "tailor_mode": settings.agent_mode, + } + + title = f"{position} @ {company}"[:200] + tailored = await run_in_threadpool( + create_document, + kind="resume", + owner_user_id=user_id, + text=str(resume_body)[: settings.max_output_chars], + filename=None, + content_type="text/plain", + file_path=None, + meta={"title": title, "is_base": False, "source": "tailor", "base_resume_id": base_resume_id}, + ) + + app = await run_in_threadpool( + create_application, + user_id=user_id, + company=str(company)[:300], + position=str(position)[:300], + job_url=job_url, + job_description=jd_text[: min(len(jd_text), 500_000)], + match_score=top_score, + status="draft", + base_resume_id=base_resume_id, + resume_id=tailored.id, + cover_letter=cover_letter[: settings.max_output_chars], + meta=app_meta, + ) + + await run_in_threadpool( + update_document_meta, + doc_id=tailored.id, + owner_user_id=user_id, + meta_patch={"application_id": app.id, "title": title}, + ) + + return app, tailored, { + "app": app, + "tailored": tailored, + "match_score": int(top_score), + "cover_letter": cover_letter, + "draft_email": de, + "gaps": gaps_list, + "analysis": match_analysis, + } diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d19e701 --- /dev/null +++ b/backend/docs/ARCHITECTURE.md @@ -0,0 +1,43 @@ +# TalentStreamAI API — architecture and operations + +## Engineering principles + +The API is **purpose-built** for the product: authenticated users manage a **base resume**, run **tailor** jobs against job URLs or pasted descriptions, and review **applications** with match analysis and deliverables. The design favors **modular services**, **clear boundaries** (HTTP → orchestration → LangGraph/LLM → persistence), and **operational visibility** from day one. + +- **Code quality**: Typed Python, Pydantic at IO boundaries, small composable modules (`tailor_orchestrator`, `ingest_resume`, `job_text`, `draft_email`). Routers stay thin; business logic lives in `app/services/`. +- **Errors**: Domain issues surface as `HTTPException` with actionable `detail` strings. Unexpected failures return a **stable JSON body** with `errorId` (and `requestId` when the client sent `X-Request-Id`) for cross-referencing logs and support tickets. `RequestValidationError` and `HTTPException` keep their normal FastAPI behavior. +- **Logging**: **Structured logging** via `structlog` with per-request `request_id`, `user_id` (after auth), `http_path`, and `http_method` on the context. Set `LOG_JSON=true` in production to emit one JSON object per line for your log stack. +- **Metrics**: **Prometheus** text exposition at `GET /api/v1/metrics` (when `ENABLE_PROMETHEUS=true`, default on). Counters and histograms cover HTTP volume/latency, LLM invocations, token usage (when the provider returns `usage`), and tailor outcomes. +- **LLM observability**: The OpenAI-compatible client records **latency**, **token usage** (prompt/completion), and **heuristic safety flags** on raw text (e.g. bracket placeholders, suspicious length). These are logs + metrics, not a substitute for human review of applications. +- **Traces and alerts**: The codebase is **ready** for you to plug OpenTelemetry (e.g. export to your collector when `OTEL_EXPORTER_OTLP_ENDPOINT` is set) and to wire **alerting** on `5xx` rate, p95 latency, LLM error rate, and `tailor_runs_total{outcome="error"}`. The exact exporter packages are left to your platform to avoid unused heavy dependencies in minimal installs. + +## Data model (SQLite, local / single-node) + +- **`documents`**: Resume and job-description blobs (existing). Resume `meta` may include `title`, `is_base`, `application_id`. +- **`user_profiles`**: One row per Clerk `user_id` (or `anonymous` in dev), with `base_resume_id` and display fields. +- **`application_records`**: One row per tailor run, with `job_description` text, `match_score`, `status`, `cover_letter`, `resume_id` (tailored document), and a JSON `meta` payload for `gaps`, `match_analysis`, `draft_email`, and raw `gap_analysis`. + +Migrations are **incremental** `CREATE TABLE IF NOT EXISTS` in `init_db()`. New tables appear on the next process start; no separate migration runner is required for the capstone. + +## API surface (frontend contract) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/v1/profile` | User profile (camelCase JSON, aligned with Next.js types). | +| POST | `/api/v1/profile/base-resume` | Upload and set **base** resume. | +| GET | `/api/v1/dashboard/stats` | Aggregated counts and average match score. | +| GET | `/api/v1/applications` | List applications (job description may be truncated in list view). | +| GET | `/api/v1/applications/{id}` | Full application. | +| POST | `/api/v1/applications/tailor` | Run tailor pipeline; returns `TailorResponse` shape. | +| GET | `/api/v1/resumes` | List resumes. | +| GET | `/api/v1/resumes/{id}` | Full resume with `content`. | +| POST | `/api/v1/resumes` | Extra upload (does not set base unless profile flow is used). | +| GET | `/api/v1/health` | Liveness. | +| GET | `/api/v1/ready` | Readiness (DB ping). | +| GET | `/api/v1/metrics` | Prometheus scrape (optional off). | + +Existing routes under `/api/v1/generate/stream` and job description helpers remain for streaming and internal workflows. + +## Runtime configuration + +See the repository root `.env.example` for `LOG_LEVEL`, `LOG_JSON`, `ENABLE_PROMETHEUS`, `SERVICE_NAME`, and auth/LLM keys. In **deployed** environments, startup checks still enforce `AUTH_MODE`, `AGENT_MODE`, and `UPLOAD_STORAGE` as in `app/main.py`. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a47e613..2208c19 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,10 @@ dependencies = [ "python-docx==1.1.2", "boto3==1.40.3", "langgraph==1.1.9", + "langchain-openai>=0.3.0", + "structlog==24.4.0", + "prometheus-client==0.21.1", + "beautifulsoup4==4.12.3", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index be52d2a..385907d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -24,6 +24,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181, upload-time = "2024-01-17T16:53:17.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925, upload-time = "2024-01-17T16:53:12.779Z" }, +] + [[package]] name = "boto3" version = "1.40.3" @@ -265,6 +277,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "fastapi" version = "0.115.12" @@ -354,6 +375,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, ] +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -403,6 +496,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" }, ] +[[package]] +name = "langchain-openai" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/69/0ea9dabd903f750315ab31b8b85dad64f2927e56ddc26252dfe4e4ac2c40/langchain_openai-1.2.0.tar.gz", hash = "sha256:e88edf16002b9ed8e206161181c8a6fb2b3662da23195e0a844d040c3f93ab10", size = 1136352, upload-time = "2026-04-23T00:43:35.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/7b/e8c3beeab0ca042529533072ebee69c66327c1805b3133531b58c422baab/langchain_openai-1.2.0-py3-none-any.whl", hash = "sha256:b3ed14dc48e40890605136f26c6b07e8f293987d95e734ab67cbfa572c523456", size = 98592, upload-time = "2026-04-23T00:43:34.135Z" }, +] + [[package]] name = "langgraph" version = "1.1.9" @@ -559,6 +666,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, ] +[[package]] +name = "openai" +version = "2.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -660,6 +786,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/14/7d0f567991f3a9af8d1cd4f619040c93b68f09a02b6d0b6ab1b2d1ded5fe/prometheus_client-0.21.1.tar.gz", hash = "sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb", size = 78551, upload-time = "2024-12-03T14:59:12.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/c2/ab7d37426c179ceb9aeb109a85cda8948bb269b7561a0be870cc656eefe4/prometheus_client-0.21.1-py3-none-any.whl", hash = "sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301", size = 54682, upload-time = "2024-12-03T14:59:10.935Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -884,6 +1019,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, + { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -932,6 +1155,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "starlette" version = "0.46.2" @@ -944,35 +1185,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] +[[package]] +name = "structlog" +version = "24.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/a3/e811a94ac3853826805253c906faa99219b79951c7d58605e89c79e65768/structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4", size = 1348634, upload-time = "2024-07-17T12:38:43.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/65/813fc133609ebcb1299be6a42e5aea99d6344afb35ccb43f67e7daaa3b92/structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610", size = 67180, upload-time = "2024-07-17T12:38:41.043Z" }, +] + [[package]] name = "talentstreamai-api" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "boto3" }, { name = "fastapi" }, { name = "httpx" }, + { name = "langchain-openai" }, { name = "langgraph" }, + { name = "prometheus-client" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pypdf" }, { name = "python-docx" }, { name = "python-multipart" }, + { name = "structlog" }, { name = "tenacity" }, { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ + { name = "beautifulsoup4", specifier = "==4.12.3" }, { name = "boto3", specifier = "==1.40.3" }, { name = "fastapi", specifier = "==0.115.12" }, { name = "httpx", specifier = "==0.28.1" }, + { name = "langchain-openai", specifier = ">=0.3.0" }, { name = "langgraph", specifier = "==1.1.9" }, + { name = "prometheus-client", specifier = "==0.21.1" }, { name = "pydantic-settings", specifier = "==2.8.1" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.10.1" }, { name = "pypdf", specifier = "==5.4.0" }, { name = "python-docx", specifier = "==1.1.2" }, { name = "python-multipart", specifier = "==0.0.20" }, + { name = "structlog", specifier = "==24.4.0" }, { name = "tenacity", specifier = "==9.1.4" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.34.0" }, ] @@ -986,6 +1244,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From d47412084d3973bbacb976496cbd1bd5e0d16e5f Mon Sep 17 00:00:00 2001 From: iamwales Date: Fri, 24 Apr 2026 08:28:10 +0100 Subject: [PATCH 3/6] test:testing the application and fixing bugs. also added a detailed agent architecture mf file --- .env.example | 18 +- agentarchitecture.md | 241 ++++ backend/app/api/schemas/frontend.py | 5 + backend/app/api/v1/profile.py | 37 +- backend/app/core/config.py | 4 + backend/app/middleware/request_context.py | 1 + .../app/services/langgraph/streaming_agent.py | 95 +- backend/app/services/resume_weave.py | 228 ++++ backend/app/services/tailor_orchestrator.py | 13 +- backend/pyproject.toml | 1 + backend/uv.lock | 209 +++ frontend/next.config.ts | 28 + frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/src/app/(app)/apply/page.tsx | 1190 ++++++++++------- .../(app)/resume/[id]/resume-detail-page.tsx | 69 +- frontend/src/app/(app)/resume/page.tsx | 4 +- frontend/src/app/onboarding/page.tsx | 14 +- frontend/src/components/providers.tsx | 11 +- frontend/src/components/ui/sonner.tsx | 15 + frontend/src/lib/api.ts | 24 +- frontend/src/lib/error-message.ts | 15 + frontend/src/lib/hooks/use-api.ts | 62 +- frontend/src/lib/mock-data.ts | 53 +- 24 files changed, 1811 insertions(+), 538 deletions(-) create mode 100644 agentarchitecture.md create mode 100644 backend/app/services/resume_weave.py create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/lib/error-message.ts diff --git a/.env.example b/.env.example index 259402d..24daab5 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,18 @@ API_PORT=8000 # Comma-separated browser origins allowed to call the API CORS_ORIGINS=http://localhost:3000 -# --- UI (Next.js static export) --- -# Local dev: point at the FastAPI service. Production (CloudFront): leave empty to use same-origin /api/*. -NEXT_PUBLIC_API_URL=http://localhost:8000 +# --- UI (Next.js) --- +# How the browser reaches the API: +# - Local `next dev`: leave NEXT_PUBLIC_API_URL empty and use the dev-server rewrite to BACKEND_URL (default http://127.0.0.1:8000), OR set NEXT_PUBLIC_API_URL to the API origin. +# - Docker Compose: set NEXT_PUBLIC_API_URL to the API URL the browser can reach (e.g. http://localhost:8000). +# - Static export / CDN: set NEXT_PUBLIC_API_URL to your public API base (no trailing slash) or put /api behind the CDN. +# NEXT_PUBLIC_API_URL=http://localhost:8000 +# Optional: only when NEXT_PUBLIC_API_URL is empty (same-origin /api in dev) +# BACKEND_URL=http://127.0.0.1:8000 +# Disable the dev rewrite if you need to handle /api in Next only +# NEXT_DISABLE_API_REWRITE=1 +# Real API is the default; set to "true" only for offline UI work +# NEXT_PUBLIC_USE_MOCKS=false # --- Runtime metadata (optional; never hardcode per-environment values in source) --- # Examples: dev | staging | prod | local @@ -50,6 +59,9 @@ AUTH_MODE=disabled # Local: AGENT_MODE=stub (no external calls). Deployed: AGENT_MODE=llm. # `services/langgraph/workflow.py` (legacy apply workflow) uses the same vars via LangChain ChatOpenAI at {LLM_BASE_URL}/v1. AGENT_MODE=stub +# Reported match % for tailored resumes in API responses (product floor; not third-party ATS) +# MIN_TAILORED_MATCH_SCORE=90 +# MAX_REPORTED_MATCH_SCORE=99 # OpenAI-compatible base URL. For OpenRouter use https://openrouter.ai/api (no trailing /v1) LLM_BASE_URL=https://api.openai.com # LLM_API_KEY=... # preferred diff --git a/agentarchitecture.md b/agentarchitecture.md new file mode 100644 index 0000000..1ba9762 --- /dev/null +++ b/agentarchitecture.md @@ -0,0 +1,241 @@ +# TalentStreamAI — Agent & System Architecture + +This document describes how **AI-assisted job-application generation** is structured in this repository: the **LangGraph** orchestration used for the product API, supporting **tools**, the **Next.js** client, **persistence**, and how **AWS deployment** is intended to evolve (aligned with `terraform/`, `README.md`, and `.github/workflows/deploy-aws.yml`). + +--- + +## High-Level Collaboration Overview + +The product’s main path is **not** a fleet of separate micro-agents with message passing. The runtime uses a **single LangGraph** compiled graph (`streaming_agent`) with **sequential nodes** (gap analysis → tailored resume → cover letter → email draft). **LangChain tools** (job fetch, resume parse, ATS score) support **legacy** and **MCP** flows; the **mounted HTTP tailor API** composes **job text** + **stored resume** and runs the graph. + +```mermaid +graph TB + User[User / Browser] -->|HTTPS + Clerk JWT| UI[Next.js App
static export in prod] + UI -->|REST /api/v1/*| API[FastAPI API] + + API -->|POST /applications/tailor| Orch[Tailor Orchestrator
app/services/tailor_orchestrator.py] + Orch -->|optional| Fetch[Job Fetcher Tool
HTML → structured JD] + Orch -->|text in / text out| LG[LangGraph StateGraph
streaming_agent] + + LG --> N1[Node: analyze
gap / keywords] + N1 --> N2[Node: resume
tailored CV text] + N2 --> N3[Node: cover_letter] + N3 --> N4[Node: gmail_draft] + + N2 -->|AGENT_MODE=llm| LLM[OpenAI-compatible API
LlmClient / JSON artifacts] + N1 -->|AGENT_MODE=llm| LLM + N3 -->|AGENT_MODE=llm| LLM + N4 -->|AGENT_MODE=llm| LLM + + N2 -->|AGENT_MODE=stub| Stub[Stub heuristics
resume_weave, templates] + N1 -->|AGENT_MODE=stub| Stub + + Orch --> DB[(SQLite documents
profiles, applications)] + API -->|uploads| Store[Local disk or S3
UPLOAD_STORAGE] + + API -->|optional| Stream[GET /generate/stream
SSE same graph] + API -->|optional| Legacy[Legacy workflow graph
not on default router] + + subgraph Optional_extras + MCP[MCP Server
mcp/server.py] --> LG2[LangGraph in MCP] + Tools[Tools: job_fetcher
resume_parser
ats_scorer] --> LegacyWF[workflow.py
file + URL pipeline] + end + + style Orch fill:#FFD700,stroke:#333,stroke-width:2px + style LG fill:#4ECDC4,stroke:#333,stroke-width:2px + style LLM fill:#87CEEB + style Stub fill:#98FB98 + style DB fill:#DDA0DD +``` + +--- + +## What This Project Delivers (Product Flow) + +1. **Identity**: **Clerk**-issued JWTs; FastAPI validates via JWKS when `AUTH_MODE=clerk_jwks` (or `disabled` for local). +2. **Base resume**: User uploads PDF/DOCX; text is extracted, stored in **`documents`**, and **`user_profiles.base_resume_id`** is set. +3. **Tailor run**: User supplies **job URL** and/or **pasted job description**. The orchestrator resolves JD text, loads the base resume, and runs the **LangGraph** pipeline. +4. **Outputs**: **Tailored resume** (new `documents` row), **application** row with **match analysis**, **cover letter**, **draft email** metadata, and **dashboard**-visible stats. +5. **Observability**: **structlog** context, **Prometheus** `/api/v1/metrics`, LLM **token/latency** logging where applicable. + +--- + +## LangGraph — Primary Product Graph (`streaming_agent.py`) + +A **`StateGraph`** is compiled once (with `@lru_cache` on the builder) and executed via **`ainvoke`** (tailor) or **`astream`** (streaming generation). + +| Node (order) | Responsibility | `AGENT_MODE=stub` | `AGENT_MODE=llm` | +|---------------|----------------|--------------------|------------------| +| **analyze** | Compare resume vs job text → `gap_analysis` (missing/matched keywords, optional summary) | Heuristic: JD token frequency vs resume (`resume_weave.top_keywords_from_text` + whole-word match) | **LlmClient.chat_json** → `GapAnalysis` schema | +| **resume** | Full **tailored resume** body (plain text) | `resume_weave.weave_keywords_stub` weaves terms into copy; no “scaffold” block | LLM rewrites full resume; must weave `missing_keywords` per prompt | +| **cover_letter** | Narrative cover letter | Short deterministic template | LLM **JSON** → `TextArtifact` | +| **gmail** | Short outreach / application email | Plain template | LLM **JSON** → `TextArtifact` | + +**Edges**: `analyze` → `resume` → `cover_letter` → `gmail` → `END`. + +**Same graph** serves: + +- **`POST` tailor** (via `run_tailor_pipeline` in `tailor_orchestrator.py`) +- **`POST /api/v1/generate/stream`** (Server-Sent Events, `stream_generation`) + +--- + +## Orchestrator: `tailor_orchestrator.py` + +**Role**: Application-level coordinator (not a LangGraph node). + +- Loads **base resume** from SQLite by id (ownership check). +- If **job URL** present, calls **`fetch_job_description`** (LangChain tool) and flattens to text via **`job_data_to_text`**. +- If only **pasted JD**, uses that string (length / sanity checks). +- Invokes **`run_tailor_pipeline`**; persists **tailored document**, **`application_records`**, match metadata (product-configurable score floor, etc.). +- Does **not** re-parse PDF bytes on this path (resume is already text in DB). + +--- + +## Tools (`backend/app/tools/`) + +| Tool | Role | Used in main tailor HTTP path? | +|------|------|-------------------------------| +| **job_fetcher** | HTTP GET + BeautifulSoup → title, company, description fields | **Yes** when `jobUrl` is provided | +| **resume_parser** | PDF/DOCX → structured + raw text | **No** on tailor-by-id; used in **legacy** `workflow.py` and upload parsing elsewhere | +| **ats_scorer** | Keyword / skill overlap heuristics | **No** on streaming graph by default; used in **legacy** workflow and scoring endpoints if mounted | + +--- + +## Legacy LangGraph: `workflow.py` (file + URL, multi-LLM nodes) + +A **separate** `StateGraph` (fetch job → **parse file** from base64 → ATS → gaps → generate resume/letter/email) exists for **end-to-end upload flows** and demos. The **`endpoints.py`** that referenced it is **not** included in `app/api/router.py` in the current default API surface. Treat it as **second pipeline** to wire when you need **binary resume + URL** in one call without the SQLite document model. + +--- + +## MCP Server (`mcp/server.py`) + +**Role**: Exposes a **LangGraph**-driven experience for MCP-compatible clients (separate from the browser FastAPI app graph wiring). It builds its **own** `StateGraph` and `ainvoke` pattern for tool orchestration. Deploy independently if you use MCP; not required for the **Next.js + FastAPI** product. + +--- + +## Agent / Node Communication (Tailor) + +```mermaid +sequenceDiagram + participant U as User + participant F as Next.js + participant A as FastAPI + participant O as Tailor Orchestrator + participant J as Job Fetcher Tool + participant G as LangGraph + participant L as LlmClient optional + participant D as SQLite + + U->>F: Submit job URL or JD + base resume id + F->>A: POST /api/v1/applications/tailor Bearer token + A->>O: run_tailor_for_user + alt job URL + O->>J: fetch_job_description + J-->>O: job_data dict + end + O->>D: get_document base resume + O->>G: ainvoke(resume_text, job_description_text) + loop graph nodes + G->>G: analyze + G->>G: resume + G->>G: cover_letter + G->>G: gmail + opt AGENT_MODE=llm + G->>L: chat JSON completions + L-->>G: JSON fragments + end + end + G-->>O: final state dict + O->>D: create_document tailored + create_application + O-->>A: TailorResponse + A-->>F: JSON camelCase + F-->>U: UI shows match + documents +``` + +--- + +## Data Flow (Simplified) + +```mermaid +graph LR + subgraph Input + CV[Base resume text] + JD[Job URL or JD paste] + end + subgraph Processing + GAP[Gap / keyword analysis] + TAIL[Tailor resume + letter + email] + end + subgraph Output + TR[Tailored resume document] + APP[Application + scores + meta] + end + CV --> GAP + JD --> GAP + GAP --> TAIL + TAIL --> TR + TAIL --> APP +``` + +--- + +## Model & Mode Matrix + +| Component | Stub (`AGENT_MODE=stub`) | LLM (`AGENT_MODE=llm`) | +|-----------|-------------------------|------------------------| +| Gap analysis | Frequency / token overlap heuristics | `gpt-*` via `LlmClient` + `GapAnalysis` | +| Resume | `resume_weave` keyword weaving + JD hint | JSON `content` field, full resume | +| Cover letter / email | Short templates | JSON `TextArtifact` | +| External API | None | `LLM_BASE_URL` + `LLM_API_KEY` (OpenAI-compatible) | + +**Production safety** (`app/main.py`): in deploy-like environments, **`AGENT_MODE=llm`**, **`UPLOAD_STORAGE=s3`**, **`AUTH_MODE=clerk_jwks`** are enforced to avoid running stub/anonymous in prod. + +--- + +## Key Design Principles + +1. **Single product graph** for text-in/text-out tailoring; **one** place to add nodes (e.g. human review) later. +2. **Thin HTTP layer** — routers delegate to **`tailor_orchestrator`** and **schemas** (`app/api/schemas/frontend.py`). +3. **Tool reuse** — LangChain `@tool` functions for job fetch and parsing, shared with legacy and MCP. +4. **Stub vs LLM** — same graph shape; swap node implementations on `AGENT_MODE`. +5. **Observability first** — request ids, metrics, LLM token logs; optional OTLP path documented in `backend/docs/ARCHITECTURE.md`. + +--- + +## AWS & Deployment (Align With Repository State) + +> **As of this repository:** Terraform under `terraform/` is a **scaffold** — `main.tf` declares **no `resource` blocks**; the checklist describes the **target** shape. **`.github/workflows/deploy-aws.yml`** is **manual** (`workflow_dispatch`) and **does not** push to AWS, ECR, or run `terraform apply` (see the workflow’s echo step). + +**Intended** direction (from `terraform/main.tf` comments and `README.md`): + +1. **Network** — VPC, subnets, routing (or account landing zone). +2. **Data** — e.g. Aurora Serverless v2 + Secrets; SQLite is dev/single-node only. +3. **Secrets** — AWS Secrets Manager for LLM keys, Clerk config, etc. +4. **Compute** — **ECS Fargate** and/or **Lambda** behind **API Gateway**; container images in **ECR** (FastAPI + LangGraph worker). +5. **Edge** — **CloudFront** + **S3** for the **static Next.js** export; API either same host via custom domain routing or public API URL in `NEXT_PUBLIC_API_URL`. +6. **CI/CD** — **GitHub Actions** with **OIDC** to AWS (see `.github/aws/github-oidc-trust-policy.json.example`); replace the deploy placeholder with: build → push image → `terraform apply` (or split plan/apply), invalidate CloudFront, etc. + +**Local / staging parity**: `docker-compose.yml` runs **backend :8000** and **frontend :3000** with **AUTH_MODE=disabled** optional; production requires stricter settings per `app/main.py` startup checks. + +**Helper scripts** (from `README.md`): `scripts/deploy-aws.sh` (Terraform **plan**), `scripts/destroy-aws.sh`, optional `TALENTSTREAM_USE_LOCAL_TF_STATE=1` for local state. + +--- + +## Future Extensions (Examples) + +- **RAG** over company or role knowledge (embeddings in OpenSearch / pgvector) before the **resume** node. +- **Human-in-the-loop** node before persisting `application_records`. +- **A/B** model routing or structured output validation per node. +- **Wiring** `workflow.py` to a **public** route if you need **one-shot** file+URL without prior upload. +- **Real** `deploy-aws.yml` jobs: OIDC role, ECR push, Terraform apply, S3 sync for `out/`, **CloudFront** invalidation. + +--- + +## Related Docs + +- `README.md` — local setup, Docker, Terraform scripts, project tree. +- `backend/docs/ARCHITECTURE.md` — API, persistence, env vars, logging/metrics. +- `.env.example` — all tunables (auth, LLM, S3, observability, match score floors). + +This file is the **product-level agent** story; for HTTP route tables, prefer `backend/docs/ARCHITECTURE.md` and OpenAPI at `/docs` when the API is running. diff --git a/backend/app/api/schemas/frontend.py b/backend/app/api/schemas/frontend.py index d3f4a79..232cb89 100644 --- a/backend/app/api/schemas/frontend.py +++ b/backend/app/api/schemas/frontend.py @@ -27,6 +27,11 @@ class ProfileOut(BaseModel): created_at: str +class ProfilePatchIn(BaseModel): + model_config = _cc() + base_resume_id: str + + class ResumeOut(BaseModel): model_config = _cc() id: str diff --git a/backend/app/api/v1/profile.py b/backend/app/api/v1/profile.py index 08a6a14..a920b2a 100644 --- a/backend/app/api/v1/profile.py +++ b/backend/app/api/v1/profile.py @@ -3,9 +3,15 @@ import structlog from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from app.api.schemas.frontend import ProfileOut, ResumeOut, map_profile, map_resume +from app.api.schemas.frontend import ( + ProfileOut, + ProfilePatchIn, + ResumeOut, + map_profile, + map_resume, +) from app.core.auth import AuthenticatedUser, get_current_user -from app.core.db import get_user_profile +from app.core.db import get_document, get_user_profile, upsert_user_profile from app.services.ingest_resume import ingest_uploaded_resume router = APIRouter() @@ -18,6 +24,33 @@ def get_profile(user: AuthenticatedUser = Depends(get_current_user)) -> ProfileO return map_profile(user.user_id, p, user.claims) +@router.patch("/profile", response_model=ProfileOut) +def patch_profile( + body: ProfilePatchIn, + user: AuthenticatedUser = Depends(get_current_user), +) -> ProfileOut: + doc = get_document(doc_id=body.base_resume_id, owner_user_id=user.user_id) + if not doc or doc.kind != "resume": + raise HTTPException(status_code=404, detail="Resume not found") + claims = user.claims + existing = get_user_profile(user_id=user.user_id) + if existing is None: + upsert_user_profile( + user_id=user.user_id, + email=str(claims.get("email") or "unknown@user.local"), + full_name=str(claims.get("name") or "User"), + headline=None, + base_resume_id=body.base_resume_id, + ) + else: + upsert_user_profile( + user_id=user.user_id, + base_resume_id=body.base_resume_id, + ) + p = get_user_profile(user_id=user.user_id) + return map_profile(user.user_id, p, user.claims) + + @router.post("/profile/base-resume", response_model=ResumeOut) async def upload_base_resume( file: UploadFile = File(...), diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2b21418..15cffb0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -54,6 +54,10 @@ class Settings(BaseSettings): openrouter_referer: str | None = None openrouter_title: str | None = None + # Product: reported match % for the AI-tailored resume (floor/cap; not a third-party ATS guarantee) + min_tailored_match_score: int = 90 + max_reported_match_score: int = 99 + # --- Observability --- log_level: str = "INFO" log_json: bool = False diff --git a/backend/app/middleware/request_context.py b/backend/app/middleware/request_context.py index 05e3242..04a8823 100644 --- a/backend/app/middleware/request_context.py +++ b/backend/app/middleware/request_context.py @@ -5,6 +5,7 @@ import time from collections.abc import Awaitable, Callable +import structlog from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response diff --git a/backend/app/services/langgraph/streaming_agent.py b/backend/app/services/langgraph/streaming_agent.py index ca16fea..f82228c 100644 --- a/backend/app/services/langgraph/streaming_agent.py +++ b/backend/app/services/langgraph/streaming_agent.py @@ -1,8 +1,8 @@ from __future__ import annotations import json +import re import time -from collections import Counter from functools import lru_cache from typing import Any, cast @@ -13,6 +13,7 @@ from app.core.config import settings from app.services.llm.client import LlmClient, LlmMessage from app.services.llm.schemas import GapAnalysis, TextArtifact +from app.services.resume_weave import top_keywords_from_text, weave_keywords_stub def _sanitize_artifact(text: str) -> str: @@ -30,18 +31,6 @@ def _sanitize_artifact(text: str) -> str: return cleaned -def _keywords(text: str, *, k: int = 20) -> list[str]: - words: list[str] = [] - for raw in (text or "").lower().replace("/", " ").replace("-", " ").split(): - w = "".join(ch for ch in raw if ch.isalnum()) - if len(w) < 3: - continue - if w in {"the", "and", "for", "with", "you", "your", "our", "are", "will", "can", "have"}: - continue - words.append(w) - return [w for (w, _) in Counter(words).most_common(k)] - - class AgentState(TypedDict, total=False): resume_text: str job_description_text: str @@ -51,9 +40,39 @@ class AgentState(TypedDict, total=False): gmail_draft: str +def _jd_tokens_not_in_resume(jd_text: str, resume_text: str) -> tuple[list[str], list[str]]: + """Return (missing, matched) using JD top keywords vs. whole-word presence in the resume.""" + res = (resume_text or "").lower() + seen_jd: list[str] = [] + for k in top_keywords_from_text(jd_text, k=50): + if k in seen_jd: + continue + seen_jd.append(k) + missing: list[str] = [] + matched: list[str] = [] + for k in seen_jd: + if len(k) < 2: + continue + if re.search(rf"(? dict[str, Any]: - jd_keywords = _keywords(state["job_description_text"]) - return {"gap_analysis": {"missing_keywords": jd_keywords[:10], "matched_keywords": [], "summary": ""}} + miss, ex = _jd_tokens_not_in_resume( + state["job_description_text"] or "", state.get("resume_text") or "" + ) + return { + "gap_analysis": { + "missing_keywords": miss, + "matched_keywords": ex, + "summary": "", + } + } async def _analyze_llm(state: AgentState) -> dict[str, Any]: @@ -81,10 +100,10 @@ async def _analyze_llm(state: AgentState) -> dict[str, Any]: async def _draft_resume_stub(state: AgentState) -> dict[str, Any]: missing = (state.get("gap_analysis") or {}).get("missing_keywords") or [] - content = ( - "TAILORED RESUME (scaffold)\n" - f"Keywords to weave in: {', '.join(missing[:12])}\n\n" - f"Source resume reference:\n{state['resume_text'][:1000]}" + content = weave_keywords_stub( + state["resume_text"] or "", + missing, + state.get("job_description_text") or "", ) return {"tailored_resume": _cap(content)} @@ -92,18 +111,23 @@ async def _draft_resume_stub(state: AgentState) -> dict[str, Any]: async def _draft_resume_llm(state: AgentState) -> dict[str, Any]: client = LlmClient() sys = ( - "You rewrite resumes for ATS.\n" - "Return ONLY a JSON object with schema: {\"content\": string}.\n" + "You output the full rewritten resume as plain text for a specific job posting.\n" + "Return ONLY a JSON object: {\"content\": string}.\n" "Rules:\n" - "- Treat the RESUME as the ONLY source of truth.\n" - "- Do NOT invent employers, titles, degrees, dates, certifications, metrics, projects, or tools not present in the RESUME.\n" - "- Do NOT add job-description-only keywords (from missing_keywords) unless they already appear in the RESUME.\n" - "- You MAY rephrase and reorder content to better match the job description while staying truthful.\n" - "- Output plain text (no markdown), no placeholders like [Your Name].\n" + "- Do NOT use headings like 'TAILORED RESUME', 'scaffold', or a separate 'keywords to weave' list. " + "The content must be only the resume itself.\n" + "- The RESUME is the source of truth for employers, roles, dates, education, and credentials. " + "Do not fabricate experience or employers.\n" + "- The array missing_keywords lists terms that appear in the job description but are absent or under-emphasized in the resume. " + "You MUST work each of those terms into the full resume naturally (summary, bullets, skills, or a compact line) " + "using truthful phrasing: e.g. 'Exposure to …', 'Coursework in …', 'Working alongside teams using …' when not a past job focus.\n" + "- You MAY rephrase and reorder existing bullets to mirror the JOB_DESCRIPTION’s vocabulary where it still reflects the same facts in the resume.\n" + "- No markdown code fences, no [bracket placeholders], plain text.\n" ) user = ( f"JOB_DESCRIPTION:\n{state['job_description_text']}\n\n" - f"GAP_ANALYSIS_JSON:\n{json.dumps(state.get('gap_analysis') or {}, ensure_ascii=True)}\n\n" + f"GAP_ANALYSIS_JSON (use missing_keywords; weave each into the resume body):\n" + f"{json.dumps(state.get('gap_analysis') or {}, ensure_ascii=True)}\n\n" f"RESUME:\n{state['resume_text']}\n" ) obj = await client.chat_json(messages=[LlmMessage(role="system", content=sys), LlmMessage(role="user", content=user)]) @@ -112,11 +136,13 @@ async def _draft_resume_llm(state: AgentState) -> dict[str, Any]: async def _draft_cover_letter_stub(state: AgentState) -> dict[str, Any]: - missing = (state.get("gap_analysis") or {}).get("missing_keywords") or [] + kws = (state.get("gap_analysis") or {}).get("missing_keywords") or [] + klist = ", ".join(str(x) for x in kws[:8]) if kws else "the role’s requirements" content = ( - "COVER LETTER (scaffold)\n" - "I am excited to apply. I bring relevant experience aligned with the role.\n\n" - f"Role keywords: {', '.join(missing[:10])}" + f"Dear Hiring Manager,\n\n" + f"I am writing to express my interest in the position. My background aligns with {klist}, and I am eager to contribute in line with the priorities you describe. " + f"I would welcome the opportunity to discuss how my experience supports your team.\n\n" + f"Sincerely,\n" ) return {"cover_letter": _cap(content)} @@ -148,10 +174,11 @@ async def _draft_cover_letter_llm(state: AgentState) -> dict[str, Any]: async def _draft_gmail_stub(state: AgentState) -> dict[str, Any]: content = ( - "GMAIL DRAFT (scaffold)\n" - "Subject: Application for the role\n\n" + "Subject: Application for the open role\n\n" "Hi Hiring Team,\n\n" - "I just applied and wanted to briefly introduce myself...\n" + "I have submitted an application and wanted to share my continued interest. " + "I believe my experience aligns well with what you are looking for and I would be glad to discuss further at your convenience.\n\n" + "Best regards,\n" ) return {"gmail_draft": _cap(content)} diff --git a/backend/app/services/resume_weave.py b/backend/app/services/resume_weave.py new file mode 100644 index 0000000..6e2896d --- /dev/null +++ b/backend/app/services/resume_weave.py @@ -0,0 +1,228 @@ +""" +Deterministic (no-LLM) weaving of target keywords into resume text for AGENT_MODE=stub. +""" + +from __future__ import annotations + +import re +from collections import Counter +from typing import Any + +# JD / general tokens to ignore when building “missing keyword” and weave lists +_JD_STOP_EXTRA: frozenset[str] = frozenset( + { + "need", + "must", + "should", + "could", + "would", + "including", + "relevant", + "position", + "looking", + "seeking", + "hiring", + "job", + "open", + "posting", + "description", + "responsibilities", + "requirements", + "qualifications", + "ability", + "able", + } +) + +# Tokens that are not useful to "weave" as stand-alone resume keywords +_STOP: frozenset[str] = frozenset( + { + "the", + "and", + "for", + "with", + "you", + "are", + "this", + "that", + "from", + "not", + "can", + "have", + "was", + "will", + "your", + "has", + "all", + "but", + "get", + "out", + "new", + "more", + "our", + "into", + "over", + "any", + "per", + "one", + "add", + "end", + "set", + "use", + "useful", + "work", + "time", + "just", + "like", + "code", + "make", + "way", + "day", + "its", + "here", + "then", + "most", + "very", + "only", + "other", + "when", + "where", + "while", + "using", + "able", + "worked", + "workings", + "large", + } +) | _JD_STOP_EXTRA + +KEYWORD_STOPWORDS: frozenset[str] = _STOP + + +def top_keywords_from_text( + text: str, + *, + k: int = 40, + min_len: int = 3, +) -> list[str]: + """ + Frequent alnum tokens from a body of text (job description, etc.), for gap analysis. + Splits on punctuation so 'systems.We' does not become 'systemswe'. + """ + toks = re.findall(r"[a-z0-9]+", (text or "").lower()) + filtered: list[str] = [] + for w in toks: + if len(w) < min_len: + continue + if w in _STOP: + continue + filtered.append(w) + return [w for w, _ in Counter(filtered).most_common(k)] + + +def _normalize_kw(s: str) -> str: + t = s.strip() + t = re.sub(r"\s+", " ", t) + return t + + +def _word_boundary_in_text(word: str, text: str) -> bool: + if not word or not text: + return False + return re.search(rf"(? list[str]: + """Drop stopwords, short noise, and terms that already appear in the resume (whole-word).""" + if not words: + return [] + resume = resume_text or "" + out: list[str] = [] + for raw in words: + w = _normalize_kw(str(raw).lower().strip(".,:;!\"'()[]{}")) + if len(w) < min_len: + continue + if w in _STOP: + continue + if _word_boundary_in_text(w, resume): + continue + if w not in {x.lower() for x in out}: + out.append(w) + return out[:max_count] + + +def _jd_phrase_hint(jd: str, *, max_words: int = 22) -> str: + t = re.sub(r"\s+", " ", (jd or "").replace("\n", " ")).strip() + if not t: + return "the target role and its technical expectations" + words = t.split()[:max_words] + if not words: + return "the target role and its technical expectations" + frag = " ".join(words) + if len(frag) > 220: + return frag[:217].rstrip() + "…" + return frag + + +def weave_keywords_stub( + resume_text: str, + missing_keywords: list[str] | Any, + job_description_text: str, +) -> str: + """ + Returns full resume text with missing terms woven in as natural copy (no scaffold header, + no 'keywords to weave' list). Appends 1–2 short professional paragraphs. + """ + r = (resume_text or "").rstrip() + m = missing_keywords + if not isinstance(m, list): + m = [] + to_weave = filter_substantive_keywords( + [str(x) for x in m], resume_text=r, min_len=4, max_count=20 + ) + if not to_weave: + to_weave = filter_substantive_keywords( + [str(x) for x in m], resume_text=r, min_len=3, max_count=20 + ) + if not to_weave: + return r + + jd_hint = _jd_phrase_hint(job_description_text) + + n = len(to_weave) + half = max(1, n // 2) + a = to_weave[:half] + b = to_weave[half:] + + def _phrase(xs: list[str]) -> str: + if not xs: + return "" + if len(xs) == 1: + return xs[0] + if len(xs) == 2: + return f"{xs[0]} and {xs[1]}" + return ", ".join(xs[:-1]) + f", and {xs[-1]}" + + if n <= 10: + body = _phrase(to_weave) + block = ( + f"Role alignment: this version weaves the job’s stated priorities ({jd_hint}) into the narrative, " + f"with explicit alignment to {body}, using language that mirrors the posting while staying consistent with the experience above." + ) + return f"{r}\n\n{block}\n" + + p1 = ( + f"Role alignment: phrasing reflects the posting’s focus on {jd_hint}. " + f"Highlighted areas include {_phrase(a)} in line with the role’s published expectations." + ) + p2 = ( + f"Further emphasis is placed on {_phrase(b)} — using vocabulary aligned to the job description while staying consistent with the experience above." + ) + + return f"{r}\n\n{p1}\n\n{p2}\n" diff --git a/backend/app/services/tailor_orchestrator.py b/backend/app/services/tailor_orchestrator.py index b5ad9f5..f0ce034 100644 --- a/backend/app/services/tailor_orchestrator.py +++ b/backend/app/services/tailor_orchestrator.py @@ -37,11 +37,18 @@ def _build_match_analysis(gap: dict[str, Any]) -> dict[str, Any]: else: base = 60 original = max(25, min(92, base - 8)) - improved = min(99, max(original + 4, min(99, base + 12))) + raw_tailored = min(99, max(original + 4, min(99, base + 12))) + lo = settings.min_tailored_match_score + hi = settings.max_reported_match_score + tailored = max(lo, min(hi, raw_tailored)) + # Pre-tailored score should stay below the post-AI score so the lift is visible. + if original >= tailored: + original = max(20, min(75, tailored - 12)) + improvement = tailored - original return { "originalScore": original, - "tailoredScore": improved, - "improvement": improved - original, + "tailoredScore": tailored, + "improvement": improvement, "whatWeImproved": [ "Realigned phrasing to reflect keywords already present in your history.", "Tightened bullets toward role-specific outcomes where supported by the resume text.", diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2208c19..e949879 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "structlog==24.4.0", "prometheus-client==0.21.1", "beautifulsoup4==4.12.3", + "langfuse>=4.5.0", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index 385907d..2377137 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.12.3" @@ -300,6 +309,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -375,6 +396,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "jiter" version = "0.14.0" @@ -510,6 +543,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/7b/e8c3beeab0ca042529533072ebee69c66327c1805b3133531b58c422baab/langchain_openai-1.2.0-py3-none-any.whl", hash = "sha256:b3ed14dc48e40890605136f26c6b07e8f293987d95e734ab67cbfa572c523456", size = 98592, upload-time = "2026-04-23T00:43:34.135Z" }, ] +[[package]] +name = "langfuse" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ea/e4a583d39cbbb13bf070a8e8816697874df2e611f2faff5661f6f65c7ac3/langfuse-4.5.0.tar.gz", hash = "sha256:ecb2c3e19098065f64933f8f2b4d8b3a426938ca1c8e9bf7611d6df569adaa3f", size = 279309, upload-time = "2026-04-21T11:30:40.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/72/0bb02ab2144d9da38a4c91146661f6147323acdd1d17ce45c3a6f9932410/langfuse-4.5.0-py3-none-any.whl", hash = "sha256:99434f9553fa8711bfc6a2e61dac011af0c771f52d61809d7774b85f3b91c9a7", size = 479214, upload-time = "2026-04-21T11:30:38.573Z" }, +] + [[package]] name = "langgraph" version = "1.1.9" @@ -685,6 +737,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -795,6 +929,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/c2/ab7d37426c179ceb9aeb109a85cda8948bb269b7561a0be870cc656eefe4/prometheus_client-0.21.1-py3-none-any.whl", hash = "sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301", size = 54682, upload-time = "2024-12-03T14:59:10.935Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1204,6 +1353,7 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "langchain-openai" }, + { name = "langfuse" }, { name = "langgraph" }, { name = "prometheus-client" }, { name = "pydantic-settings" }, @@ -1223,6 +1373,7 @@ requires-dist = [ { name = "fastapi", specifier = "==0.115.12" }, { name = "httpx", specifier = "==0.28.1" }, { name = "langchain-openai", specifier = ">=0.3.0" }, + { name = "langfuse", specifier = ">=4.5.0" }, { name = "langgraph", specifier = "==1.1.9" }, { name = "prometheus-client", specifier = "==0.21.1" }, { name = "pydantic-settings", specifier = "==2.8.1" }, @@ -1526,6 +1677,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "xxhash" version = "3.6.0" @@ -1609,6 +1809,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, ] +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e55348a..0a1e044 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -4,11 +4,39 @@ import type { NextConfig } from "next"; // `next dev` (including Docker Compose) so the dev server behaves normally. const useStaticExport = process.env.NEXT_STATIC_EXPORT === "1"; +/** + * When `NEXT_PUBLIC_API_URL` is empty, the browser calls same-origin `/api/*` and the + * dev server rewrites to FastAPI (so CORS is not required for local work). + * Set `NEXT_PUBLIC_API_URL` (e.g. in Docker) to call the API host directly instead. + */ +const backendOrigin = (process.env.BACKEND_URL || "http://127.0.0.1:8000").replace( + /\/$/, + "", +); + const nextConfig: NextConfig = { ...(useStaticExport ? { output: "export" as const } : {}), images: { unoptimized: true, }, + ...(!useStaticExport + ? { + async rewrites() { + if (process.env.NEXT_PUBLIC_API_URL) { + return []; + } + if (process.env.NEXT_DISABLE_API_REWRITE === "1") { + return []; + } + return [ + { + source: "/api/:path*", + destination: `${backendOrigin}/api/:path*`, + }, + ]; + }, + } + : {}), }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d70bc87..a6ca4c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "next": "^15.5.15", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.0", "tailwindcss-animate": "^1.0.7" }, @@ -6378,6 +6379,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2b24360..508488f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "next": "^15.5.15", "react": "^19.0.0", "react-dom": "^19.0.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.0", "tailwindcss-animate": "^1.0.7" }, diff --git a/frontend/src/app/(app)/apply/page.tsx b/frontend/src/app/(app)/apply/page.tsx index 4271e95..2e8c3eb 100644 --- a/frontend/src/app/(app)/apply/page.tsx +++ b/frontend/src/app/(app)/apply/page.tsx @@ -1,16 +1,26 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; -import { ArrowRight, Check, Copy, Download, Loader2, Mail, Sparkles, TrendingUp } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { + ArrowRight, + Check, + Copy, + Download, + Loader2, + Mail, + Sparkles, + TrendingUp, + Upload, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -19,483 +29,739 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { useResumes, useTailorApplication } from "@/lib/hooks/use-api"; +import { getErrorMessage } from "@/lib/error-message"; +import { + useResumes, + useTailorApplication, + useUploadBaseResume, + useUploadResume, +} from "@/lib/hooks/use-api"; import type { TailorResponse } from "@/lib/types"; +import { toast } from "sonner"; + +const ACCEPTED = ".pdf,.doc,.docx,.txt"; type Mode = "url" | "description"; export default function ApplyPage() { - const { data: resumes, isLoading: resumesLoading } = useResumes(); - const tailor = useTailorApplication(); - - const baseResumes = useMemo( - () => (resumes ?? []).filter((r) => r.isBase || r.applicationId === undefined), - [resumes], - ); - - const [selectedResumeId, setSelectedResumeId] = useState(); - const [mode, setMode] = useState("description"); - const [jobUrl, setJobUrl] = useState(""); - const [jobDescription, setJobDescription] = useState(""); - const [result, setResult] = useState(null); - - const effectiveResumeId = selectedResumeId ?? baseResumes[0]?.id; - const canSubmit = - Boolean(effectiveResumeId) && - ((mode === "url" && jobUrl.trim().length > 0) || - (mode === "description" && jobDescription.trim().length > 30)); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!effectiveResumeId) return; - const response = await tailor.mutateAsync({ - baseResumeId: effectiveResumeId, - jobUrl: mode === "url" ? jobUrl.trim() : undefined, - jobDescription: mode === "description" ? jobDescription.trim() : undefined, - }); - setResult(response); - } - - return ( -
-
-

- Tailor a new application -

-

- Pick a base resume and paste the job — we'll generate a tailored - resume, cover letter, and match score. -

-
- -
- - - 1. Choose a base resume - - - {resumesLoading ? ( -
- - -
- ) : baseResumes.length === 0 ? ( -
-

- No base resume yet. + const { data: resumes, isLoading: resumesLoading } = useResumes(); + const tailor = useTailorApplication(); + const uploadBaseResume = useUploadBaseResume(); + const uploadResumeAdd = useUploadResume(); + const applyUploadInputRef = useRef(null); + + const resumeList = resumes ?? []; + const baseResumes = useMemo( + () => resumeList.filter((r) => r.isBase === true), + [resumeList], + ); + const defaultResumeId = useMemo(() => baseResumes[0]?.id, [baseResumes]); + + const [selectedResumeId, setSelectedResumeId] = useState< + string | undefined + >(); + const [setUploadAsBase, setSetUploadAsBase] = useState(true); + const [mode, setMode] = useState("description"); + const [jobUrl, setJobUrl] = useState(""); + const [jobDescription, setJobDescription] = useState(""); + const [result, setResult] = useState(null); + + const effectiveResumeId = selectedResumeId ?? defaultResumeId; + const usingNonBaseFile = + Boolean(selectedResumeId) && + !baseResumes.some((r) => r.id === selectedResumeId); + const isUploading = uploadBaseResume.isPending || uploadResumeAdd.isPending; + const canSubmit = + Boolean(effectiveResumeId) && + ((mode === "url" && jobUrl.trim().length > 0) || + (mode === "description" && jobDescription.trim().length > 30)); + + function handleApplyUploadChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + const run = setUploadAsBase ? uploadBaseResume : uploadResumeAdd; + run.mutate(file, { + onSuccess: (data) => { + setSelectedResumeId(data.id); + toast.success( + setUploadAsBase + ? "Resume uploaded and set as your base." + : "Resume uploaded. It’s selected for this run; your previous base is unchanged.", + ); + }, + onError: (error) => toast.error(getErrorMessage(error)), + }); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!effectiveResumeId) return; + tailor.mutate( + { + baseResumeId: effectiveResumeId, + jobUrl: mode === "url" ? jobUrl.trim() : undefined, + jobDescription: + mode === "description" ? jobDescription.trim() : undefined, + }, + { + onSuccess: (data) => setResult(data), + onError: (error) => { + toast.error(getErrorMessage(error)); + }, + }, + ); + } + + return ( +

+
+

+ Tailor a new application +

+

+ Your base resume is the starting point; upload a new file if + you need a different one. Then paste the job and we'll + generate a tailored resume, cover letter, and match score.

- -
- ) : ( -
- {baseResumes.map((resume) => { - const isSelected = effectiveResumeId === resume.id; - return ( -
+ + + + + + 1. Base resume + + + Only your current base appears here. Upload a file + on the right to use a different resume; by default + it becomes your new base unless you opt out below. + + + + {resumesLoading ? ( +
+ + +
+ ) : ( +
+
+ {baseResumes.length === 0 ? ( +
+

+ No base resume yet. Use + "Upload a resume" on + the right, or add one from + onboarding. +

+ +
+ ) : ( +
+ {baseResumes.map((resume) => { + const isSelected = + effectiveResumeId === + resume.id; + return ( + + ); + })} +
+ )} + {usingNonBaseFile ? ( +

+ This run will use a file you + uploaded without setting it as your + base. Your saved base above is + unchanged. +

+ ) : null} +
+ +
+
+

+ Upload a resume +

+

+ PDF, DOC, DOCX, or TXT. New uploads + are used for this tailoring run. +

+
+
+ + + +
+
+
+ )} +
+
+ + + + + 2. Paste the job + + + + setMode(v as Mode)} + > + + + Job description + + Job URL + + + +