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. 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 ### 1.3 Create Chatbot Service Dockerfile
- **Status**: âś… Completed - **Status**: âś… Completed
- **Description**: Create proper Dockerfile for the ASP.NET chatbot service - **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 - **Details**: Multi-stage build with proper .NET 9.0 runtime and configuration
## Phase 2: Backend Service Integration ## 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 ### 3.1 Add MySQL Database Support
- **Status**: âś… Completed - **Status**: âś… Completed
- **Description**: Replace SQLite with MySQL database connection in ASP.NET service - **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 - **Details**: Add MySQL connection string and update database operations
### 3.2 Add Interview Context Endpoints ### 3.2 Add Interview Context Endpoints
- **Status**: âś… Completed - **Status**: âś… Completed
- **Description**: Create endpoints for interview initialization and context management - **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 - **Details**: Add endpoints for interview start, status, and completion
### 3.3 Implement Conversation Sync ### 3.3 Implement Conversation Sync
- **Status**: âś… Completed - **Status**: âś… Completed
- **Description**: Sync conversation data between ASP.NET service and MySQL database - **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 - **Details**: Update conversation persistence to use MySQL instead of SQLite
### 3.4 Add Interview-Specific Prompts ### 3.4 Add Interview-Specific Prompts
- **Status**: âś… Completed - **Status**: âś… Completed
- **Description**: Modify system prompts based on job requirements and interview context - **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 - **Details**: Dynamic prompt generation based on job details and interview stage
## Phase 4: Database Integration ## Phase 4: Database Integration
+1 -1
View File
@@ -22,7 +22,7 @@ services:
chatbot: chatbot:
build: build:
context: ../../tuna/tuna context: ../../AISApp
dockerfile: Dockerfile dockerfile: Dockerfile
image: candidat/chatbot:latest image: candidat/chatbot:latest
container_name: backend-chatbot 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", version: process.env.APP_VERSION || "1.0.0",
description: description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" + "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: { contact: {
name: "Candivista Team", name: "Candivista Team",
url: "https://candivista.com", url: "https://candivista.com",
@@ -51,7 +56,7 @@ import {$log} from "@tsed/logger";
{ name: "Users", description: "User profile and token summary" }, { name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" }, { name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" }, { 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: { components: {
securitySchemes: { securitySchemes: {
+35 -2
View File
@@ -1,5 +1,5 @@
import { Controller } from "@tsed/di"; 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 { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http"; import { Req } from "@tsed/platform-http";
import { BadRequest, NotFound } from "@tsed/exceptions"; import { BadRequest, NotFound } from "@tsed/exceptions";
@@ -8,6 +8,7 @@ import { AIService } from "../../services/AIService.js";
import axios from "axios"; import axios from "axios";
@Controller("/ai") @Controller("/ai")
@Tags("AI")
export class AIController { export class AIController {
private jobService = new JobService(); private jobService = new JobService();
private aiService = new AIService(); private aiService = new AIService();
@@ -17,6 +18,9 @@ export class AIController {
// Test AI connection // Test AI connection
@Get("/test-ai") @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() { async testAI() {
try { try {
if (this.aiProvider === 'openrouter') { if (this.aiProvider === 'openrouter') {
@@ -60,6 +64,10 @@ export class AIController {
// Get mandatory questions for the job // Get mandatory questions for the job
@Get("/mandatory-questions/:linkId") @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) { async getMandatoryQuestions(@PathParams("linkId") linkId: string) {
try { try {
// Verify the job exists and link is valid // Verify the job exists and link is valid
@@ -83,6 +91,11 @@ export class AIController {
// Submit mandatory question answers // Submit mandatory question answers
@Post("/submit-mandatory-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) { async submitMandatoryAnswers(@BodyParams() body: any, @QueryParams() query: any) {
try { try {
const { candidateName, job, linkId, answers } = body; const { candidateName, job, linkId, answers } = body;
@@ -135,6 +148,12 @@ export class AIController {
// Start interview with AI agent (only after mandatory questions) // Start interview with AI agent (only after mandatory questions)
@Post("/start-interview") @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) { async startInterview(@BodyParams() body: any, @QueryParams() query: any) {
try { try {
const { candidateName, job, linkId } = body; const { candidateName, job, linkId } = body;
@@ -197,6 +216,12 @@ export class AIController {
// Handle chat messages // Handle chat messages
@Post("/chat") @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) { async handleChat(@BodyParams() body: any, @QueryParams() query: any) {
try { try {
const { message, candidateName, job, linkId, conversationHistory } = body; 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)}`); console.log(`[DEBUG] Using frontend conversation history (test mode): ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
// Filter out any messages with undefined content // Filter out any messages with undefined content
conversationHistoryToUse = conversationHistoryToUse.filter(msg => conversationHistoryToUse = conversationHistoryToUse.filter((msg: any) =>
msg && msg.message && msg.message !== 'undefined' && msg.sender msg && msg.message && msg.message !== 'undefined' && msg.sender
); );
console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`); console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
@@ -275,6 +300,10 @@ export class AIController {
// Get conversation history // Get conversation history
@Get("/conversation/:linkId") @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) { async getConversation(@PathParams("linkId") linkId: string) {
try { try {
const jobData = await this.jobService.getJobByLinkId(linkId); const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -304,6 +333,10 @@ export class AIController {
// End interview // End interview
@Post("/end-interview/:linkId") @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) { async endInterview(@PathParams("linkId") linkId: string) {
try { try {
const jobData = await this.jobService.getJobByLinkId(linkId); const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -246,4 +246,21 @@ export class AdminController {
await this.checkAdmin(req); await this.checkAdmin(req);
return await this.adminService.getPaymentById(id); 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(); 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 Service
frontend: frontend:
build: build:
context: ./frontend/candidat-frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
image: candidat/frontend:${APP_VERSION:-latest} image: candidat/frontend:${APP_VERSION:-latest}
container_name: candidat-frontend container_name: candidat-frontend
@@ -93,7 +93,7 @@ services:
- "${FRONTEND_PORT:-3000}:3000" - "${FRONTEND_PORT:-3000}:3000"
volumes: volumes:
# Development hot reloading (only if NODE_ENV=development) # Development hot reloading (only if NODE_ENV=development)
- ./frontend/candidat-frontend/src:/app/src:ro - ./frontend/src:/app/src:ro
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -116,7 +116,7 @@ services:
# Chatbot Service # Chatbot Service
chatbot: chatbot:
build: build:
context: ./tuna/tuna context: ./AISApp
dockerfile: Dockerfile dockerfile: Dockerfile
image: candidat/chatbot:${APP_VERSION:-latest} image: candidat/chatbot:${APP_VERSION:-latest}
container_name: candidat-chatbot 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** ## 🏗️ **Project Structure**
``` ```
frontend/candidat-frontend/ frontend/
├── src/ ├── src/
│ ├── app/ │ ├── app/
│ │ ├── page.tsx # Main landing page │ │ ├── page.tsx # Main landing page
+21
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import axios from "axios";
import AnimatedCounter from "@/components/AnimatedCounter"; import AnimatedCounter from "@/components/AnimatedCounter";
import FeatureCard from "@/components/FeatureCard"; import FeatureCard from "@/components/FeatureCard";
import PricingCard from "@/components/PricingCard"; import PricingCard from "@/components/PricingCard";
@@ -17,10 +18,30 @@ export default function Home() {
// Check if user is already logged in // Check if user is already logged in
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (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"); router.push("/dashboard");
} else { } else {
setIsLoading(false); 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]); }, [router]);
useEffect(() => { useEffect(() => {
+42
View File
@@ -137,6 +137,42 @@ export default function JobManagement() {
setIsAddTokensModalOpen(true); 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) => { const handleToggleJobStatus = async (job: Job) => {
try { try {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -392,6 +428,12 @@ export default function JobManagement() {
> >
Add Tokens Add Tokens
</button> </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> </div>
<button <button
onClick={() => handleToggleJobStatus(job)} onClick={() => handleToggleJobStatus(job)}
Submodule
+1
Submodule tuna added at bcd25503c5