Compare commits

4 Commits
Author SHA1 Message Date
blindxfish 7a868d7f14 added a test link so the admin can test the interviews too, admin can now go to the landing page too, fixes for the swagger links 2025-09-20 15:04:56 +02:00
blindxfish 824bf93dfb always forgetting the portainer creds so added to the readme as a best practice 2025-09-20 12:25:55 +02:00
blindxfish 7f9cf79a21 Merged the projects 2025-09-20 11:56:15 +02:00
blindxfish b83c448573 Fix Docker build paths after flattening nested structures
- Remove obsolete version lines from docker-compose.yml files
- Create root Dockerfile for ASP.NET chatbot service
- Update backend docker-compose.yml to use correct build context
- Fix all references to old nested tuna/tuna path structure
2025-09-20 10:48:31 +02:00
14 changed files with 260 additions and 15 deletions
+46
View File
@@ -0,0 +1,46 @@
# Multi-stage build for ASP.NET Core application
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /app
# Copy project files
COPY AISApp.csproj .
COPY . .
# Restore dependencies
RUN dotnet restore AISApp.csproj
# Build the application
RUN dotnet build AISApp.csproj -c Release -o /app/build
# Publish the application
RUN dotnet publish AISApp.csproj -c Release -o /app/publish
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy published application
COPY --from=build /app/publish .
# Copy prompt.txt file
COPY prompt.txt .
# Create directory for static files
RUN mkdir -p static
# Expose port
EXPOSE 80
# Set environment variables
ENV ASPNETCORE_URLS=http://+:80
ENV ASPNETCORE_ENVIRONMENT=Production
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost/api/chat || exit 1
# Run the application
ENTRYPOINT ["dotnet", "AISApp.dll"]
+2
View File
@@ -20,3 +20,5 @@ This will start the application with all necessary services.
---
This README provides the basic steps to configure and run the AISApp project using Docker Compose and environment variables.
portainer= tunaadmin/tunatainer8!
+5 -5
View File
@@ -20,7 +20,7 @@ Integrate the ASP.NET chatbot service into the existing Node.js backend system t
### 1.3 Create Chatbot Service Dockerfile
- **Status**: âś… Completed
- **Description**: Create proper Dockerfile for the ASP.NET chatbot service
- **Files**: `tuna/tuna/Dockerfile`
- **Files**: `AISApp/Dockerfile`
- **Details**: Multi-stage build with proper .NET 9.0 runtime and configuration
## Phase 2: Backend Service Integration
@@ -54,25 +54,25 @@ Integrate the ASP.NET chatbot service into the existing Node.js backend system t
### 3.1 Add MySQL Database Support
- **Status**: âś… Completed
- **Description**: Replace SQLite with MySQL database connection in ASP.NET service
- **Files**: `tuna/tuna/AISApp/AIS.cs`, `tuna/tuna/AISApp/Program.cs`
- **Files**: `AISApp/AISApp/AIS.cs`, `AISApp/AISApp/Program.cs`
- **Details**: Add MySQL connection string and update database operations
### 3.2 Add Interview Context Endpoints
- **Status**: âś… Completed
- **Description**: Create endpoints for interview initialization and context management
- **Files**: `tuna/tuna/AISApp/Program.cs`
- **Files**: `AISApp/AISApp/Program.cs`
- **Details**: Add endpoints for interview start, status, and completion
### 3.3 Implement Conversation Sync
- **Status**: âś… Completed
- **Description**: Sync conversation data between ASP.NET service and MySQL database
- **Files**: `tuna/tuna/AISApp/AIS.cs`
- **Files**: `AISApp/AISApp/AIS.cs`
- **Details**: Update conversation persistence to use MySQL instead of SQLite
### 3.4 Add Interview-Specific Prompts
- **Status**: âś… Completed
- **Description**: Modify system prompts based on job requirements and interview context
- **Files**: `tuna/tuna/AISApp/prompt.txt`, `tuna/tuna/AISApp/AIS.cs`
- **Files**: `AISApp/AISApp/prompt.txt`, `AISApp/AISApp/AIS.cs`
- **Details**: Dynamic prompt generation based on job details and interview stage
## Phase 4: Database Integration
+1 -1
View File
@@ -22,7 +22,7 @@ services:
chatbot:
build:
context: ../../tuna/tuna
context: ../../AISApp
dockerfile: Dockerfile
image: candidat/chatbot:latest
container_name: backend-chatbot
+7 -2
View File
@@ -35,7 +35,12 @@ import {$log} from "@tsed/logger";
version: process.env.APP_VERSION || "1.0.0",
description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI, and admin reporting.",
"Includes endpoints for auth, users, jobs, tokens, AI-powered interviews (OpenRouter/Ollama), and admin reporting.\n\n" +
"AI Features:\n" +
"- OpenRouter integration for cloud-based AI interviews\n" +
"- Ollama support for local AI processing\n" +
"- Test mode for admin interview testing\n" +
"- Mandatory question support before AI interviews",
contact: {
name: "Candivista Team",
url: "https://candivista.com",
@@ -51,7 +56,7 @@ import {$log} from "@tsed/logger";
{ name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" },
{ name: "AI", description: "AI provider tests and operations" }
{ name: "AI", description: "AI-powered interview operations with OpenRouter and Ollama support" }
],
components: {
securitySchemes: {
+35 -2
View File
@@ -1,5 +1,5 @@
import { Controller } from "@tsed/di";
import { Post, Get } from "@tsed/schema";
import { Post, Get, Tags, Summary, Description, Returns, Security } from "@tsed/schema";
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { BadRequest, NotFound } from "@tsed/exceptions";
@@ -8,6 +8,7 @@ import { AIService } from "../../services/AIService.js";
import axios from "axios";
@Controller("/ai")
@Tags("AI")
export class AIController {
private jobService = new JobService();
private aiService = new AIService();
@@ -17,6 +18,9 @@ export class AIController {
// Test AI connection
@Get("/test-ai")
@Summary("Test AI connection")
@Description("Test the AI service connection and configuration. Works with both Ollama and OpenRouter providers.")
@(Returns(200, Object).Description("AI test result with success status and response"))
async testAI() {
try {
if (this.aiProvider === 'openrouter') {
@@ -60,6 +64,10 @@ export class AIController {
// Get mandatory questions for the job
@Get("/mandatory-questions/:linkId")
@Summary("Get mandatory interview questions")
@Description("Retrieve mandatory questions for a specific job interview link")
@(Returns(200, Object).Description("List of mandatory questions for the job"))
@(Returns(404, Object).Description("Interview link not found or expired"))
async getMandatoryQuestions(@PathParams("linkId") linkId: string) {
try {
// Verify the job exists and link is valid
@@ -83,6 +91,11 @@ export class AIController {
// Submit mandatory question answers
@Post("/submit-mandatory-answers")
@Summary("Submit mandatory question answers")
@Description("Submit answers to mandatory interview questions before starting the AI interview")
@(Returns(200, Object).Description("Success response with interview data"))
@(Returns(400, Object).Description("Missing required fields"))
@(Returns(404, Object).Description("Interview link not found or expired"))
async submitMandatoryAnswers(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { candidateName, job, linkId, answers } = body;
@@ -135,6 +148,12 @@ export class AIController {
// Start interview with AI agent (only after mandatory questions)
@Post("/start-interview")
@Summary("Start AI interview")
@Description("Initialize an AI-powered interview session. Can be used in test mode for admins.")
@(Returns(200, Object).Description("Interview started successfully with initial AI message"))
@(Returns(400, Object).Description("Missing required fields"))
@(Returns(404, Object).Description("Interview link not found or expired"))
@(Returns(500, Object).Description("AI service unavailable"))
async startInterview(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { candidateName, job, linkId } = body;
@@ -197,6 +216,12 @@ export class AIController {
// Handle chat messages
@Post("/chat")
@Summary("Send chat message to AI")
@Description("Send a message to the AI interviewer and receive a response. Supports both test and production modes.")
@(Returns(200, Object).Description("AI response message"))
@(Returns(400, Object).Description("Missing required fields"))
@(Returns(404, Object).Description("Interview link not found or expired"))
@(Returns(500, Object).Description("AI service unavailable"))
async handleChat(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { message, candidateName, job, linkId, conversationHistory } = body;
@@ -226,7 +251,7 @@ export class AIController {
console.log(`[DEBUG] Using frontend conversation history (test mode): ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
// Filter out any messages with undefined content
conversationHistoryToUse = conversationHistoryToUse.filter(msg =>
conversationHistoryToUse = conversationHistoryToUse.filter((msg: any) =>
msg && msg.message && msg.message !== 'undefined' && msg.sender
);
console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
@@ -275,6 +300,10 @@ export class AIController {
// Get conversation history
@Get("/conversation/:linkId")
@Summary("Get conversation history")
@Description("Retrieve the conversation history for a specific interview")
@(Returns(200, Object).Description("Conversation history messages"))
@(Returns(404, Object).Description("Interview link not found or expired"))
async getConversation(@PathParams("linkId") linkId: string) {
try {
const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -304,6 +333,10 @@ export class AIController {
// End interview
@Post("/end-interview/:linkId")
@Summary("End interview session")
@Description("End an active interview session and mark it as completed")
@(Returns(200, Object).Description("Interview ended successfully"))
@(Returns(404, Object).Description("Interview link or interview not found"))
async endInterview(@PathParams("linkId") linkId: string) {
try {
const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -246,4 +246,21 @@ export class AdminController {
await this.checkAdmin(req);
return await this.adminService.getPaymentById(id);
}
// Job Links Management
@Get("/jobs/:id/links")
@Summary("Get job links")
@(Returns(200).Description("Job links returned"))
async getJobLinks(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.getJobLinks(id);
}
@Post("/jobs/:id/create-link")
@Summary("Create job link for testing")
@(Returns(200).Description("Job link created"))
async createJobLink(@Req() req: any, @PathParams("id") id: string, @BodyParams() linkData: any) {
await this.checkAdmin(req);
return await this.adminService.createJobLink(id, linkData.tokensAvailable || 1);
}
}
+54
View File
@@ -773,4 +773,58 @@ export class AdminService {
connection.release();
}
}
// Job Links Management
async getJobLinks(jobId: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM job_links WHERE job_id = ? ORDER BY created_at DESC',
[jobId]
);
if (Array.isArray(rows)) {
return rows;
}
return [];
} catch (error) {
$log.error('Error getting job links:', error);
return [];
} finally {
connection.release();
}
}
async createJobLink(jobId: string, tokensAvailable: number = 1) {
const connection = await pool.getConnection();
try {
// Generate a random URL slug and UUID
const linkId = randomUUID();
const urlSlug = randomUUID().replace(/-/g, '').substring(0, 8);
await connection.execute(
'INSERT INTO job_links (id, job_id, url_slug, tokens_available, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
[linkId, jobId, urlSlug, tokensAvailable]
);
// Get the created link
const [rows] = await connection.execute(
'SELECT * FROM job_links WHERE id = ?',
[linkId]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
throw new Error('Failed to create job link');
} catch (error) {
$log.error('Error creating job link:', error);
throw error;
} finally {
connection.release();
}
}
}
+24
View File
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AISApp", "AISApp\AISApp.csproj", "{1671248C-43AD-2D25-4F0F-918991BEF94A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {14FC4F28-B89B-49C6-BD21-D4B1F80AE49C}
EndGlobalSection
EndGlobal
+3 -3
View File
@@ -82,7 +82,7 @@ services:
# Frontend Service
frontend:
build:
context: ./frontend/candidat-frontend
context: ./frontend
dockerfile: Dockerfile
image: candidat/frontend:${APP_VERSION:-latest}
container_name: candidat-frontend
@@ -93,7 +93,7 @@ services:
- "${FRONTEND_PORT:-3000}:3000"
volumes:
# Development hot reloading (only if NODE_ENV=development)
- ./frontend/candidat-frontend/src:/app/src:ro
- ./frontend/src:/app/src:ro
depends_on:
backend:
condition: service_healthy
@@ -116,7 +116,7 @@ services:
# Chatbot Service
chatbot:
build:
context: ./tuna/tuna
context: ./AISApp
dockerfile: Dockerfile
image: candidat/chatbot:${APP_VERSION:-latest}
container_name: candidat-chatbot
+1 -1
View File
@@ -29,7 +29,7 @@ A stunning, responsive frontend built with Next.js 15, TypeScript, and Tailwind
## 🏗️ **Project Structure**
```
frontend/candidat-frontend/
frontend/
├── src/
│ ├── app/
│ │ ├── page.tsx # Main landing page
+21
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import axios from "axios";
import AnimatedCounter from "@/components/AnimatedCounter";
import FeatureCard from "@/components/FeatureCard";
import PricingCard from "@/components/PricingCard";
@@ -17,10 +18,30 @@ export default function Home() {
// Check if user is already logged in
const token = localStorage.getItem("token");
if (token) {
// Check if user is admin - if so, allow them to stay on landing page
// Non-admin users will be redirected to dashboard
axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then(response => {
const userData = response.data;
if (userData.role !== 'admin') {
router.push("/dashboard");
} else {
setIsLoading(false);
}
})
.catch(() => {
// If token is invalid, remove it and stay on landing page
localStorage.removeItem("token");
localStorage.removeItem("user");
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
+42
View File
@@ -137,6 +137,42 @@ export default function JobManagement() {
setIsAddTokensModalOpen(true);
};
const handleTestInterview = async (job: Job) => {
try {
const token = localStorage.getItem("token");
// Get job links for this job
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/jobs/${job.id}/links`, {
headers: {
Authorization: `Bearer ${token}`
}
});
let linkId;
if (response.data && response.data.length > 0) {
// Use the first available link
linkId = response.data[0].url_slug;
} else {
// Create a test link if none exists
const createLinkResponse = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/jobs/${job.id}/create-link`, {
tokensAvailable: 1
}, {
headers: {
Authorization: `Bearer ${token}`
}
});
linkId = createLinkResponse.data.url_slug;
}
// Open interview page in test mode with the correct link ID
const testUrl = `/interview?id=${linkId}&test=true`;
window.open(testUrl, '_blank');
} catch (error) {
console.error("Failed to get job link for testing:", error);
alert("Failed to create test interview link. Please try again.");
}
};
const handleToggleJobStatus = async (job: Job) => {
try {
const token = localStorage.getItem("token");
@@ -392,6 +428,12 @@ export default function JobManagement() {
>
Add Tokens
</button>
<button
onClick={() => handleTestInterview(job)}
className="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 text-sm font-medium"
>
Test Interview
</button>
</div>
<button
onClick={() => handleToggleJobStatus(job)}
Submodule
+1
Submodule tuna added at bcd25503c5