feat: migrate AI to llama and use local db
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m13s

This commit is contained in:
2026-01-19 21:25:38 +01:00
parent 6e75be19ed
commit a8d3bf1b3b
10 changed files with 12059 additions and 980 deletions

View File

@@ -1,19 +1,14 @@
ADMIN_EMAIL="" ADMIN_EMAIL=""
CRON_SECRET="" CRON_SECRET=""
HOME_URL="" HOME_URL=""
OVHCLOUD_API_KEY=""
MAINTENANCE_MODE="0" MAINTENANCE_MODE="0"
NEWS_LIMIT="50" NEWS_LIMIT="50"
NEWS_TO_USE="10" NEWS_TO_USE="10"
NEXT_PUBLIC_BRAND_COUNTRY="" NEXT_PUBLIC_BRAND_COUNTRY=""
NEXT_PUBLIC_BRAND_EMAIL="" NEXT_PUBLIC_BRAND_EMAIL=""
NEXT_PUBLIC_BRAND_NAME="" NEXT_PUBLIC_BRAND_NAME=""
POSTGRES_DATABASE="" DATABASE_URL=""
POSTGRES_HOST=""
POSTGRES_PASSWORD=""
POSTGRES_PRISMA_URL=""
POSTGRES_URL=""
POSTGRES_URL_NON_POOLING=""
POSTGRES_USER=""
RESEND_FROM="" RESEND_FROM=""
RESEND_KEY="" RESEND_KEY=""
SECRET_HASH="" SECRET_HASH=""

View File

@@ -0,0 +1,41 @@
name: Deploy
on:
push:
branches: [ main ]
jobs:
lint-build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Run linting
run: npm run lint
- name: Build
run: npm run build
- name: Deploy to VPS
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 51.210.247.57 >> ~/.ssh/known_hosts
ssh debian@51.210.247.57 << 'EOF'
cd /home/debian/newsletter-hackernews
git pull origin main
cd /home/debian/gitea
docker compose up -d --build newsletter
EOF

View File

@@ -4,12 +4,20 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN yarn RUN npm install
COPY . . COPY . .
RUN yarn build ARG NEXT_PUBLIC_BRAND_NAME
ARG NEXT_PUBLIC_BRAND_EMAIL
ARG NEXT_PUBLIC_BRAND_COUNTRY
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME
ENV NEXT_PUBLIC_BRAND_EMAIL=$NEXT_PUBLIC_BRAND_EMAIL
ENV NEXT_PUBLIC_BRAND_COUNTRY=$NEXT_PUBLIC_BRAND_COUNTRY
RUN npm run build
EXPOSE 3000 EXPOSE 3000
CMD [ "yarn", "start" ] CMD [ "npm", "start" ]

View File

@@ -1,8 +1,16 @@
version: '3.8' version: '3.8'
services: services:
app: postgres:
build: image: postgres:16-alpine
context: . container_name: newsletter-db
dockerfile: Dockerfile environment:
- POSTGRES_USER=newsletter
- POSTGRES_PASSWORD=newsletter
- POSTGRES_DB=newsletter
ports: ports:
- '3000:3000' - '5432:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:

10863
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@vercel/analytics": "^1.1.1", "@vercel/analytics": "^1.1.1",
"ai": "^5.0.68", "openai": "^4.77.0",
"axios": "^1.12.0", "axios": "^1.12.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
@@ -41,7 +41,7 @@
"resend": "^3.1.0", "resend": "^3.1.0",
"tailwind-merge": "^2.1.0", "tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^18.4.3", "@commitlint/cli": "^18.4.3",

View File

@@ -4,8 +4,7 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling url = env("DATABASE_URL")
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
} }
model User { model User {

View File

@@ -1,52 +1,103 @@
import { generateText, tool, jsonSchema } from 'ai'; import OpenAI from 'openai';
import { BaseTool } from './tool'; import { BaseTool } from './tool';
import type { JSONSchema7 } from 'json-schema';
export async function getMessage<T>(text: string, baseTool: BaseTool) { let ovhAI: OpenAI | null = null;
console.info('Vercel AI Gateway request with text: ', text);
try { function getClient(): OpenAI {
const { steps } = await generateText({ if (!ovhAI) {
model: 'anthropic/claude-sonnet-4.5', ovhAI = new OpenAI({
temperature: 1, apiKey: process.env.OVHCLOUD_API_KEY,
tools: { baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1'
[baseTool.name]: tool({
description: baseTool.input_schema.description || '',
inputSchema: jsonSchema(baseTool.input_schema as JSONSchema7),
execute: async args => args
})
},
toolChoice: {
type: 'tool',
toolName: baseTool.name
},
prompt: text
}); });
}
console.info('Vercel AI Gateway response steps: ', steps); return ovhAI;
const toolCalls = steps.flatMap(step => step.toolCalls);
if (!toolCalls || toolCalls.length === 0) {
throw new Error('No tool calls found in response');
} }
const typedCall = toolCalls[0] as unknown as { const MAX_RETRIES = 3;
toolName: string;
input: Record<string, unknown>;
};
console.info('Tool call input: ', typedCall.input); export async function getMessage<T>(
text: string,
baseTool: BaseTool
): Promise<T> {
const requiredFields = baseTool.input_schema.required
? [...baseTool.input_schema.required]
: Object.keys(baseTool.input_schema.properties);
if (typedCall.toolName !== baseTool.name) { let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.info(`OVH AI request attempt ${attempt}/${MAX_RETRIES}`);
const completion = await getClient().chat.completions.create({
model: 'Meta-Llama-3_3-70B-Instruct',
temperature: 0.7,
max_tokens: 16000,
tools: [
{
type: 'function',
function: {
name: baseTool.name,
description: baseTool.input_schema.description || '',
parameters: {
type: 'object',
properties: baseTool.input_schema.properties,
required: requiredFields
}
}
}
],
tool_choice: {
type: 'function',
function: { name: baseTool.name }
},
messages: [
{
role: 'system',
content: `You are a professional tech journalist creating newsletter content.
CRITICAL REQUIREMENTS:
1. You MUST call the function with ALL required fields populated
2. Required fields: ${requiredFields.join(', ')}
3. Every field must be fully populated with high-quality content
4. Do NOT leave any field as null, undefined, or empty`
},
{
role: 'user',
content: text
}
]
});
const message = completion.choices[0]?.message;
if (!message?.tool_calls || message.tool_calls.length === 0) {
throw new Error('No function call found in response');
}
const toolCall = message.tool_calls[0];
if (toolCall.function.name !== baseTool.name) {
throw new Error( throw new Error(
`Expected tool ${baseTool.name} but got ${typedCall.toolName}` `Expected tool ${baseTool.name} but got ${toolCall.function.name}`
); );
} }
return typedCall.input as T; const result = JSON.parse(toolCall.function.arguments);
console.info('OVH AI response:', result);
return result as T;
} catch (error) { } catch (error) {
console.error('Vercel AI Gateway error: ', error); console.error(`Attempt ${attempt} failed:`, error);
throw new Error('Failed to get message from AI Gateway'); lastError = error as Error;
if (attempt < MAX_RETRIES) {
const delay = 1000 * attempt;
console.info(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} }
} }
}
console.error('All retry attempts failed');
throw lastError || new Error('OVH AI Endpoints client error.');
}

View File

@@ -25,20 +25,25 @@ export const newsletterTool: BaseTool = {
name: 'NewsletterTool' as const, name: 'NewsletterTool' as const,
input_schema: { input_schema: {
type: 'object' as const, type: 'object' as const,
description: 'Newsletter', description:
'Generate a tech newsletter with title, content, and focus sections',
properties: { properties: {
title: { title: {
type: 'string' as const, type: 'string' as const,
description: 'The title of the newsletter' description:
'The title of the newsletter (40-50 characters, capturing key themes)'
}, },
content: { content: {
type: 'string' as const, type: 'string' as const,
description: 'The main content of the newsletter' description:
'The main content of the newsletter (300-400 words, HTML formatted with paragraph tags and links)'
}, },
focus: { focus: {
type: 'string' as const, type: 'string' as const,
description: 'The text of the focus segment' description:
} 'Forward-looking assessment of key developments and trends'
} }
},
required: ['title', 'content', 'focus'] as const
} }
} as const; } as const;

1927
yarn.lock

File diff suppressed because it is too large Load Diff