Initial commit: Real-Time Interview Assistant with Authelia Authentication

🎯 Features implemented:
- Multi-language speech recognition (EN, FR, ES, DE)
- CV upload and parsing with regex-escaped skills extraction
- Authelia authentication integration for n8n webhook
- Complete n8n workflow for AI question generation
- Real-time language switching with enhanced UI
- Professional authentication modal with dual login options

🔧 Technical stack:
- Angular 18 with standalone components and signals
- TypeScript with strict typing and interfaces
- Authelia session-based authentication
- n8n workflow automation with OpenAI integration
- PDF.js for CV text extraction
- Web Speech API for voice recognition

🛠️ Infrastructure:
- Secure authentication flow with proper error handling
- Environment-based configuration for dev/prod
- Comprehensive documentation and workflow templates
- Clean project structure with proper git ignore rules

🔒 Security features:
- Cookie-based session management with CORS
- Protected n8n webhooks via Authelia
- Graceful fallback to local processing
- Secure redirect handling and session persistence

🚀 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Interview Assistant Developer 2025-09-17 15:16:13 +00:00
commit 326a4aaa29
67 changed files with 27316 additions and 0 deletions

17
.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

45
.eslintrc.json Normal file
View File

@ -0,0 +1,45 @@
{
"root": true,
"ignorePatterns": ["projects/**/*"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"project": ["tsconfig.json"],
"createDefaultProgram": true
},
"extends": [
"@angular-eslint/recommended",
"@angular-eslint/template/process-inline-templates",
"@typescript-eslint/recommended",
"prettier"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-explicit-any": "warn"
}
},
{
"files": ["*.html"],
"extends": ["@angular-eslint/template/recommended"],
"rules": {}
}
]
}

72
.gitignore vendored Normal file
View File

@ -0,0 +1,72 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
# Test files and temporary scripts (not for production)
test-*.js
test-*.html
test-*.ts
# Environment-specific files
.env
.env.local
.env.production
# Build artifacts
*.tsbuildinfo
# Log files
*.log
# Temporary files
temp/
tmp/
# n8n workflow files in root (keep docs/ versions)
/n8n-*.json
!docs/n8n-*.json
# Authentication test files
auth-test.*
# Development test files
/tests

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
}

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# InterviewAssistant
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

149
angular.json Normal file
View File

@ -0,0 +1,149 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"interview-assistant": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "interview-assistant:build:production"
},
"development": {
"buildTarget": "interview-assistant:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
},
"cypress-run": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "interview-assistant:serve"
},
"configurations": {
"production": {
"devServerTarget": "interview-assistant:serve:production"
}
}
},
"cypress-open": {
"builder": "@cypress/schematic:cypress",
"options": {
"watch": true,
"headless": false
}
},
"ct": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "interview-assistant:serve",
"watch": true,
"headless": false,
"testingType": "component"
},
"configurations": {
"development": {
"devServerTarget": "interview-assistant:serve:development"
}
}
},
"e2e": {
"builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "interview-assistant:serve",
"watch": true,
"headless": false
},
"configurations": {
"production": {
"devServerTarget": "interview-assistant:serve:production"
}
}
}
}
}
},
"cli": {
"schematicCollections": [
"@cypress/schematic",
"@schematics/angular"
]
}
}

18
cypress.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
'baseUrl': 'http://localhost:4200'
},
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
specPattern: '**/*.cy.ts'
}
})

6
cypress/e2e/spec.cy.ts Normal file
View File

@ -0,0 +1,6 @@
describe('My First Test', () => {
it('Visits the initial project page', () => {
cy.visit('/')
cy.contains('app is running')
})
})

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io"
}

View File

@ -0,0 +1,43 @@
// ***********************************************
// This example namespace declaration will help
// with Intellisense and code completion in your
// IDE or Text Editor.
// ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> {
// customCommand(param: any): typeof customCommand;
// }
// }
//
// function customCommand(param: any): void {
// console.warn(param);
// }
//
// NOTE: You can use it like so:
// Cypress.Commands.add('customCommand', customCommand);
//
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@ -0,0 +1,39 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/angular'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(MyComponent)

17
cypress/support/e2e.ts Normal file
View File

@ -0,0 +1,17 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax
// import './commands';

8
cypress/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["**/*.ts"],
"compilerOptions": {
"sourceMap": false,
"types": ["cypress"]
}
}

114
docs/README.md Normal file
View File

@ -0,0 +1,114 @@
# Interview Assistant - Documentation
## n8n Workflow Integration
### Overview
This directory contains n8n workflow templates for integrating AI-powered question generation with the Interview Assistant application.
### Files
#### `n8n-cv-analysis-workflow.json`
Complete n8n workflow for CV analysis and interview question generation.
**Features:**
- Receives CV data via webhook from Angular app
- Processes CV content for key information extraction
- Generates personalized interview questions using OpenAI/Claude
- Returns structured question bank with categories and difficulty levels
- Includes error handling and fallback questions
**Setup Instructions:**
1. **Import to n8n:**
- Open your n8n instance
- Go to "Import from File" or use Ctrl+I
- Select `n8n-cv-analysis-workflow.json`
2. **Configure OpenAI API:**
- Add your OpenAI API key to the "Generate Questions (OpenAI)" node
- Or replace with Claude/other AI provider if preferred
3. **Test the Webhook:**
- Copy the webhook URL from the imported workflow
- Update Angular environment files if URL differs
4. **Authentication:**
- Ensure webhook is protected by Authelia (as configured)
- Test authentication flow with Angular app
### API Reference
#### Webhook Endpoint
- **URL:** `https://n8n.gm-tech.org/webhook/cv-analysis`
- **Method:** POST
- **Authentication:** Authelia session-based
- **Content-Type:** application/json
#### Request Format
```json
{
"analysisId": "uuid-string",
"cvProfile": {
"personalInfo": {
"fullName": "string",
"email": "string"
},
"skills": [
{
"name": "string",
"category": "technical|soft|language",
"yearsOfExperience": number
}
],
"experience": [...],
"education": [...],
"parsedText": "full CV content as string"
}
}
```
#### Response Format
```json
{
"status": "completed|failed|processing",
"analysisId": "uuid-string",
"questionBankId": "generated-id",
"questionsGenerated": number,
"candidateName": "string",
"questions": [
{
"id": number,
"question": "string",
"category": "technical|behavioral|scenario",
"difficulty": "easy|medium|hard",
"expectedSkills": ["skill1", "skill2"],
"reasoning": "why this question is relevant"
}
],
"metadata": {
"skillsAnalyzed": number,
"experienceYears": number,
"processingTime": "ISO timestamp"
}
}
```
### Security Considerations
- **Authelia Protection:** All webhooks require valid authentication
- **CORS Configuration:** Properly configured for Angular app origin
- **Data Sanitization:** CV data is processed securely
- **No Data Persistence:** Workflow processes data in memory only
- **Error Handling:** Graceful fallbacks prevent data exposure
### Troubleshooting
**Common Issues:**
1. **401 Authentication Error:** Ensure user is logged into Authelia
2. **Workflow Not Found:** Check if workflow is imported and activated
3. **OpenAI API Errors:** Verify API key configuration and rate limits
4. **Webhook Timeout:** Check n8n server performance and AI response times
**Debug Mode:**
- Enable debug logging in n8n workflow nodes
- Check Angular console for detailed error messages
- Use test scripts provided in project root for debugging

View File

@ -0,0 +1,189 @@
{
"name": "CV Interview Assistant",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "cv-analysis",
"responseMode": "responseNode",
"options": {}
},
"id": "f8b4c5d6-7890-1234-5678-90abcdef1234",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1.1,
"position": [240, 300],
"webhookId": "cv-analysis-webhook"
},
{
"parameters": {
"jsCode": "// Extract and structure CV data for AI processing\nconst input = $input.first().json;\nconst cvProfile = input.cvProfile || {};\nconst personalInfo = cvProfile.personalInfo || {};\nconst skills = cvProfile.skills || [];\nconst experience = cvProfile.experience || [];\nconst education = cvProfile.education || [];\n\n// Extract key information\nconst candidateName = personalInfo.fullName || 'Candidate';\nconst skillNames = skills.map(s => s.name).filter(Boolean);\nconst technicalSkills = skills.filter(s => s.category === 'technical').map(s => s.name);\nconst yearsExperience = Math.max(...skills.map(s => s.yearsOfExperience || 0), 0);\nconst totalPositions = experience.length;\nconst educationLevel = education.length > 0 ? education[0].degree : 'Not specified';\n\n// Create structured prompt data\nconst promptData = {\n analysisId: input.analysisId,\n candidateName: candidateName,\n email: personalInfo.email || '',\n skills: skillNames,\n technicalSkills: technicalSkills,\n yearsExperience: yearsExperience,\n totalPositions: totalPositions,\n educationLevel: educationLevel,\n fullCvText: cvProfile.parsedText || '',\n experienceSummary: experience.map(exp => `${exp.jobTitle} at ${exp.company} (${exp.duration})`).join('; '),\n skillsSummary: skills.map(s => `${s.name}${s.yearsOfExperience ? ` (${s.yearsOfExperience} years)` : ''}`).join(', ')\n};\n\nreturn [{ json: promptData }];"
},
"id": "a1b2c3d4-5678-9012-3456-789abcdef012",
"name": "Extract CV Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [460, 300]
},
{
"parameters": {
"resource": "chat",
"operation": "create",
"model": "gpt-4",
"messages": {
"values": [
{
"role": "system",
"content": "You are an expert interview question generator. Create relevant, insightful interview questions based on the candidate's CV profile."
},
{
"role": "user",
"content": "Generate 10 diverse interview questions for this candidate profile:\n\n**Candidate:** {{$json.candidateName}}\n**Email:** {{$json.email}}\n**Experience:** {{$json.yearsExperience}} years total, {{$json.totalPositions}} positions\n**Education:** {{$json.educationLevel}}\n**Technical Skills:** {{$json.technicalSkills.join(', ')}}\n**All Skills:** {{$json.skillsSummary}}\n**Experience Summary:** {{$json.experienceSummary}}\n\n**Full CV Content:**\n{{$json.fullCvText}}\n\n---\n\nGenerate exactly 10 questions covering:\n- 4 Technical questions (specific to their skills/experience)\n- 3 Behavioral questions (leadership, teamwork, problem-solving)\n- 2 Scenario-based questions (hypothetical situations)\n- 1 Career/Growth question\n\nReturn ONLY a valid JSON array with this exact format:\n```json\n[\n {\n \"id\": 1,\n \"question\": \"Detailed question text here\",\n \"category\": \"technical\",\n \"difficulty\": \"medium\",\n \"expectedSkills\": [\"JavaScript\", \"Problem Solving\"],\n \"reasoning\": \"Why this question is relevant to the candidate\"\n }\n]\n```\n\nEnsure questions are:\n- Specific to the candidate's actual experience\n- Appropriate difficulty level\n- Clear and actionable\n- Professional and respectful"
}
]
},
"options": {
"temperature": 0.7,
"maxTokens": 2000
}
},
"id": "b2c3d4e5-6789-0123-4567-89abcdef0123",
"name": "Generate Questions (OpenAI)",
"type": "n8n-nodes-base.openAi",
"typeVersion": 1.3,
"position": [680, 300]
},
{
"parameters": {
"jsCode": "// Parse and validate AI response\nconst aiResponse = $input.first().json.choices[0].message.content;\nconst extractData = $('Extract CV Data').first().json;\n\ntry {\n // Extract JSON from AI response (handle markdown code blocks)\n let jsonStr = aiResponse;\n if (aiResponse.includes('```json')) {\n const start = aiResponse.indexOf('```json') + 7;\n const end = aiResponse.lastIndexOf('```');\n jsonStr = aiResponse.substring(start, end).trim();\n }\n \n const questions = JSON.parse(jsonStr);\n \n // Validate and enhance questions\n const validatedQuestions = questions.map((q, index) => ({\n id: q.id || (index + 1),\n question: q.question || 'Question not generated',\n category: q.category || 'general',\n difficulty: q.difficulty || 'medium',\n expectedSkills: Array.isArray(q.expectedSkills) ? q.expectedSkills : [],\n reasoning: q.reasoning || 'Standard interview question',\n generatedAt: new Date().toISOString()\n }));\n \n // Generate question bank ID\n const questionBankId = `qb_${extractData.analysisId}_${Date.now()}`;\n \n return [{\n json: {\n status: 'completed',\n analysisId: extractData.analysisId,\n questionBankId: questionBankId,\n questionsGenerated: validatedQuestions.length,\n candidateName: extractData.candidateName,\n questions: validatedQuestions,\n metadata: {\n skillsAnalyzed: extractData.skills.length,\n experienceYears: extractData.yearsExperience,\n processingTime: new Date().toISOString()\n }\n }\n }];\n \n} catch (error) {\n // Handle parsing errors gracefully\n return [{\n json: {\n status: 'failed',\n analysisId: extractData.analysisId,\n error: 'Failed to generate questions',\n errorDetails: error.message,\n fallbackQuestions: [\n {\n id: 1,\n question: `Tell me about your experience with ${extractData.technicalSkills[0] || 'your main technology stack'}.`,\n category: 'technical',\n difficulty: 'medium',\n expectedSkills: extractData.technicalSkills.slice(0, 2),\n reasoning: 'Fallback technical question'\n },\n {\n id: 2,\n question: 'Describe a challenging project you worked on and how you overcame obstacles.',\n category: 'behavioral',\n difficulty: 'medium',\n expectedSkills: ['Problem Solving', 'Communication'],\n reasoning: 'Fallback behavioral question'\n }\n ]\n }\n }];\n}"
},
"id": "c3d4e5f6-7890-1234-5678-9abcdef01234",
"name": "Process AI Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 300]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{$json}}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Access-Control-Allow-Origin",
"value": "*"
},
{
"name": "Access-Control-Allow-Methods",
"value": "POST, OPTIONS"
},
{
"name": "Access-Control-Allow-Headers",
"value": "Content-Type, Authorization"
}
]
}
}
},
"id": "d4e5f6g7-8901-2345-6789-abcdef012345",
"name": "Return Response",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1120, 300]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "error-condition",
"leftValue": "={{$json.status}}",
"rightValue": "failed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "e5f6g7h8-9012-3456-7890-bcdef0123456",
"name": "Check for Errors",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1120, 480]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Extract CV Data",
"type": "main",
"index": 0
}
]
]
},
"Extract CV Data": {
"main": [
[
{
"node": "Generate Questions (OpenAI)",
"type": "main",
"index": 0
}
]
]
},
"Generate Questions (OpenAI)": {
"main": [
[
{
"node": "Process AI Response",
"type": "main",
"index": 0
}
]
]
},
"Process AI Response": {
"main": [
[
{
"node": "Return Response",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"id": "interview-assistant",
"name": "Interview Assistant"
}
],
"triggerCount": 1,
"updatedAt": "2025-09-16T22:00:00.000Z",
"versionId": "1"
}

60
karma.conf.js Normal file
View File

@ -0,0 +1,60 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution order
random: true
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/interview-assistant'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' },
{ type: 'lcov' }
],
check: {
global: {
statements: 80,
branches: 80,
functions: 80,
lines: 80
}
}
},
reporters: ['progress', 'kjhtml', 'coverage'],
browsers: ['Chrome'],
restartOnFileChange: true,
customLaunchers: {
ChromeHeadless: {
base: 'Chrome',
flags: [
'--no-sandbox',
'--disable-web-security',
'--disable-gpu',
'--remote-debugging-port=9222',
'--headless'
]
}
}
});
};

13455
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View File

@ -0,0 +1,65 @@
{
"name": "interview-assistant",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"e2e": "ng e2e",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"dexie": "^4.2.0",
"pdfjs-dist": "^5.4.149",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-eslint/eslint-plugin": "^20.3.0",
"@angular-eslint/eslint-plugin-template": "^20.3.0",
"@angular-eslint/template-parser": "^20.3.0",
"@angular/build": "^20.3.1",
"@angular/cli": "^20.3.1",
"@angular/compiler-cli": "^20.3.0",
"@cypress/schematic": "^4.1.2",
"@types/dom-speech-recognition": "^0.0.6",
"@types/jasmine": "~5.1.0",
"@typescript-eslint/eslint-plugin": "^8.44.0",
"@typescript-eslint/parser": "^8.44.0",
"cypress": "^15.2.0",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.6.2",
"typescript": "~5.9.2"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

14
src/app/app.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi())
]
};

45
src/app/app.html Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<div class="app-container">
<!-- Navigation Header -->
<header class="app-header">
<div class="header-content">
<h1 class="app-title">🎤 {{ title() }}</h1>
<nav class="app-nav">
<a routerLink="/upload"
routerLinkActive="active"
class="nav-link">
📄 CV Upload
</a>
<a routerLink="/interview"
routerLinkActive="active"
class="nav-link">
🎙️ Interview Session
</a>
</nav>
<div class="header-actions">
<button class="btn btn-outline" (click)="navigateToUpload()">
📁 New Session
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="app-main">
<router-outlet></router-outlet>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="footer-content">
<p>&copy; 2024 Interview Assistant - AI-Powered Interview Preparation</p>
<div class="footer-links">
<a href="#" class="footer-link">Privacy</a>
<a href="#" class="footer-link">Terms</a>
<a href="#" class="footer-link">Help</a>
</div>
</div>
</footer>
</div>

10
src/app/app.routes.ts Normal file
View File

@ -0,0 +1,10 @@
import { Routes } from '@angular/router';
import { CVUploadComponent } from './components/cv-upload/cv-upload.component';
import { InterviewSessionComponent } from './components/interview-session/interview-session.component';
export const routes: Routes = [
{ path: '', redirectTo: '/upload', pathMatch: 'full' },
{ path: 'upload', component: CVUploadComponent },
{ path: 'interview', component: InterviewSessionComponent },
{ path: '**', redirectTo: '/upload' }
];

315
src/app/app.scss Normal file
View File

@ -0,0 +1,315 @@
// Global styles for the Interview Assistant application
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8f9fa;
}
// Header styles
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
.app-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
white-space: nowrap;
}
.app-nav {
display: flex;
gap: 1.5rem;
.nav-link {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
white-space: nowrap;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
&.active {
background: rgba(255, 255, 255, 0.2);
color: white;
font-weight: 600;
}
}
}
.header-actions {
.btn {
padding: 0.5rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: white;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
white-space: nowrap;
&.btn-outline {
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
}
}
}
}
}
// Main content area
.app-main {
flex: 1;
padding: 2rem 0;
background-color: #f8f9fa;
min-height: calc(100vh - 120px); // Account for header and footer
}
// Footer styles
.app-footer {
background: #343a40;
color: #dee2e6;
padding: 2rem 0;
margin-top: auto;
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
p {
margin: 0;
font-size: 0.9rem;
}
.footer-links {
display: flex;
gap: 1.5rem;
.footer-link {
color: #adb5bd;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s ease;
&:hover {
color: #dee2e6;
}
}
}
}
}
// Responsive design
@media (max-width: 768px) {
.app-header .header-content {
flex-direction: column;
gap: 1rem;
padding: 1rem;
.app-nav {
order: 2;
width: 100%;
justify-content: center;
}
.header-actions {
order: 3;
width: 100%;
text-align: center;
}
}
.app-main {
padding: 1rem 0;
}
.app-footer .footer-content {
flex-direction: column;
gap: 1rem;
text-align: center;
padding: 0 1rem;
}
}
@media (max-width: 480px) {
.app-header .header-content {
.app-title {
font-size: 1.25rem;
}
.app-nav {
flex-direction: column;
gap: 0.5rem;
.nav-link {
text-align: center;
padding: 0.75rem;
}
}
}
.app-footer .footer-content .footer-links {
flex-direction: column;
gap: 0.75rem;
}
}
// Global utility classes
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
text-align: center;
text-decoration: none;
transition: all 0.3s ease;
line-height: 1.5;
&.btn-primary {
background-color: #007bff;
color: white;
&:hover {
background-color: #0056b3;
transform: translateY(-1px);
}
}
&.btn-success {
background-color: #28a745;
color: white;
&:hover {
background-color: #1e7e34;
transform: translateY(-1px);
}
}
&.btn-danger {
background-color: #dc3545;
color: white;
&:hover {
background-color: #c82333;
transform: translateY(-1px);
}
}
&.btn-secondary {
background-color: #6c757d;
color: white;
&:hover {
background-color: #545b62;
transform: translateY(-1px);
}
}
&.btn-outline {
background-color: transparent;
border: 1px solid #6c757d;
color: #6c757d;
&:hover {
background-color: #6c757d;
color: white;
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
}
// Container utility
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
// Text utilities
.text-center {
text-align: center;
}
.text-muted {
color: #6c757d;
}
// Spacing utilities
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 1rem; }
.mt-4 { margin-top: 1.5rem; }
.mt-5 { margin-top: 3rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.mb-4 { margin-bottom: 1.5rem; }
.mb-5 { margin-bottom: 3rem; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 1rem; }
.p-4 { padding: 1.5rem; }
.p-5 { padding: 3rem; }
// Animation utilities
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
.slide-up {
animation: slideUp 0.3s ease-out;
}

23
src/app/app.spec.ts Normal file
View File

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, interview-assistant');
});
});

24
src/app/app.ts Normal file
View File

@ -0,0 +1,24 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink, RouterLinkActive, Router } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
protected readonly title = signal('Interview Assistant');
constructor(private router: Router) {}
navigateToUpload() {
this.router.navigate(['/upload']);
}
navigateToInterview() {
this.router.navigate(['/interview']);
}
}

View File

@ -0,0 +1,350 @@
import { Component, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AutheliaAuthService } from '../../services/authelia-auth.service';
import { environment } from '../../../environments/environment';
@Component({
selector: 'app-auth-login',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="auth-login-container" [class.hidden]="!showLogin()">
<div class="auth-login-modal">
<div class="auth-header">
<h3>🔐 Authentication Required</h3>
<p>Please login to access n8n services</p>
</div>
<form (ngSubmit)="onLogin()" class="auth-form" [class.loading]="isLoading()">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
type="text"
[(ngModel)]="username"
name="username"
required
placeholder="Enter your username"
[disabled]="isLoading()"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
[(ngModel)]="password"
name="password"
required
placeholder="Enter your password"
[disabled]="isLoading()"
/>
</div>
@if (errorMessage()) {
<div class="error-message">
{{ errorMessage() }}
</div>
}
<div class="form-actions">
<button
type="submit"
[disabled]="!username || !password || isLoading()"
class="login-btn"
>
@if (isLoading()) {
<span class="spinner"></span> Authenticating...
} @else {
🔑 Login
}
</button>
<button
type="button"
(click)="redirectToAuthelia()"
class="redirect-btn"
[disabled]="isLoading()"
>
🌐 Login via Authelia Portal
</button>
<button
type="button"
(click)="onCancel()"
class="cancel-btn"
[disabled]="isLoading()"
>
Cancel
</button>
</div>
</form>
<div class="auth-info">
<p><strong>Authelia Portal:</strong> {{ authBaseUrl }}</p>
<p><small>You can also login directly through the Authelia web interface</small></p>
</div>
</div>
</div>
`,
styles: [`
.auth-login-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.auth-login-container.hidden {
display: none;
}
.auth-login-modal {
background: var(--background-color, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 24px;
min-width: 400px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.auth-header {
text-align: center;
margin-bottom: 24px;
}
.auth-header h3 {
color: var(--text-color, #ffffff);
margin: 0 0 8px 0;
font-size: 1.25rem;
}
.auth-header p {
color: var(--text-secondary, #cccccc);
margin: 0;
font-size: 0.9rem;
}
.auth-form {
margin-bottom: 20px;
}
.auth-form.loading {
opacity: 0.7;
pointer-events: none;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
color: var(--text-color, #ffffff);
margin-bottom: 6px;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--input-background, #2a2a2a);
color: var(--text-color, #ffffff);
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-color, #4CAF50);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: rgba(244, 67, 54, 0.1);
border: 1px solid #f44336;
color: #f44336;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.form-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-actions button {
padding: 12px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.login-btn {
background: var(--accent-color, #4CAF50);
color: white;
}
.login-btn:hover:not(:disabled) {
background: var(--accent-hover, #45a049);
transform: translateY(-1px);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.redirect-btn {
background: var(--secondary-color, #2196F3);
color: white;
}
.redirect-btn:hover:not(:disabled) {
background: var(--secondary-hover, #1976D2);
transform: translateY(-1px);
}
.cancel-btn {
background: var(--danger-color, #f44336);
color: white;
}
.cancel-btn:hover:not(:disabled) {
background: var(--danger-hover, #d32f2f);
transform: translateY(-1px);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-info {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 12px;
font-size: 0.85rem;
}
.auth-info p {
margin: 4px 0;
color: var(--text-secondary, #cccccc);
}
.auth-info strong {
color: var(--text-color, #ffffff);
}
`]
})
export class AuthLoginComponent {
@Output() loginSuccess = new EventEmitter<void>();
@Output() loginCancelled = new EventEmitter<void>();
public username = '';
public password = '';
public showLogin = signal(false);
public isLoading = signal(false);
public errorMessage = signal('');
public authBaseUrl = environment.authelia?.baseUrl || 'https://auth.gm-tech.org';
constructor(private autheliaAuth: AutheliaAuthService) {}
public show(): void {
this.showLogin.set(true);
this.clearForm();
}
public hide(): void {
this.showLogin.set(false);
this.clearForm();
}
public onLogin(): void {
if (!this.username || !this.password) {
this.errorMessage.set('Please enter both username and password');
return;
}
this.isLoading.set(true);
this.errorMessage.set('');
console.log('🔐 Attempting login with Authelia...');
this.autheliaAuth.login(this.username, this.password, window.location.origin).subscribe({
next: (response) => {
this.isLoading.set(false);
if (response.status === 'OK') {
console.log('✅ Login successful');
this.hide();
this.loginSuccess.emit();
} else {
console.log('❌ Login failed:', response.message);
this.errorMessage.set(response.message || 'Login failed');
}
},
error: (error) => {
this.isLoading.set(false);
console.error('❌ Login error:', error);
this.errorMessage.set('Login failed. Please check your credentials.');
}
});
}
public redirectToAuthelia(): void {
console.log('🌐 Redirecting to Authelia portal...');
// Redirect back to the Angular app, not to the webhook URL
this.autheliaAuth.redirectToLogin(window.location.origin);
}
public onCancel(): void {
this.hide();
this.loginCancelled.emit();
}
private clearForm(): void {
this.username = '';
this.password = '';
this.errorMessage.set('');
this.isLoading.set(false);
}
}

View File

@ -0,0 +1,469 @@
.comment-session-container {
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
margin-bottom: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
border-radius: 8px 8px 0 0;
h3 {
margin: 0;
color: #495057;
font-weight: 600;
font-size: 1.1rem;
}
.btn {
padding: 0.375rem 0.75rem;
border: 1px solid #6c757d;
background: transparent;
color: #6c757d;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s ease;
&.btn-outline {
&:hover {
background: #6c757d;
color: white;
}
}
&.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
}
}
.comment-content {
padding: 1.5rem;
}
.quick-comment-section {
margin-bottom: 2rem;
.comment-input-group {
margin-bottom: 1rem;
.comment-input {
width: 100%;
padding: 1rem;
border: 1px solid #ced4da;
border-radius: 6px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
transition: border-color 0.3s ease;
&:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
}
.comment-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-top: 1rem;
.comment-type-selector {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
.type-option {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
padding: 0.375rem 0.75rem;
border: 1px solid #dee2e6;
border-radius: 20px;
font-size: 0.85rem;
transition: all 0.3s ease;
background: white;
input[type="radio"] {
display: none;
}
&.selected {
background: #007bff;
color: white;
border-color: #007bff;
}
&:hover:not(.selected) {
border-color: #007bff;
background: #f0f8ff;
}
}
}
.comment-actions {
display: flex;
gap: 0.5rem;
.btn-primary {
background: #007bff;
color: white;
border: none;
&:hover:not(:disabled) {
background: #0056b3;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
}
.tag-input-section {
.tag-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9rem;
transition: border-color 0.3s ease;
&:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}
}
}
}
.comments-history {
margin-bottom: 2rem;
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h4 {
margin: 0;
color: #495057;
font-weight: 600;
}
.history-filters {
display: flex;
gap: 0.5rem;
align-items: center;
.filter-select {
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.85rem;
background: white;
&:focus {
outline: none;
border-color: #007bff;
}
}
}
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
.comment-item {
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 1rem;
background: white;
transition: all 0.3s ease;
&.type-note {
border-left: 4px solid #6c757d;
}
&.type-question {
border-left: 4px solid #ffc107;
}
&.type-observation {
border-left: 4px solid #17a2b8;
}
&.type-improvement {
border-left: 4px solid #28a745;
}
.comment-header-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
.comment-meta {
display: flex;
gap: 0.75rem;
align-items: center;
.comment-type-badge {
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
&.badge-note {
background: #e9ecef;
color: #495057;
}
&.badge-question {
background: #fff3cd;
color: #856404;
}
&.badge-observation {
background: #d1ecf1;
color: #0c5460;
}
&.badge-improvement {
background: #d4edda;
color: #155724;
}
}
.comment-time {
color: #6c757d;
font-size: 0.8rem;
font-family: 'Courier New', monospace;
}
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
color: #6c757d;
transition: color 0.3s ease;
&:hover {
color: #dc3545;
}
}
}
.comment-content-item {
color: #212529;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.comment-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
.tag {
background: #f8f9fa;
color: #495057;
padding: 0.2rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
border: 1px solid #dee2e6;
}
}
}
}
}
.quick-actions-section {
margin-bottom: 2rem;
h4 {
margin: 0 0 1rem 0;
color: #495057;
font-weight: 600;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
.quick-action-btn {
padding: 0.75rem;
border: 1px solid #dee2e6;
background: #f8f9fa;
color: #495057;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
text-align: left;
transition: all 0.3s ease;
&:hover {
border-color: #007bff;
background: #e7f3ff;
color: #007bff;
}
}
}
}
.export-section {
padding-top: 1.5rem;
border-top: 1px solid #e9ecef;
h4 {
margin: 0 0 1rem 0;
color: #495057;
font-weight: 600;
}
.export-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
.btn {
padding: 0.5rem 1rem;
border: 1px solid #6c757d;
background: transparent;
color: #6c757d;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
&.btn-outline {
&:hover {
background: #6c757d;
color: white;
}
}
}
}
}
.minimized-summary {
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
.summary-text {
color: #6c757d;
font-size: 0.9rem;
}
.summary-types {
display: flex;
gap: 0.25rem;
.type-mini-badge {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
&.badge-note {
background: #e9ecef;
}
&.badge-question {
background: #fff3cd;
}
&.badge-observation {
background: #d1ecf1;
}
&.badge-improvement {
background: #d4edda;
}
}
}
}
@media (max-width: 768px) {
.comment-header {
padding: 0.75rem 1rem;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.comment-content {
padding: 1rem;
}
.comment-controls {
flex-direction: column !important;
align-items: stretch !important;
.comment-type-selector {
justify-content: center;
}
.comment-actions {
justify-content: center;
}
}
.history-header {
flex-direction: column !important;
gap: 0.75rem;
align-items: stretch !important;
.history-filters {
justify-content: center;
}
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
.export-actions {
flex-direction: column;
.btn {
width: 100%;
}
}
.minimized-summary {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
}

View File

@ -0,0 +1,349 @@
import { Component, Output, EventEmitter, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
interface Comment {
id: string;
content: string;
timestamp: Date;
type: 'note' | 'question' | 'observation' | 'improvement';
tags: string[];
}
@Component({
selector: 'app-comment-session',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="comment-session-container">
<div class="comment-header">
<h3>📝 Session Notes & Comments</h3>
<button class="btn btn-sm btn-outline"
(click)="toggleExpanded()">
{{ isExpanded() ? ' Collapse' : ' Expand' }}
</button>
</div>
@if (isExpanded()) {
<div class="comment-content">
<!-- Quick Add Comment -->
<div class="quick-comment-section">
<div class="comment-input-group">
<textarea
class="comment-input"
placeholder="Add a quick note, observation, or improvement suggestion..."
[(ngModel)]="currentComment"
(keydown.ctrl.enter)="addComment()"
(keydown.cmd.enter)="addComment()"
rows="2">
</textarea>
<div class="comment-controls">
<div class="comment-type-selector">
<label class="type-option" [class.selected]="selectedType() === 'note'">
<input type="radio"
name="commentType"
value="note"
[(ngModel)]="selectedType">
📝 Note
</label>
<label class="type-option" [class.selected]="selectedType() === 'question'">
<input type="radio"
name="commentType"
value="question"
[(ngModel)]="selectedType">
Question
</label>
<label class="type-option" [class.selected]="selectedType() === 'observation'">
<input type="radio"
name="commentType"
value="observation"
[(ngModel)]="selectedType">
👁 Observation
</label>
<label class="type-option" [class.selected]="selectedType() === 'improvement'">
<input type="radio"
name="commentType"
value="improvement"
[(ngModel)]="selectedType">
💡 Improvement
</label>
</div>
<div class="comment-actions">
<button class="btn btn-sm btn-primary"
(click)="addComment()"
[disabled]="!currentComment.trim()">
Add Comment
</button>
<button class="btn btn-sm btn-outline"
(click)="clearComment()">
🗑 Clear
</button>
</div>
</div>
</div>
<!-- Tag Input -->
<div class="tag-input-section">
<input type="text"
class="tag-input"
placeholder="Add tags (comma-separated): e.g., technical, follow-up, important"
[(ngModel)]="currentTags"
(keydown.enter)="addComment()">
</div>
</div>
<!-- Comments History -->
@if (comments().length > 0) {
<div class="comments-history">
<div class="history-header">
<h4>💬 Session Comments ({{ comments().length }})</h4>
<div class="history-filters">
<select class="filter-select" [(ngModel)]="filterType" (change)="applyFilter()">
<option value="all">All Types</option>
<option value="note">Notes</option>
<option value="question">Questions</option>
<option value="observation">Observations</option>
<option value="improvement">Improvements</option>
</select>
<button class="btn btn-sm btn-outline" (click)="clearAllComments()">
🗑 Clear All
</button>
</div>
</div>
<div class="comments-list">
@for (comment of filteredComments(); track comment.id) {
<div class="comment-item" [class]="'type-' + comment.type">
<div class="comment-header-item">
<div class="comment-meta">
<span class="comment-type-badge" [class]="'badge-' + comment.type">
{{ getTypeIcon(comment.type) }} {{ comment.type }}
</span>
<span class="comment-time">
{{ formatTime(comment.timestamp) }}
</span>
</div>
<button class="btn-icon" (click)="deleteComment(comment.id)">
</button>
</div>
<div class="comment-content-item">
{{ comment.content }}
</div>
@if (comment.tags.length > 0) {
<div class="comment-tags">
@for (tag of comment.tags; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
}
</div>
}
</div>
</div>
}
<!-- Quick Actions -->
<div class="quick-actions-section">
<h4> Quick Actions</h4>
<div class="quick-actions-grid">
<button class="quick-action-btn" (click)="addPredefinedComment('Interviewer seems satisfied with answer')">
😊 Good Response
</button>
<button class="quick-action-btn" (click)="addPredefinedComment('Need to elaborate more on this topic')">
📚 Need More Detail
</button>
<button class="quick-action-btn" (click)="addPredefinedComment('Question caught me off guard')">
😅 Unexpected Q
</button>
<button class="quick-action-btn" (click)="addPredefinedComment('Should prepare better answer for this')">
🎯 Prepare Better
</button>
<button class="quick-action-btn" (click)="addPredefinedComment('Technical question - good to research more')">
🔬 Research Topic
</button>
<button class="quick-action-btn" (click)="addPredefinedComment('Interviewer showed interest in this area')">
Key Interest
</button>
</div>
</div>
<!-- Export Options -->
<div class="export-section">
<h4>📤 Export Comments</h4>
<div class="export-actions">
<button class="btn btn-outline" (click)="exportAsText()">
📄 Export as Text
</button>
<button class="btn btn-outline" (click)="exportAsJSON()">
📋 Export as JSON
</button>
<button class="btn btn-outline" (click)="copyToClipboard()">
📋 Copy to Clipboard
</button>
</div>
</div>
</div>
}
<!-- Minimized View -->
@if (!isExpanded() && comments().length > 0) {
<div class="minimized-summary">
<span class="summary-text">
{{ comments().length }} comments added this session
</span>
<div class="summary-types">
@for (type of getActiveTypes(); track type) {
<span class="type-mini-badge" [class]="'badge-' + type">
{{ getTypeIcon(type) }}
</span>
}
</div>
</div>
}
</div>
`,
styleUrl: './comment-session.component.scss'
})
export class CommentSessionComponent {
@Output() commentAdded = new EventEmitter<string>();
// Component state
isExpanded = signal(false);
currentComment = '';
currentTags = '';
selectedType = signal<'note' | 'question' | 'observation' | 'improvement'>('note');
comments = signal<Comment[]>([]);
filteredComments = signal<Comment[]>([]);
filterType = 'all';
toggleExpanded() {
this.isExpanded.update(expanded => !expanded);
}
addComment() {
const content = this.currentComment.trim();
if (!content) return;
const tags = this.currentTags
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
const newComment: Comment = {
id: `comment-${Date.now()}`,
content: content,
timestamp: new Date(),
type: this.selectedType(),
tags: tags
};
this.comments.update(comments => [...comments, newComment]);
this.applyFilter();
// Emit the comment content
this.commentAdded.emit(content);
// Clear inputs
this.clearComment();
}
addPredefinedComment(content: string) {
this.currentComment = content;
this.selectedType.set('observation');
this.addComment();
}
clearComment() {
this.currentComment = '';
this.currentTags = '';
}
deleteComment(commentId: string) {
this.comments.update(comments =>
comments.filter(comment => comment.id !== commentId)
);
this.applyFilter();
}
clearAllComments() {
if (confirm('Are you sure you want to clear all comments?')) {
this.comments.set([]);
this.filteredComments.set([]);
}
}
applyFilter() {
const allComments = this.comments();
if (this.filterType === 'all') {
this.filteredComments.set(allComments);
} else {
this.filteredComments.set(
allComments.filter(comment => comment.type === this.filterType)
);
}
}
getTypeIcon(type: string): string {
switch (type) {
case 'note': return '📝';
case 'question': return '❓';
case 'observation': return '👁️';
case 'improvement': return '💡';
default: return '📝';
}
}
getActiveTypes(): string[] {
const types = new Set(this.comments().map(c => c.type));
return Array.from(types);
}
formatTime(timestamp: Date): string {
return timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
}
exportAsText() {
const text = this.comments()
.map(comment => {
const tags = comment.tags.length > 0 ? ` [${comment.tags.join(', ')}]` : '';
return `[${comment.type.toUpperCase()}] ${this.formatTime(comment.timestamp)} - ${comment.content}${tags}`;
})
.join('\n\n');
this.downloadText(text, 'interview-comments.txt');
}
exportAsJSON() {
const json = JSON.stringify(this.comments(), null, 2);
this.downloadText(json, 'interview-comments.json');
}
copyToClipboard() {
const text = this.comments()
.map(comment => `${comment.type}: ${comment.content}`)
.join('\n');
navigator.clipboard.writeText(text).then(() => {
// Could show a toast notification
console.log('Comments copied to clipboard');
});
}
private downloadText(content: string, filename: string) {
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
}
}

View File

@ -0,0 +1,305 @@
.cv-upload-container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
h2 {
color: #1a1a1a;
margin-bottom: 2rem;
text-align: center;
font-weight: 600;
}
h3 {
color: #333;
margin-bottom: 1rem;
font-weight: 500;
}
}
.upload-section {
margin-bottom: 2rem;
}
.drag-drop-area {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #fafafa;
&:hover {
border-color: #007bff;
background-color: #f0f8ff;
}
&.dragover {
border-color: #007bff;
background-color: #e6f3ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.6;
}
.upload-text {
font-size: 1.1rem;
color: #333;
margin-bottom: 0.5rem;
font-weight: 500;
}
.upload-subtext {
color: #666;
font-size: 0.9rem;
margin-bottom: 0;
}
.file-name {
font-size: 1.1rem;
color: #007bff;
font-weight: 600;
margin-bottom: 0.5rem;
}
.file-size {
color: #666;
font-size: 0.9rem;
margin-bottom: 0;
}
}
.error-message {
background-color: #fee;
color: #d63384;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 1rem;
border: 1px solid #f5c2c7;
}
.manual-input-section {
margin-bottom: 2rem;
.manual-input {
width: 100%;
min-height: 120px;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
transition: border-color 0.3s ease;
&:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
}
}
.processing-section {
text-align: center;
padding: 2rem;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 2rem;
.loading-spinner {
width: 40px;
height: 40px;
margin: 0 auto 1rem;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
p {
color: #666;
font-size: 1rem;
margin: 0;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.cv-summary {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
border: 1px solid #e9ecef;
.profile-details {
p {
margin: 0.5rem 0;
color: #555;
strong {
color: #333;
font-weight: 600;
}
}
}
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
&.btn-primary {
background-color: #007bff;
color: white;
&:hover {
background-color: #0056b3;
transform: translateY(-1px);
}
}
&.btn-success {
background-color: #28a745;
color: white;
&:hover {
background-color: #1e7e34;
transform: translateY(-1px);
}
}
&.btn-secondary {
background-color: #6c757d;
color: white;
&:hover {
background-color: #545b62;
transform: translateY(-1px);
}
}
&:active {
transform: translateY(0);
}
}
}
@media (max-width: 768px) {
.cv-upload-container {
margin: 1rem;
padding: 1rem;
}
.drag-drop-area {
padding: 2rem 1rem;
}
.action-buttons {
flex-direction: column;
align-items: center;
.btn {
width: 100%;
max-width: 300px;
}
}
}
// Extracted text preview styles
.extracted-text-section {
margin-top: 1.5rem;
border-top: 1px solid #e0e0e0;
padding-top: 1.5rem;
h4 {
color: #333;
margin-bottom: 1rem;
font-size: 1.1rem;
font-weight: 600;
}
.btn {
margin-bottom: 1rem;
}
}
.extracted-text-preview {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 1rem;
.text-stats {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #dee2e6;
color: #6c757d;
font-size: 0.85rem;
strong {
color: #495057;
}
}
.text-content {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
max-height: 400px;
overflow-y: auto;
pre {
margin: 0;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
color: #333;
}
}
.text-expansion {
margin-top: 0.75rem;
text-align: center;
.btn-link {
background: none;
border: none;
color: #007bff;
text-decoration: underline;
cursor: pointer;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
&:hover {
color: #0056b3;
}
}
}
}

View File

@ -0,0 +1,437 @@
import { Component, OnInit, inject, signal, computed, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CVParserService } from '../../services/cv-parser.service';
import { N8nSyncService } from '../../services/n8n-sync.service';
import { LoggingService } from '../../services/logging.service';
import { AutheliaAuthService } from '../../services/authelia-auth.service';
import { AuthLoginComponent } from '../auth-login/auth-login.component';
import { CVProfile } from '../../models/cv-profile.interface';
@Component({
selector: 'app-cv-upload',
standalone: true,
imports: [CommonModule, AuthLoginComponent],
template: `
<div class="cv-upload-container">
<h2>Upload Your CV</h2>
<div class="upload-section">
<div class="drag-drop-area"
[class.dragover]="isDragOver()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onFileDrop($event)"
(click)="fileInput.click()">
<div class="upload-icon">📄</div>
@if (!uploadedFile()) {
<p class="upload-text">
Drop your CV here or click to browse
</p>
<p class="upload-subtext">
Supports PDF, DOC, DOCX, and TXT files
</p>
} @else {
<p class="file-name">{{ uploadedFile()?.name }}</p>
<p class="file-size">{{ formatFileSize(uploadedFile()?.size || 0) }}</p>
}
<input #fileInput
type="file"
accept=".pdf,.doc,.docx,.txt"
(change)="onFileSelected($event)"
style="display: none;">
</div>
@if (uploadError()) {
<div class="error-message">
{{ uploadError() }}
</div>
}
</div>
@if (uploadedFile() && !isProcessing()) {
<div class="manual-input-section">
<h3>Additional Information</h3>
<textarea
class="manual-input"
placeholder="Add any additional information about your experience, skills, or notes that might help during the interview..."
[value]="manualNotes()"
(input)="updateManualNotes($event)">
</textarea>
</div>
}
@if (isProcessing()) {
<div class="processing-section">
<div class="loading-spinner"></div>
<p>Processing your CV and generating interview preparation...</p>
</div>
}
@if (cvProfile()) {
<div class="cv-summary">
<h3>CV Summary</h3>
<div class="profile-details">
<p><strong>Name:</strong> {{ cvProfile()?.personalInfo?.fullName }}</p>
<p><strong>Email:</strong> {{ cvProfile()?.personalInfo?.email }}</p>
<p><strong>Skills:</strong> {{ skillsDisplay() }}</p>
<p><strong>Experience:</strong> {{ cvProfile()?.experience?.length || 0 }} positions</p>
<p><strong>Education:</strong> {{ cvProfile()?.education?.length || 0 }} entries</p>
</div>
<!-- Extracted Text Preview -->
<div class="extracted-text-section">
<h4>📄 Extracted Text Preview</h4>
<button class="btn btn-sm btn-outline" (click)="toggleTextPreview()">
{{ showExtractedText() ? ' Hide' : '👁️ Show' }} Extracted Text
</button>
@if (showExtractedText()) {
<div class="extracted-text-preview">
<div class="text-stats">
<small>
<strong>Characters:</strong> {{ getExtractedTextLength() }} |
<strong>Words:</strong> {{ getExtractedWordCount() }} |
<strong>File:</strong> {{ cvProfile()?.fileName }}
</small>
</div>
<div class="text-content">
<pre>{{ getExtractedTextPreview() }}</pre>
</div>
@if (isExtractedTextLong()) {
<div class="text-expansion">
<button class="btn btn-sm btn-link" (click)="toggleFullText()">
{{ showFullText() ? 'Show Less' : 'Show Full Text' }}
</button>
</div>
}
</div>
}
</div>
</div>
}
<div class="action-buttons">
@if (uploadedFile() && !isProcessing()) {
<button class="btn btn-primary" (click)="processCV()">
Process CV & Generate Questions
</button>
}
@if (cvProfile()) {
<button class="btn btn-success" (click)="startInterview()">
Start Interview Session
</button>
}
@if (uploadedFile()) {
<button class="btn btn-secondary" (click)="clearUpload()">
Clear & Upload New CV
</button>
}
</div>
</div>
<!-- Authentication Modal -->
<app-auth-login
#authLogin
(loginSuccess)="onAuthSuccess()"
(loginCancelled)="onAuthCancelled()">
</app-auth-login>
`,
styleUrl: './cv-upload.component.scss'
})
export class CVUploadComponent implements OnInit {
@ViewChild('authLogin') authLogin!: AuthLoginComponent;
private cvParserService = inject(CVParserService);
private n8nSyncService = inject(N8nSyncService);
private loggingService = inject(LoggingService);
private autheliaAuth = inject(AutheliaAuthService);
uploadedFile = signal<File | null>(null);
isDragOver = signal(false);
uploadError = signal<string | null>(null);
isProcessing = signal(false);
cvProfile = signal<CVProfile | null>(null);
manualNotes = signal('');
// Text preview state
showExtractedText = signal(false);
showFullText = signal(false);
// Computed properties
skillsDisplay = computed(() => {
const skills = this.cvProfile()?.skills;
return skills ? skills.map(s => s.name).join(', ') : 'No skills listed';
});
ngOnInit() {
console.log('CVUploadComponent initialized');
}
onDragOver(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(true);
}
onDragLeave(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(false);
}
onFileDrop(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(false);
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.handleFileUpload(files[0]);
}
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.handleFileUpload(input.files[0]);
}
}
private handleFileUpload(file: File) {
this.uploadError.set(null);
// Validate file type
const allowedTypes = ['application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'];
if (!allowedTypes.includes(file.type)) {
this.uploadError.set('Please upload a PDF, DOC, DOCX, or TXT file.');
return;
}
// Validate file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
this.uploadError.set('File size must be less than 10MB.');
return;
}
this.uploadedFile.set(file);
console.log(`File uploaded: ${file.name} (${file.size} bytes)`);
}
updateManualNotes(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
this.manualNotes.set(textarea.value);
}
async processCV() {
const file = this.uploadedFile();
if (!file) return;
this.isProcessing.set(true);
this.uploadError.set(null);
try {
// Parse CV content
const cvProfile = await this.cvParserService.parseCV(file);
this.cvProfile.set(cvProfile);
// Send to n8n for question generation (with Authelia authentication)
this.sendToN8nWithAuth(cvProfile);
console.log('CV processed successfully', { profileId: cvProfile.id });
} catch (error) {
this.uploadError.set('Failed to process CV. Please try again.');
console.error('CV processing failed', error);
} finally {
this.isProcessing.set(false);
}
}
startInterview() {
// Navigate to interview session
// This will be implemented when we set up routing
console.log('Starting interview session');
}
clearUpload() {
this.uploadedFile.set(null);
this.cvProfile.set(null);
this.manualNotes.set('');
this.uploadError.set(null);
this.isProcessing.set(false);
}
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Text preview methods
toggleTextPreview() {
this.showExtractedText.update(show => !show);
}
toggleFullText() {
this.showFullText.update(show => !show);
}
getExtractedTextLength(): number {
return this.cvProfile()?.parsedText?.length || 0;
}
getExtractedWordCount(): number {
const text = this.cvProfile()?.parsedText || '';
return text.trim() ? text.trim().split(/\s+/).length : 0;
}
isExtractedTextLong(): boolean {
const text = this.cvProfile()?.parsedText || '';
return text.length > 1000; // Show expand option if text is longer than 1000 chars
}
getExtractedTextPreview(): string {
const text = this.cvProfile()?.parsedText || 'No text extracted';
if (!this.showFullText() && this.isExtractedTextLong()) {
return text.substring(0, 1000) + '\n\n... (truncated)';
}
return text;
}
private async pollForAnalysisResults(analysisId: string) {
try {
this.n8nSyncService.pollAnalysisStatus(analysisId).subscribe({
next: (response) => {
if (response.status === 'completed') {
console.log('N8n analysis completed:', response);
if (response.questionBankId) {
// Question bank generated, could fetch details if needed
console.log('Question bank ID:', response.questionBankId);
}
} else if (response.status === 'failed') {
console.error('N8n analysis failed');
}
// Continue polling for processing states
},
error: (error) => {
console.error('Error polling N8n analysis status:', error);
}
});
} catch (error) {
console.error('Failed to start polling for analysis results:', error);
}
}
/**
* Send CV to n8n with Authelia authentication handling
*/
private async sendToN8nWithAuth(cvProfile: CVProfile): Promise<void> {
try {
// Check if already authenticated
if (this.autheliaAuth.isAuthenticated) {
console.log('✅ Already authenticated, submitting CV...');
await this.submitToN8n(cvProfile);
return;
}
// Check authentication status
console.log('🔍 Checking authentication status...');
this.autheliaAuth.checkAuthenticationStatus().subscribe({
next: async (isAuthenticated) => {
if (isAuthenticated) {
console.log('✅ Authentication verified, submitting CV...');
await this.submitToN8n(cvProfile);
} else {
console.log('🔒 Authentication required, showing login...');
this.showAuthLogin(cvProfile);
}
},
error: (error) => {
console.log('❌ Authentication check failed:', error);
this.showAuthLogin(cvProfile);
}
});
} catch (error) {
console.error('❌ Error in sendToN8nWithAuth:', error);
this.uploadError.set('Failed to connect to n8n services. Please try again.');
}
}
/**
* Actually submit CV to n8n (assumes authentication is valid)
*/
private async submitToN8n(cvProfile: CVProfile): Promise<void> {
try {
console.log('🚀 Submitting CV to n8n...');
const analysisResponse = await this.n8nSyncService.submitCVForAnalysis(cvProfile).toPromise();
if (analysisResponse) {
console.log('✅ CV analysis submitted to n8n:', analysisResponse.analysisId);
// Poll for results if analysis is async
if (analysisResponse.status === 'processing') {
this.pollForAnalysisResults(analysisResponse.analysisId);
} else if (analysisResponse.status === 'completed') {
console.log('🎉 CV analysis completed immediately');
}
}
} catch (error) {
console.error('❌ n8n submission failed:', error);
if (error instanceof Error && error.message === 'Authelia authentication required') {
console.log('🔒 Authentication expired, showing login...');
this.showAuthLogin(cvProfile);
} else {
console.warn('⚠️ n8n analysis failed, continuing with local processing');
this.uploadError.set('n8n analysis unavailable. CV processed locally.');
}
}
}
/**
* Show authentication login modal
*/
private showAuthLogin(cvProfile?: CVProfile): void {
console.log('🔐 Showing authentication login...');
// Store CV profile for retry after login
if (cvProfile) {
(this as any).pendingCVProfile = cvProfile;
}
this.authLogin.show();
}
/**
* Handle successful authentication
*/
public onAuthSuccess(): void {
console.log('✅ Authentication successful!');
// Retry CV submission if we had a pending one
const pendingCV = (this as any).pendingCVProfile;
if (pendingCV) {
console.log('🔄 Retrying CV submission after authentication...');
(this as any).pendingCVProfile = null;
this.submitToN8n(pendingCV);
}
}
/**
* Handle authentication cancellation
*/
public onAuthCancelled(): void {
console.log('❌ Authentication cancelled');
(this as any).pendingCVProfile = null;
this.uploadError.set('Authentication required for n8n integration. CV processed locally only.');
}
}

View File

@ -0,0 +1,366 @@
.interview-session-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
h2 {
margin: 0;
font-weight: 600;
}
.session-controls {
display: flex;
align-items: center;
gap: 1rem;
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&.btn-primary {
background-color: #28a745;
color: white;
&:hover {
background-color: #1e7e34;
}
}
&.btn-danger {
background-color: #dc3545;
color: white;
&:hover {
background-color: #c82333;
}
}
}
.session-status {
display: flex;
flex-direction: column;
align-items: center;
font-size: 0.9rem;
.status-indicator {
margin-bottom: 0.25rem;
&.active {
color: #90EE90;
}
&.inactive {
color: #FFB6C1;
}
}
.session-time {
font-family: 'Courier New', monospace;
font-weight: bold;
}
}
}
}
.speech-recognition-panel {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
.speech-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
margin: 0;
color: #333;
font-weight: 500;
}
.listening-indicator {
color: #28a745;
font-weight: 600;
animation: pulse 2s infinite;
}
.not-listening {
color: #6c757d;
}
}
.speech-content {
margin-bottom: 1rem;
.current-speech, .detected-question {
background: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #dee2e6;
h4 {
margin: 0 0 0.5rem 0;
color: #495057;
font-size: 0.9rem;
font-weight: 600;
}
.speech-text, .question-text {
margin: 0;
color: #212529;
font-size: 1rem;
line-height: 1.5;
min-height: 1.5rem;
}
.repeat-indicator {
display: inline-block;
background: #ffc107;
color: #212529;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
margin-top: 0.5rem;
}
}
.detected-question {
border-color: #007bff;
background: #e7f3ff;
}
}
.speech-controls {
display: flex;
gap: 0.5rem;
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
&.btn-primary {
background-color: #007bff;
color: white;
}
&.btn-secondary {
background-color: #6c757d;
color: white;
}
&.btn-outline {
background-color: transparent;
border: 1px solid #6c757d;
color: #6c757d;
&:hover {
background-color: #6c757d;
color: white;
}
}
&.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.session-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
.stat-item {
background: white;
padding: 1rem;
border-radius: 8px;
border: 1px solid #e9ecef;
text-align: center;
.stat-label {
display: block;
color: #6c757d;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 600;
color: #007bff;
}
}
}
.pre-session-setup {
background: #f8f9fa;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
.setup-instructions {
margin-bottom: 2rem;
h3 {
color: #333;
margin-bottom: 1rem;
}
ul {
color: #555;
line-height: 1.6;
li {
margin-bottom: 0.5rem;
}
}
}
.microphone-test {
h4 {
color: #333;
margin-bottom: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: 1px solid #007bff;
background: transparent;
color: #007bff;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #007bff;
color: white;
}
}
.mic-level-indicator {
width: 200px;
height: 8px;
background: #e9ecef;
border-radius: 4px;
margin-top: 1rem;
overflow: hidden;
.mic-level-bar {
height: 100%;
background: linear-gradient(90deg, #28a745, #ffc107, #dc3545);
transition: width 0.1s ease;
}
}
}
}
.session-history {
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
h3 {
margin: 0 0 1rem 0;
color: #333;
}
.history-list {
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #e9ecef;
border-radius: 4px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #f8f9fa;
border-color: #007bff;
}
.session-date {
font-weight: 500;
color: #333;
}
.session-duration {
color: #6c757d;
font-family: 'Courier New', monospace;
}
.session-questions {
color: #007bff;
font-weight: 500;
}
}
}
}
@media (max-width: 768px) {
.interview-session-container {
padding: 0.5rem;
}
.session-header {
flex-direction: column;
gap: 1rem;
text-align: center;
.session-controls {
flex-direction: column;
width: 100%;
}
}
.session-stats {
grid-template-columns: 1fr;
}
.speech-controls {
flex-wrap: wrap;
}
.history-item {
flex-direction: column !important;
align-items: flex-start !important;
gap: 0.5rem;
}
}

View File

@ -0,0 +1,501 @@
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SpeechService } from '../../services/speech.service';
import { QuestionBankService } from '../../services/question-bank.service';
import { AnalyticsService } from '../../services/analytics.service';
import { LoggingService } from '../../services/logging.service';
import { QuestionDisplayComponent } from '../question-display/question-display.component';
import { CommentSessionComponent } from '../comment-session/comment-session.component';
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';
import { InterviewSession, QuestionAnswer, SessionStatus, CommentCategory } from '../../models/interview-session.interface';
import { Question, QuestionCategory, QuestionDifficulty } from '../../models/question-bank.interface';
@Component({
selector: 'app-interview-session',
standalone: true,
imports: [CommonModule, QuestionDisplayComponent, CommentSessionComponent, LanguageSelectorComponent],
template: `
<div class="interview-session-container">
<!-- Header -->
<div class="session-header">
<h2>Interview Assistant</h2>
<div class="session-controls">
@if (!isSessionActive()) {
<button class="btn btn-primary" (click)="startSession()">
🎤 Start Interview Session
</button>
} @else {
<button class="btn btn-danger" (click)="stopSession()">
Stop Session
</button>
}
<div class="session-status">
@if (isSessionActive()) {
<span class="status-indicator active">🟢 Active</span>
} @else {
<span class="status-indicator inactive">🔴 Inactive</span>
}
<span class="session-time">{{ formatSessionTime(sessionDuration()) }}</span>
</div>
</div>
</div>
@if (isSessionActive()) {
<!-- Live Speech Recognition -->
<div class="speech-recognition-panel">
<div class="speech-header">
<h3>🎙 Live Audio Analysis</h3>
@if (isListening()) {
<span class="listening-indicator">Listening...</span>
} @else {
<span class="not-listening">Not Listening</span>
}
</div>
<div class="speech-content">
<div class="current-speech">
<h4>Current Speech:</h4>
<p class="speech-text">{{ currentSpeech() || 'Waiting for speech...' }}</p>
</div>
@if (detectedQuestion()) {
<div class="detected-question">
<h4>🔍 Detected Question:</h4>
<p class="question-text">{{ detectedQuestion() }}</p>
@if (isUserRepeat()) {
<span class="repeat-indicator">🔄 User requesting help</span>
}
</div>
}
</div>
<div class="speech-controls">
<button class="btn btn-sm" [class.btn-primary]="!isListening()" [class.btn-secondary]="isListening()"
(click)="toggleListening()">
{{ isListening() ? '🔇 Mute' : '🎤 Listen' }}
</button>
<button class="btn btn-sm btn-outline" (click)="clearSpeech()">
🗑 Clear
</button>
</div>
</div>
<!-- Question Display Component -->
<app-question-display
(answerSelected)="onAnswerSelected($event)">
</app-question-display>
<!-- Session Statistics -->
<div class="session-stats">
<div class="stat-item">
<span class="stat-label">Questions Detected:</span>
<span class="stat-value">{{ questionsDetected() }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Answers Provided:</span>
<span class="stat-value">{{ answersProvided() }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Response Time (avg):</span>
<span class="stat-value">{{ averageResponseTime() }}ms</span>
</div>
</div>
<!-- Comment Session Component -->
<app-comment-session
(commentAdded)="onCommentAdded($event)">
</app-comment-session>
} @else {
<!-- Pre-session Setup -->
<div class="pre-session-setup">
<div class="setup-instructions">
<h3>📋 Interview Preparation</h3>
<ul>
<li>Ensure your microphone is working and positioned properly</li>
<li>Test your audio levels - speak clearly and at normal volume</li>
<li>Position the screen where you can see answers discreetly</li>
<li>Have your CV and notes ready for reference</li>
<li>Start the session when you're ready to begin the interview</li>
</ul>
</div>
<div class="microphone-test">
<h4>🎤 Microphone Test</h4>
<button class="btn btn-outline" (click)="testMicrophone()">
{{ isTesting() ? 'Testing...' : 'Test Microphone' }}
</button>
@if (microphoneLevel()) {
<div class="mic-level-indicator">
<div class="mic-level-bar" [style.width.%]="microphoneLevel()"></div>
</div>
}
</div>
<!-- Language Selector -->
<app-language-selector></app-language-selector>
</div>
}
<!-- Session History (if any previous sessions) -->
@if (sessionHistory().length > 0) {
<div class="session-history">
<h3>📈 Previous Sessions</h3>
<div class="history-list">
@for (session of sessionHistory(); track session.id) {
<div class="history-item" (click)="viewSession(session)">
<span class="session-date">{{ formatDate(session.startTime) }}</span>
<span class="session-duration">{{ formatDuration(session.duration) }}</span>
<span class="session-questions">{{ session.questionsAnswered.length }} Q&A</span>
</div>
}
</div>
</div>
}
</div>
`,
styleUrl: './interview-session.component.scss'
})
export class InterviewSessionComponent implements OnInit, OnDestroy {
private speechService = inject(SpeechService);
private questionBankService = inject(QuestionBankService);
private analyticsService = inject(AnalyticsService);
private loggingService = inject(LoggingService);
// Session state
isSessionActive = signal(false);
sessionDuration = signal(0);
currentSession = signal<InterviewSession | null>(null);
sessionHistory = signal<InterviewSession[]>([]);
// Speech recognition state
isListening = signal(false);
isTesting = signal(false);
currentSpeech = signal('');
detectedQuestion = signal('');
isUserRepeat = signal(false);
microphoneLevel = signal(0);
// Question/Answer state
currentQuestion = signal<Question | null>(null);
suggestedAnswer = signal('');
isProcessingAnswer = signal(false);
// Statistics
questionsDetected = signal(0);
answersProvided = signal(0);
averageResponseTime = signal(0);
private sessionTimer?: number;
private responseStartTime = 0;
ngOnInit() {
console.log('InterviewSessionComponent initialized');
this.loadSessionHistory();
this.setupSpeechRecognition();
}
ngOnDestroy() {
this.stopSession();
this.speechService.stopListening();
if (this.sessionTimer) {
clearInterval(this.sessionTimer);
}
}
private setupSpeechRecognition() {
// Listen for speech events
this.speechService.speechResults$.subscribe((result: any) => {
this.currentSpeech.set(result.transcript);
this.analyzeForQuestions(result.transcript);
});
// Mock other observables for now
// this.speechService.listeningState$.subscribe((isListening: boolean) => {
// this.isListening.set(isListening);
// });
// this.speechService.microphoneLevel$.subscribe((level: number) => {
// this.microphoneLevel.set(level);
// });
}
async startSession() {
try {
const sessionId = `session-${Date.now()}`;
const newSession: InterviewSession = {
id: sessionId,
startTime: new Date(),
endTime: undefined,
duration: 0,
questionsAnswered: [],
cvProfileId: '', // Should be loaded from previous CV upload
questionBankId: '',
detectedQuestions: [],
providedAnswers: [],
manualComments: [],
speechHints: [],
analytics: {
totalQuestions: 0,
answersFromBank: 0,
answersGenerated: 0,
averageResponseTime: 0,
accuracyRate: 0,
topicsCovered: []
},
status: SessionStatus.ACTIVE
};
this.currentSession.set(newSession);
this.isSessionActive.set(true);
this.sessionDuration.set(0);
// Start session timer
this.sessionTimer = setInterval(() => {
this.sessionDuration.update(duration => duration + 1);
}, 1000);
// Start speech recognition
await this.speechService.startListening();
// Track session start
// this.analyticsService.trackSessionStart(sessionId);
console.log('Interview session started', { sessionId });
} catch (error) {
console.error('Failed to start interview session', error);
}
}
stopSession() {
if (!this.isSessionActive()) return;
const session = this.currentSession();
if (session) {
session.endTime = new Date();
session.duration = this.sessionDuration();
// Save session to history
this.saveSession(session);
// Track session end
// this.analyticsService.trackSessionEnd(session.id, {
// duration: session.duration,
// questionsAnswered: session.questionsAnswered.length,
// averageResponseTime: this.averageResponseTime()
// });
}
this.isSessionActive.set(false);
this.speechService.stopListening();
if (this.sessionTimer) {
clearInterval(this.sessionTimer);
this.sessionTimer = undefined;
}
console.log('Interview session stopped');
}
toggleListening() {
if (this.isListening()) {
this.speechService.stopListening();
} else {
this.speechService.startListening();
}
}
clearSpeech() {
this.currentSpeech.set('');
this.detectedQuestion.set('');
this.isUserRepeat.set(false);
}
async testMicrophone() {
this.isTesting.set(true);
try {
// await this.speechService.testMicrophone();
// Mock microphone test
console.log('Testing microphone...');
setTimeout(() => this.isTesting.set(false), 3000);
} catch (error) {
this.isTesting.set(false);
console.error('Microphone test failed', error);
}
}
private async analyzeForQuestions(transcript: string) {
if (!transcript.trim()) return;
// Detect if user is repeating a question (simplified logic)
const isRepeat = this.detectUserRepeat(transcript);
this.isUserRepeat.set(isRepeat);
// Extract potential questions
const question = this.extractQuestionFromSpeech(transcript);
if (question) {
this.detectedQuestion.set(question);
this.questionsDetected.update(count => count + 1);
// Find answer from question bank
await this.findAnswer(question);
}
}
private detectUserRepeat(transcript: string): boolean {
// Simple heuristics to detect if user is repeating a question for help
const repeatPatterns = [
/what (is|are|was|were)/i,
/how (do|does|can|would)/i,
/tell me about/i,
/explain/i,
/(can you|could you) (explain|tell)/i
];
return repeatPatterns.some(pattern => pattern.test(transcript));
}
private extractQuestionFromSpeech(transcript: string): string {
// Extract question-like phrases from transcript
const sentences = transcript.split(/[.!?]+/).filter(s => s.trim());
for (const sentence of sentences) {
if (sentence.includes('?') ||
/^(what|how|why|when|where|who|which)/i.test(sentence.trim())) {
return sentence.trim();
}
}
return '';
}
private async findAnswer(question: string) {
if (!question) return;
this.isProcessingAnswer.set(true);
this.responseStartTime = Date.now();
try {
// Search question bank for matching answer
// const answer = await this.questionBankService.findAnswer(question);
// Mock answer for now
const mockQuestion: Question = {
id: '1',
text: question,
category: QuestionCategory.GENERAL,
difficulty: QuestionDifficulty.MEDIUM,
tags: [],
answer: {
id: '1',
content: 'This is a mock answer for: ' + question,
keyPoints: [],
followUpQuestions: [],
estimatedDuration: 30,
personalizedContext: ''
},
relatedSkills: [],
confidence: 0.8,
timesUsed: 0,
source: 'mock'
};
const answer = {
question: mockQuestion,
answer: 'This is a mock answer for: ' + question
};
if (answer) {
this.currentQuestion.set(answer.question);
this.suggestedAnswer.set(answer.answer);
const responseTime = Date.now() - this.responseStartTime;
this.updateResponseTime(responseTime);
console.log('Answer found', {
question: question,
responseTime: responseTime
});
}
} catch (error) {
console.error('Failed to find answer', error);
} finally {
this.isProcessingAnswer.set(false);
}
}
onAnswerSelected(answer: string) {
this.answersProvided.update(count => count + 1);
// Add to session history
const session = this.currentSession();
if (session) {
const qa: QuestionAnswer = {
question: this.detectedQuestion(),
answer: answer,
timestamp: new Date(),
responseTime: Date.now() - this.responseStartTime,
source: 'question-bank'
};
session.questionsAnswered.push(qa);
}
// this.analyticsService.trackAnswerProvided(answer);
}
onCommentAdded(comment: string) {
const session = this.currentSession();
if (session) {
session.manualComments.push({
id: `comment-${Date.now()}`,
content: comment,
timestamp: new Date(),
category: CommentCategory.OBSERVATION,
tags: []
});
}
}
private updateResponseTime(responseTime: number) {
const currentAvg = this.averageResponseTime();
const count = this.answersProvided();
const newAvg = count === 0 ? responseTime : (currentAvg * count + responseTime) / (count + 1);
this.averageResponseTime.set(Math.round(newAvg));
}
private loadSessionHistory() {
// Load previous sessions from storage
// This would typically come from a service
this.sessionHistory.set([]);
}
private saveSession(session: InterviewSession) {
// Save session to storage and update history
const history = this.sessionHistory();
history.unshift(session);
this.sessionHistory.set([...history.slice(0, 10)]); // Keep last 10 sessions
}
viewSession(session: InterviewSession) {
// Navigate to session details view
console.log('Viewing session details', { sessionId: session.id });
}
formatSessionTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
formatDate(date: Date): string {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
return `${mins}m ${seconds % 60}s`;
}
}

View File

@ -0,0 +1,436 @@
.language-selector {
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 16px;
margin: 16px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
color: var(--text-primary, #333333);
.selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
h4 {
margin: 0;
color: var(--text-primary, #333);
font-size: 1.1rem;
font-weight: 600;
}
.current-language {
.language-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent-color, #007bff);
color: white;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 500;
}
}
}
.language-dropdown {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary, #333);
}
.language-select {
width: 100%;
padding: 12px 16px;
padding-right: 40px; /* Space for dropdown arrow */
border: 2px solid var(--border-color, #d0d0d0);
border-radius: 8px;
background: var(--input-background, #ffffff);
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m6 9 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
font-size: 1rem;
font-weight: 500;
color: var(--text-primary, #333333) !important;
cursor: pointer;
transition: all 0.2s ease;
/* Ensure text is always visible */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
&:hover {
border-color: var(--accent-color, #007bff);
background-color: var(--background-light, #f8f9fa);
}
&:focus {
outline: none;
border-color: var(--accent-color, #007bff);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15);
background-color: var(--input-background, #ffffff);
}
option {
padding: 12px 16px;
background: var(--input-background, #ffffff) !important;
color: var(--text-primary, #333333) !important;
font-size: 0.95rem;
font-weight: 500;
&:checked {
background: var(--accent-color, #007bff) !important;
color: white !important;
}
&:hover {
background: var(--background-light, #f8f9fa) !important;
}
}
}
}
.language-info {
background: var(--background-light, #f8f9fa);
padding: 16px;
border-radius: 6px;
margin-bottom: 16px;
.info-section {
margin-bottom: 16px;
h5 {
margin: 0 0 12px 0;
color: var(--text-primary, #333);
font-size: 0.95rem;
font-weight: 600;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 8px;
.feature-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
.feature-label {
color: var(--text-secondary, #666);
}
.feature-value {
color: var(--text-primary, #333);
font-weight: 500;
}
}
}
}
.example-patterns {
h5 {
margin: 0 0 12px 0;
color: var(--text-primary, #333);
font-size: 0.95rem;
font-weight: 600;
}
.pattern-examples {
display: flex;
flex-wrap: wrap;
gap: 8px;
.pattern-tag {
background: var(--accent-color, #007bff);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
}
}
}
.auto-detect-section {
background: var(--background-light, #f8f9fa);
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-primary, #333);
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-color, #007bff);
}
}
.auto-detect-info {
margin-top: 8px;
padding-left: 24px;
small {
color: var(--text-secondary, #666);
font-size: 0.8rem;
.mismatch {
color: var(--warning-color, #ff9800);
font-weight: 500;
}
}
}
}
.language-actions {
display: flex;
gap: 12px;
margin-bottom: 16px;
.btn {
padding: 8px 16px;
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 6px;
background: white;
color: var(--text-primary, #333);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background: var(--background-light, #f8f9fa);
}
&.btn-outline {
background: transparent;
&:hover {
background: var(--accent-color, #007bff);
color: white;
border-color: var(--accent-color, #007bff);
}
}
&.btn-sm {
padding: 6px 12px;
font-size: 0.8rem;
}
}
}
.language-test {
background: var(--info-background, #e3f2fd);
border: 1px solid var(--info-border, #90caf9);
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
.test-instructions {
h5 {
margin: 0 0 12px 0;
color: var(--info-text, #0d47a1);
font-size: 0.95rem;
font-weight: 600;
}
p {
margin: 0 0 12px 0;
color: var(--text-primary, #333);
font-size: 0.9rem;
}
.test-examples {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
.example {
background: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.85rem;
font-style: italic;
color: var(--text-secondary, #666);
border-left: 3px solid var(--accent-color, #007bff);
}
}
.test-controls {
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: var(--accent-color, #007bff);
color: white;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background-color 0.2s ease;
&:hover {
background: var(--accent-color-dark, #0056b3);
}
&.btn-primary {
background: var(--accent-color, #007bff);
&:hover {
background: var(--accent-color-dark, #0056b3);
}
}
&.btn-sm {
padding: 6px 12px;
font-size: 0.8rem;
}
}
}
}
}
.test-result {
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
&.success {
background: var(--success-background, #e8f5e8);
border: 1px solid var(--success-border, #4caf50);
.result-header {
color: var(--success-text, #2e7d32);
}
}
&:not(.success) {
background: var(--error-background, #ffebee);
border: 1px solid var(--error-border, #f44336);
.result-header {
color: var(--error-text, #c62828);
}
}
.result-header {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 8px;
}
.result-details {
font-size: 0.85rem;
.transcript {
margin-bottom: 4px;
strong {
color: var(--text-primary, #333);
}
}
.confidence {
margin-bottom: 4px;
strong {
color: var(--text-primary, #333);
}
}
.error {
color: var(--error-text, #c62828);
strong {
color: var(--error-text, #c62828);
}
}
}
}
// Responsive design
@media (max-width: 768px) {
padding: 12px;
.selector-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.language-actions {
flex-direction: column;
}
.test-examples {
.example {
font-size: 0.8rem;
}
}
}
}
// Default light mode variables
.language-selector {
--surface-color: #ffffff;
--border-color: #e0e0e0;
--text-primary: #333333;
--text-secondary: #666666;
--background-light: #f8f9fa;
--accent-color: #007bff;
--accent-color-dark: #0056b3;
--input-background: #ffffff;
--info-background: #e3f2fd;
--info-border: #90caf9;
--info-text: #0d47a1;
--success-background: #e8f5e8;
--success-border: #4caf50;
--success-text: #2e7d32;
--error-background: #ffebee;
--error-border: #f44336;
--error-text: #c62828;
--warning-color: #ff9800;
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.language-selector {
--surface-color: #2d2d2d;
--border-color: #404040;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--background-light: #3a3a3a;
--accent-color: #4a9eff;
--accent-color-dark: #357abd;
--input-background: #404040;
--info-background: #1a2937;
--info-border: #2196f3;
--info-text: #64b5f6;
--success-background: #1b2e1b;
--success-border: #4caf50;
--success-text: #81c784;
--error-background: #2e1b1b;
--error-border: #f44336;
--error-text: #e57373;
--warning-color: #ffb74d;
}
}

View File

@ -0,0 +1,348 @@
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { LanguageConfigService, SpeechLanguage } from '../../services/language-config.service';
@Component({
selector: 'app-language-selector',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="language-selector">
<div class="selector-header">
<h4>🌐 Speech Recognition Language</h4>
<div class="current-language">
@if (currentLanguage()) {
<span class="language-indicator">
{{ currentLanguage()?.flag }} {{ currentLanguage()?.nativeName }}
</span>
}
</div>
</div>
<div class="language-dropdown">
<label for="language-select">Select Language:</label>
<select
id="language-select"
class="language-select"
[(ngModel)]="selectedLanguageCode"
(ngModelChange)="onLanguageChange($event)">
@for (language of supportedLanguages(); track language.code) {
<option [value]="language.code">
{{ language.flag }} {{ language.name }} ({{ language.nativeName }})
</option>
}
</select>
</div>
<div class="language-info">
@if (currentLanguage()) {
<div class="info-section">
<h5>Language Features:</h5>
<div class="feature-list">
<div class="feature-item">
<span class="feature-label">Question Patterns:</span>
<span class="feature-value">{{ currentLanguage()!.questionPatterns.length }} patterns</span>
</div>
<div class="feature-item">
<span class="feature-label">Hint Patterns:</span>
<span class="feature-value">{{ currentLanguage()!.hintPatterns.length }} patterns</span>
</div>
</div>
</div>
<div class="example-patterns">
<h5>Example Question Words:</h5>
<div class="pattern-examples">
@switch (currentLanguage()?.code) {
@case ('en-US') {
<span class="pattern-tag">what</span>
<span class="pattern-tag">how</span>
<span class="pattern-tag">why</span>
<span class="pattern-tag">when</span>
}
@case ('fr-FR') {
<span class="pattern-tag">qu'est-ce que</span>
<span class="pattern-tag">comment</span>
<span class="pattern-tag">pourquoi</span>
<span class="pattern-tag">quand</span>
}
@case ('es-ES') {
<span class="pattern-tag">qué</span>
<span class="pattern-tag">cómo</span>
<span class="pattern-tag">por qué</span>
<span class="pattern-tag">cuándo</span>
}
@case ('de-DE') {
<span class="pattern-tag">was</span>
<span class="pattern-tag">wie</span>
<span class="pattern-tag">warum</span>
<span class="pattern-tag">wann</span>
}
}
</div>
</div>
}
</div>
<div class="auto-detect-section">
<label class="checkbox-label">
<input
type="checkbox"
[checked]="autoDetectEnabled()"
(change)="onAutoDetectChange($event)">
<span class="checkmark"></span>
Auto-detect language from browser
</label>
@if (autoDetectEnabled()) {
<div class="auto-detect-info">
<small>
🔍 Detected: {{ browserLanguage() }}
@if (browserLanguage() !== currentLanguage()?.code) {
<span class="mismatch">(Override active)</span>
}
</small>
</div>
}
</div>
<div class="language-actions">
<button class="btn btn-outline btn-sm" (click)="resetToDefaults()">
🔄 Reset to Defaults
</button>
<button class="btn btn-outline btn-sm" (click)="testLanguage()">
🎤 Test Language
</button>
</div>
@if (testingLanguage()) {
<div class="language-test">
<div class="test-instructions">
<h5>🎙 Language Test</h5>
<p>Say a question in {{ currentLanguage()?.nativeName }} to test recognition:</p>
@switch (currentLanguage()?.code) {
@case ('en-US') {
<div class="test-examples">
<span class="example">"What is your experience?"</span>
<span class="example">"How do you handle challenges?"</span>
</div>
}
@case ('fr-FR') {
<div class="test-examples">
<span class="example">"Qu'est-ce que votre expérience?"</span>
<span class="example">"Comment gérez-vous les défis?"</span>
</div>
}
@case ('es-ES') {
<div class="test-examples">
<span class="example">"¿Cuál es tu experiencia?"</span>
<span class="example">"¿Cómo manejas los desafíos?"</span>
</div>
}
@case ('de-DE') {
<div class="test-examples">
<span class="example">"Was ist deine Erfahrung?"</span>
<span class="example">"Wie gehst du mit Herausforderungen um?"</span>
</div>
}
}
<div class="test-controls">
<button class="btn btn-primary btn-sm" (click)="stopLanguageTest()">
Stop Test
</button>
</div>
</div>
</div>
}
@if (lastTestResult()) {
<div class="test-result" [class.success]="lastTestResult()?.success">
<div class="result-header">
{{ lastTestResult()?.success ? '✅' : '❌' }}
Test {{ lastTestResult()?.success ? 'Successful' : 'Failed' }}
</div>
<div class="result-details">
@if (lastTestResult()?.transcript) {
<div class="transcript">
<strong>Recognized:</strong> "{{ lastTestResult()?.transcript }}"
</div>
<div class="confidence">
<strong>Confidence:</strong> {{ (lastTestResult()?.confidence || 0) * 100 | number:'1.0-0' }}%
</div>
}
@if (lastTestResult()?.error) {
<div class="error">
<strong>Error:</strong> {{ lastTestResult()?.error }}
</div>
}
</div>
</div>
}
</div>
`,
styleUrl: './language-selector.component.scss'
})
export class LanguageSelectorComponent implements OnInit, OnDestroy {
private languageConfigService = inject(LanguageConfigService);
private destroy$ = new Subject<void>();
// Component state
selectedLanguageCode = '';
supportedLanguages = signal<SpeechLanguage[]>([]);
currentLanguage = signal<SpeechLanguage | null>(null);
autoDetectEnabled = signal(false);
browserLanguage = signal('');
testingLanguage = signal(false);
lastTestResult = signal<{
success: boolean;
transcript?: string;
confidence?: number;
error?: string;
} | null>(null);
ngOnInit() {
this.loadSupportedLanguages();
this.setupLanguageSubscription();
this.loadConfiguration();
this.detectBrowserLanguage();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
private loadSupportedLanguages() {
const languages = this.languageConfigService.getSupportedLanguages();
this.supportedLanguages.set(languages);
}
private setupLanguageSubscription() {
this.languageConfigService.currentLanguage$
.pipe(takeUntil(this.destroy$))
.subscribe(languageCode => {
this.selectedLanguageCode = languageCode;
const language = this.languageConfigService.getLanguageByCode(languageCode);
this.currentLanguage.set(language || null);
});
this.languageConfigService.config$
.pipe(takeUntil(this.destroy$))
.subscribe(config => {
this.autoDetectEnabled.set(config.autoDetect);
});
}
private loadConfiguration() {
const currentLang = this.languageConfigService.getCurrentLanguage();
this.selectedLanguageCode = currentLang;
const language = this.languageConfigService.getLanguageByCode(currentLang);
this.currentLanguage.set(language || null);
const config = this.languageConfigService.getConfig();
this.autoDetectEnabled.set(config.autoDetect);
}
private detectBrowserLanguage() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en-US';
this.browserLanguage.set(browserLang);
}
onLanguageChange(languageCode: string) {
this.languageConfigService.setLanguage(languageCode);
// Clear previous test results when language changes
this.lastTestResult.set(null);
console.log(`Language changed to: ${languageCode}`);
}
onAutoDetectChange(event: Event) {
const checkbox = event.target as HTMLInputElement;
const autoDetect = checkbox.checked;
this.languageConfigService.updateConfig({ autoDetect });
if (autoDetect) {
// Trigger auto-detection
const detectedLanguage = this.languageConfigService.autoDetectLanguage();
console.log(`Auto-detected language: ${detectedLanguage}`);
}
}
resetToDefaults() {
this.languageConfigService.resetToDefaults();
this.lastTestResult.set(null);
console.log('Language settings reset to defaults');
}
testLanguage() {
if (this.testingLanguage()) {
this.stopLanguageTest();
return;
}
this.testingLanguage.set(true);
this.lastTestResult.set(null);
// Simple test using Web Speech API
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.lang = this.currentLanguage()?.code || 'en-US';
recognition.continuous = false;
recognition.interimResults = false;
recognition.onresult = (event: any) => {
const result = event.results[0];
const transcript = result[0].transcript;
const confidence = result[0].confidence || 0.9;
this.lastTestResult.set({
success: true,
transcript,
confidence
});
this.stopLanguageTest();
};
recognition.onerror = (event: any) => {
this.lastTestResult.set({
success: false,
error: event.error || 'Recognition failed'
});
this.stopLanguageTest();
};
recognition.onend = () => {
this.testingLanguage.set(false);
};
try {
recognition.start();
} catch (error) {
this.lastTestResult.set({
success: false,
error: 'Failed to start speech recognition'
});
this.stopLanguageTest();
}
} else {
this.lastTestResult.set({
success: false,
error: 'Speech recognition not supported in this browser'
});
this.stopLanguageTest();
}
}
stopLanguageTest() {
this.testingLanguage.set(false);
}
}

View File

@ -0,0 +1,421 @@
.question-display-container {
position: relative;
margin-bottom: 2rem;
}
.question-panel {
background: white;
border: 2px solid #007bff;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
animation: slideIn 0.3s ease-out;
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
h3 {
margin: 0;
color: #007bff;
font-weight: 600;
}
.processing-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
color: #6c757d;
font-size: 0.9rem;
.spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
.response-time {
color: #28a745;
font-weight: 600;
font-size: 0.9rem;
background: #d4edda;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.question-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 0.75rem 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
.question-text {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
border-left: 4px solid #007bff;
font-size: 1.1rem;
line-height: 1.5;
color: #212529;
margin-bottom: 0.5rem;
}
.question-category {
.category-tag {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.answer-section {
margin-bottom: 1.5rem;
.answer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h4 {
margin: 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
.answer-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
.btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&.btn-primary {
background: #28a745;
color: white;
&:hover {
background: #1e7e34;
}
}
&.btn-outline {
background: transparent;
border: 1px solid #6c757d;
color: #6c757d;
&:hover {
background: #6c757d;
color: white;
}
}
&.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
}
}
}
.answer-text {
background: #e8f5e8;
padding: 1.25rem;
border-radius: 6px;
border-left: 4px solid #28a745;
line-height: 1.6;
color: #212529;
white-space: pre-line;
transition: max-height 0.3s ease;
&.collapsed {
max-height: 100px;
overflow: hidden;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, #e8f5e8);
}
}
}
.answer-truncated-indicator {
margin-top: 0.5rem;
.btn-link {
background: none;
border: none;
color: #007bff;
cursor: pointer;
font-size: 0.9rem;
text-decoration: underline;
&:hover {
color: #0056b3;
}
}
}
.answer-source {
margin-top: 0.5rem;
text-align: right;
small {
color: #6c757d;
font-style: italic;
}
}
}
.no-answer-section {
margin-bottom: 1.5rem;
.no-answer-message {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 1.25rem;
h4 {
margin: 0 0 0.75rem 0;
color: #856404;
font-size: 1rem;
}
p {
margin: 0 0 1rem 0;
color: #856404;
}
.fallback-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
.btn {
padding: 0.5rem 1rem;
border: 1px solid #856404;
background: transparent;
color: #856404;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
&:hover {
background: #856404;
color: white;
}
}
}
}
}
.alternatives-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 1rem 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
.alternatives-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
.alternative-item {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #007bff;
background: #e7f3ff;
}
.alt-preview {
color: #212529;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.alt-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
.alt-confidence {
color: #28a745;
font-weight: 600;
}
.alt-category {
background: #6c757d;
color: white;
padding: 0.2rem 0.4rem;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
}
}
}
}
.quick-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
.btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: #6c757d;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s ease;
&.btn-ghost {
&:hover {
background: #f8f9fa;
color: #495057;
}
}
&.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
}
}
.feedback-toast {
position: fixed;
top: 20px;
right: 20px;
background: #dc3545;
color: white;
padding: 1rem 1.5rem;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideInRight 0.3s ease-out;
z-index: 1000;
&.success {
background: #28a745;
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 768px) {
.question-panel {
padding: 1rem;
margin: 0.5rem;
}
.answer-header {
flex-direction: column;
align-items: flex-start !important;
gap: 0.75rem;
.answer-actions {
width: 100%;
justify-content: space-between;
}
}
.alternatives-list .alternative-item .alt-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.quick-actions {
flex-direction: column;
.btn {
width: 100%;
}
}
.feedback-toast {
position: fixed;
top: auto;
bottom: 20px;
left: 20px;
right: 20px;
text-align: center;
}
}

View File

@ -0,0 +1,294 @@
import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Question } from '../../models/question-bank.interface';
@Component({
selector: 'app-question-display',
standalone: true,
imports: [CommonModule],
template: `
<div class="question-display-container">
@if (currentQuestion() || suggestedAnswer() || isProcessing()) {
<div class="question-panel">
<div class="panel-header">
<h3>💡 Interview Assistant</h3>
@if (isProcessing()) {
<div class="processing-indicator">
<div class="spinner"></div>
<span>Finding answer...</span>
</div>
} @else if (responseTime()) {
<div class="response-time">
{{ responseTime() }}ms
</div>
}
</div>
@if (currentQuestion()) {
<div class="question-section">
<h4>🎯 Detected Question:</h4>
<div class="question-text">
{{ currentQuestion()!.text }}
</div>
@if (currentQuestion()!.category) {
<div class="question-category">
<span class="category-tag">{{ currentQuestion()!.category }}</span>
</div>
}
</div>
}
@if (suggestedAnswer() && !isProcessing()) {
<div class="answer-section">
<div class="answer-header">
<h4>💬 Suggested Answer:</h4>
<div class="answer-actions">
<button class="btn btn-sm btn-primary"
(click)="selectAnswer(suggestedAnswer())">
Use This Answer
</button>
<button class="btn btn-sm btn-outline"
(click)="copyToClipboard(suggestedAnswer())">
📋 Copy
</button>
<button class="btn btn-sm btn-outline"
(click)="toggleAnswerExpanded()">
{{ isAnswerExpanded() ? ' Collapse' : ' Expand' }}
</button>
</div>
</div>
<div class="answer-text"
[class.collapsed]="!isAnswerExpanded() && isAnswerLong()">
{{ formatAnswer(suggestedAnswer()) }}
</div>
@if (isAnswerLong() && !isAnswerExpanded()) {
<div class="answer-truncated-indicator">
<button class="btn-link" (click)="toggleAnswerExpanded()">
Show full answer ({{ getAnswerWordCount() }} words)
</button>
</div>
}
@if (currentQuestion()?.source) {
<div class="answer-source">
<small>Source: {{ getSourceDisplay(currentQuestion()?.source || '') }}</small>
</div>
}
</div>
}
@if (!suggestedAnswer() && !isProcessing() && currentQuestion()) {
<div class="no-answer-section">
<div class="no-answer-message">
<h4> No Pre-generated Answer Found</h4>
<p>This question wasn't in your preparation bank. Consider these options:</p>
<div class="fallback-actions">
<button class="btn btn-outline" (click)="requestNewAnswer()">
🔄 Generate Answer from CV
</button>
<button class="btn btn-outline" (click)="addToQuestionBank()">
📝 Add to Question Bank
</button>
<button class="btn btn-outline" (click)="skipQuestion()">
Skip This Question
</button>
</div>
</div>
</div>
}
<!-- Alternative Answers -->
@if (alternativeAnswers().length > 0) {
<div class="alternatives-section">
<h4>🔄 Alternative Answers:</h4>
<div class="alternatives-list">
@for (alt of alternativeAnswers(); track alt.id) {
<div class="alternative-item" (click)="selectAnswer(alt.answer)">
<div class="alt-preview">{{ getAnswerPreview(alt.answer) }}</div>
<div class="alt-meta">
<span class="alt-confidence">{{ alt.confidence }}% match</span>
@if (alt.category) {
<span class="alt-category">{{ alt.category }}</span>
}
</div>
</div>
}
</div>
</div>
}
<!-- Quick Actions -->
<div class="quick-actions">
<button class="btn btn-sm btn-ghost" (click)="markAsHelpful(true)">
👍 Helpful
</button>
<button class="btn btn-sm btn-ghost" (click)="markAsHelpful(false)">
👎 Not Helpful
</button>
<button class="btn btn-sm btn-ghost" (click)="requestImprovement()">
Suggest Improvement
</button>
<button class="btn btn-sm btn-ghost" (click)="closePanel()">
Close
</button>
</div>
</div>
}
<!-- Feedback Toast -->
@if (showFeedbackToast()) {
<div class="feedback-toast" [class.success]="lastFeedbackPositive()">
{{ lastFeedbackPositive() ? 'Thank you for the feedback!' : 'Feedback noted. We\'ll improve this answer.' }}
</div>
}
</div>
`,
styleUrl: './question-display.component.scss'
})
export class QuestionDisplayComponent {
@Input() currentQuestion = signal<Question | null>(null);
@Input() suggestedAnswer = signal('');
@Input() isProcessing = signal(false);
@Input() responseTime = signal<number | null>(null);
@Output() answerSelected = new EventEmitter<string>();
@Output() questionSkipped = new EventEmitter<void>();
@Output() answerRequested = new EventEmitter<string>();
@Output() feedbackGiven = new EventEmitter<{question: string, answer: string, helpful: boolean}>();
// Component state
isAnswerExpanded = signal(false);
alternativeAnswers = signal<{id: string, answer: string, confidence: number, category?: string}[]>([]);
showFeedbackToast = signal(false);
lastFeedbackPositive = signal(true);
// Computed properties
isAnswerLong = computed(() => {
const answer = this.suggestedAnswer();
return answer.length > 200 || answer.split(' ').length > 50;
});
selectAnswer(answer: string) {
this.answerSelected.emit(answer);
this.trackAnswerUsage(answer);
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
// Could show a toast notification
});
}
toggleAnswerExpanded() {
this.isAnswerExpanded.update(expanded => !expanded);
}
formatAnswer(answer: string): string {
// Basic formatting for better readability
return answer
.replace(/\. /g, '.\n\n')
.replace(/: /g, ':\n')
.trim();
}
getAnswerWordCount(): number {
return this.suggestedAnswer().split(' ').length;
}
getAnswerPreview(answer: string): string {
const words = answer.split(' ');
return words.length > 15 ? words.slice(0, 15).join(' ') + '...' : answer;
}
getSourceDisplay(source: string): string {
switch (source) {
case 'cv-analysis': return 'CV Analysis';
case 'question-bank': return 'Question Bank';
case 'manual-input': return 'Manual Notes';
case 'ai-generated': return 'AI Generated';
default: return source;
}
}
requestNewAnswer() {
const question = this.currentQuestion();
if (question) {
this.answerRequested.emit(question.text);
}
}
addToQuestionBank() {
const question = this.currentQuestion();
if (question) {
// Open modal or form to add question to bank
console.log('Adding to question bank:', question.text);
}
}
skipQuestion() {
this.questionSkipped.emit();
}
markAsHelpful(helpful: boolean) {
const question = this.currentQuestion();
const answer = this.suggestedAnswer();
if (question && answer) {
this.feedbackGiven.emit({
question: question.text,
answer: answer,
helpful: helpful
});
this.lastFeedbackPositive.set(helpful);
this.showFeedbackToast.set(true);
// Hide toast after 3 seconds
setTimeout(() => {
this.showFeedbackToast.set(false);
}, 3000);
}
}
requestImprovement() {
// Open improvement request modal
console.log('Improvement requested for:', this.currentQuestion()?.text);
}
closePanel() {
// Reset all signals to hide the panel
this.currentQuestion.set(null);
this.suggestedAnswer.set('');
this.isProcessing.set(false);
this.responseTime.set(null);
this.isAnswerExpanded.set(false);
this.alternativeAnswers.set([]);
}
private trackAnswerUsage(answer: string) {
// Track which answers are being used for analytics
console.log('Answer used:', answer.substring(0, 50) + '...');
}
// Mock alternative answers (would come from service in real implementation)
private loadAlternativeAnswers(questionText: string) {
// This would be called when a question is set
// For now, just mock data
this.alternativeAnswers.set([
{
id: '1',
answer: 'Alternative explanation focusing on practical examples...',
confidence: 85,
category: 'practical'
},
{
id: '2',
answer: 'More technical explanation with code examples...',
confidence: 78,
category: 'technical'
}
]);
}
}

View File

@ -0,0 +1,121 @@
// CV Profile Data Model Interfaces
export interface CVProfile {
id: string; // UUID
fileName: string; // Original CV filename
uploadDate: Date; // When uploaded
personalInfo: PersonalInfo; // Extracted personal details
experience: WorkExperience[]; // Job history
education: Education[]; // Educational background
skills: Skill[]; // Technical and soft skills
certifications: Certification[]; // Professional certifications
languages: Language[]; // Spoken languages
parsedText: string; // Full extracted text
extractedText?: string; // Alternative property name for compatibility
lastModified: Date; // Last update timestamp
sensitiveDataWarnings?: string[]; // Warnings about sensitive information
}
export interface PersonalInfo {
fullName: string;
email: string;
phone: string;
location: string;
linkedIn?: string;
github?: string;
website?: string;
}
export interface WorkExperience {
id: string;
company: string;
position: string;
startDate: Date;
endDate?: Date; // null for current position
description: string;
technologies: string[]; // Tech stack used
achievements: string[]; // Key accomplishments
}
export interface Education {
id: string;
institution: string;
degree: string;
field: string;
startDate: Date;
endDate?: Date;
gpa?: number;
honors?: string[];
}
export interface Skill {
id: string;
name: string;
category: SkillCategory; // Technical, Soft, Language, etc.
proficiency: SkillLevel; // Beginner, Intermediate, Advanced, Expert
yearsOfExperience?: number;
lastUsed?: Date;
}
export interface Certification {
id: string;
name: string;
issuer: string;
issueDate: Date;
expiryDate?: Date;
credentialId?: string;
}
export interface Language {
id: string;
name: string;
proficiency: LanguageProficiency; // Native, Fluent, Conversational, Basic
}
// Enumerations for CV Profile
export enum SkillCategory {
TECHNICAL = 'technical',
SOFT = 'soft',
LANGUAGE = 'language',
CERTIFICATION = 'certification'
}
export enum SkillLevel {
BEGINNER = 'beginner',
INTERMEDIATE = 'intermediate',
ADVANCED = 'advanced',
EXPERT = 'expert'
}
export enum LanguageProficiency {
NATIVE = 'native',
FLUENT = 'fluent',
CONVERSATIONAL = 'conversational',
BASIC = 'basic'
}
// Utility types for CV processing
export interface CVParsingResult {
profile: CVProfile;
confidence: number;
warnings: string[];
suggestions: string[];
}
export interface CVCorrections {
personalInfo?: Partial<PersonalInfo>;
experience?: WorkExperience[];
education?: Education[];
skills?: Skill[];
certifications?: Certification[];
languages?: Language[];
}
export interface ParsingAccuracy {
overall: number;
personalInfo: number;
experience: number;
education: number;
skills: number;
certifications: number;
}

View File

@ -0,0 +1,166 @@
// Interview Session Data Model Interfaces
export interface InterviewSession {
id: string; // UUID
cvProfileId: string; // Associated CV
questionBankId: string; // Used question bank
startTime: Date; // Session start
endTime?: Date; // Session end (null if ongoing)
duration: number; // Session duration in seconds
detectedQuestions: DetectedQuestion[]; // Questions heard
providedAnswers: ProvidedAnswer[]; // Responses given
questionsAnswered: QuestionAnswer[]; // Legacy compatibility
manualComments: ManualComment[]; // User notes
speechHints: SpeechHint[]; // Conversation hints
analytics: SessionAnalytics; // Performance data
status: SessionStatus; // Active, Paused, Completed
}
export interface DetectedQuestion {
id: string;
timestamp: Date; // When detected
originalText: string; // Exact speech heard
normalizedText: string; // Cleaned/standardized version
confidence: number; // Detection confidence (0-1)
questionBankMatch?: string; // Matched question ID
responseTime: number; // ms to provide answer
wasHelpRequested: boolean; // User asked for help
isQuestion: boolean; // Determined to be a question vs statement
}
export interface ProvidedAnswer {
id: string;
questionId: string; // Reference to detected question
answerId?: string; // Question bank answer used
content: string; // Actual response provided
timestamp: Date; // When provided
source: AnswerSource; // QuestionBank, Generated, Manual
userRating?: number; // User feedback (1-5)
}
export interface ManualComment {
id: string;
timestamp: Date;
content: string; // User's note
category: CommentCategory; // Correction, Addition, Observation
relatedQuestionId?: string; // Associated question
tags: string[]; // Categorization
}
export interface SpeechHint {
id: string;
timestamp: Date;
text: string; // Detected speech pattern
hintType: HintType; // QuestionIntro, TopicShift, Clarification
confidence: number; // Detection confidence
preparedAnswers: string[]; // Question bank IDs prepared
type: string; // Alternative naming for compatibility
detectedText: string; // Alternative naming for compatibility
}
export interface SessionAnalytics {
totalQuestions: number;
answersFromBank: number;
answersGenerated: number;
averageResponseTime: number; // milliseconds
accuracyRate: number; // 0-1
topicsCovered: string[]; // Main subjects discussed
difficultyDistribution?: { [key in QuestionDifficulty]: number };
}
// Enumerations for Interview Session
export enum SessionStatus {
ACTIVE = 'active',
PAUSED = 'paused',
COMPLETED = 'completed'
}
export enum AnswerSource {
QUESTION_BANK = 'question_bank',
GENERATED = 'generated',
MANUAL = 'manual'
}
export enum CommentCategory {
CORRECTION = 'correction',
ADDITION = 'addition',
OBSERVATION = 'observation',
IMPROVEMENT = 'improvement'
}
export enum HintType {
QUESTION_INTRO = 'question_intro',
TOPIC_SHIFT = 'topic_shift',
CLARIFICATION = 'clarification',
FOLLOW_UP = 'follow_up'
}
export enum QuestionDifficulty {
EASY = 'easy',
MEDIUM = 'medium',
HARD = 'hard'
}
// Speech recognition interfaces
export interface SpeechResult {
transcript: string;
confidence: number;
isFinal: boolean;
timestamp: Date;
}
export interface SpeechAnalysis {
originalText: string;
isQuestion: boolean;
confidence: number;
questionType?: string;
detectedPatterns: string[];
}
export interface SpeechError {
type: string;
message: string;
timestamp: Date;
canRetry: boolean;
}
// Session management interfaces
export interface SessionSummary {
sessionId: string;
duration: number; // minutes
questionsAnswered: number;
successRate: number;
improvements: string[]; // Suggested enhancements
newQuestions: Question[]; // Questions to add to bank
}
export interface SessionConfiguration {
enableSpeechRecognition: boolean;
enableQuestionHints: boolean;
responseTimeTarget: number; // milliseconds
confidenceThreshold: number; // minimum confidence for auto-response
maxSessionDuration: number; // minutes
}
// Legacy compatibility interface
export interface QuestionAnswer {
question: string;
answer: string;
timestamp: Date;
responseTime: number;
source: string;
}
// Import from question bank for dependencies
interface Question {
id: string;
text: string;
category: any;
difficulty: any;
tags: string[];
answer: any;
relatedSkills: string[];
confidence: number;
timesUsed: number;
lastUsed?: Date;
}

View File

@ -0,0 +1,241 @@
// N8n Synchronization Data Model Interfaces
import { CVProfile } from './cv-profile.interface';
import { QuestionBank } from './question-bank.interface';
import { SessionAnalytics, SessionSummary } from './interview-session.interface';
export interface N8nSyncData {
id: string;
sessionId: string; // Associated session
lastSyncTime: Date; // When last synced
syncStatus: SyncStatus; // Pending, InProgress, Success, Failed
dataSnapshot: {
cvProfile: CVProfile;
questionBank: QuestionBank;
sessionSummary: SessionSummary;
analytics: SessionAnalytics;
};
errors?: SyncError[]; // Any sync failures
}
export interface SyncError {
timestamp: Date;
errorCode: string;
message: string;
retryCount: number;
}
export enum SyncStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
SUCCESS = 'success',
FAILED = 'failed'
}
// N8n API Request/Response Interfaces
export interface CVAnalysisRequest {
cvProfileId: string;
personalInfo: PersonalInfo;
experience: WorkExperience[];
education: Education[];
skills: Skill[];
certifications: Certification[];
parsedText: string;
}
export interface CVAnalysisResponse {
analysisId: string;
status: 'processing' | 'completed' | 'failed';
estimatedCompletionTime: number;
questionBankId: string;
}
export interface QuestionBankResponse {
questionBankId: string;
cvProfileId: string;
generatedDate: string;
questions: Question[];
metadata: QuestionBankMetadata;
}
export interface SessionSyncRequest {
sessionId: string;
cvProfileId: string;
startTime?: string;
endTime?: string;
detectedQuestions: DetectedQuestion[];
providedAnswers: ProvidedAnswer[];
manualComments: ManualComment[];
analytics: SessionAnalytics;
}
export interface SessionSyncResponse {
syncId: string;
status: 'success' | 'partial' | 'failed';
updatedQuestions: Question[];
recommendations: string[];
errors: SyncError[];
}
export interface AnalyticsRequest {
sessionId: string;
analytics: SessionAnalytics;
improvements: string[];
}
export interface AnalyticsResponse {
processed: boolean;
insights: string[];
recommendedActions: string[];
}
// N8n Authentication interfaces
export interface AutheliaAuthToken {
token: string;
expiresAt: Date;
refreshToken?: string;
}
export interface N8nApiConfig {
baseUrl: string;
authToken: string;
timeout: number;
retryAttempts: number;
}
// Data transformation interfaces
export interface DataTransformer<TInput, TOutput> {
transform(input: TInput): TOutput;
validate(data: TOutput): boolean;
}
export interface N8nDataMapper {
mapCVProfileToRequest(profile: CVProfile): CVAnalysisRequest;
mapResponseToQuestionBank(response: QuestionBankResponse): QuestionBank;
mapSessionToSyncRequest(session: InterviewSession): SessionSyncRequest;
mapSyncResponseToSession(response: SessionSyncResponse, session: InterviewSession): InterviewSession;
}
// Import types from other interfaces (for compatibility)
interface PersonalInfo {
fullName: string;
email: string;
phone: string;
location: string;
linkedIn?: string;
github?: string;
website?: string;
}
interface WorkExperience {
id: string;
company: string;
position: string;
startDate: Date;
endDate?: Date;
description: string;
technologies: string[];
achievements: string[];
}
interface Education {
id: string;
institution: string;
degree: string;
field: string;
startDate: Date;
endDate?: Date;
gpa?: number;
honors?: string[];
}
interface Skill {
id: string;
name: string;
category: string;
proficiency: string;
yearsOfExperience?: number;
lastUsed?: Date;
}
interface Certification {
id: string;
name: string;
issuer: string;
issueDate: Date;
expiryDate?: Date;
credentialId?: string;
}
interface Question {
id: string;
text: string;
category: string;
difficulty: string;
tags: string[];
answer: Answer;
relatedSkills: string[];
confidence: number;
timesUsed: number;
lastUsed?: Date;
}
interface Answer {
id: string;
content: string;
keyPoints: string[];
followUpQuestions: string[];
estimatedDuration: number;
personalizedContext: string;
}
interface QuestionBankMetadata {
totalQuestions: number;
categoriesDistribution: { [key: string]: number };
averageConfidence: number;
lastUpdated: string;
}
interface DetectedQuestion {
id: string;
timestamp: Date;
originalText: string;
normalizedText: string;
confidence: number;
questionBankMatch?: string;
responseTime: number;
wasHelpRequested: boolean;
}
interface ProvidedAnswer {
id: string;
questionId: string;
answerId?: string;
content: string;
timestamp: Date;
source: string;
userRating?: number;
}
interface ManualComment {
id: string;
timestamp: Date;
content: string;
category: string;
relatedQuestionId?: string;
tags: string[];
}
interface InterviewSession {
id: string;
cvProfileId: string;
questionBankId: string;
startTime: Date;
endTime?: Date;
detectedQuestions: DetectedQuestion[];
providedAnswers: ProvidedAnswer[];
manualComments: ManualComment[];
speechHints: any[];
analytics: SessionAnalytics;
status: string;
}

View File

@ -0,0 +1,141 @@
// Question Bank Data Model Interfaces
export interface QuestionBank {
id: string; // UUID
cvProfileId: string; // Associated CV profile
questions: Question[]; // Generated questions
generatedDate: Date; // When created
lastUsed: Date; // Last accessed
accuracy: number; // Success rate (0-1)
metadata: QuestionBankMetadata;
}
export interface Question {
id: string;
text: string; // The actual question
category: QuestionCategory; // Technical, Behavioral, Experience
difficulty: QuestionDifficulty; // Easy, Medium, Hard
tags: string[]; // Keywords for matching
answer: Answer; // Prepared response
relatedSkills: string[]; // Associated skills from CV
confidence: number; // AI confidence in answer (0-1)
timesUsed: number; // Usage frequency
lastUsed?: Date; // Last accessed
source?: string; // Source of the question (cv-analysis, question-bank, etc.)
}
export interface Answer {
id: string;
questionId?: string; // Reference to parent question
content: string; // Main response text
keyPoints: string[]; // Bullet points for quick reference
followUpQuestions: string[]; // Potential follow-ups
estimatedDuration: number; // Seconds to deliver
personalizedContext: string; // CV-specific details to include
}
export interface QuestionBankMetadata {
totalQuestions: number;
categoriesDistribution: { [key: string]: number };
averageConfidence: number;
lastUpdated: string;
}
// Enumerations for Question Bank
export enum QuestionCategory {
TECHNICAL = 'technical',
BEHAVIORAL = 'behavioral',
EXPERIENCE = 'experience',
SITUATIONAL = 'situational',
COMPANY_SPECIFIC = 'company_specific',
EDUCATION = 'education',
GENERAL = 'general'
}
export enum QuestionDifficulty {
EASY = 'easy',
MEDIUM = 'medium',
HARD = 'hard'
}
// Question matching and retrieval interfaces
export interface QuestionMatch {
question: Question;
similarity: number;
matchType: 'exact' | 'high' | 'medium' | 'low';
}
export interface QuestionSearchOptions {
maxResults?: number;
minConfidence?: number;
categories?: QuestionCategory[];
difficulties?: QuestionDifficulty[];
fuzzyThreshold?: number;
}
export interface QuestionBankOptimization {
pruneThreshold: number; // Remove questions below this confidence
maxQuestions: number; // Maximum questions to keep
categoryBalance: boolean; // Maintain category distribution
}
// Question generation interfaces
export interface QuestionGenerationRequest {
cvProfileId: string;
focusAreas: string[];
difficulty: QuestionDifficulty[];
maxQuestions: number;
personalizeAnswers: boolean;
}
export interface QuestionGenerationResponse {
questionBankId: string;
questionsGenerated: number;
confidence: number;
estimatedAccuracy: number;
processingTime: number;
}
// Additional interfaces for question-bank.service.ts
export interface QuestionSearchCriteria {
query?: string;
category?: QuestionCategory;
difficulty?: QuestionDifficulty;
minConfidence?: number;
tags?: string[];
sortBy?: 'confidence' | 'lastUsed' | 'timesUsed' | 'text';
sortOrder?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface QuestionMatchResult {
hasMatch: boolean;
confidence: number;
matches: QuestionMatch[];
}
export interface QuestionSearchResult {
results: Question[];
totalResults: number;
searchTime: number;
}
export interface QuestionBankGeneration {
includeGeneral: boolean;
includeTechnical: boolean;
includeExperience: boolean;
includeEducation: boolean;
maxQuestionsPerCategory: number;
personalizeAnswers: boolean;
}
export interface QuestionOptimization {
removeUnused?: boolean;
unusedThresholdDays?: number;
removeLowConfidence?: boolean;
confidenceThreshold?: number;
mergeSimilar?: boolean;
similarityThreshold?: number;
rebalanceCategories?: boolean;
}

View File

@ -0,0 +1,460 @@
// Validation Schemas and Utility Functions
import {
CVProfile,
PersonalInfo,
WorkExperience,
Education,
Skill,
Certification,
Language,
SkillCategory,
SkillLevel,
LanguageProficiency
} from './cv-profile.interface';
import {
QuestionBank,
Question,
Answer,
QuestionCategory,
QuestionDifficulty
} from './question-bank.interface';
import {
InterviewSession,
DetectedQuestion,
ProvidedAnswer,
ManualComment,
SessionStatus,
AnswerSource,
CommentCategory
} from './interview-session.interface';
// Validation result interface
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
field: string;
message: string;
code: string;
value?: any;
}
export interface ValidationWarning {
field: string;
message: string;
suggestion?: string;
}
// CV Profile Validation
export class CVProfileValidator {
static validate(profile: CVProfile): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate required fields
if (!profile.id || !this.isValidUUID(profile.id)) {
errors.push({
field: 'id',
message: 'Invalid or missing UUID',
code: 'INVALID_UUID'
});
}
if (!profile.fileName || profile.fileName.trim().length === 0) {
errors.push({
field: 'fileName',
message: 'File name is required',
code: 'REQUIRED_FIELD'
});
}
// Validate personal info
const personalInfoResult = this.validatePersonalInfo(profile.personalInfo);
errors.push(...personalInfoResult.errors);
warnings.push(...personalInfoResult.warnings);
// Validate experience
profile.experience.forEach((exp, index) => {
const expResult = this.validateWorkExperience(exp);
expResult.errors.forEach(error => {
errors.push({
...error,
field: `experience[${index}].${error.field}`
});
});
});
// Validate education
profile.education.forEach((edu, index) => {
const eduResult = this.validateEducation(edu);
eduResult.errors.forEach(error => {
errors.push({
...error,
field: `education[${index}].${error.field}`
});
});
});
// Validate skills
profile.skills.forEach((skill, index) => {
const skillResult = this.validateSkill(skill);
skillResult.errors.forEach(error => {
errors.push({
...error,
field: `skills[${index}].${error.field}`
});
});
});
return {
isValid: errors.length === 0,
errors,
warnings
};
}
private static validatePersonalInfo(info: PersonalInfo): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate email
if (!info.email || !this.isValidEmail(info.email)) {
errors.push({
field: 'email',
message: 'Valid email address is required',
code: 'INVALID_EMAIL',
value: info.email
});
}
// Validate phone
if (info.phone && !this.isValidPhone(info.phone)) {
warnings.push({
field: 'phone',
message: 'Phone number format may be invalid',
suggestion: 'Use international format: +1234567890'
});
}
// Validate full name
if (!info.fullName || info.fullName.trim().length < 2) {
errors.push({
field: 'fullName',
message: 'Full name must be at least 2 characters',
code: 'INVALID_NAME'
});
}
return { isValid: errors.length === 0, errors, warnings };
}
private static validateWorkExperience(exp: WorkExperience): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate dates
if (exp.endDate && exp.startDate >= exp.endDate) {
errors.push({
field: 'endDate',
message: 'End date must be after start date',
code: 'INVALID_DATE_RANGE'
});
}
// Validate company and position
if (!exp.company || exp.company.trim().length === 0) {
errors.push({
field: 'company',
message: 'Company name is required',
code: 'REQUIRED_FIELD'
});
}
if (!exp.position || exp.position.trim().length === 0) {
errors.push({
field: 'position',
message: 'Position title is required',
code: 'REQUIRED_FIELD'
});
}
return { isValid: errors.length === 0, errors, warnings };
}
private static validateEducation(edu: Education): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate GPA
if (edu.gpa !== undefined && (edu.gpa < 0 || edu.gpa > 4.0)) {
errors.push({
field: 'gpa',
message: 'GPA must be between 0.0 and 4.0',
code: 'INVALID_GPA',
value: edu.gpa
});
}
// Validate required fields
if (!edu.institution || edu.institution.trim().length === 0) {
errors.push({
field: 'institution',
message: 'Institution name is required',
code: 'REQUIRED_FIELD'
});
}
return { isValid: errors.length === 0, errors, warnings };
}
private static validateSkill(skill: Skill): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate skill category
if (!Object.values(SkillCategory).includes(skill.category)) {
errors.push({
field: 'category',
message: 'Invalid skill category',
code: 'INVALID_ENUM_VALUE',
value: skill.category
});
}
// Validate proficiency
if (!Object.values(SkillLevel).includes(skill.proficiency)) {
errors.push({
field: 'proficiency',
message: 'Invalid skill proficiency level',
code: 'INVALID_ENUM_VALUE',
value: skill.proficiency
});
}
// Validate years of experience
if (skill.yearsOfExperience !== undefined) {
if (skill.yearsOfExperience < 0 || skill.yearsOfExperience > 50) {
warnings.push({
field: 'yearsOfExperience',
message: 'Years of experience seems unusual',
suggestion: 'Verify the number is correct'
});
}
}
return { isValid: errors.length === 0, errors, warnings };
}
// Utility validation methods
private static isValidUUID(uuid: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
}
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
private static isValidPhone(phone: string): boolean {
const phoneRegex = /^\+?[\d\s\-\(\)]{10,20}$/;
return phoneRegex.test(phone);
}
}
// Question Bank Validation
export class QuestionBankValidator {
static validate(questionBank: QuestionBank): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate confidence range
if (questionBank.accuracy < 0 || questionBank.accuracy > 1) {
errors.push({
field: 'accuracy',
message: 'Accuracy must be between 0 and 1',
code: 'INVALID_RANGE',
value: questionBank.accuracy
});
}
// Validate questions
questionBank.questions.forEach((question, index) => {
const questionResult = this.validateQuestion(question);
questionResult.errors.forEach(error => {
errors.push({
...error,
field: `questions[${index}].${error.field}`
});
});
});
// Check for duplicate questions
const questionTexts = questionBank.questions.map(q => q.text.toLowerCase().trim());
const duplicates = questionTexts.filter((text, index) => questionTexts.indexOf(text) !== index);
if (duplicates.length > 0) {
warnings.push({
field: 'questions',
message: 'Duplicate questions detected',
suggestion: 'Remove or merge similar questions'
});
}
return { isValid: errors.length === 0, errors, warnings };
}
private static validateQuestion(question: Question): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate category
if (!Object.values(QuestionCategory).includes(question.category)) {
errors.push({
field: 'category',
message: 'Invalid question category',
code: 'INVALID_ENUM_VALUE',
value: question.category
});
}
// Validate difficulty
if (!Object.values(QuestionDifficulty).includes(question.difficulty)) {
errors.push({
field: 'difficulty',
message: 'Invalid question difficulty',
code: 'INVALID_ENUM_VALUE',
value: question.difficulty
});
}
// Validate confidence
if (question.confidence < 0 || question.confidence > 1) {
errors.push({
field: 'confidence',
message: 'Confidence must be between 0 and 1',
code: 'INVALID_RANGE',
value: question.confidence
});
}
// Validate answer
const answerResult = this.validateAnswer(question.answer);
answerResult.errors.forEach(error => {
errors.push({
...error,
field: `answer.${error.field}`
});
});
return { isValid: errors.length === 0, errors, warnings };
}
private static validateAnswer(answer: Answer): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate estimated duration
if (answer.estimatedDuration < 5 || answer.estimatedDuration > 300) {
warnings.push({
field: 'estimatedDuration',
message: 'Estimated duration seems unusual (5-300 seconds expected)',
suggestion: 'Verify the duration is realistic'
});
}
// Validate content length
if (!answer.content || answer.content.trim().length < 10) {
errors.push({
field: 'content',
message: 'Answer content must be at least 10 characters',
code: 'INSUFFICIENT_CONTENT'
});
}
if (answer.content && answer.content.length > 2000) {
warnings.push({
field: 'content',
message: 'Answer content is very long',
suggestion: 'Consider breaking into key points'
});
}
return { isValid: errors.length === 0, errors, warnings };
}
}
// Session Validation
export class InterviewSessionValidator {
static validate(session: InterviewSession): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Validate session timing
if (session.endTime && session.startTime >= session.endTime) {
errors.push({
field: 'endTime',
message: 'End time must be after start time',
code: 'INVALID_TIME_RANGE'
});
}
// Validate status
if (!Object.values(SessionStatus).includes(session.status)) {
errors.push({
field: 'status',
message: 'Invalid session status',
code: 'INVALID_ENUM_VALUE',
value: session.status
});
}
// Validate analytics
if (session.analytics.accuracyRate < 0 || session.analytics.accuracyRate > 1) {
errors.push({
field: 'analytics.accuracyRate',
message: 'Accuracy rate must be between 0 and 1',
code: 'INVALID_RANGE',
value: session.analytics.accuracyRate
});
}
return { isValid: errors.length === 0, errors, warnings };
}
}
// Utility functions for data sanitization
export class DataSanitizer {
static sanitizeText(text: string): string {
return text
.trim()
.replace(/\s+/g, ' ') // Normalize whitespace
.replace(/[^\w\s\-\.\@]/g, ''); // Remove special characters except basic ones
}
static normalizePhoneNumber(phone: string): string {
return phone.replace(/[\s\-\(\)]/g, '');
}
static normalizeEmail(email: string): string {
return email.toLowerCase().trim();
}
static generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}
// Export all validation functions
export const validateCVProfile = CVProfileValidator.validate.bind(CVProfileValidator);
export const validateQuestionBank = QuestionBankValidator.validate.bind(QuestionBankValidator);
export const validateInterviewSession = InterviewSessionValidator.validate.bind(InterviewSessionValidator);

View File

@ -0,0 +1,630 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
import { map, tap, filter } from 'rxjs/operators';
import {
InterviewSession,
SessionAnalytics,
DetectedQuestion,
ProvidedAnswer,
QuestionDifficulty,
AnswerSource
} from '../models/interview-session.interface';
import { Question, QuestionCategory } from '../models/question-bank.interface';
export interface PerformanceMetrics {
overallScore: number;
responseTimeScore: number;
accuracyScore: number;
completenessScore: number;
improvementAreas: string[];
strengths: string[];
}
export interface SessionComparison {
currentSession: SessionAnalytics;
previousSession?: SessionAnalytics;
improvement: {
responseTime: number;
accuracy: number;
questionsAnswered: number;
};
trends: {
improving: string[];
declining: string[];
stable: string[];
};
}
export interface CategoryPerformance {
category: QuestionCategory;
questionsAnswered: number;
averageResponseTime: number;
accuracyRate: number;
difficulty: {
[QuestionDifficulty.EASY]: number;
[QuestionDifficulty.MEDIUM]: number;
[QuestionDifficulty.HARD]: number;
};
}
@Injectable({
providedIn: 'root'
})
export class AnalyticsService {
private currentSessionSubject = new BehaviorSubject<InterviewSession | null>(null);
private sessionsHistorySubject = new BehaviorSubject<InterviewSession[]>([]);
private performanceMetricsSubject = new BehaviorSubject<PerformanceMetrics | null>(null);
public currentSession$ = this.currentSessionSubject.asObservable();
public sessionsHistory$ = this.sessionsHistorySubject.asObservable();
public performanceMetrics$ = this.performanceMetricsSubject.asObservable();
// Real-time analytics streams
public realTimeAnalytics$ = combineLatest([
this.currentSession$,
this.performanceMetrics$
]).pipe(
filter(([session, metrics]) => !!session),
map(([session, metrics]) => ({
session: session!,
metrics,
liveStats: this.calculateLiveStats(session!)
}))
);
constructor() {
this.loadSessionsHistory();
}
// Session Management
public startSessionAnalytics(session: InterviewSession): void {
this.currentSessionSubject.next(session);
this.updatePerformanceMetrics(session);
}
public updateSessionAnalytics(session: InterviewSession): void {
this.currentSessionSubject.next(session);
this.updatePerformanceMetrics(session);
}
public completeSessionAnalytics(session: InterviewSession): Observable<SessionAnalytics> {
const analytics = this.calculateFinalAnalytics(session);
// Update session with final analytics
session.analytics = analytics;
// Add to history
this.addToHistory(session);
// Clear current session
this.currentSessionSubject.next(null);
this.performanceMetricsSubject.next(null);
return new Observable(observer => {
observer.next(analytics);
observer.complete();
});
}
// Performance Analysis
public calculatePerformanceMetrics(session: InterviewSession): PerformanceMetrics {
const analytics = session.analytics;
const totalQuestions = analytics.totalQuestions;
if (totalQuestions === 0) {
return {
overallScore: 0,
responseTimeScore: 0,
accuracyScore: 0,
completenessScore: 0,
improvementAreas: ['No questions answered yet'],
strengths: []
};
}
// Calculate individual scores (0-100)
const responseTimeScore = this.calculateResponseTimeScore(analytics.averageResponseTime);
const accuracyScore = analytics.accuracyRate * 100;
const completenessScore = this.calculateCompletenessScore(session);
// Calculate overall score (weighted average)
const overallScore = (
responseTimeScore * 0.3 +
accuracyScore * 0.4 +
completenessScore * 0.3
);
const improvementAreas = this.identifyImprovementAreas(session);
const strengths = this.identifyStrengths(session);
return {
overallScore: Math.round(overallScore),
responseTimeScore: Math.round(responseTimeScore),
accuracyScore: Math.round(accuracyScore),
completenessScore: Math.round(completenessScore),
improvementAreas,
strengths
};
}
public compareWithPreviousSession(currentSession: InterviewSession): SessionComparison {
const history = this.sessionsHistorySubject.value;
const previousSession = history.length > 0 ? history[history.length - 1] : undefined;
const currentAnalytics = currentSession.analytics;
const previousAnalytics = previousSession?.analytics;
const improvement = {
responseTime: previousAnalytics ?
((previousAnalytics.averageResponseTime - currentAnalytics.averageResponseTime) / previousAnalytics.averageResponseTime) * 100 : 0,
accuracy: previousAnalytics ?
(currentAnalytics.accuracyRate - previousAnalytics.accuracyRate) * 100 : 0,
questionsAnswered: previousAnalytics ?
currentAnalytics.totalQuestions - previousAnalytics.totalQuestions : currentAnalytics.totalQuestions
};
const trends = this.analyzeTrends(currentAnalytics, previousAnalytics);
return {
currentSession: currentAnalytics,
previousSession: previousAnalytics,
improvement,
trends
};
}
public analyzeCategoryPerformance(session: InterviewSession): CategoryPerformance[] {
const categoryMap = new Map<QuestionCategory, {
questions: DetectedQuestion[];
answers: ProvidedAnswer[];
responseTimes: number[];
}>();
// Group questions by category
session.detectedQuestions.forEach(question => {
// Note: We'd need to get category from question bank
// For now, assuming we can determine category from text patterns
const category = this.inferQuestionCategory(question.originalText);
if (!categoryMap.has(category)) {
categoryMap.set(category, {
questions: [],
answers: [],
responseTimes: []
});
}
const categoryData = categoryMap.get(category)!;
categoryData.questions.push(question);
categoryData.responseTimes.push(question.responseTime);
// Find corresponding answer
const answer = session.providedAnswers.find(a => a.questionId === question.id);
if (answer) {
categoryData.answers.push(answer);
}
});
// Calculate performance metrics for each category
const performances: CategoryPerformance[] = [];
categoryMap.forEach((data, category) => {
const questionsAnswered = data.answers.length;
const averageResponseTime = data.responseTimes.reduce((sum, time) => sum + time, 0) / data.responseTimes.length;
const accuracyRate = this.calculateCategoryAccuracy(data.questions, data.answers);
// Analyze difficulty distribution (simplified)
const difficulty = {
[QuestionDifficulty.EASY]: data.questions.filter(q => this.inferDifficulty(q) === QuestionDifficulty.EASY).length,
[QuestionDifficulty.MEDIUM]: data.questions.filter(q => this.inferDifficulty(q) === QuestionDifficulty.MEDIUM).length,
[QuestionDifficulty.HARD]: data.questions.filter(q => this.inferDifficulty(q) === QuestionDifficulty.HARD).length
};
performances.push({
category,
questionsAnswered,
averageResponseTime,
accuracyRate,
difficulty
});
});
return performances;
}
// Real-time Statistics
private calculateLiveStats(session: InterviewSession) {
const now = new Date();
const sessionDuration = session.startTime ? (now.getTime() - session.startTime.getTime()) / 1000 / 60 : 0; // minutes
return {
sessionDuration: Math.round(sessionDuration),
questionsPerMinute: sessionDuration > 0 ? session.detectedQuestions.length / sessionDuration : 0,
currentStreak: this.calculateCurrentStreak(session),
timeToNextQuestion: this.estimateTimeToNextQuestion(session)
};
}
private calculateCurrentStreak(session: InterviewSession): number {
const recentAnswers = session.providedAnswers
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 10);
let streak = 0;
for (const answer of recentAnswers) {
if (answer.userRating && answer.userRating >= 4) {
streak++;
} else {
break;
}
}
return streak;
}
private estimateTimeToNextQuestion(session: InterviewSession): number {
const recentQuestions = session.detectedQuestions
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 5);
if (recentQuestions.length < 2) return 0;
const intervals = [];
for (let i = 0; i < recentQuestions.length - 1; i++) {
const interval = recentQuestions[i].timestamp.getTime() - recentQuestions[i + 1].timestamp.getTime();
intervals.push(interval / 1000); // Convert to seconds
}
return Math.round(intervals.reduce((sum, interval) => sum + interval, 0) / intervals.length);
}
// Analytics Calculations
private calculateFinalAnalytics(session: InterviewSession): SessionAnalytics {
const detectedQuestions = session.detectedQuestions;
const providedAnswers = session.providedAnswers;
const totalQuestions = detectedQuestions.length;
const answersFromBank = providedAnswers.filter(a => a.source === AnswerSource.QUESTION_BANK).length;
const answersGenerated = providedAnswers.filter(a => a.source === AnswerSource.GENERATED).length;
const responseTimes = detectedQuestions.map(q => q.responseTime).filter(time => time > 0);
const averageResponseTime = responseTimes.length > 0 ?
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length : 0;
const accuracyRate = this.calculateOverallAccuracy(detectedQuestions, providedAnswers);
const topicsCovered = this.extractTopicsCovered(session);
const difficultyDistribution = this.calculateDifficultyDistribution(detectedQuestions);
return {
totalQuestions,
answersFromBank,
answersGenerated,
averageResponseTime,
accuracyRate,
topicsCovered,
difficultyDistribution
};
}
private calculateResponseTimeScore(averageResponseTime: number): number {
// Ideal response time is 3-5 seconds
// Score decreases as time increases beyond 5 seconds or below 2 seconds
if (averageResponseTime <= 0) return 0;
const idealTime = 4000; // 4 seconds in milliseconds
const tolerance = 2000; // 2 seconds tolerance
if (averageResponseTime >= idealTime - tolerance && averageResponseTime <= idealTime + tolerance) {
return 100;
}
const deviation = Math.abs(averageResponseTime - idealTime);
const score = Math.max(0, 100 - (deviation / 100)); // Lose 1 point per 100ms deviation
return Math.min(100, score);
}
private calculateCompletenessScore(session: InterviewSession): number {
const questionsAnswered = session.providedAnswers.length;
const questionsDetected = session.detectedQuestions.length;
if (questionsDetected === 0) return 100;
const completionRate = questionsAnswered / questionsDetected;
return completionRate * 100;
}
private calculateOverallAccuracy(questions: DetectedQuestion[], answers: ProvidedAnswer[]): number {
if (answers.length === 0) return 0;
const ratedAnswers = answers.filter(a => a.userRating !== undefined);
if (ratedAnswers.length === 0) return 0.8; // Default accuracy if no ratings
const totalRating = ratedAnswers.reduce((sum, answer) => sum + (answer.userRating || 0), 0);
const maxPossibleRating = ratedAnswers.length * 5;
return totalRating / maxPossibleRating;
}
private calculateCategoryAccuracy(questions: DetectedQuestion[], answers: ProvidedAnswer[]): number {
if (answers.length === 0) return 0;
const ratedAnswers = answers.filter(a => a.userRating !== undefined);
if (ratedAnswers.length === 0) return 0.8;
const totalRating = ratedAnswers.reduce((sum, answer) => sum + (answer.userRating || 0), 0);
const maxPossibleRating = ratedAnswers.length * 5;
return totalRating / maxPossibleRating;
}
private extractTopicsCovered(session: InterviewSession): string[] {
const topics = new Set<string>();
session.detectedQuestions.forEach(question => {
// Extract topics from question text using simple keyword matching
const text = question.originalText.toLowerCase();
// Technical topics
if (text.includes('javascript') || text.includes('js')) topics.add('JavaScript');
if (text.includes('react') || text.includes('angular') || text.includes('vue')) topics.add('Frontend Frameworks');
if (text.includes('node') || text.includes('express') || text.includes('api')) topics.add('Backend Development');
if (text.includes('database') || text.includes('sql') || text.includes('mongodb')) topics.add('Databases');
if (text.includes('test') || text.includes('testing')) topics.add('Testing');
// Behavioral topics
if (text.includes('team') || text.includes('collaboration')) topics.add('Teamwork');
if (text.includes('challenge') || text.includes('problem')) topics.add('Problem Solving');
if (text.includes('lead') || text.includes('manage')) topics.add('Leadership');
if (text.includes('conflict') || text.includes('difficult')) topics.add('Conflict Resolution');
});
return Array.from(topics);
}
private calculateDifficultyDistribution(questions: DetectedQuestion[]): { [key in QuestionDifficulty]: number } {
const distribution = {
[QuestionDifficulty.EASY]: 0,
[QuestionDifficulty.MEDIUM]: 0,
[QuestionDifficulty.HARD]: 0
};
questions.forEach(question => {
const difficulty = this.inferDifficulty(question);
distribution[difficulty]++;
});
return distribution;
}
private identifyImprovementAreas(session: InterviewSession): string[] {
const areas: string[] = [];
const analytics = session.analytics;
if (analytics.averageResponseTime > 8000) {
areas.push('Response time could be faster');
}
if (analytics.accuracyRate < 0.7) {
areas.push('Answer quality needs improvement');
}
if (analytics.answersFromBank / analytics.totalQuestions < 0.5) {
areas.push('Better preparation with question bank needed');
}
const categoryPerformance = this.analyzeCategoryPerformance(session);
const weakCategories = categoryPerformance.filter(cp => cp.accuracyRate < 0.6);
weakCategories.forEach(category => {
areas.push(`Improve ${category.category} questions`);
});
return areas;
}
private identifyStrengths(session: InterviewSession): string[] {
const strengths: string[] = [];
const analytics = session.analytics;
if (analytics.averageResponseTime < 5000) {
strengths.push('Quick response times');
}
if (analytics.accuracyRate > 0.8) {
strengths.push('High answer quality');
}
if (analytics.answersFromBank / analytics.totalQuestions > 0.7) {
strengths.push('Well-prepared with question bank');
}
const categoryPerformance = this.analyzeCategoryPerformance(session);
const strongCategories = categoryPerformance.filter(cp => cp.accuracyRate > 0.8);
strongCategories.forEach(category => {
strengths.push(`Strong in ${category.category} questions`);
});
return strengths;
}
private analyzeTrends(current: SessionAnalytics, previous?: SessionAnalytics): { improving: string[], declining: string[], stable: string[] } {
const trends = {
improving: [] as string[],
declining: [] as string[],
stable: [] as string[]
};
if (!previous) {
return trends;
}
// Response time trend
const responseTimeDiff = (previous.averageResponseTime - current.averageResponseTime) / previous.averageResponseTime;
if (responseTimeDiff > 0.1) trends.improving.push('Response time');
else if (responseTimeDiff < -0.1) trends.declining.push('Response time');
else trends.stable.push('Response time');
// Accuracy trend
const accuracyDiff = current.accuracyRate - previous.accuracyRate;
if (accuracyDiff > 0.05) trends.improving.push('Answer accuracy');
else if (accuracyDiff < -0.05) trends.declining.push('Answer accuracy');
else trends.stable.push('Answer accuracy');
// Questions answered trend
const questionsDiff = current.totalQuestions - previous.totalQuestions;
if (questionsDiff > 2) trends.improving.push('Questions answered');
else if (questionsDiff < -2) trends.declining.push('Questions answered');
else trends.stable.push('Questions answered');
return trends;
}
// Helper Methods
private inferQuestionCategory(questionText: string): QuestionCategory {
const text = questionText.toLowerCase();
if (text.includes('experience') || text.includes('project') || text.includes('work')) {
return QuestionCategory.EXPERIENCE;
}
if (text.includes('technical') || text.includes('code') || text.includes('programming')) {
return QuestionCategory.TECHNICAL;
}
if (text.includes('team') || text.includes('challenge') || text.includes('difficult')) {
return QuestionCategory.BEHAVIORAL;
}
if (text.includes('education') || text.includes('degree') || text.includes('school')) {
return QuestionCategory.EDUCATION;
}
return QuestionCategory.GENERAL;
}
private inferDifficulty(question: DetectedQuestion): QuestionDifficulty {
const text = question.originalText.toLowerCase();
const complexWords = ['complex', 'challenging', 'difficult', 'advanced', 'sophisticated'];
const easyWords = ['simple', 'basic', 'straightforward', 'easy'];
if (complexWords.some(word => text.includes(word))) {
return QuestionDifficulty.HARD;
}
if (easyWords.some(word => text.includes(word))) {
return QuestionDifficulty.EASY;
}
// Default to medium
return QuestionDifficulty.MEDIUM;
}
private updatePerformanceMetrics(session: InterviewSession): void {
const metrics = this.calculatePerformanceMetrics(session);
this.performanceMetricsSubject.next(metrics);
}
private addToHistory(session: InterviewSession): void {
const history = this.sessionsHistorySubject.value;
const updatedHistory = [...history, session];
// Keep only last 10 sessions
if (updatedHistory.length > 10) {
updatedHistory.shift();
}
this.sessionsHistorySubject.next(updatedHistory);
this.saveSessionsHistory(updatedHistory);
}
private loadSessionsHistory(): void {
try {
const stored = localStorage.getItem('interview_sessions_history');
if (stored) {
const history = JSON.parse(stored);
// Convert date strings back to Date objects
const convertedHistory = history.map((session: any) => ({
...session,
startTime: new Date(session.startTime),
endTime: session.endTime ? new Date(session.endTime) : undefined,
detectedQuestions: session.detectedQuestions.map((q: any) => ({
...q,
timestamp: new Date(q.timestamp)
})),
providedAnswers: session.providedAnswers.map((a: any) => ({
...a,
timestamp: new Date(a.timestamp)
})),
manualComments: session.manualComments.map((c: any) => ({
...c,
timestamp: new Date(c.timestamp)
}))
}));
this.sessionsHistorySubject.next(convertedHistory);
}
} catch (error) {
console.error('Failed to load sessions history:', error);
}
}
private saveSessionsHistory(history: InterviewSession[]): void {
try {
localStorage.setItem('interview_sessions_history', JSON.stringify(history));
} catch (error) {
console.error('Failed to save sessions history:', error);
}
}
// Export Methods
public exportSessionAnalytics(session: InterviewSession): string {
const analytics = {
session: {
id: session.id,
startTime: session.startTime,
endTime: session.endTime,
duration: session.endTime ?
(session.endTime.getTime() - session.startTime.getTime()) / 1000 / 60 : 0
},
analytics: session.analytics,
performance: this.calculatePerformanceMetrics(session),
categoryPerformance: this.analyzeCategoryPerformance(session),
comparison: this.compareWithPreviousSession(session)
};
return JSON.stringify(analytics, null, 2);
}
public generateAnalyticsReport(session: InterviewSession): string {
const metrics = this.calculatePerformanceMetrics(session);
const comparison = this.compareWithPreviousSession(session);
let report = `Interview Session Analytics Report\n`;
report += `=====================================\n\n`;
report += `Session ID: ${session.id}\n`;
report += `Date: ${session.startTime.toLocaleDateString()}\n`;
report += `Duration: ${session.endTime ? Math.round((session.endTime.getTime() - session.startTime.getTime()) / 1000 / 60) : 'Ongoing'} minutes\n\n`;
report += `Performance Metrics:\n`;
report += `- Overall Score: ${metrics.overallScore}/100\n`;
report += `- Response Time Score: ${metrics.responseTimeScore}/100\n`;
report += `- Accuracy Score: ${metrics.accuracyScore}/100\n`;
report += `- Completeness Score: ${metrics.completenessScore}/100\n\n`;
report += `Strengths:\n`;
metrics.strengths.forEach(strength => {
report += `- ${strength}\n`;
});
report += `\nImprovement Areas:\n`;
metrics.improvementAreas.forEach(area => {
report += `- ${area}\n`;
});
if (comparison.previousSession) {
report += `\nComparison with Previous Session:\n`;
report += `- Response Time: ${comparison.improvement.responseTime > 0 ? 'Improved' : 'Declined'} by ${Math.abs(comparison.improvement.responseTime).toFixed(1)}%\n`;
report += `- Accuracy: ${comparison.improvement.accuracy > 0 ? 'Improved' : 'Declined'} by ${Math.abs(comparison.improvement.accuracy).toFixed(1)}%\n`;
report += `- Questions Answered: ${comparison.improvement.questionsAnswered > 0 ? 'Increased' : 'Decreased'} by ${Math.abs(comparison.improvement.questionsAnswered)}\n`;
}
return report;
}
}

View File

@ -0,0 +1,585 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { BehaviorSubject, Observable, of, throwError, timer } from 'rxjs';
import { map, catchError, tap, switchMap, filter } from 'rxjs/operators';
export interface AuthCredentials {
username: string;
password: string;
}
export interface AuthToken {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
scope?: string;
}
export interface User {
id: string;
username: string;
email?: string;
displayName?: string;
roles: string[];
permissions: string[];
lastLogin?: Date;
profileData?: any;
}
export interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: AuthToken | null;
expiresAt: Date | null;
lastActivity: Date;
}
export interface SessionInfo {
sessionId: string;
startTime: Date;
lastActivity: Date;
ipAddress?: string;
userAgent?: string;
isActive: boolean;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly STORAGE_KEY = 'interview_assistant_auth';
private readonly REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes before expiry
private readonly SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes of inactivity
private authStateSubject = new BehaviorSubject<AuthState>({
isAuthenticated: false,
user: null,
token: null,
expiresAt: null,
lastActivity: new Date()
});
private sessionInfoSubject = new BehaviorSubject<SessionInfo | null>(null);
private autoRefreshTimer: any;
private sessionTimeoutTimer: any;
public authState$ = this.authStateSubject.asObservable();
public user$ = this.authState$.pipe(map(state => state.user));
public isAuthenticated$ = this.authState$.pipe(map(state => state.isAuthenticated));
public sessionInfo$ = this.sessionInfoSubject.asObservable();
// N8n/Authelia configuration
private readonly authConfig = {
autheliaBaseUrl: 'https://n8n.gm-tech.org', // Authelia endpoint
n8nBaseUrl: 'https://n8n.gm-tech.org',
loginEndpoint: '/api/verify',
refreshEndpoint: '/api/refresh',
logoutEndpoint: '/api/logout',
userInfoEndpoint: '/api/user',
verifyEndpoint: '/api/verify'
};
constructor(private http: HttpClient) {
this.initializeAuthService();
this.setupActivityMonitoring();
this.setupAutoRefresh();
}
// Core Authentication Methods
public login(credentials: AuthCredentials): Observable<AuthState> {
const loginUrl = `${this.authConfig.autheliaBaseUrl}${this.authConfig.loginEndpoint}`;
return this.http.post<any>(loginUrl, {
username: credentials.username,
password: credentials.password
}, {
headers: new HttpHeaders({
'Content-Type': 'application/json'
}),
withCredentials: true // Important for Authelia session cookies
}).pipe(
switchMap(response => {
if (response.status === 'OK') {
// Authelia login successful, now get user info
return this.getUserInfo();
} else {
throw new Error('Authentication failed');
}
}),
tap(user => {
const authState = this.createAuthState(user, credentials.username);
this.updateAuthState(authState);
this.startSession();
}),
map(() => this.authStateSubject.value),
catchError(error => {
console.error('Login failed:', error);
return throwError(this.handleAuthError(error));
})
);
}
public logout(): Observable<void> {
const logoutUrl = `${this.authConfig.autheliaBaseUrl}${this.authConfig.logoutEndpoint}`;
return this.http.post(logoutUrl, {}, {
withCredentials: true
}).pipe(
tap(() => {
this.clearAuthState();
this.endSession();
}),
map(() => void 0),
catchError(error => {
// Even if logout fails on server, clear local state
console.warn('Logout request failed, clearing local state:', error);
this.clearAuthState();
this.endSession();
return of(void 0);
})
);
}
public refreshToken(): Observable<AuthState> {
const refreshUrl = `${this.authConfig.autheliaBaseUrl}${this.authConfig.refreshEndpoint}`;
return this.http.post<any>(refreshUrl, {}, {
withCredentials: true
}).pipe(
switchMap(response => {
if (response.status === 'OK') {
return this.getUserInfo();
} else {
throw new Error('Token refresh failed');
}
}),
tap(user => {
const currentState = this.authStateSubject.value;
const newState = {
...currentState,
user,
lastActivity: new Date()
};
this.updateAuthState(newState);
}),
map(() => this.authStateSubject.value),
catchError(error => {
console.error('Token refresh failed:', error);
this.clearAuthState();
return throwError('Session expired');
})
);
}
public verifySession(): Observable<boolean> {
const verifyUrl = `${this.authConfig.autheliaBaseUrl}${this.authConfig.verifyEndpoint}`;
return this.http.get<any>(verifyUrl, {
withCredentials: true
}).pipe(
map(response => response.status === 'OK'),
tap(isValid => {
if (!isValid && this.authStateSubject.value.isAuthenticated) {
this.clearAuthState();
}
}),
catchError(error => {
console.warn('Session verification failed:', error);
this.clearAuthState();
return of(false);
})
);
}
// User Information
private getUserInfo(): Observable<User> {
const userInfoUrl = `${this.authConfig.autheliaBaseUrl}${this.authConfig.userInfoEndpoint}`;
return this.http.get<any>(userInfoUrl, {
withCredentials: true
}).pipe(
map(response => this.mapUserResponse(response)),
catchError(error => {
console.error('Failed to get user info:', error);
return throwError('Failed to retrieve user information');
})
);
}
private mapUserResponse(response: any): User {
return {
id: response.user || response.username || 'unknown',
username: response.username || response.user || 'unknown',
email: response.email,
displayName: response.name || response.display_name || response.username,
roles: response.groups || response.roles || [],
permissions: this.extractPermissions(response.groups || response.roles || []),
lastLogin: new Date(),
profileData: {
avatar: response.avatar,
preferences: response.preferences || {}
}
};
}
private extractPermissions(groups: string[]): string[] {
const permissions: string[] = [];
// Map Authelia groups to application permissions
if (groups.includes('interview-admin')) {
permissions.push('admin', 'manage-users', 'view-analytics', 'export-data');
}
if (groups.includes('interview-user') || groups.includes('users')) {
permissions.push('use-app', 'upload-cv', 'view-sessions');
}
if (groups.includes('interview-guest')) {
permissions.push('use-app');
}
// Default permissions for any authenticated user
if (permissions.length === 0) {
permissions.push('use-app');
}
return permissions;
}
// Permission Checking
public hasPermission(permission: string): boolean {
const user = this.authStateSubject.value.user;
return user ? user.permissions.includes(permission) : false;
}
public hasRole(role: string): boolean {
const user = this.authStateSubject.value.user;
return user ? user.roles.includes(role) : false;
}
public hasAnyRole(roles: string[]): boolean {
const user = this.authStateSubject.value.user;
return user ? roles.some(role => user.roles.includes(role)) : false;
}
public requiresPermission(permission: string): Observable<boolean> {
return this.isAuthenticated$.pipe(
map(isAuth => isAuth && this.hasPermission(permission))
);
}
// Session Management
private startSession(): void {
const sessionInfo: SessionInfo = {
sessionId: this.generateSessionId(),
startTime: new Date(),
lastActivity: new Date(),
ipAddress: 'unknown', // Would need additional service to get IP
userAgent: navigator.userAgent,
isActive: true
};
this.sessionInfoSubject.next(sessionInfo);
this.setupSessionTimeout();
}
private endSession(): void {
const currentSession = this.sessionInfoSubject.value;
if (currentSession) {
this.sessionInfoSubject.next({
...currentSession,
isActive: false
});
}
this.clearSessionTimeout();
}
private updateActivity(): void {
const currentState = this.authStateSubject.value;
const currentSession = this.sessionInfoSubject.value;
if (currentState.isAuthenticated) {
// Update auth state activity
this.authStateSubject.next({
...currentState,
lastActivity: new Date()
});
// Update session activity
if (currentSession) {
this.sessionInfoSubject.next({
...currentSession,
lastActivity: new Date()
});
}
// Reset session timeout
this.setupSessionTimeout();
}
}
private setupSessionTimeout(): void {
this.clearSessionTimeout();
this.sessionTimeoutTimer = setTimeout(() => {
console.log('Session timeout - logging out user');
this.logout().subscribe();
}, this.SESSION_TIMEOUT);
}
private clearSessionTimeout(): void {
if (this.sessionTimeoutTimer) {
clearTimeout(this.sessionTimeoutTimer);
this.sessionTimeoutTimer = null;
}
}
// Activity Monitoring
private setupActivityMonitoring(): void {
// Monitor user activity events
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'];
activityEvents.forEach(event => {
document.addEventListener(event, () => {
this.updateActivity();
}, { passive: true });
});
// Monitor page visibility
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.updateActivity();
}
});
}
// Auto-refresh Setup
private setupAutoRefresh(): void {
this.authState$.pipe(
filter(state => state.isAuthenticated && !!state.expiresAt)
).subscribe(state => {
this.scheduleTokenRefresh(state.expiresAt!);
});
}
private scheduleTokenRefresh(expiresAt: Date): void {
if (this.autoRefreshTimer) {
clearTimeout(this.autoRefreshTimer);
}
const refreshTime = expiresAt.getTime() - Date.now() - this.REFRESH_THRESHOLD;
if (refreshTime > 0) {
this.autoRefreshTimer = setTimeout(() => {
console.log('Auto-refreshing token');
this.refreshToken().subscribe({
next: () => console.log('Token refreshed successfully'),
error: (error) => console.error('Auto-refresh failed:', error)
});
}, refreshTime);
}
}
// State Management
private createAuthState(user: User, username?: string): AuthState {
// For Authelia, we rely on cookies rather than JWT tokens
// So we create a minimal token representation
const token: AuthToken = {
access_token: 'authelia_session', // Placeholder
token_type: 'session',
expires_in: 3600 // 1 hour default
};
return {
isAuthenticated: true,
user,
token,
expiresAt: new Date(Date.now() + token.expires_in * 1000),
lastActivity: new Date()
};
}
private updateAuthState(state: AuthState): void {
this.authStateSubject.next(state);
this.saveAuthState(state);
}
private clearAuthState(): void {
const clearedState: AuthState = {
isAuthenticated: false,
user: null,
token: null,
expiresAt: null,
lastActivity: new Date()
};
this.authStateSubject.next(clearedState);
this.clearStoredAuthState();
if (this.autoRefreshTimer) {
clearTimeout(this.autoRefreshTimer);
this.autoRefreshTimer = null;
}
}
// Storage Management
private initializeAuthService(): void {
this.loadStoredAuthState();
// Verify stored session on startup
const currentState = this.authStateSubject.value;
if (currentState.isAuthenticated) {
this.verifySession().subscribe(isValid => {
if (!isValid) {
console.log('Stored session is invalid, clearing auth state');
this.clearAuthState();
} else {
console.log('Stored session is valid');
this.startSession();
}
});
}
}
private saveAuthState(state: AuthState): void {
try {
const stateToStore = {
...state,
expiresAt: state.expiresAt?.toISOString(),
lastActivity: state.lastActivity.toISOString()
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(stateToStore));
} catch (error) {
console.error('Failed to save auth state:', error);
}
}
private loadStoredAuthState(): void {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const parsedState = JSON.parse(stored);
const state: AuthState = {
...parsedState,
expiresAt: parsedState.expiresAt ? new Date(parsedState.expiresAt) : null,
lastActivity: new Date(parsedState.lastActivity)
};
// Check if stored state is expired
if (state.expiresAt && state.expiresAt <= new Date()) {
console.log('Stored auth state is expired');
this.clearStoredAuthState();
return;
}
this.authStateSubject.next(state);
}
} catch (error) {
console.error('Failed to load stored auth state:', error);
this.clearStoredAuthState();
}
}
private clearStoredAuthState(): void {
try {
localStorage.removeItem(this.STORAGE_KEY);
} catch (error) {
console.error('Failed to clear stored auth state:', error);
}
}
// HTTP Interceptor Support
public getAuthHeaders(): HttpHeaders {
// For Authelia, authentication is handled via cookies
// But we can provide headers if needed for API calls
return new HttpHeaders({
'Content-Type': 'application/json'
});
}
public isTokenExpired(): boolean {
const state = this.authStateSubject.value;
return !state.expiresAt || state.expiresAt <= new Date();
}
// Utility Methods
private generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private handleAuthError(error: any): string {
if (error.status === 401) {
return 'Invalid username or password';
} else if (error.status === 403) {
return 'Access forbidden';
} else if (error.status === 429) {
return 'Too many login attempts. Please try again later.';
} else if (error.status === 0) {
return 'Unable to connect to authentication server';
} else {
return 'Authentication failed. Please try again.';
}
}
// Public API Methods
public getCurrentUser(): User | null {
return this.authStateSubject.value.user;
}
public getCurrentSession(): SessionInfo | null {
return this.sessionInfoSubject.value;
}
public getAuthState(): AuthState {
return this.authStateSubject.value;
}
public forceLogout(): void {
console.log('Force logout initiated');
this.clearAuthState();
this.endSession();
}
// Test/Development Methods
public simulateLogin(username: string): Observable<AuthState> {
// For development/testing purposes
if (!environment.production) {
const mockUser: User = {
id: `dev_${username}`,
username,
email: `${username}@example.com`,
displayName: username,
roles: ['interview-user'],
permissions: ['use-app', 'upload-cv', 'view-sessions'],
lastLogin: new Date()
};
const authState = this.createAuthState(mockUser);
this.updateAuthState(authState);
this.startSession();
return of(authState);
}
return throwError('Simulate login only available in development mode');
}
// Cleanup
public destroy(): void {
if (this.autoRefreshTimer) {
clearTimeout(this.autoRefreshTimer);
}
if (this.sessionTimeoutTimer) {
clearTimeout(this.sessionTimeoutTimer);
}
this.endSession();
}
}
// Environment check (would normally be imported)
const environment = {
production: !(window as any).location?.hostname?.includes('localhost')
};

View File

@ -0,0 +1,238 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { BehaviorSubject, Observable, throwError, of } from 'rxjs';
import { map, catchError, tap, retry } from 'rxjs/operators';
import { environment } from '../../environments/environment';
export interface AutheliaUser {
username: string;
displayName: string;
email: string;
groups: string[];
}
export interface AutheliaSession {
username: string;
displayName: string;
email: string;
groups: string[];
authenticated: boolean;
expires: string;
}
export interface AutheliaLoginRequest {
username: string;
password: string;
keepMeLoggedIn?: boolean;
targetURL?: string;
}
export interface AutheliaLoginResponse {
status: 'OK' | 'FAILED';
message?: string;
data?: {
redirect?: string;
};
}
@Injectable({
providedIn: 'root'
})
export class AutheliaAuthService {
private readonly authBaseUrl = environment.authelia?.baseUrl || 'https://auth.gm-tech.org';
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
private userSubject = new BehaviorSubject<AutheliaUser | null>(null);
private sessionSubject = new BehaviorSubject<AutheliaSession | null>(null);
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
public user$ = this.userSubject.asObservable();
public session$ = this.sessionSubject.asObservable();
constructor(private http: HttpClient) {
this.checkAuthenticationStatus();
}
/**
* Check if user is currently authenticated with Authelia
*/
public checkAuthenticationStatus(): Observable<boolean> {
const verifyUrl = `${this.authBaseUrl}/api/verify`;
return this.http.get<AutheliaSession>(verifyUrl, {
withCredentials: true,
headers: this.getHeaders()
}).pipe(
map(session => {
if (session.authenticated) {
this.sessionSubject.next(session);
this.userSubject.next({
username: session.username,
displayName: session.displayName,
email: session.email,
groups: session.groups
});
this.isAuthenticatedSubject.next(true);
console.log('✅ User authenticated with Authelia:', session.username);
return true;
} else {
this.clearSession();
return false;
}
}),
catchError((error: HttpErrorResponse) => {
console.log('❌ Authentication check failed:', error.status);
this.clearSession();
return of(false);
})
);
}
/**
* Login with Authelia
*/
public login(username: string, password: string, targetURL?: string): Observable<AutheliaLoginResponse> {
const loginUrl = `${this.authBaseUrl}/api/firstfactor`;
const loginData: AutheliaLoginRequest = {
username,
password,
keepMeLoggedIn: true,
targetURL: targetURL || environment.n8nWebhookUrl
};
return this.http.post<AutheliaLoginResponse>(loginUrl, loginData, {
withCredentials: true,
headers: this.getHeaders()
}).pipe(
tap(response => {
if (response.status === 'OK') {
console.log('✅ Login successful');
// Refresh authentication status
this.checkAuthenticationStatus().subscribe();
} else {
console.log('❌ Login failed:', response.message);
}
}),
catchError(this.handleError)
);
}
/**
* Logout from Authelia
*/
public logout(): Observable<any> {
const logoutUrl = `${this.authBaseUrl}/api/logout`;
return this.http.post(logoutUrl, {}, {
withCredentials: true,
headers: this.getHeaders()
}).pipe(
tap(() => {
console.log('✅ Logout successful');
this.clearSession();
}),
catchError(this.handleError)
);
}
/**
* Get authentication headers for API requests
*/
public getAuthHeaders(): HttpHeaders {
return this.getHeaders();
}
/**
* Make authenticated request to protected resource
*/
public makeAuthenticatedRequest<T>(url: string, data?: any, method: 'GET' | 'POST' = 'POST'): Observable<T> {
const options = {
withCredentials: true,
headers: this.getHeaders()
};
if (method === 'GET') {
return this.http.get<T>(url, options).pipe(
retry(1),
catchError(this.handleAuthenticatedRequestError.bind(this))
);
} else {
return this.http.post<T>(url, data, options).pipe(
retry(1),
catchError(this.handleAuthenticatedRequestError.bind(this))
);
}
}
/**
* Redirect to Authelia login page
*/
public redirectToLogin(targetURL?: string): void {
const returnURL = targetURL || window.location.href;
const loginURL = `${this.authBaseUrl}/?rd=${encodeURIComponent(returnURL)}`;
window.location.href = loginURL;
}
/**
* Get current authentication status
*/
public get isAuthenticated(): boolean {
return this.isAuthenticatedSubject.value;
}
/**
* Get current user
*/
public get currentUser(): AutheliaUser | null {
return this.userSubject.value;
}
/**
* Get current session
*/
public get currentSession(): AutheliaSession | null {
return this.sessionSubject.value;
}
private getHeaders(): HttpHeaders {
return new HttpHeaders({
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
});
}
private clearSession(): void {
this.isAuthenticatedSubject.next(false);
this.userSubject.next(null);
this.sessionSubject.next(null);
}
private handleAuthenticatedRequestError(error: HttpErrorResponse): Observable<never> {
if (error.status === 401 || error.status === 403) {
console.log('🔒 Authentication required for protected resource');
this.clearSession();
// Don't automatically redirect for API calls, let the calling component handle it
return throwError(() => new Error('Authentication required'));
}
return this.handleError(error);
}
private handleError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'An error occurred';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `Error: ${error.error.message}`;
} else {
// Server-side error
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
}
console.error('❌ Authelia service error:', errorMessage);
return throwError(() => new Error(errorMessage));
}
}

View File

@ -0,0 +1,455 @@
import { TestBed } from '@angular/core/testing';
import { CVParserService } from './cv-parser.service';
// Mock PDF.js
const mockPDFJS = {
getDocument: jasmine.createSpy('getDocument').and.returnValue({
promise: Promise.resolve({
numPages: 2,
getPage: jasmine.createSpy('getPage').and.returnValue(Promise.resolve({
getTextContent: jasmine.createSpy('getTextContent').and.returnValue(Promise.resolve({
items: [
{ str: 'John Doe' },
{ str: 'Senior Software Developer' },
{ str: 'john.doe@example.com' },
{ str: '+1 (555) 123-4567' },
{ str: 'New York, NY' },
{ str: 'Experience:' },
{ str: 'Tech Corp - Senior Developer (2020-2023)' },
{ str: 'Led development of Angular applications' },
{ str: 'Education:' },
{ str: 'University of Technology - Computer Science (2016-2020)' },
{ str: 'Skills: Angular, TypeScript, Node.js, RxJS' }
]
}))
}))
})
})
};
describe('CVParserService - Integration Tests', () => {
let service: CVParserService;
beforeEach(() => {
// Mock PDF.js globally
(window as any).pdfjsLib = mockPDFJS;
TestBed.configureTestingModule({
providers: [CVParserService]
});
service = TestBed.inject(CVParserService);
});
afterEach(() => {
delete (window as any).pdfjsLib;
});
describe('T013: Integration test CV parsing functionality', () => {
it('should parse PDF CV and extract personal information', async () => {
// Arrange
const mockPDFFile = new File(['mock pdf content'], 'john-doe-cv.pdf', {
type: 'application/pdf'
});
// Act
const result = await service.parseCV(mockPDFFile);
// Assert
expect(result.personalInfo.fullName).toBe('John Doe');
expect(result.personalInfo.email).toBe('john.doe@example.com');
expect(result.personalInfo.phone).toBe('+1 (555) 123-4567');
expect(result.personalInfo.location).toBe('New York, NY');
expect(result.fileName).toBe('john-doe-cv.pdf');
expect(result.uploadDate).toBeInstanceOf(Date);
});
it('should extract work experience with proper parsing', async () => {
// Arrange
const mockPDFFile = new File(['mock pdf content'], 'cv-with-experience.pdf', {
type: 'application/pdf'
});
// Mock more detailed experience text
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'John Doe' },
{ str: 'Work Experience:' },
{ str: 'Tech Corp' },
{ str: 'Senior Software Developer' },
{ str: 'January 2020 - December 2023' },
{ str: 'Led team of 5 developers in building Angular applications' },
{ str: 'Reduced application load time by 40%' },
{ str: 'Technologies: Angular, TypeScript, Node.js, MongoDB' },
{ str: 'Previous Company' },
{ str: 'Junior Developer' },
{ str: 'June 2018 - December 2019' },
{ str: 'Developed React components and REST APIs' }
]
})
}));
});
// Act
const result = await service.parseCV(mockPDFFile);
// Assert
expect(result.experience.length).toBeGreaterThan(0);
const seniorRole = result.experience.find(exp => exp.position === 'Senior Software Developer');
expect(seniorRole).toBeDefined();
expect(seniorRole!.company).toBe('Tech Corp');
expect(seniorRole!.startDate.getFullYear()).toBe(2020);
expect(seniorRole!.endDate?.getFullYear()).toBe(2023);
expect(seniorRole!.technologies).toContain('Angular');
expect(seniorRole!.technologies).toContain('TypeScript');
expect(seniorRole!.achievements).toContain('Reduced application load time by 40%');
});
it('should extract education information correctly', async () => {
// Arrange
const mockPDFFile = new File(['mock pdf content'], 'cv-with-education.pdf', {
type: 'application/pdf'
});
// Mock education-focused content
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'Education:' },
{ str: 'University of Technology' },
{ str: 'Bachelor of Science in Computer Science' },
{ str: 'September 2016 - May 2020' },
{ str: 'GPA: 3.8/4.0' },
{ str: 'Magna Cum Laude' },
{ str: 'Community College' },
{ str: 'Associate Degree in Information Technology' },
{ str: 'September 2014 - May 2016' }
]
})
}));
});
// Act
const result = await service.parseCV(mockPDFFile);
// Assert
expect(result.education.length).toBeGreaterThan(0);
const bachelorDegree = result.education.find(edu => edu.degree.includes('Bachelor'));
expect(bachelorDegree).toBeDefined();
expect(bachelorDegree!.institution).toBe('University of Technology');
expect(bachelorDegree!.field).toBe('Computer Science');
expect(bachelorDegree!.startDate.getFullYear()).toBe(2016);
expect(bachelorDegree!.endDate?.getFullYear()).toBe(2020);
expect(bachelorDegree!.gpa).toBe(3.8);
expect(bachelorDegree!.honors).toContain('Magna Cum Laude');
});
it('should extract and categorize skills properly', async () => {
// Arrange
const mockPDFFile = new File(['mock pdf content'], 'cv-with-skills.pdf', {
type: 'application/pdf'
});
// Mock skills-focused content
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'Technical Skills:' },
{ str: 'Angular (5 years)' },
{ str: 'TypeScript (4 years)' },
{ str: 'Node.js (3 years)' },
{ str: 'RxJS (2 years)' },
{ str: 'Soft Skills:' },
{ str: 'Leadership' },
{ str: 'Communication' },
{ str: 'Problem Solving' },
{ str: 'Languages:' },
{ str: 'English (Native)' },
{ str: 'Spanish (Conversational)' }
]
})
}));
});
// Act
const result = await service.parseCV(mockPDFFile);
// Assert
expect(result.skills.length).toBeGreaterThan(0);
const technicalSkills = result.skills.filter(skill => skill.category === 'technical');
expect(technicalSkills.length).toBeGreaterThan(0);
const angularSkill = technicalSkills.find(skill => skill.name === 'Angular');
expect(angularSkill).toBeDefined();
expect(angularSkill!.yearsOfExperience).toBe(5);
expect(angularSkill!.proficiency).toBe('advanced');
const softSkills = result.skills.filter(skill => skill.category === 'soft');
expect(softSkills.length).toBeGreaterThan(0);
expect(softSkills.map(s => s.name)).toContain('Leadership');
expect(result.languages.length).toBeGreaterThan(0);
const english = result.languages.find(lang => lang.name === 'English');
expect(english?.proficiency).toBe('native');
});
it('should handle different CV formats and layouts', async () => {
// Test multiple format variations
const formats = [
{ name: 'traditional-format.pdf', layout: 'traditional' },
{ name: 'modern-format.pdf', layout: 'modern' },
{ name: 'creative-format.pdf', layout: 'creative' }
];
for (const format of formats) {
// Arrange
const mockFile = new File(['mock content'], format.name, { type: 'application/pdf' });
// Act
const result = await service.parseCV(mockFile);
// Assert
expect(result.fileName).toBe(format.name);
expect(result.personalInfo.fullName).toBeTruthy();
expect(result.parsedText).toBeTruthy();
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
}
});
it('should extract certifications and professional achievements', async () => {
// Arrange
const mockPDFFile = new File(['mock pdf content'], 'cv-with-certifications.pdf', {
type: 'application/pdf'
});
// Mock certification content
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'Certifications:' },
{ str: 'AWS Certified Solutions Architect' },
{ str: 'Amazon Web Services' },
{ str: 'Issued: January 2023' },
{ str: 'Expires: January 2026' },
{ str: 'Credential ID: AWS-123456' },
{ str: 'Angular Certified Developer' },
{ str: 'Google' },
{ str: 'Issued: March 2022' }
]
})
}));
});
// Act
const result = await service.parseCV(mockPDFFile);
// Assert
expect(result.certifications.length).toBeGreaterThan(0);
const awsCert = result.certifications.find(cert => cert.name.includes('AWS'));
expect(awsCert).toBeDefined();
expect(awsCert!.issuer).toBe('Amazon Web Services');
expect(awsCert!.issueDate.getFullYear()).toBe(2023);
expect(awsCert!.expiryDate?.getFullYear()).toBe(2026);
expect(awsCert!.credentialId).toBe('AWS-123456');
});
it('should handle malformed or corrupted PDF files', async () => {
// Arrange
const corruptedFile = new File(['corrupted content'], 'corrupted.pdf', {
type: 'application/pdf'
});
mockPDFJS.getDocument.and.returnValue({
promise: Promise.reject(new Error('Invalid PDF format'))
});
// Act & Assert
await expectAsync(service.parseCV(corruptedFile))
.toBeRejectedWithError('Failed to parse PDF: Invalid PDF format');
});
it('should handle very large PDF files efficiently', async () => {
// Arrange
const largePDFFile = new File(
[new ArrayBuffer(10 * 1024 * 1024)], // 10MB file
'large-cv.pdf',
{ type: 'application/pdf' }
);
// Act
const startTime = performance.now();
const result = await service.parseCV(largePDFFile);
const endTime = performance.now();
// Assert
expect(endTime - startTime).toBeLessThan(5000); // Should complete within 5 seconds
expect(result).toBeDefined();
});
it('should extract contact information with various formats', async () => {
// Test different contact format variations
const contactVariations = [
{ email: 'john.doe@example.com', phone: '+1-555-123-4567' },
{ email: 'jane_smith@company.org', phone: '(555) 987-6543' },
{ email: 'developer123@gmail.com', phone: '555.456.7890' }
];
for (const contact of contactVariations) {
// Arrange
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'Contact Information:' },
{ str: contact.email },
{ str: contact.phone },
{ str: 'LinkedIn: https://linkedin.com/in/profile' },
{ str: 'GitHub: https://github.com/username' }
]
})
}));
});
const mockFile = new File(['mock content'], 'test-cv.pdf', { type: 'application/pdf' });
// Act
const result = await service.parseCV(mockFile);
// Assert
expect(result.personalInfo.email).toBe(contact.email);
expect(result.personalInfo.phone).toBe(contact.phone);
expect(result.personalInfo.linkedIn).toContain('linkedin.com');
expect(result.personalInfo.github).toContain('github.com');
}
});
it('should provide manual correction interface for parsing errors', async () => {
// Arrange
const mockFile = new File(['mock content'], 'test-cv.pdf', { type: 'application/pdf' });
const parseResult = await service.parseCV(mockFile);
const corrections = {
personalInfo: {
fullName: 'Corrected Name',
email: 'corrected@example.com'
},
skills: [
{
id: 'new-skill-id',
name: 'Vue.js',
category: 'technical' as const,
proficiency: 'intermediate' as const,
yearsOfExperience: 2
}
]
};
// Act
const correctedResult = await service.applyCorrections(parseResult, corrections);
// Assert
expect(correctedResult.personalInfo.fullName).toBe('Corrected Name');
expect(correctedResult.personalInfo.email).toBe('corrected@example.com');
expect(correctedResult.skills.some(skill => skill.name === 'Vue.js')).toBe(true);
expect(correctedResult.lastModified).toBeInstanceOf(Date);
});
it('should maintain parsing accuracy above 90%', async () => {
// Test with known good CV samples
const testCVs = [
'standard-format-cv.pdf',
'academic-cv.pdf',
'technical-resume.pdf',
'executive-cv.pdf',
'fresh-graduate-cv.pdf'
];
let totalAccuracy = 0;
for (const cvName of testCVs) {
// Arrange
const mockFile = new File(['mock content'], cvName, { type: 'application/pdf' });
// Act
const result = await service.parseCV(mockFile);
const accuracy = await service.calculateParsingAccuracy(result);
// Assert
expect(accuracy).toBeGreaterThan(0.9); // 90% accuracy requirement
totalAccuracy += accuracy;
}
const averageAccuracy = totalAccuracy / testCVs.length;
expect(averageAccuracy).toBeGreaterThan(0.9);
});
it('should handle non-English CVs with proper encoding', async () => {
// Arrange
const nonEnglishFile = new File(['mock content'], 'cv-spanish.pdf', {
type: 'application/pdf'
});
// Mock non-English content
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'José García' },
{ str: 'Desarrollador Senior' },
{ str: 'Experiencia:' },
{ str: 'Tecnologías: Angular, TypeScript' },
{ str: 'Educación:' },
{ str: 'Universidad Politécnica' }
]
})
}));
});
// Act
const result = await service.parseCV(nonEnglishFile);
// Assert
expect(result.personalInfo.fullName).toBe('José García');
expect(result.parsedText).toContain('Desarrollador Senior');
expect(result.experience.length).toBeGreaterThan(0);
});
it('should detect and warn about sensitive information', async () => {
// Arrange
const mockFile = new File(['mock content'], 'cv-with-sensitive.pdf', {
type: 'application/pdf'
});
// Mock content with sensitive information
mockPDFJS.getDocument().promise.then((pdf: any) => {
pdf.getPage.and.returnValue(Promise.resolve({
getTextContent: () => Promise.resolve({
items: [
{ str: 'John Doe' },
{ str: 'SSN: 123-45-6789' },
{ str: 'Date of Birth: 01/01/1990' },
{ str: 'Driver License: D1234567' }
]
})
}));
});
// Act
const result = await service.parseCV(mockFile);
// Assert
expect(result.sensitiveDataWarnings).toBeDefined();
expect(result.sensitiveDataWarnings!.length).toBeGreaterThan(0);
expect(result.sensitiveDataWarnings).toContain('SSN detected');
expect(result.sensitiveDataWarnings).toContain('Date of birth detected');
});
});
});

View File

@ -0,0 +1,540 @@
import { Injectable } from '@angular/core';
import * as pdfjsLib from 'pdfjs-dist';
import {
CVProfile,
PersonalInfo,
WorkExperience,
Education,
Skill,
Certification,
Language,
SkillCategory,
SkillLevel,
LanguageProficiency,
CVParsingResult,
CVCorrections,
ParsingAccuracy
} from '../models/cv-profile.interface';
import { DataSanitizer, validateCVProfile } from '../models/validation';
// Configure PDF.js worker - use local worker from node_modules
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
@Injectable({
providedIn: 'root'
})
export class CVParserService {
private readonly EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g;
private readonly PHONE_REGEX = /(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/g;
private readonly DATE_REGEX = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}|\b\d{1,2}\/\d{1,2}\/\d{4}|\b\d{4}\b/gi;
private readonly URL_REGEX = /https?:\/\/[^\s]+|(?:linkedin\.com|github\.com)\/[^\s]+/gi;
private readonly SKILL_KEYWORDS = {
technical: ['javascript', 'typescript', 'angular', 'react', 'vue', 'node.js', 'python', 'java', 'c#', 'c++', 'sql', 'mongodb', 'docker', 'kubernetes', 'aws', 'azure', 'git'],
soft: ['leadership', 'communication', 'teamwork', 'problem solving', 'analytical', 'creative', 'adaptable', 'organized'],
language: ['english', 'spanish', 'french', 'german', 'mandarin', 'japanese', 'portuguese', 'italian', 'russian', 'arabic']
};
private readonly SECTION_HEADERS = {
experience: ['experience', 'work experience', 'employment', 'professional experience', 'career history'],
education: ['education', 'academic background', 'qualifications', 'degrees'],
skills: ['skills', 'technical skills', 'core competencies', 'expertise'],
certifications: ['certifications', 'certificates', 'credentials', 'licenses']
};
constructor() {}
public async parseCV(file: File): Promise<CVProfile> {
console.log(`🔧 Starting CV parsing for file: ${file.name} (${file.size} bytes, type: ${file.type})`);
try {
console.log(`📄 Extracting text from PDF...`);
const extractedText = await this.extractTextFromPDF(file);
console.log(`✅ Text extraction completed. Length: ${extractedText.length} characters`);
console.log(`📝 First 200 characters: "${extractedText.substring(0, 200)}..."`);
const profile = this.parseTextToProfile(extractedText, file.name);
console.log(`🔍 Profile parsing completed`);
// Add file metadata
profile.uploadDate = new Date();
profile.lastModified = new Date();
profile.id = DataSanitizer.generateUUID();
profile.fileName = file.name;
profile.parsedText = extractedText;
// Detect sensitive information
profile.sensitiveDataWarnings = this.detectSensitiveInformation(extractedText);
console.log(`🎉 CV parsing successful - Profile ID: ${profile.id}`);
return profile;
} catch (error) {
console.error(`❌ CV parsing failed:`, error);
throw new Error(`Failed to parse PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async extractTextFromPDF(file: File): Promise<string> {
try {
console.log(`🔧 Converting file to array buffer...`);
const arrayBuffer = await file.arrayBuffer();
console.log(`✅ Array buffer created: ${arrayBuffer.byteLength} bytes`);
console.log(`🔧 Loading PDF document...`);
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
console.log(`✅ PDF loaded successfully: ${pdf.numPages} pages`);
let fullText = '';
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
console.log(`🔧 Processing page ${pageNum}/${pdf.numPages}...`);
const page = await pdf.getPage(pageNum);
const textContent = await page.getTextContent();
console.log(`📄 Page ${pageNum} has ${textContent.items.length} text items`);
const pageText = textContent.items
.map((item: any) => item.str)
.join(' ');
console.log(`✅ Page ${pageNum} text (${pageText.length} chars): "${pageText.substring(0, 100)}..."`);
fullText += pageText + '\n';
}
const finalText = fullText.trim();
console.log(`🎉 PDF text extraction complete: ${finalText.length} total characters`);
return finalText;
} catch (error) {
console.error(`❌ PDF extraction failed:`, error);
throw new Error(`Failed to extract text from PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private parseTextToProfile(text: string, fileName: string): CVProfile {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
return {
id: '', // Will be set by caller
fileName,
uploadDate: new Date(),
personalInfo: this.extractPersonalInfo(lines),
experience: this.extractWorkExperience(lines),
education: this.extractEducation(lines),
skills: this.extractSkills(lines),
certifications: this.extractCertifications(lines),
languages: this.extractLanguages(lines),
parsedText: text,
lastModified: new Date()
};
}
private extractPersonalInfo(lines: string[]): PersonalInfo {
const fullText = lines.join(' ');
// Extract email
const emailMatch = fullText.match(this.EMAIL_REGEX);
const email = emailMatch ? emailMatch[0] : '';
// Extract phone
const phoneMatch = fullText.match(this.PHONE_REGEX);
const phone = phoneMatch ? phoneMatch[0] : '';
// Extract URLs
const urlMatches = fullText.match(this.URL_REGEX) || [];
let linkedIn = '';
let github = '';
let website = '';
urlMatches.forEach(url => {
if (url.includes('linkedin.com')) linkedIn = url;
else if (url.includes('github.com')) github = url;
else website = url;
});
// Extract name (assume first non-empty line is the name)
const fullName = lines.find(line =>
line.length > 2 &&
!line.includes('@') &&
!line.match(/\d/) &&
line.split(' ').length >= 2
) || '';
// Extract location (look for city, state patterns)
const locationPattern = /([A-Za-z\s]+),\s*([A-Z]{2}|[A-Za-z\s]+)/;
const locationMatch = fullText.match(locationPattern);
const location = locationMatch ? locationMatch[0] : '';
return {
fullName: DataSanitizer.sanitizeText(fullName),
email: DataSanitizer.normalizeEmail(email),
phone: DataSanitizer.normalizePhoneNumber(phone),
location: DataSanitizer.sanitizeText(location),
linkedIn,
github,
website
};
}
private extractWorkExperience(lines: string[]): WorkExperience[] {
const experiences: WorkExperience[] = [];
const experienceSection = this.findSection(lines, this.SECTION_HEADERS.experience);
if (!experienceSection.length) return experiences;
let currentExperience: Partial<WorkExperience> | null = null;
for (const line of experienceSection) {
// Check if line looks like a company/position header
if (this.looksLikeJobTitle(line)) {
if (currentExperience && currentExperience.company && currentExperience.position) {
experiences.push(this.completeWorkExperience(currentExperience));
}
currentExperience = this.parseJobHeader(line);
} else if (currentExperience) {
// Add to description or parse dates
const dates = this.extractDatesFromLine(line);
if (dates.length >= 1) {
currentExperience.startDate = dates[0];
if (dates.length >= 2) {
currentExperience.endDate = dates[1];
}
} else if (line.length > 10) {
currentExperience.description = (currentExperience.description || '') + ' ' + line;
}
}
}
// Add final experience
if (currentExperience && currentExperience.company && currentExperience.position) {
experiences.push(this.completeWorkExperience(currentExperience));
}
return experiences;
}
private extractEducation(lines: string[]): Education[] {
const education: Education[] = [];
const educationSection = this.findSection(lines, this.SECTION_HEADERS.education);
if (!educationSection.length) return education;
let currentEducation: Partial<Education> | null = null;
for (const line of educationSection) {
if (this.looksLikeEducationEntry(line)) {
if (currentEducation && currentEducation.institution) {
education.push(this.completeEducation(currentEducation));
}
currentEducation = this.parseEducationHeader(line);
} else if (currentEducation) {
const dates = this.extractDatesFromLine(line);
if (dates.length >= 1) {
currentEducation.startDate = dates[0];
if (dates.length >= 2) {
currentEducation.endDate = dates[1];
}
}
// Extract GPA
const gpaMatch = line.match(/GPA:?\s*(\d+\.?\d*)/i);
if (gpaMatch) {
currentEducation.gpa = parseFloat(gpaMatch[1]);
}
// Extract honors
const honorsKeywords = ['magna cum laude', 'summa cum laude', 'cum laude', 'dean\'s list', 'honors'];
const honor = honorsKeywords.find(keyword => line.toLowerCase().includes(keyword));
if (honor) {
currentEducation.honors = [honor];
}
}
}
if (currentEducation && currentEducation.institution) {
education.push(this.completeEducation(currentEducation));
}
return education;
}
private extractSkills(lines: string[]): Skill[] {
const skills: Skill[] = [];
const skillsSection = this.findSection(lines, this.SECTION_HEADERS.skills);
const fullText = lines.join(' ').toLowerCase();
// Extract from skills section and full text
const allText = [...skillsSection, fullText].join(' ');
// Technical skills
this.SKILL_KEYWORDS.technical.forEach(skillName => {
if (allText.toLowerCase().includes(skillName.toLowerCase())) {
const escapedSkillName = skillName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const yearsMatch = allText.match(new RegExp(`${escapedSkillName}.*?(\\d+)\\s*years?`, 'i'));
const years = yearsMatch ? parseInt(yearsMatch[1]) : undefined;
skills.push({
id: DataSanitizer.generateUUID(),
name: skillName,
category: SkillCategory.TECHNICAL,
proficiency: this.determineProficiency(years),
yearsOfExperience: years
});
}
});
// Soft skills
this.SKILL_KEYWORDS.soft.forEach(skillName => {
if (allText.toLowerCase().includes(skillName.toLowerCase())) {
skills.push({
id: DataSanitizer.generateUUID(),
name: skillName,
category: SkillCategory.SOFT,
proficiency: SkillLevel.INTERMEDIATE // Default for soft skills
});
}
});
return skills;
}
private extractCertifications(lines: string[]): Certification[] {
const certifications: Certification[] = [];
const certSection = this.findSection(lines, this.SECTION_HEADERS.certifications);
for (const line of certSection) {
if (line.length > 5) {
const cert = this.parseCertificationLine(line);
if (cert) {
certifications.push(cert);
}
}
}
return certifications;
}
private extractLanguages(lines: string[]): Language[] {
const languages: Language[] = [];
const fullText = lines.join(' ').toLowerCase();
this.SKILL_KEYWORDS.language.forEach(langName => {
const pattern = new RegExp(`${langName}\\s*\\(?([^\\)]*?)\\)?`, 'i');
const match = fullText.match(pattern);
if (match) {
const proficiencyText = match[1] || '';
let proficiency = LanguageProficiency.CONVERSATIONAL;
if (proficiencyText.includes('native') || proficiencyText.includes('mother tongue')) {
proficiency = LanguageProficiency.NATIVE;
} else if (proficiencyText.includes('fluent')) {
proficiency = LanguageProficiency.FLUENT;
} else if (proficiencyText.includes('basic') || proficiencyText.includes('beginner')) {
proficiency = LanguageProficiency.BASIC;
}
languages.push({
id: DataSanitizer.generateUUID(),
name: langName,
proficiency
});
}
});
return languages;
}
// Helper methods
private findSection(lines: string[], headers: string[]): string[] {
let sectionStart = -1;
let sectionEnd = lines.length;
// Find section start
for (let i = 0; i < lines.length; i++) {
const line = lines[i].toLowerCase();
if (headers.some(header => line.includes(header))) {
sectionStart = i + 1;
break;
}
}
if (sectionStart === -1) return [];
// Find section end (next section header)
for (let i = sectionStart; i < lines.length; i++) {
const line = lines[i].toLowerCase();
const isNextSection = Object.values(this.SECTION_HEADERS)
.flat()
.some(header => line.includes(header) && !headers.includes(header));
if (isNextSection) {
sectionEnd = i;
break;
}
}
return lines.slice(sectionStart, sectionEnd);
}
private looksLikeJobTitle(line: string): boolean {
const commonTitles = ['developer', 'engineer', 'manager', 'analyst', 'coordinator', 'specialist', 'director', 'lead'];
return commonTitles.some(title => line.toLowerCase().includes(title)) &&
line.split(' ').length <= 8;
}
private looksLikeEducationEntry(line: string): boolean {
const educationKeywords = ['university', 'college', 'institute', 'school', 'bachelor', 'master', 'phd', 'degree'];
return educationKeywords.some(keyword => line.toLowerCase().includes(keyword));
}
private parseJobHeader(line: string): Partial<WorkExperience> {
const parts = line.split(/[-–—]/);
if (parts.length >= 2) {
return {
id: DataSanitizer.generateUUID(),
company: DataSanitizer.sanitizeText(parts[0].trim()),
position: DataSanitizer.sanitizeText(parts[1].trim()),
description: '',
technologies: [],
achievements: []
};
}
return {
id: DataSanitizer.generateUUID(),
company: 'Unknown Company',
position: DataSanitizer.sanitizeText(line),
description: '',
technologies: [],
achievements: []
};
}
private parseEducationHeader(line: string): Partial<Education> {
const parts = line.split(/[-–—]/);
return {
id: DataSanitizer.generateUUID(),
institution: DataSanitizer.sanitizeText(parts[0] || 'Unknown Institution'),
degree: DataSanitizer.sanitizeText(parts[1] || 'Unknown Degree'),
field: DataSanitizer.sanitizeText(parts[2] || 'Unknown Field')
};
}
private parseCertificationLine(line: string): Certification | null {
const parts = line.split(/[-–—]/);
if (parts.length >= 2) {
const dates = this.extractDatesFromLine(line);
return {
id: DataSanitizer.generateUUID(),
name: DataSanitizer.sanitizeText(parts[0]),
issuer: DataSanitizer.sanitizeText(parts[1]),
issueDate: dates[0] || new Date(),
expiryDate: dates[1]
};
}
return null;
}
private extractDatesFromLine(line: string): Date[] {
const dateMatches = line.match(this.DATE_REGEX) || [];
return dateMatches.map(dateStr => new Date(dateStr)).filter(date => !isNaN(date.getTime()));
}
private determineProficiency(years?: number): SkillLevel {
if (!years) return SkillLevel.INTERMEDIATE;
if (years >= 7) return SkillLevel.EXPERT;
if (years >= 4) return SkillLevel.ADVANCED;
if (years >= 2) return SkillLevel.INTERMEDIATE;
return SkillLevel.BEGINNER;
}
private completeWorkExperience(partial: Partial<WorkExperience>): WorkExperience {
return {
id: partial.id || DataSanitizer.generateUUID(),
company: partial.company || 'Unknown Company',
position: partial.position || 'Unknown Position',
startDate: partial.startDate || new Date(),
endDate: partial.endDate,
description: partial.description || '',
technologies: partial.technologies || [],
achievements: partial.achievements || []
};
}
private completeEducation(partial: Partial<Education>): Education {
return {
id: partial.id || DataSanitizer.generateUUID(),
institution: partial.institution || 'Unknown Institution',
degree: partial.degree || 'Unknown Degree',
field: partial.field || 'Unknown Field',
startDate: partial.startDate || new Date(),
endDate: partial.endDate,
gpa: partial.gpa,
honors: partial.honors
};
}
private detectSensitiveInformation(text: string): string[] {
const warnings: string[] = [];
// SSN detection
if (text.match(/\b\d{3}-\d{2}-\d{4}\b/)) {
warnings.push('SSN detected');
}
// Date of birth detection
if (text.match(/\b(?:born|dob|date of birth)\b/i)) {
warnings.push('Date of birth detected');
}
// Driver license detection
if (text.match(/\b(?:driver|license|dl)\s*#?\s*\w+/i)) {
warnings.push('Driver license detected');
}
return warnings;
}
// Public methods for test compatibility
public async applyCorrections(profile: CVProfile, corrections: CVCorrections): Promise<CVProfile> {
const correctedProfile = { ...profile };
if (corrections.personalInfo) {
correctedProfile.personalInfo = { ...profile.personalInfo, ...corrections.personalInfo };
}
if (corrections.skills) {
correctedProfile.skills = [...profile.skills, ...corrections.skills];
}
if (corrections.experience) {
correctedProfile.experience = corrections.experience;
}
if (corrections.education) {
correctedProfile.education = corrections.education;
}
correctedProfile.lastModified = new Date();
return correctedProfile;
}
public async calculateParsingAccuracy(profile: CVProfile): Promise<number> {
const validation = validateCVProfile(profile);
const totalFields = 10; // Adjust based on key fields
const validFields = totalFields - validation.errors.length;
return Math.max(0, validFields / totalFields);
}
}

View File

@ -0,0 +1,276 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface SpeechLanguage {
code: string;
name: string;
nativeName: string;
flag: string;
questionPatterns: RegExp[];
hintPatterns: { pattern: RegExp; description: string }[];
}
export interface LanguageConfig {
selectedLanguage: string;
autoDetect: boolean;
fallbackLanguage: string;
}
@Injectable({
providedIn: 'root'
})
export class LanguageConfigService {
private readonly STORAGE_KEY = 'interview-assistant-language-config';
private readonly DEFAULT_LANGUAGE = 'en-US';
// Supported languages for speech recognition
private readonly supportedLanguages: SpeechLanguage[] = [
{
code: 'en-US',
name: 'English (US)',
nativeName: 'English',
flag: '🇺🇸',
questionPatterns: [
/^(what|how|when|where|why|who|which|can|could|would|should|do|does|did|is|are|was|were|will|have|has|had)/i,
/(tell me about|explain|describe|walk me through)/i,
/(\?|question)/i
],
hintPatterns: [
{ pattern: /(let me ask you about|let's talk about|tell me about)/i, description: 'Question introduction' },
{ pattern: /(now|next|moving on|let's move to)/i, description: 'Topic shift' },
{ pattern: /(can you clarify|what do you mean|could you explain)/i, description: 'Clarification request' },
{ pattern: /(follow up|following up|one more thing)/i, description: 'Follow-up question' }
]
},
{
code: 'fr-FR',
name: 'French (France)',
nativeName: 'Français',
flag: '🇫🇷',
questionPatterns: [
/^(qu'est-ce que|qu'est-ce qui|que|quoi|comment|quand|où|pourquoi|qui|quel|quelle|peux-tu|pouvez-vous|peux tu|pouvez vous)/i,
/(parlez-moi de|expliquez|décrivez|dites-moi|expliquez-moi)/i,
/(question|\?)/i,
/^(est-ce que|est ce que)/i,
/(pouvez-vous me dire|peux-tu me dire)/i
],
hintPatterns: [
{ pattern: /(permettez-moi de vous demander|parlons de|dites-moi)/i, description: 'Introduction de question' },
{ pattern: /(maintenant|ensuite|passons à|allons à)/i, description: 'Changement de sujet' },
{ pattern: /(pouvez-vous clarifier|que voulez-vous dire|pouvez-vous expliquer)/i, description: 'Demande de clarification' },
{ pattern: /(pour continuer|en continuant|une autre chose)/i, description: 'Question de suivi' }
]
},
{
code: 'es-ES',
name: 'Spanish (Spain)',
nativeName: 'Español',
flag: '🇪🇸',
questionPatterns: [
/^(qué|cómo|cuándo|dónde|por qué|quién|cuál|puedes|puede|puedes tú|puede usted)/i,
/(háblame de|explica|describe|dime|explícame)/i,
/(pregunta|\?)/i,
/^(es que|es esto que)/i,
/(puedes decirme|puede decirme)/i
],
hintPatterns: [
{ pattern: /(déjame preguntarte|hablemos de|dime)/i, description: 'Introducción de pregunta' },
{ pattern: /(ahora|siguiente|pasemos a|vamos a)/i, description: 'Cambio de tema' },
{ pattern: /(puedes aclarar|qué quieres decir|puedes explicar)/i, description: 'Solicitud de aclaración' },
{ pattern: /(para continuar|continuando|otra cosa)/i, description: 'Pregunta de seguimiento' }
]
},
{
code: 'de-DE',
name: 'German (Germany)',
nativeName: 'Deutsch',
flag: '🇩🇪',
questionPatterns: [
/^(was|wie|wann|wo|warum|wer|welche|welcher|können sie|kannst du|könnten sie)/i,
/(erzählen sie mir|erklären sie|beschreiben sie|sagen sie mir)/i,
/(frage|\?)/i,
/(können sie mir sagen|kannst du mir sagen)/i
],
hintPatterns: [
{ pattern: /(lassen sie mich fragen|sprechen wir über|sagen sie mir)/i, description: 'Frage Einleitung' },
{ pattern: /(jetzt|nächste|gehen wir zu|kommen wir zu)/i, description: 'Themenwechsel' },
{ pattern: /(können sie klären|was meinen sie|können sie erklären)/i, description: 'Klärung anfordern' },
{ pattern: /(um fortzufahren|fortsetzend|noch etwas)/i, description: 'Nachfrage' }
]
}
];
private currentLanguageSubject = new BehaviorSubject<string>(this.DEFAULT_LANGUAGE);
private configSubject = new BehaviorSubject<LanguageConfig>(this.getDefaultConfig());
public currentLanguage$ = this.currentLanguageSubject.asObservable();
public config$ = this.configSubject.asObservable();
constructor() {
this.loadConfiguration();
}
// Get all supported languages
getSupportedLanguages(): SpeechLanguage[] {
return [...this.supportedLanguages];
}
// Get current language configuration
getCurrentLanguage(): string {
return this.currentLanguageSubject.value;
}
// Get language details by code
getLanguageByCode(code: string): SpeechLanguage | undefined {
return this.supportedLanguages.find(lang => lang.code === code);
}
// Set current language
setLanguage(languageCode: string): void {
const language = this.getLanguageByCode(languageCode);
if (language) {
this.currentLanguageSubject.next(languageCode);
this.updateConfig({ selectedLanguage: languageCode });
console.log(`Language changed to: ${language.name} (${languageCode})`);
} else {
console.warn(`Language code ${languageCode} not supported. Using default.`);
}
}
// Get question patterns for current language
getCurrentQuestionPatterns(): RegExp[] {
const language = this.getLanguageByCode(this.getCurrentLanguage());
return language?.questionPatterns || this.supportedLanguages[0].questionPatterns;
}
// Get hint patterns for current language
getCurrentHintPatterns(): { pattern: RegExp; description: string }[] {
const language = this.getLanguageByCode(this.getCurrentLanguage());
return language?.hintPatterns || this.supportedLanguages[0].hintPatterns;
}
// Update language configuration
updateConfig(partialConfig: Partial<LanguageConfig>): void {
const currentConfig = this.configSubject.value;
const newConfig = { ...currentConfig, ...partialConfig };
this.configSubject.next(newConfig);
this.saveConfiguration(newConfig);
}
// Get current configuration
getConfig(): LanguageConfig {
return this.configSubject.value;
}
// Auto-detect language from browser
autoDetectLanguage(): string {
const browserLang = navigator.language || navigator.languages?.[0] || this.DEFAULT_LANGUAGE;
// Map browser language to supported language
const supportedCode = this.mapBrowserLanguageToSupported(browserLang);
if (this.getConfig().autoDetect) {
this.setLanguage(supportedCode);
}
return supportedCode;
}
// Check if a language is supported
isLanguageSupported(languageCode: string): boolean {
return this.supportedLanguages.some(lang => lang.code === languageCode);
}
// Get localized error messages
getLocalizedErrorMessage(errorType: string): string {
const language = this.getCurrentLanguage();
const errorMessages: { [key: string]: { [key: string]: string } } = {
'not-allowed': {
'en-US': 'Microphone access denied. Please allow microphone access in your browser settings.',
'fr-FR': 'Accès au microphone refusé. Veuillez autoriser l\'accès au microphone dans les paramètres de votre navigateur.',
'es-ES': 'Acceso al micrófono denegado. Permita el acceso al micrófono en la configuración de su navegador.',
'de-DE': 'Mikrofonzugriff verweigert. Bitte erlauben Sie den Mikrofonzugriff in Ihren Browsereinstellungen.'
},
'no-speech': {
'en-US': 'No speech detected. Please speak clearly into your microphone.',
'fr-FR': 'Aucune parole détectée. Veuillez parler clairement dans votre microphone.',
'es-ES': 'No se detectó habla. Hable claramente en su micrófono.',
'de-DE': 'Keine Sprache erkannt. Bitte sprechen Sie deutlich in Ihr Mikrofon.'
},
'network': {
'en-US': 'Network error occurred during speech recognition.',
'fr-FR': 'Erreur réseau lors de la reconnaissance vocale.',
'es-ES': 'Error de red durante el reconocimiento de voz.',
'de-DE': 'Netzwerkfehler bei der Spracherkennung.'
}
};
return errorMessages[errorType]?.[language] || errorMessages[errorType]?.['en-US'] || 'An error occurred';
}
// Reset to default configuration
resetToDefaults(): void {
const defaultConfig = this.getDefaultConfig();
this.configSubject.next(defaultConfig);
this.currentLanguageSubject.next(this.DEFAULT_LANGUAGE);
this.saveConfiguration(defaultConfig);
}
private getDefaultConfig(): LanguageConfig {
return {
selectedLanguage: this.DEFAULT_LANGUAGE,
autoDetect: true,
fallbackLanguage: this.DEFAULT_LANGUAGE
};
}
private mapBrowserLanguageToSupported(browserLang: string): string {
// Extract language code (e.g., 'fr-FR' from 'fr-FR' or 'fr' from 'fr')
const langCode = browserLang.toLowerCase();
// Direct match
if (this.isLanguageSupported(langCode)) {
return langCode;
}
// Try with region (fr -> fr-FR)
const langMap: { [key: string]: string } = {
'fr': 'fr-FR',
'en': 'en-US',
'es': 'es-ES',
'de': 'de-DE'
};
const baseLanguage = langCode.split('-')[0];
if (langMap[baseLanguage] && this.isLanguageSupported(langMap[baseLanguage])) {
return langMap[baseLanguage];
}
return this.DEFAULT_LANGUAGE;
}
private loadConfiguration(): void {
try {
const savedConfig = localStorage.getItem(this.STORAGE_KEY);
if (savedConfig) {
const config: LanguageConfig = JSON.parse(savedConfig);
this.configSubject.next(config);
this.currentLanguageSubject.next(config.selectedLanguage);
} else if (this.getConfig().autoDetect) {
this.autoDetectLanguage();
}
} catch (error) {
console.warn('Failed to load language configuration from localStorage:', error);
this.resetToDefaults();
}
}
private saveConfiguration(config: LanguageConfig): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.warn('Failed to save language configuration to localStorage:', error);
}
}
}

View File

@ -0,0 +1,587 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
FATAL = 4
}
export interface LogEntry {
id: string;
timestamp: Date;
level: LogLevel;
category: string;
message: string;
data?: any;
source?: string;
userId?: string;
sessionId?: string;
stackTrace?: string;
}
export interface LogFilter {
level?: LogLevel;
category?: string;
source?: string;
startDate?: Date;
endDate?: Date;
searchTerm?: string;
}
export interface LogExport {
format: 'json' | 'csv' | 'txt';
filter?: LogFilter;
includeData?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class LoggingService {
private readonly MAX_LOGS = 1000;
private readonly STORAGE_KEY = 'interview_assistant_logs';
private currentLogLevel = LogLevel.INFO;
private logs: LogEntry[] = [];
private logsSubject = new BehaviorSubject<LogEntry[]>([]);
private currentSessionId?: string;
private currentUserId?: string;
public logs$ = this.logsSubject.asObservable();
public logCount$ = new BehaviorSubject<number>(0);
// Log categories
private readonly categories = {
SPEECH: 'speech',
CV_PARSER: 'cv-parser',
QUESTION_BANK: 'question-bank',
N8N_SYNC: 'n8n-sync',
ANALYTICS: 'analytics',
UI: 'ui',
AUTH: 'auth',
STORAGE: 'storage',
NETWORK: 'network',
ERROR: 'error',
PERFORMANCE: 'performance',
USER_ACTION: 'user-action',
SYSTEM: 'system'
};
constructor() {
this.loadLogsFromStorage();
this.setupErrorHandling();
this.setupPerformanceMonitoring();
// Set log level based on environment
if (this.isProduction()) {
this.currentLogLevel = LogLevel.WARN;
} else {
this.currentLogLevel = LogLevel.DEBUG;
}
this.info(this.categories.SYSTEM, 'Logging service initialized', {
logLevel: LogLevel[this.currentLogLevel],
maxLogs: this.MAX_LOGS
});
}
// Configuration Methods
public setLogLevel(level: LogLevel): void {
this.currentLogLevel = level;
this.info(this.categories.SYSTEM, `Log level changed to ${LogLevel[level]}`);
}
public setSessionId(sessionId: string): void {
this.currentSessionId = sessionId;
this.info(this.categories.SYSTEM, `Session ID set: ${sessionId}`);
}
public setUserId(userId: string): void {
this.currentUserId = userId;
this.info(this.categories.SYSTEM, `User ID set: ${userId}`);
}
// Core Logging Methods
public debug(category: string, message: string, data?: any, source?: string): void {
this.log(LogLevel.DEBUG, category, message, data, source);
}
public info(category: string, message: string, data?: any, source?: string): void {
this.log(LogLevel.INFO, category, message, data, source);
}
public warn(category: string, message: string, data?: any, source?: string): void {
this.log(LogLevel.WARN, category, message, data, source);
}
public error(category: string, message: string, error?: any, source?: string): void {
let data = error;
let stackTrace: string | undefined;
if (error instanceof Error) {
data = {
name: error.name,
message: error.message,
stack: error.stack
};
stackTrace = error.stack;
}
this.log(LogLevel.ERROR, category, message, data, source, stackTrace);
}
public fatal(category: string, message: string, error?: any, source?: string): void {
let data = error;
let stackTrace: string | undefined;
if (error instanceof Error) {
data = {
name: error.name,
message: error.message,
stack: error.stack
};
stackTrace = error.stack;
}
this.log(LogLevel.FATAL, category, message, data, source, stackTrace);
}
// Specialized Logging Methods
public logSpeechEvent(event: string, data?: any): void {
this.info(this.categories.SPEECH, `Speech event: ${event}`, data, 'SpeechService');
}
public logCVParserEvent(event: string, data?: any): void {
this.info(this.categories.CV_PARSER, `CV parser event: ${event}`, data, 'CVParserService');
}
public logQuestionBankEvent(event: string, data?: any): void {
this.info(this.categories.QUESTION_BANK, `Question bank event: ${event}`, data, 'QuestionBankService');
}
public logN8nSyncEvent(event: string, data?: any): void {
this.info(this.categories.N8N_SYNC, `N8n sync event: ${event}`, data, 'N8nSyncService');
}
public logUserAction(action: string, data?: any): void {
this.info(this.categories.USER_ACTION, `User action: ${action}`, data, 'UI');
}
public logPerformance(operation: string, duration: number, data?: any): void {
this.info(this.categories.PERFORMANCE, `Performance: ${operation} took ${duration}ms`, {
operation,
duration,
...data
}, 'PerformanceMonitor');
}
public logNetworkRequest(method: string, url: string, status: number, duration: number): void {
const level = status >= 400 ? LogLevel.ERROR : LogLevel.INFO;
this.log(level, this.categories.NETWORK, `${method} ${url} - ${status}`, {
method,
url,
status,
duration
}, 'HttpClient');
}
// Search and Filter Methods
public searchLogs(filter: LogFilter): LogEntry[] {
return this.logs.filter(log => {
if (filter.level !== undefined && log.level < filter.level) {
return false;
}
if (filter.category && log.category !== filter.category) {
return false;
}
if (filter.source && log.source !== filter.source) {
return false;
}
if (filter.startDate && log.timestamp < filter.startDate) {
return false;
}
if (filter.endDate && log.timestamp > filter.endDate) {
return false;
}
if (filter.searchTerm) {
const searchTerm = filter.searchTerm.toLowerCase();
return log.message.toLowerCase().includes(searchTerm) ||
log.category.toLowerCase().includes(searchTerm) ||
(log.source && log.source.toLowerCase().includes(searchTerm));
}
return true;
});
}
public getLogsByLevel(level: LogLevel): LogEntry[] {
return this.logs.filter(log => log.level === level);
}
public getLogsByCategory(category: string): LogEntry[] {
return this.logs.filter(log => log.category === category);
}
public getLogsByTimeRange(startDate: Date, endDate: Date): LogEntry[] {
return this.logs.filter(log =>
log.timestamp >= startDate && log.timestamp <= endDate
);
}
// Export Methods
public exportLogs(exportConfig: LogExport): string {
const filteredLogs = exportConfig.filter ? this.searchLogs(exportConfig.filter) : this.logs;
switch (exportConfig.format) {
case 'json':
return this.exportAsJson(filteredLogs, exportConfig.includeData);
case 'csv':
return this.exportAsCsv(filteredLogs, exportConfig.includeData);
case 'txt':
return this.exportAsText(filteredLogs, exportConfig.includeData);
default:
throw new Error(`Unsupported export format: ${exportConfig.format}`);
}
}
public clearLogs(): void {
this.logs = [];
this.logsSubject.next([]);
this.logCount$.next(0);
this.clearStoredLogs();
this.info(this.categories.SYSTEM, 'Logs cleared');
}
public getLogStatistics(): any {
const stats = {
total: this.logs.length,
byLevel: {} as any,
byCategory: {} as any,
bySource: {} as any,
timeRange: {
oldest: this.logs.length > 0 ? this.logs[0].timestamp : null,
newest: this.logs.length > 0 ? this.logs[this.logs.length - 1].timestamp : null
}
};
// Count by level
Object.values(LogLevel).forEach(level => {
if (typeof level === 'number') {
stats.byLevel[LogLevel[level]] = this.logs.filter(log => log.level === level).length;
}
});
// Count by category
this.logs.forEach(log => {
stats.byCategory[log.category] = (stats.byCategory[log.category] || 0) + 1;
});
// Count by source
this.logs.forEach(log => {
if (log.source) {
stats.bySource[log.source] = (stats.bySource[log.source] || 0) + 1;
}
});
return stats;
}
// Core Logging Implementation
private log(level: LogLevel, category: string, message: string, data?: any, source?: string, stackTrace?: string): void {
if (level < this.currentLogLevel) {
return; // Skip logs below current level
}
const logEntry: LogEntry = {
id: this.generateLogId(),
timestamp: new Date(),
level,
category,
message,
data: this.sanitizeData(data),
source,
userId: this.currentUserId,
sessionId: this.currentSessionId,
stackTrace
};
this.addLogEntry(logEntry);
// Also log to browser console for development
if (!this.isProduction()) {
this.logToConsole(logEntry);
}
// Store critical errors immediately
if (level >= LogLevel.ERROR) {
this.storeLogsToStorage();
}
}
private addLogEntry(entry: LogEntry): void {
this.logs.push(entry);
// Maintain maximum log count
if (this.logs.length > this.MAX_LOGS) {
this.logs.shift(); // Remove oldest log
}
this.logsSubject.next([...this.logs]);
this.logCount$.next(this.logs.length);
}
private logToConsole(entry: LogEntry): void {
const prefix = `[${LogLevel[entry.level]}] [${entry.category}]`;
const message = `${prefix} ${entry.message}`;
switch (entry.level) {
case LogLevel.DEBUG:
console.debug(message, entry.data);
break;
case LogLevel.INFO:
console.info(message, entry.data);
break;
case LogLevel.WARN:
console.warn(message, entry.data);
break;
case LogLevel.ERROR:
case LogLevel.FATAL:
console.error(message, entry.data);
if (entry.stackTrace) {
console.error(entry.stackTrace);
}
break;
}
}
// Storage Methods
private loadLogsFromStorage(): void {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const parsedLogs = JSON.parse(stored);
this.logs = parsedLogs.map((log: any) => ({
...log,
timestamp: new Date(log.timestamp)
}));
this.logsSubject.next([...this.logs]);
this.logCount$.next(this.logs.length);
}
} catch (error) {
console.error('Failed to load logs from storage:', error);
}
}
private storeLogsToStorage(): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.logs));
} catch (error) {
console.error('Failed to store logs to storage:', error);
// If storage is full, try clearing old logs
if (error instanceof DOMException && error.code === 22) {
this.clearOldLogs();
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.logs));
} catch (retryError) {
console.error('Failed to store logs after clearing old ones:', retryError);
}
}
}
}
private clearStoredLogs(): void {
try {
localStorage.removeItem(this.STORAGE_KEY);
} catch (error) {
console.error('Failed to clear stored logs:', error);
}
}
private clearOldLogs(): void {
// Keep only the most recent 500 logs
const keepCount = 500;
if (this.logs.length > keepCount) {
this.logs = this.logs.slice(-keepCount);
this.logsSubject.next([...this.logs]);
this.logCount$.next(this.logs.length);
}
}
// Export Implementation
private exportAsJson(logs: LogEntry[], includeData = true): string {
const exportLogs = logs.map(log => {
const exportLog: any = {
id: log.id,
timestamp: log.timestamp.toISOString(),
level: LogLevel[log.level],
category: log.category,
message: log.message,
source: log.source,
userId: log.userId,
sessionId: log.sessionId
};
if (includeData && log.data) {
exportLog.data = log.data;
}
if (log.stackTrace) {
exportLog.stackTrace = log.stackTrace;
}
return exportLog;
});
return JSON.stringify(exportLogs, null, 2);
}
private exportAsCsv(logs: LogEntry[], includeData = true): string {
const headers = ['ID', 'Timestamp', 'Level', 'Category', 'Message', 'Source', 'UserID', 'SessionID'];
if (includeData) {
headers.push('Data');
}
let csv = headers.join(',') + '\n';
logs.forEach(log => {
const row = [
log.id,
log.timestamp.toISOString(),
LogLevel[log.level],
log.category,
`"${log.message.replace(/"/g, '""')}"`, // Escape quotes
log.source || '',
log.userId || '',
log.sessionId || ''
];
if (includeData) {
const dataStr = log.data ? JSON.stringify(log.data).replace(/"/g, '""') : '';
row.push(`"${dataStr}"`);
}
csv += row.join(',') + '\n';
});
return csv;
}
private exportAsText(logs: LogEntry[], includeData = true): string {
let text = 'Interview Assistant - Log Export\n';
text += '='.repeat(50) + '\n\n';
logs.forEach(log => {
text += `[${log.timestamp.toISOString()}] `;
text += `[${LogLevel[log.level]}] `;
text += `[${log.category}] `;
if (log.source) {
text += `[${log.source}] `;
}
text += `${log.message}\n`;
if (includeData && log.data) {
text += ` Data: ${JSON.stringify(log.data)}\n`;
}
if (log.stackTrace) {
text += ` Stack: ${log.stackTrace}\n`;
}
text += '\n';
});
return text;
}
// Utility Methods
private generateLogId(): string {
return `log_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private sanitizeData(data: any): any {
if (!data) return data;
try {
// Deep clone to avoid circular references
return JSON.parse(JSON.stringify(data, (key, value) => {
// Remove sensitive information
if (typeof key === 'string' && key.toLowerCase().includes('password')) {
return '[REDACTED]';
}
if (typeof key === 'string' && key.toLowerCase().includes('token')) {
return '[REDACTED]';
}
if (typeof key === 'string' && key.toLowerCase().includes('secret')) {
return '[REDACTED]';
}
return value;
}));
} catch (error) {
return '[CIRCULAR_REFERENCE]';
}
}
private isProduction(): boolean {
// Check if we're in production environment
return (window as any).location?.hostname !== 'localhost' &&
!(window as any).location?.hostname?.includes('127.0.0.1');
}
// Error Handling Setup
private setupErrorHandling(): void {
// Global error handler
window.addEventListener('error', (event) => {
this.error(this.categories.ERROR, 'Global error caught', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error
}, 'GlobalErrorHandler');
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (event) => {
this.error(this.categories.ERROR, 'Unhandled promise rejection', {
reason: event.reason,
promise: event.promise
}, 'PromiseRejectionHandler');
});
}
// Performance Monitoring Setup
private setupPerformanceMonitoring(): void {
// Monitor navigation timing
if (window.performance && window.performance.timing) {
window.addEventListener('load', () => {
const timing = window.performance.timing;
const navigationTime = timing.loadEventEnd - timing.navigationStart;
this.logPerformance('page-load', navigationTime, {
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
domInteractive: timing.domInteractive - timing.navigationStart,
domComplete: timing.domComplete - timing.navigationStart
});
});
}
}
// Public API for categories
public getCategories(): typeof this.categories {
return this.categories;
}
// Cleanup
public destroy(): void {
this.storeLogsToStorage();
this.info(this.categories.SYSTEM, 'Logging service destroyed');
}
}

View File

@ -0,0 +1,371 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { N8nSyncService } from './n8n-sync.service';
describe('N8nSyncService - Contract Tests', () => {
let service: N8nSyncService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [N8nSyncService]
});
service = TestBed.inject(N8nSyncService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
describe('T007: Contract test POST /interview/cv-analysis', () => {
it('should submit CV for analysis with correct request format', () => {
// Arrange
const mockCVAnalysisRequest = {
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
personalInfo: {
fullName: 'John Doe',
email: 'john.doe@example.com',
phone: '+1234567890',
location: 'New York, NY',
linkedIn: 'https://linkedin.com/in/johndoe',
github: 'https://github.com/johndoe'
},
experience: [
{
id: '123e4567-e89b-12d3-a456-426614174001',
company: 'Tech Corp',
position: 'Senior Developer',
startDate: new Date('2020-01-01'),
endDate: new Date('2023-12-31'),
description: 'Full-stack development',
technologies: ['Angular', 'Node.js', 'TypeScript'],
achievements: ['Led team of 5 developers', 'Reduced load time by 40%']
}
],
education: [
{
id: '123e4567-e89b-12d3-a456-426614174002',
institution: 'University of Technology',
degree: 'Bachelor of Science',
field: 'Computer Science',
startDate: new Date('2016-09-01'),
endDate: new Date('2020-05-31')
}
],
skills: [
{
id: '123e4567-e89b-12d3-a456-426614174003',
name: 'Angular',
category: 'technical' as const,
proficiency: 'advanced' as const,
yearsOfExperience: 5
}
],
certifications: [],
parsedText: 'Full CV text content here...'
};
const expectedResponse = {
analysisId: '123e4567-e89b-12d3-a456-426614174004',
status: 'processing' as const,
estimatedCompletionTime: 300,
questionBankId: '123e4567-e89b-12d3-a456-426614174005'
};
// Act
service.submitCVAnalysis(mockCVAnalysisRequest).subscribe(response => {
// Assert
expect(response).toEqual(expectedResponse);
expect(response.analysisId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(response.status).toBe('processing');
expect(response.estimatedCompletionTime).toBeGreaterThan(0);
expect(response.questionBankId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
// Verify HTTP request
const req = httpMock.expectOne('https://n8n.gm-tech.org/webhook/interview/cv-analysis');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('Content-Type')).toBe('application/json');
expect(req.request.headers.get('Authorization')).toContain('Bearer');
expect(req.request.body).toEqual(mockCVAnalysisRequest);
// Respond with mock data
req.flush(expectedResponse);
});
it('should handle 401 Unauthorized response', () => {
const mockRequest = {
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
personalInfo: { fullName: 'John Doe', email: 'john@example.com' },
experience: [],
education: [],
skills: [],
certifications: [],
parsedText: 'CV text'
};
service.submitCVAnalysis(mockRequest).subscribe({
next: () => fail('Should have failed with 401'),
error: (error) => {
expect(error.status).toBe(401);
expect(error.error.error).toBe('Invalid or missing authentication token');
}
});
const req = httpMock.expectOne('https://n8n.gm-tech.org/webhook/interview/cv-analysis');
req.flush(
{ error: 'Invalid or missing authentication token' },
{ status: 401, statusText: 'Unauthorized' }
);
});
it('should handle 429 Rate Limited response', () => {
const mockRequest = {
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
personalInfo: { fullName: 'John Doe', email: 'john@example.com' },
experience: [],
education: [],
skills: [],
certifications: [],
parsedText: 'CV text'
};
service.submitCVAnalysis(mockRequest).subscribe({
next: () => fail('Should have failed with 429'),
error: (error) => {
expect(error.status).toBe(429);
expect(error.error.error).toBe('Rate limit exceeded. Please try again later.');
expect(error.error.retryAfter).toBeGreaterThan(0);
}
});
const req = httpMock.expectOne('https://n8n.gm-tech.org/webhook/interview/cv-analysis');
req.flush(
{
error: 'Rate limit exceeded. Please try again later.',
retryAfter: 60
},
{ status: 429, statusText: 'Too Many Requests' }
);
});
});
describe('T008: Contract test GET /interview/question-bank', () => {
it('should retrieve question bank with correct parameters', () => {
const cvProfileId = '123e4567-e89b-12d3-a456-426614174000';
const expectedResponse = {
questionBankId: '123e4567-e89b-12d3-a456-426614174005',
cvProfileId: cvProfileId,
generatedDate: '2023-12-15T10:30:00Z',
questions: [
{
id: '123e4567-e89b-12d3-a456-426614174006',
text: 'What is Angular dependency injection?',
category: 'technical' as const,
difficulty: 'medium' as const,
tags: ['angular', 'dependency-injection', 'typescript'],
answer: {
id: '123e4567-e89b-12d3-a456-426614174007',
content: 'Angular dependency injection is a design pattern...',
keyPoints: ['Singleton services', 'Hierarchical injectors', 'Provider configuration'],
followUpQuestions: ['How do you create a singleton service?'],
estimatedDuration: 45,
personalizedContext: 'Based on your 5 years of Angular experience...'
},
relatedSkills: ['Angular'],
confidence: 0.95,
timesUsed: 0
}
],
metadata: {
totalQuestions: 25,
categoriesDistribution: {
technical: 15,
behavioral: 5,
experience: 5
},
averageConfidence: 0.89,
lastUpdated: '2023-12-15T10:30:00Z'
}
};
service.getQuestionBank(cvProfileId).subscribe(response => {
expect(response).toEqual(expectedResponse);
expect(response.questionBankId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(response.cvProfileId).toBe(cvProfileId);
expect(response.questions.length).toBeGreaterThan(0);
expect(response.metadata.totalQuestions).toBeGreaterThan(0);
});
const req = httpMock.expectOne(`https://n8n.gm-tech.org/webhook/interview/question-bank?cvProfileId=${cvProfileId}`);
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('Authorization')).toContain('Bearer');
req.flush(expectedResponse);
});
it('should handle 404 Not Found for invalid CV profile', () => {
const invalidCvProfileId = 'invalid-uuid';
service.getQuestionBank(invalidCvProfileId).subscribe({
next: () => fail('Should have failed with 404'),
error: (error) => {
expect(error.status).toBe(404);
expect(error.error.error).toBe('Question bank not found for the specified CV profile');
}
});
const req = httpMock.expectOne(`https://n8n.gm-tech.org/webhook/interview/question-bank?cvProfileId=${invalidCvProfileId}`);
req.flush(
{ error: 'Question bank not found for the specified CV profile' },
{ status: 404, statusText: 'Not Found' }
);
});
});
describe('T009: Contract test POST /interview/session-sync', () => {
it('should synchronize session data with correct format', () => {
const mockSessionSyncRequest = {
sessionId: '123e4567-e89b-12d3-a456-426614174008',
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
startTime: '2023-12-15T14:00:00Z',
endTime: '2023-12-15T15:30:00Z',
detectedQuestions: [
{
id: '123e4567-e89b-12d3-a456-426614174009',
timestamp: '2023-12-15T14:15:00Z',
originalText: 'What is Angular dependency injection?',
normalizedText: 'what is angular dependency injection',
confidence: 0.95,
questionBankMatch: '123e4567-e89b-12d3-a456-426614174006',
responseTime: 250,
wasHelpRequested: true
}
],
providedAnswers: [
{
id: '123e4567-e89b-12d3-a456-426614174010',
questionId: '123e4567-e89b-12d3-a456-426614174009',
answerId: '123e4567-e89b-12d3-a456-426614174007',
content: 'Angular dependency injection is a design pattern that allows...',
timestamp: '2023-12-15T14:15:00Z',
source: 'question_bank' as const,
userRating: 5
}
],
manualComments: [
{
id: '123e4567-e89b-12d3-a456-426614174011',
timestamp: '2023-12-15T14:20:00Z',
content: 'Need to emphasize SOLID principles in future answers',
category: 'improvement' as const,
relatedQuestionId: '123e4567-e89b-12d3-a456-426614174009',
tags: ['solid-principles', 'improvement']
}
],
analytics: {
totalQuestions: 1,
answersFromBank: 1,
answersGenerated: 0,
averageResponseTime: 250,
accuracyRate: 1.0,
topicsCovered: ['angular', 'dependency-injection']
}
};
const expectedResponse = {
syncId: '123e4567-e89b-12d3-a456-426614174012',
status: 'success' as const,
updatedQuestions: [],
recommendations: ['Consider adding more examples to dependency injection answers'],
errors: []
};
service.syncSessionData(mockSessionSyncRequest).subscribe(response => {
expect(response).toEqual(expectedResponse);
expect(response.syncId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(response.status).toBe('success');
});
const req = httpMock.expectOne('https://n8n.gm-tech.org/webhook/interview/session-sync');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(mockSessionSyncRequest);
req.flush(expectedResponse);
});
it('should handle 400 Bad Request for invalid data', () => {
const invalidRequest = {
sessionId: 'invalid-uuid',
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
detectedQuestions: [],
providedAnswers: []
};
service.syncSessionData(invalidRequest).subscribe({
next: () => fail('Should have failed with 400'),
error: (error) => {
expect(error.status).toBe(400);
expect(error.error.error).toBeDefined();
expect(error.error.validationErrors).toBeInstanceOf(Array);
}
});
const req = httpMock.expectOne('https://n8n.gm-tech.org/webhook/interview/session-sync');
req.flush(
{
error: 'Invalid request data',
validationErrors: ['sessionId must be a valid UUID']
},
{ status: 400, statusText: 'Bad Request' }
);
});
});
describe('T010: Contract test POST /interview/analytics', () => {
it('should submit analytics with correct format', () => {
const mockAnalyticsRequest = {
sessionId: '123e4567-e89b-12d3-a456-426614174008',
analytics: {
totalQuestions: 15,
answersFromBank: 12,
answersGenerated: 3,
averageResponseTime: 350,
accuracyRate: 0.87,
topicsCovered: ['angular', 'typescript', 'rxjs', 'testing']
},
improvements: [
'Add more behavioral question examples',
'Improve response time for complex technical questions'
]
};
const expectedResponse = {
processed: true,
insights: [
'Strong performance on Angular questions',
'Consider practicing more complex scenarios'
],
recommendedActions: [
'Focus on behavioral interview preparation',
'Practice explaining complex technical concepts simply'
]
};
service.submitAnalytics(mockAnalyticsRequest).subscribe(response => {
expect(response).toEqual(expectedResponse);
expect(response.processed).toBe(true);
expect(response.insights.length).toBeGreaterThan(0);
expect(response.recommendedActions.length).toBeGreaterThan(0);
});
const req = httpMock.expectOne('https://n8n.gm-tech.org/webhook/interview/analytics');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(mockAnalyticsRequest);
req.flush(expectedResponse);
});
});
});

View File

@ -0,0 +1,604 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { BehaviorSubject, Observable, of, throwError, timer, EMPTY } from 'rxjs';
import { AutheliaAuthService } from './authelia-auth.service';
import { map, catchError, retry, switchMap, tap, delay, timeout } from 'rxjs/operators';
import {
N8nSyncData,
SyncStatus,
SyncError,
CVAnalysisRequest,
CVAnalysisResponse,
QuestionBankResponse,
SessionSyncRequest,
SessionSyncResponse,
AnalyticsRequest,
AnalyticsResponse,
AutheliaAuthToken,
N8nApiConfig,
N8nDataMapper
} from '../models/n8n-sync.interface';
import { CVProfile } from '../models/cv-profile.interface';
import { QuestionBank } from '../models/question-bank.interface';
import { InterviewSession, SessionSummary, SessionAnalytics } from '../models/interview-session.interface';
import { DataSanitizer } from '../models/validation';
@Injectable({
providedIn: 'root'
})
export class N8nSyncService implements N8nDataMapper {
private readonly apiConfig: N8nApiConfig = {
baseUrl: environment.n8nWebhookUrl.replace('/webhook/cv-analysis', ''),
authToken: '',
timeout: 30000,
retryAttempts: 3
};
private authTokenSubject = new BehaviorSubject<AutheliaAuthToken | null>(null);
private syncStatusSubject = new BehaviorSubject<SyncStatus>(SyncStatus.PENDING);
private lastSyncSubject = new BehaviorSubject<Date | null>(null);
private isOnlineSubject = new BehaviorSubject<boolean>(navigator.onLine);
public authToken$ = this.authTokenSubject.asObservable();
public syncStatus$ = this.syncStatusSubject.asObservable();
public lastSync$ = this.lastSyncSubject.asObservable();
public isOnline$ = this.isOnlineSubject.asObservable();
private pendingSyncs = new Map<string, N8nSyncData>();
private syncQueue: string[] = [];
constructor(
private http: HttpClient,
private autheliaAuth: AutheliaAuthService
) {
this.setupNetworkStatusMonitoring();
this.initializeAuthToken();
this.startSyncQueueProcessor();
}
// Authentication Methods
public async authenticateWithAuthelia(username: string, password: string): Promise<boolean> {
try {
const authEndpoint = `${this.apiConfig.baseUrl}/api/verify`;
const response = await this.http.post<any>(authEndpoint, {
username,
password
}, {
headers: new HttpHeaders({
'Content-Type': 'application/json'
}),
withCredentials: true
}).toPromise();
if (response && response.token) {
const authToken: AutheliaAuthToken = {
token: response.token,
expiresAt: new Date(Date.now() + (response.expires_in || 3600) * 1000),
refreshToken: response.refresh_token
};
this.authTokenSubject.next(authToken);
this.apiConfig.authToken = response.token;
this.storeAuthToken(authToken);
return true;
}
return false;
} catch (error) {
console.error('Authelia authentication failed:', error);
return false;
}
}
public async refreshAuthToken(): Promise<boolean> {
const currentToken = this.authTokenSubject.value;
if (!currentToken?.refreshToken) {
return false;
}
try {
const refreshEndpoint = `${this.apiConfig.baseUrl}/api/refresh`;
const response = await this.http.post<any>(refreshEndpoint, {
refresh_token: currentToken.refreshToken
}, {
headers: new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken.token}`
})
}).toPromise();
if (response && response.token) {
const newAuthToken: AutheliaAuthToken = {
token: response.token,
expiresAt: new Date(Date.now() + (response.expires_in || 3600) * 1000),
refreshToken: response.refresh_token || currentToken.refreshToken
};
this.authTokenSubject.next(newAuthToken);
this.apiConfig.authToken = response.token;
this.storeAuthToken(newAuthToken);
return true;
}
return false;
} catch (error) {
console.error('Token refresh failed:', error);
this.clearAuthToken();
return false;
}
}
// CV Analysis Methods
public submitCVForAnalysis(cvProfile: CVProfile): Observable<CVAnalysisResponse> {
// Check if user is authenticated with Authelia
if (!this.autheliaAuth.isAuthenticated) {
console.log('🔒 Authelia authentication required for CV analysis');
return throwError(() => new Error('Authelia authentication required'));
}
this.syncStatusSubject.next(SyncStatus.IN_PROGRESS);
const request = this.mapCVProfileToRequest(cvProfile);
const endpoint = environment.apiEndpoints.cvAnalysis;
console.log('🚀 Submitting CV for analysis with Authelia auth');
// Use AutheliaAuthService for authenticated requests
return this.autheliaAuth.makeAuthenticatedRequest<CVAnalysisResponse>(endpoint, request, 'POST').pipe(
retry(this.apiConfig.retryAttempts),
tap(response => {
console.log('✅ CV analysis submitted:', response.analysisId);
this.syncStatusSubject.next(SyncStatus.SUCCESS);
}),
catchError(error => {
console.error('❌ CV analysis failed:', error);
this.syncStatusSubject.next(SyncStatus.FAILED);
if (error.message === 'Authentication required') {
console.log('🔐 Redirecting to Authelia login...');
// Let the component handle the redirect to login
}
return throwError(() => error);
})
);
}
public pollAnalysisStatus(analysisId: string): Observable<CVAnalysisResponse> {
const statusEndpoint = `${this.apiConfig.baseUrl}/webhook/cv-analysis/${analysisId}/status`;
return timer(0, 5000).pipe( // Poll every 5 seconds
switchMap(() =>
this.http.get<CVAnalysisResponse>(statusEndpoint, {
headers: this.getAuthHeaders()
}).pipe(
catchError(error => {
console.warn('Status polling error:', error);
return of({ status: 'failed' } as CVAnalysisResponse);
})
)
),
tap(response => {
if (response.status === 'completed' || response.status === 'failed') {
this.syncStatusSubject.next(
response.status === 'completed' ? SyncStatus.SUCCESS : SyncStatus.FAILED
);
}
})
);
}
public getGeneratedQuestionBank(questionBankId: string): Observable<QuestionBank> {
if (!this.isAuthenticated()) {
return throwError('Authentication required');
}
const endpoint = `${this.apiConfig.baseUrl}/webhook/question-bank/${questionBankId}`;
return this.http.get<QuestionBankResponse>(endpoint, {
headers: this.getAuthHeaders()
}).pipe(
map(response => this.mapResponseToQuestionBank(response)),
retry(this.apiConfig.retryAttempts),
catchError(this.handleHttpError.bind(this))
);
}
// Session Synchronization Methods
public syncInterviewSession(session: InterviewSession): Observable<SessionSyncResponse> {
if (!this.isAuthenticated()) {
return this.queueSyncForLater(session);
}
this.syncStatusSubject.next(SyncStatus.IN_PROGRESS);
const request = this.mapSessionToSyncRequest(session);
const endpoint = `${this.apiConfig.baseUrl}/webhook/session-sync`;
return this.http.post<SessionSyncResponse>(endpoint, request, {
headers: this.getAuthHeaders(),
timeout: this.apiConfig.timeout
}).pipe(
retry(this.apiConfig.retryAttempts),
tap(response => {
this.syncStatusSubject.next(
response.status === 'success' ? SyncStatus.SUCCESS : SyncStatus.FAILED
);
this.lastSyncSubject.next(new Date());
if (response.status === 'success') {
this.removePendingSync(session.id);
}
}),
catchError(error => {
this.syncStatusSubject.next(SyncStatus.FAILED);
this.queueSyncForLater(session);
return this.handleHttpError(error);
})
);
}
public syncSessionAnalytics(sessionId: string, analytics: SessionAnalytics, improvements: string[]): Observable<AnalyticsResponse> {
if (!this.isAuthenticated()) {
return throwError('Authentication required');
}
const request: AnalyticsRequest = {
sessionId,
analytics,
improvements
};
const endpoint = `${this.apiConfig.baseUrl}/webhook/analytics`;
return this.http.post<AnalyticsResponse>(endpoint, request, {
headers: this.getAuthHeaders()
}).pipe(
retry(this.apiConfig.retryAttempts),
catchError(this.handleHttpError.bind(this))
);
}
// Bulk Synchronization Methods
public syncAllPendingData(): Observable<void> {
if (!this.isAuthenticated() || this.pendingSyncs.size === 0) {
return of(void 0);
}
this.syncStatusSubject.next(SyncStatus.IN_PROGRESS);
const syncObservables: Observable<any>[] = [];
this.pendingSyncs.forEach(syncData => {
if (syncData.dataSnapshot.sessionSummary) {
// Reconstruct session from snapshot
const session: InterviewSession = {
id: syncData.sessionId,
cvProfileId: syncData.dataSnapshot.cvProfile.id,
questionBankId: '', // Will be filled from session summary
startTime: new Date(),
endTime: undefined,
duration: 0,
detectedQuestions: [],
providedAnswers: [],
questionsAnswered: [],
manualComments: [],
speechHints: [],
analytics: syncData.dataSnapshot.analytics,
status: 'completed' as any
};
syncObservables.push(this.syncInterviewSession(session));
}
});
if (syncObservables.length === 0) {
this.syncStatusSubject.next(SyncStatus.SUCCESS);
return of(void 0);
}
return new Observable(observer => {
Promise.all(syncObservables.map(obs => obs.toPromise()))
.then(() => {
this.syncStatusSubject.next(SyncStatus.SUCCESS);
observer.next();
observer.complete();
})
.catch(error => {
this.syncStatusSubject.next(SyncStatus.FAILED);
observer.error(error);
});
});
}
// Data Mapping Methods (N8nDataMapper implementation)
public mapCVProfileToRequest(profile: CVProfile): CVAnalysisRequest {
return {
cvProfileId: profile.id,
personalInfo: profile.personalInfo,
experience: profile.experience,
education: profile.education,
skills: profile.skills,
certifications: profile.certifications,
parsedText: profile.extractedText || ''
};
}
public mapResponseToQuestionBank(response: QuestionBankResponse): QuestionBank {
// Convert the response questions to our internal format
const mappedQuestions = response.questions.map(q => ({
...q,
category: q.category as any, // Type assertion for enum conversion
difficulty: q.difficulty as any, // Type assertion for enum conversion
}));
return {
id: response.questionBankId,
cvProfileId: response.cvProfileId,
generatedDate: new Date(response.generatedDate),
lastUsed: new Date(),
questions: mappedQuestions,
accuracy: 0.9, // Default accuracy
metadata: {
...response.metadata,
categoriesDistribution: response.metadata.categoriesDistribution || {}
}
};
}
public mapSessionToSyncRequest(session: InterviewSession): SessionSyncRequest {
return {
sessionId: session.id,
cvProfileId: session.cvProfileId,
startTime: session.startTime.toISOString(),
endTime: session.endTime?.toISOString(),
detectedQuestions: session.detectedQuestions,
providedAnswers: session.providedAnswers,
manualComments: session.manualComments,
analytics: session.analytics
};
}
public mapSyncResponseToSession(response: SessionSyncResponse, session: InterviewSession): InterviewSession {
return {
...session,
// Update questions based on server response
detectedQuestions: session.detectedQuestions.map(dq => {
const updatedQuestion = response.updatedQuestions.find(uq => uq.id === dq.questionBankMatch);
if (updatedQuestion) {
return {
...dq,
confidence: Math.max(dq.confidence, updatedQuestion.confidence)
};
}
return dq;
})
};
}
// Queue Management Methods
private queueSyncForLater(session: InterviewSession): Observable<SessionSyncResponse> {
const syncData: N8nSyncData = {
id: DataSanitizer.generateUUID(),
sessionId: session.id,
lastSyncTime: new Date(),
syncStatus: SyncStatus.PENDING,
dataSnapshot: {
cvProfile: {} as CVProfile, // Would need to get from service
questionBank: {} as QuestionBank, // Would need to get from service
sessionSummary: this.createSessionSummary(session),
analytics: session.analytics
}
};
this.pendingSyncs.set(session.id, syncData);
this.syncQueue.push(session.id);
// Return a pending response
return of({
syncId: syncData.id,
status: 'partial',
updatedQuestions: [],
recommendations: ['Sync queued for when connection is available'],
errors: []
});
}
private createSessionSummary(session: InterviewSession): SessionSummary {
const duration = session.endTime && session.startTime ?
(session.endTime.getTime() - session.startTime.getTime()) / (1000 * 60) : 0;
return {
sessionId: session.id,
duration,
questionsAnswered: session.providedAnswers.length,
successRate: session.analytics.accuracyRate,
improvements: [],
newQuestions: []
};
}
private removePendingSync(sessionId: string): void {
this.pendingSyncs.delete(sessionId);
const index = this.syncQueue.indexOf(sessionId);
if (index > -1) {
this.syncQueue.splice(index, 1);
}
}
private startSyncQueueProcessor(): void {
// Process sync queue when online
this.isOnline$.subscribe(isOnline => {
if (isOnline && this.syncQueue.length > 0 && this.isAuthenticated()) {
this.processSyncQueue();
}
});
}
private processSyncQueue(): void {
if (this.syncQueue.length === 0) return;
const sessionId = this.syncQueue.shift();
if (!sessionId) return;
const syncData = this.pendingSyncs.get(sessionId);
if (!syncData) return;
// Process this sync item
setTimeout(() => {
if (this.isAuthenticated() && this.isOnlineSubject.value) {
// Try to sync this item
// This would need the actual session data
console.log('Processing queued sync for session:', sessionId);
}
}, 1000);
}
// Utility Methods
private isAuthenticated(): boolean {
const token = this.authTokenSubject.value;
return !!(token && token.expiresAt > new Date());
}
private getAuthHeaders(): HttpHeaders {
const token = this.authTokenSubject.value;
return new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token?.token || ''}`
});
}
private handleHttpError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'An error occurred';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `Error: ${error.error.message}`;
} else {
// Server-side error
switch (error.status) {
case 401:
errorMessage = 'Authentication failed';
this.clearAuthToken();
break;
case 403:
errorMessage = 'Access forbidden';
break;
case 404:
errorMessage = 'Service not found';
break;
case 500:
errorMessage = 'Server error';
break;
case 503:
errorMessage = 'Service unavailable';
break;
default:
errorMessage = `Error ${error.status}: ${error.message}`;
}
}
console.error('N8n API Error:', errorMessage, error);
return throwError(errorMessage);
}
private setupNetworkStatusMonitoring(): void {
window.addEventListener('online', () => {
this.isOnlineSubject.next(true);
console.log('Network connection restored');
});
window.addEventListener('offline', () => {
this.isOnlineSubject.next(false);
console.log('Network connection lost');
});
}
private initializeAuthToken(): void {
const storedToken = this.getStoredAuthToken();
if (storedToken && storedToken.expiresAt > new Date()) {
this.authTokenSubject.next(storedToken);
this.apiConfig.authToken = storedToken.token;
}
}
private storeAuthToken(token: AutheliaAuthToken): void {
try {
localStorage.setItem('n8n_auth_token', JSON.stringify({
...token,
expiresAt: token.expiresAt.toISOString()
}));
} catch (error) {
console.error('Failed to store auth token:', error);
}
}
private getStoredAuthToken(): AutheliaAuthToken | null {
try {
const stored = localStorage.getItem('n8n_auth_token');
if (stored) {
const parsed = JSON.parse(stored);
return {
...parsed,
expiresAt: new Date(parsed.expiresAt)
};
}
} catch (error) {
console.error('Failed to retrieve stored auth token:', error);
}
return null;
}
private clearAuthToken(): void {
this.authTokenSubject.next(null);
this.apiConfig.authToken = '';
localStorage.removeItem('n8n_auth_token');
}
// Health Check Methods
public checkN8nHealth(): Observable<boolean> {
const healthEndpoint = `${this.apiConfig.baseUrl}/healthz`;
return this.http.get(healthEndpoint, {
timeout: 5000
}).pipe(
map(() => true),
catchError(() => of(false))
);
}
public getApiStatus(): Observable<any> {
if (!this.isAuthenticated()) {
return of({ authenticated: false, online: this.isOnlineSubject.value });
}
const statusEndpoint = `${this.apiConfig.baseUrl}/api/status`;
return this.http.get(statusEndpoint, {
headers: this.getAuthHeaders()
}).pipe(
map(response => ({
authenticated: true,
online: this.isOnlineSubject.value,
api: response
})),
catchError(() => of({
authenticated: false,
online: this.isOnlineSubject.value,
error: 'Failed to get API status'
}))
);
}
// Cleanup
public destroy(): void {
// Clear any ongoing timers or subscriptions
this.pendingSyncs.clear();
this.syncQueue.length = 0;
}
}

View File

@ -0,0 +1,425 @@
import { TestBed } from '@angular/core/testing';
import { QuestionBankService } from './question-bank.service';
// Mock Dexie and IndexedDB
class MockDexieDB {
cvProfiles = {
add: jasmine.createSpy('add').and.returnValue(Promise.resolve('test-id')),
get: jasmine.createSpy('get').and.returnValue(Promise.resolve(null)),
put: jasmine.createSpy('put').and.returnValue(Promise.resolve('test-id')),
delete: jasmine.createSpy('delete').and.returnValue(Promise.resolve()),
toArray: jasmine.createSpy('toArray').and.returnValue(Promise.resolve([])),
where: jasmine.createSpy('where').and.returnValue({
equals: jasmine.createSpy('equals').and.returnValue({
toArray: jasmine.createSpy('toArray').and.returnValue(Promise.resolve([]))
})
})
};
questionBanks = {
add: jasmine.createSpy('add').and.returnValue(Promise.resolve('test-id')),
get: jasmine.createSpy('get').and.returnValue(Promise.resolve(null)),
put: jasmine.createSpy('put').and.returnValue(Promise.resolve('test-id')),
delete: jasmine.createSpy('delete').and.returnValue(Promise.resolve()),
toArray: jasmine.createSpy('toArray').and.returnValue(Promise.resolve([])),
where: jasmine.createSpy('where').and.returnValue({
equals: jasmine.createSpy('equals').and.returnValue({
first: jasmine.createSpy('first').and.returnValue(Promise.resolve(null)),
toArray: jasmine.createSpy('toArray').and.returnValue(Promise.resolve([]))
})
})
};
interviewSessions = {
add: jasmine.createSpy('add').and.returnValue(Promise.resolve('test-id')),
get: jasmine.createSpy('get').and.returnValue(Promise.resolve(null)),
put: jasmine.createSpy('put').and.returnValue(Promise.resolve('test-id')),
delete: jasmine.createSpy('delete').and.returnValue(Promise.resolve()),
toArray: jasmine.createSpy('toArray').and.returnValue(Promise.resolve([]))
};
open = jasmine.createSpy('open').and.returnValue(Promise.resolve());
close = jasmine.createSpy('close');
}
describe('QuestionBankService - Integration Tests', () => {
let service: QuestionBankService;
let mockDb: MockDexieDB;
beforeEach(() => {
mockDb = new MockDexieDB();
TestBed.configureTestingModule({
providers: [QuestionBankService]
});
service = TestBed.inject(QuestionBankService);
// Inject mock database
(service as any).db = mockDb;
});
describe('T012: Integration test IndexedDB operations', () => {
it('should initialize database connection', async () => {
// Act
await service.initializeDatabase();
// Assert
expect(mockDb.open).toHaveBeenCalled();
});
it('should store CV profile in IndexedDB', async () => {
// Arrange
const cvProfile = {
id: '123e4567-e89b-12d3-a456-426614174000',
fileName: 'john-doe-cv.pdf',
uploadDate: new Date(),
personalInfo: {
fullName: 'John Doe',
email: 'john.doe@example.com',
phone: '+1234567890',
location: 'New York, NY'
},
experience: [
{
id: '123e4567-e89b-12d3-a456-426614174001',
company: 'Tech Corp',
position: 'Senior Developer',
startDate: new Date('2020-01-01'),
endDate: new Date('2023-12-31'),
description: 'Full-stack development',
technologies: ['Angular', 'Node.js'],
achievements: ['Led team of 5']
}
],
education: [],
skills: [
{
id: '123e4567-e89b-12d3-a456-426614174003',
name: 'Angular',
category: 'technical' as const,
proficiency: 'advanced' as const,
yearsOfExperience: 5
}
],
certifications: [],
languages: [],
parsedText: 'Full CV text content',
lastModified: new Date()
};
// Act
const result = await service.saveCVProfile(cvProfile);
// Assert
expect(mockDb.cvProfiles.put).toHaveBeenCalledWith(cvProfile);
expect(result).toBe('test-id');
});
it('should retrieve CV profile from IndexedDB', async () => {
// Arrange
const expectedProfile = {
id: '123e4567-e89b-12d3-a456-426614174000',
fileName: 'john-doe-cv.pdf',
personalInfo: { fullName: 'John Doe', email: 'john@example.com' }
};
mockDb.cvProfiles.get.and.returnValue(Promise.resolve(expectedProfile));
// Act
const result = await service.getCVProfile('123e4567-e89b-12d3-a456-426614174000');
// Assert
expect(mockDb.cvProfiles.get).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000');
expect(result).toEqual(expectedProfile);
});
it('should store question bank in IndexedDB', async () => {
// Arrange
const questionBank = {
id: '123e4567-e89b-12d3-a456-426614174005',
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
questions: [
{
id: '123e4567-e89b-12d3-a456-426614174006',
text: 'What is Angular dependency injection?',
category: 'technical' as const,
difficulty: 'medium' as const,
tags: ['angular', 'dependency-injection'],
answer: {
id: '123e4567-e89b-12d3-a456-426614174007',
content: 'Angular dependency injection is...',
keyPoints: ['Singleton services'],
followUpQuestions: [],
estimatedDuration: 45,
personalizedContext: 'Based on your experience...'
},
relatedSkills: ['Angular'],
confidence: 0.95,
timesUsed: 0
}
],
generatedDate: new Date(),
lastUpdated: new Date(),
accuracy: 0.89,
totalUsage: 0
};
// Act
const result = await service.saveQuestionBank(questionBank);
// Assert
expect(mockDb.questionBanks.put).toHaveBeenCalledWith(questionBank);
expect(result).toBe('test-id');
});
it('should retrieve question bank by CV profile ID', async () => {
// Arrange
const expectedQuestionBank = {
id: '123e4567-e89b-12d3-a456-426614174005',
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
questions: []
};
mockDb.questionBanks.where().equals().first.and.returnValue(Promise.resolve(expectedQuestionBank));
// Act
const result = await service.getQuestionBankByCVProfile('123e4567-e89b-12d3-a456-426614174000');
// Assert
expect(mockDb.questionBanks.where).toHaveBeenCalledWith('cvProfileId');
expect(result).toEqual(expectedQuestionBank);
});
it('should find answers by question text with fuzzy matching', async () => {
// Arrange
const questionBank = {
id: '123e4567-e89b-12d3-a456-426614174005',
questions: [
{
id: '123e4567-e89b-12d3-a456-426614174006',
text: 'What is Angular dependency injection?',
tags: ['angular', 'dependency-injection', 'typescript'],
answer: {
content: 'Angular dependency injection is a design pattern...',
keyPoints: ['Singleton services', 'Hierarchical injectors']
},
confidence: 0.95
},
{
id: '123e4567-e89b-12d3-a456-426614174008',
text: 'How do you create components in Angular?',
tags: ['angular', 'components', 'typescript'],
answer: {
content: 'Angular components are created using...',
keyPoints: ['Component decorator', 'Template and styles']
},
confidence: 0.92
}
]
};
mockDb.questionBanks.toArray.and.returnValue(Promise.resolve([questionBank]));
// Act - Test exact match
const exactMatch = await service.findAnswer('What is Angular dependency injection?');
// Assert
expect(exactMatch).toBeDefined();
expect(exactMatch!.content).toContain('Angular dependency injection is a design pattern');
expect(exactMatch!.confidence).toBe(0.95);
// Act - Test fuzzy match
const fuzzyMatch = await service.findAnswer('angular dependency injection');
// Assert
expect(fuzzyMatch).toBeDefined();
expect(fuzzyMatch!.content).toContain('Angular dependency injection is a design pattern');
});
it('should perform fast question bank queries under 100ms', async () => {
// Arrange
const largeQuestionBank = {
id: '123e4567-e89b-12d3-a456-426614174005',
questions: Array.from({ length: 500 }, (_, i) => ({
id: `question-${i}`,
text: `Question ${i} about Angular`,
tags: ['angular', 'typescript'],
answer: { content: `Answer ${i}` },
confidence: 0.9
}))
};
mockDb.questionBanks.toArray.and.returnValue(Promise.resolve([largeQuestionBank]));
// Act
const startTime = performance.now();
const result = await service.findAnswer('Question 250 about Angular');
const endTime = performance.now();
// Assert
expect(endTime - startTime).toBeLessThan(100); // Sub-100ms requirement
expect(result).toBeDefined();
});
it('should handle IndexedDB storage quota exceeded', async () => {
// Arrange
const quotaError = new DOMException('QuotaExceededError', 'QuotaExceededError');
mockDb.questionBanks.put.and.returnValue(Promise.reject(quotaError));
const questionBank = { id: 'test', questions: [] };
// Act & Assert
await expectAsync(service.saveQuestionBank(questionBank))
.toBeRejectedWithError('Storage quota exceeded');
});
it('should manage session data with proper indexing', async () => {
// Arrange
const sessionData = {
id: '123e4567-e89b-12d3-a456-426614174008',
cvProfileId: '123e4567-e89b-12d3-a456-426614174000',
questionBankId: '123e4567-e89b-12d3-a456-426614174005',
startTime: new Date(),
endTime: new Date(),
detectedQuestions: [
{
id: '123e4567-e89b-12d3-a456-426614174009',
timestamp: new Date(),
originalText: 'What is Angular?',
normalizedText: 'what is angular',
confidence: 0.95,
questionBankMatch: '123e4567-e89b-12d3-a456-426614174006',
responseTime: 250,
wasHelpRequested: true
}
],
providedAnswers: [],
manualComments: [],
speechHints: [],
analytics: {
totalQuestions: 1,
answersFromBank: 1,
answersGenerated: 0,
averageResponseTime: 250,
accuracyRate: 1.0,
topicsCovered: ['angular']
},
status: 'completed' as const
};
// Act
const result = await service.saveInterviewSession(sessionData);
// Assert
expect(mockDb.interviewSessions.put).toHaveBeenCalledWith(sessionData);
expect(result).toBe('test-id');
});
it('should update question usage statistics', async () => {
// Arrange
const questionBank = {
id: '123e4567-e89b-12d3-a456-426614174005',
questions: [
{
id: '123e4567-e89b-12d3-a456-426614174006',
text: 'What is Angular?',
timesUsed: 5,
confidence: 0.95
}
],
totalUsage: 10
};
mockDb.questionBanks.get.and.returnValue(Promise.resolve(questionBank));
// Act
await service.updateQuestionUsage('123e4567-e89b-12d3-a456-426614174006');
// Assert
expect(mockDb.questionBanks.put).toHaveBeenCalledWith({
...questionBank,
questions: [
{
...questionBank.questions[0],
timesUsed: 6,
lastUsed: jasmine.any(Date)
}
],
totalUsage: 11,
lastUpdated: jasmine.any(Date)
});
});
it('should handle database corruption and recovery', async () => {
// Arrange
const corruptionError = new DOMException('Database corruption detected', 'CorruptionError');
mockDb.open.and.returnValue(Promise.reject(corruptionError));
// Act & Assert
await expectAsync(service.initializeDatabase())
.toBeRejectedWithError('Database corruption detected');
// Verify recovery attempt
expect(service.isDatabaseCorrupted()).toBe(true);
});
it('should optimize question bank for fast retrieval', async () => {
// Arrange
const questionBank = {
id: '123e4567-e89b-12d3-a456-426614174005',
questions: Array.from({ length: 100 }, (_, i) => ({
id: `question-${i}`,
text: `Question ${i}`,
tags: i % 2 === 0 ? ['angular'] : ['typescript'],
confidence: 0.9 - (i * 0.001) // Varying confidence
}))
};
// Act
const optimizedBank = await service.optimizeQuestionBank(questionBank);
// Assert
expect(optimizedBank.questions.length).toBeLessThanOrEqual(50); // Pruned for performance
expect(optimizedBank.questions[0].confidence).toBeGreaterThan(0.8); // High confidence first
});
it('should handle concurrent database operations', async () => {
// Arrange
const operations = Array.from({ length: 10 }, (_, i) =>
service.saveCVProfile({
id: `profile-${i}`,
fileName: `cv-${i}.pdf`,
personalInfo: { fullName: `User ${i}`, email: `user${i}@example.com` }
})
);
// Act
const results = await Promise.all(operations);
// Assert
expect(results.length).toBe(10);
results.forEach(result => expect(result).toBe('test-id'));
expect(mockDb.cvProfiles.put).toHaveBeenCalledTimes(10);
});
it('should clean up old session data automatically', async () => {
// Arrange
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 30); // 30 days ago
const sessions = [
{ id: 'old-session', startTime: oldDate },
{ id: 'recent-session', startTime: new Date() }
];
mockDb.interviewSessions.toArray.and.returnValue(Promise.resolve(sessions));
// Act
await service.cleanupOldSessions(7); // Keep 7 days
// Assert
expect(mockDb.interviewSessions.delete).toHaveBeenCalledWith('old-session');
expect(mockDb.interviewSessions.delete).not.toHaveBeenCalledWith('recent-session');
});
});
});

View File

@ -0,0 +1,705 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, from, of, EMPTY } from 'rxjs';
import { map, catchError, switchMap, tap } from 'rxjs/operators';
import Dexie, { Table } from 'dexie';
import {
QuestionBank,
Question,
Answer,
QuestionSearchCriteria,
QuestionMatch,
QuestionMatchResult,
QuestionSearchResult,
QuestionCategory,
QuestionDifficulty,
QuestionBankGeneration,
QuestionOptimization
} from '../models/question-bank.interface';
import { CVProfile } from '../models/cv-profile.interface';
import { DataSanitizer } from '../models/validation';
class QuestionBankDatabase extends Dexie {
questionBanks!: Table<QuestionBank>;
questions!: Table<Question>;
answers!: Table<Answer>;
constructor() {
super('InterviewAssistantDB');
this.version(1).stores({
questionBanks: 'id, cvProfileId, generatedDate, lastUsed, accuracy',
questions: 'id, bankId, category, difficulty, text, confidence, timesUsed, lastUsed',
answers: 'id, questionId, content, estimatedDuration'
});
}
}
@Injectable({
providedIn: 'root'
})
export class QuestionBankService {
private db = new QuestionBankDatabase();
private currentQuestionBankSubject = new BehaviorSubject<QuestionBank | null>(null);
private availableQuestionsSubject = new BehaviorSubject<Question[]>([]);
private isLoadingSubject = new BehaviorSubject<boolean>(false);
public currentQuestionBank$ = this.currentQuestionBankSubject.asObservable();
public availableQuestions$ = this.availableQuestionsSubject.asObservable();
public isLoading$ = this.isLoadingSubject.asObservable();
constructor() {
this.initializeDatabase();
}
private initializeDatabase(): void {
this.db.open().catch(err => {
console.error('Failed to open question bank database:', err);
});
}
public createQuestionBank(cvProfile: CVProfile, generation: QuestionBankGeneration): Observable<QuestionBank> {
this.isLoadingSubject.next(true);
const questionBank: QuestionBank = {
id: DataSanitizer.generateUUID(),
cvProfileId: cvProfile.id,
generatedDate: new Date(),
lastUsed: new Date(),
questions: [],
accuracy: 0.9,
metadata: {
totalQuestions: 0,
categoriesDistribution: {},
averageConfidence: 0,
lastUpdated: new Date().toISOString()
}
};
return from(this.db.questionBanks.add(questionBank)).pipe(
switchMap(() => this.generateQuestionsFromCV(cvProfile, questionBank.id, generation)),
switchMap(questions => this.saveQuestions(questions, questionBank.id)),
switchMap(() => this.updateQuestionBankMetadata(questionBank.id)),
switchMap(() => this.getQuestionBank(questionBank.id)),
tap(bank => {
if (bank) {
this.currentQuestionBankSubject.next(bank);
}
this.isLoadingSubject.next(false);
}),
map(bank => bank!),
catchError(error => {
console.error('Error creating question bank:', error);
this.isLoadingSubject.next(false);
return EMPTY;
})
);
}
public loadQuestionBank(bankId: string): Observable<QuestionBank | null> {
this.isLoadingSubject.next(true);
return from(this.db.questionBanks.get(bankId)).pipe(
switchMap(bank => {
if (!bank) return of(null);
return this.loadQuestionsForBank(bankId).pipe(
map(questions => {
bank.questions = questions;
return bank;
})
);
}),
tap(bank => {
this.currentQuestionBankSubject.next(bank);
if (bank) {
this.availableQuestionsSubject.next(bank.questions);
this.updateLastUsed(bankId);
}
this.isLoadingSubject.next(false);
}),
catchError(error => {
console.error('Error loading question bank:', error);
this.isLoadingSubject.next(false);
return of(null);
})
);
}
public getQuestionBank(bankId: string): Observable<QuestionBank | null> {
return from(this.db.questionBanks.get(bankId)).pipe(
switchMap(bank => {
if (!bank) return of(null);
return this.loadQuestionsForBank(bankId).pipe(
map(questions => {
bank.questions = questions;
return bank;
})
);
}),
catchError(error => {
console.error('Error getting question bank:', error);
return of(null);
})
);
}
public searchQuestions(criteria: QuestionSearchCriteria): Observable<QuestionSearchResult> {
const currentBank = this.currentQuestionBankSubject.value;
if (!currentBank) {
return of({
results: [],
totalResults: 0,
searchTime: 0
});
}
const startTime = performance.now();
return this.loadQuestionsForBank(currentBank.id).pipe(
map(questions => {
let filteredQuestions = questions;
if (criteria.query) {
const queryLower = criteria.query.toLowerCase();
filteredQuestions = filteredQuestions.filter(q =>
q.text.toLowerCase().includes(queryLower) ||
q.tags.some(tag => tag.toLowerCase().includes(queryLower)) ||
q.answer.content.toLowerCase().includes(queryLower)
);
}
if (criteria.category) {
filteredQuestions = filteredQuestions.filter(q => q.category === criteria.category);
}
if (criteria.difficulty) {
filteredQuestions = filteredQuestions.filter(q => q.difficulty === criteria.difficulty);
}
if (criteria.minConfidence !== undefined) {
filteredQuestions = filteredQuestions.filter(q => q.confidence >= criteria.minConfidence!);
}
if (criteria.tags && criteria.tags.length > 0) {
filteredQuestions = filteredQuestions.filter(q =>
criteria.tags!.some((tag: string) => q.tags.includes(tag))
);
}
// Apply sorting
if (criteria.sortBy) {
filteredQuestions.sort((a, b) => {
let comparison = 0;
switch (criteria.sortBy) {
case 'confidence':
comparison = b.confidence - a.confidence;
break;
case 'lastUsed':
comparison = (b.lastUsed?.getTime() || 0) - (a.lastUsed?.getTime() || 0);
break;
case 'timesUsed':
comparison = b.timesUsed - a.timesUsed;
break;
case 'text':
comparison = a.text.localeCompare(b.text);
break;
}
return criteria.sortOrder === 'desc' ? comparison : -comparison;
});
}
// Apply pagination
const startIndex = (criteria.page || 0) * (criteria.limit || 50);
const endIndex = startIndex + (criteria.limit || 50);
const paginatedResults = filteredQuestions.slice(startIndex, endIndex);
const searchTime = performance.now() - startTime;
return {
results: paginatedResults,
totalResults: filteredQuestions.length,
searchTime
};
}),
catchError(error => {
console.error('Error searching questions:', error);
return of({
results: [],
totalResults: 0,
searchTime: performance.now() - startTime
});
})
);
}
public findBestMatch(detectedText: string, threshold: number = 0.7): Observable<QuestionMatchResult> {
const currentBank = this.currentQuestionBankSubject.value;
if (!currentBank) {
return of({
hasMatch: false,
confidence: 0,
matches: []
});
}
return this.loadQuestionsForBank(currentBank.id).pipe(
map(questions => {
const normalizedInput = this.normalizeText(detectedText);
const matches: QuestionMatch[] = [];
questions.forEach(question => {
const normalizedQuestion = this.normalizeText(question.text);
const similarity = this.calculateTextSimilarity(normalizedInput, normalizedQuestion);
if (similarity >= threshold) {
matches.push({
question,
similarity,
matchType: this.determineMatchType(similarity)
});
}
});
// Sort by similarity (highest first)
matches.sort((a, b) => b.similarity - a.similarity);
return {
hasMatch: matches.length > 0,
confidence: matches.length > 0 ? matches[0].similarity : 0,
matches: matches.slice(0, 5) // Return top 5 matches
};
}),
catchError(error => {
console.error('Error finding question match:', error);
return of({
hasMatch: false,
confidence: 0,
matches: []
});
})
);
}
public updateQuestionUsage(questionId: string): Observable<void> {
return from(this.db.questions.where('id').equals(questionId).modify(question => {
question.timesUsed = question.timesUsed + 1;
question.lastUsed = new Date();
})).pipe(
tap(() => {
// Update local state
const currentQuestions = this.availableQuestionsSubject.value;
const updatedQuestions = currentQuestions.map(q => {
if (q.id === questionId) {
return { ...q, timesUsed: q.timesUsed + 1, lastUsed: new Date() };
}
return q;
});
this.availableQuestionsSubject.next(updatedQuestions);
}),
map(() => void 0),
catchError(error => {
console.error('Error updating question usage:', error);
return EMPTY;
})
);
}
public optimizeQuestionBank(optimization: QuestionOptimization): Observable<void> {
const currentBank = this.currentQuestionBankSubject.value;
if (!currentBank) {
return EMPTY;
}
return this.loadQuestionsForBank(currentBank.id).pipe(
switchMap(questions => {
let optimizedQuestions = [...questions];
if (optimization.removeUnused && optimization.unusedThresholdDays) {
const thresholdDate = new Date();
thresholdDate.setDate(thresholdDate.getDate() - optimization.unusedThresholdDays);
optimizedQuestions = optimizedQuestions.filter(q =>
q.timesUsed > 0 || !q.lastUsed || q.lastUsed > thresholdDate
);
}
if (optimization.removeLowConfidence && optimization.confidenceThreshold) {
optimizedQuestions = optimizedQuestions.filter(q =>
q.confidence >= optimization.confidenceThreshold!
);
}
if (optimization.mergeSimilar && optimization.similarityThreshold) {
optimizedQuestions = this.mergeSimilarQuestions(optimizedQuestions, optimization.similarityThreshold);
}
if (optimization.rebalanceCategories) {
optimizedQuestions = this.rebalanceQuestionCategories(optimizedQuestions);
}
// Save optimized questions
return this.replaceQuestionsInBank(currentBank.id, optimizedQuestions);
}),
switchMap(() => this.updateQuestionBankMetadata(currentBank.id)),
map(() => void 0),
catchError(error => {
console.error('Error optimizing question bank:', error);
return EMPTY;
})
);
}
public getQuestionBanksByProfile(cvProfileId: string): Observable<QuestionBank[]> {
return from(this.db.questionBanks.where('cvProfileId').equals(cvProfileId).toArray()).pipe(
catchError(error => {
console.error('Error getting question banks for profile:', error);
return of([]);
})
);
}
public deleteQuestionBank(bankId: string): Observable<void> {
return from(this.db.transaction('rw', this.db.questionBanks, this.db.questions, this.db.answers, async () => {
// Delete all questions and answers for this bank
const questions = await this.db.questions.where('bankId').equals(bankId).toArray();
const questionIds = questions.map(q => q.id);
await this.db.answers.where('questionId').anyOf(questionIds).delete();
await this.db.questions.where('bankId').equals(bankId).delete();
await this.db.questionBanks.delete(bankId);
})).pipe(
tap(() => {
if (this.currentQuestionBankSubject.value?.id === bankId) {
this.currentQuestionBankSubject.next(null);
this.availableQuestionsSubject.next([]);
}
}),
map(() => void 0),
catchError(error => {
console.error('Error deleting question bank:', error);
return EMPTY;
})
);
}
private generateQuestionsFromCV(cvProfile: CVProfile, bankId: string, generation: QuestionBankGeneration): Observable<Question[]> {
const questions: Question[] = [];
// Generate behavioral questions
if (generation.includeGeneral) {
questions.push(...this.generateBehavioralQuestions(bankId));
}
// Generate technical questions based on skills
if (generation.includeTechnical) {
questions.push(...this.generateTechnicalQuestions(cvProfile, bankId));
}
// Generate experience-based questions
if (generation.includeExperience) {
questions.push(...this.generateExperienceQuestions(cvProfile, bankId));
}
// Generate education questions
if (generation.includeEducation) {
questions.push(...this.generateEducationQuestions(cvProfile, bankId));
}
return of(questions);
}
private generateBehavioralQuestions(bankId: string): Question[] {
const behavioralTemplates = [
'Tell me about a time when you had to overcome a significant challenge.',
'Describe a situation where you had to work with a difficult team member.',
'Give me an example of when you had to learn something new quickly.',
'Tell me about a time when you had to make a difficult decision.',
'Describe a situation where you had to meet a tight deadline.',
'Give me an example of when you had to resolve a conflict.',
'Tell me about a time when you had to adapt to a major change.',
'Describe a situation where you had to lead a team or project.',
'Give me an example of when you had to deal with failure.',
'Tell me about a time when you had to give difficult feedback.'
];
return behavioralTemplates.map(template => this.createQuestion(
template,
QuestionCategory.BEHAVIORAL,
QuestionDifficulty.MEDIUM,
['behavioral', 'soft-skills'],
bankId
));
}
private generateTechnicalQuestions(cvProfile: CVProfile, bankId: string): Question[] {
const questions: Question[] = [];
cvProfile.skills.forEach(skill => {
const skillQuestions = this.generateSkillQuestions(skill.name, skill.category, bankId);
questions.push(...skillQuestions);
});
return questions;
}
private generateSkillQuestions(skillName: string, category: string, bankId: string): Question[] {
const questions: Question[] = [];
// Generate basic skill questions
questions.push(this.createQuestion(
`How would you rate your proficiency in ${skillName}?`,
QuestionCategory.TECHNICAL,
QuestionDifficulty.EASY,
['skills', skillName.toLowerCase(), category.toLowerCase()],
bankId
));
questions.push(this.createQuestion(
`Can you describe a project where you used ${skillName}?`,
QuestionCategory.TECHNICAL,
QuestionDifficulty.MEDIUM,
['skills', 'project-experience', skillName.toLowerCase()],
bankId
));
return questions;
}
private generateExperienceQuestions(cvProfile: CVProfile, bankId: string): Question[] {
const questions: Question[] = [];
cvProfile.experience.forEach(exp => {
questions.push(this.createQuestion(
`Tell me about your role as ${exp.position} at ${exp.company}.`,
QuestionCategory.EXPERIENCE,
QuestionDifficulty.MEDIUM,
['experience', exp.company.toLowerCase(), exp.position.toLowerCase()],
bankId
));
if (exp.achievements.length > 0) {
questions.push(this.createQuestion(
`What was your biggest achievement at ${exp.company}?`,
QuestionCategory.EXPERIENCE,
QuestionDifficulty.MEDIUM,
['achievements', exp.company.toLowerCase()],
bankId
));
}
});
return questions;
}
private generateEducationQuestions(cvProfile: CVProfile, bankId: string): Question[] {
const questions: Question[] = [];
cvProfile.education.forEach(edu => {
questions.push(this.createQuestion(
`How did your ${edu.degree} in ${edu.field} prepare you for this role?`,
QuestionCategory.EDUCATION,
QuestionDifficulty.EASY,
['education', edu.degree.toLowerCase(), edu.field.toLowerCase()],
bankId
));
});
return questions;
}
private createQuestion(text: string, category: QuestionCategory, difficulty: QuestionDifficulty, tags: string[], bankId: string): Question {
const questionId = DataSanitizer.generateUUID();
const answerId = DataSanitizer.generateUUID();
const answer: Answer = {
id: answerId,
content: `[Generated answer for: ${text}]`,
keyPoints: [],
followUpQuestions: [],
estimatedDuration: 60,
personalizedContext: ''
};
return {
id: questionId,
text,
category,
difficulty,
tags,
answer,
relatedSkills: [],
confidence: 0.8,
timesUsed: 0,
lastUsed: undefined
};
}
private saveQuestions(questions: Question[], bankId: string): Observable<void> {
return from(this.db.transaction('rw', this.db.questions, this.db.answers, async () => {
for (const question of questions) {
// Add bankId to question for indexing
(question as any).bankId = bankId;
await this.db.questions.add(question);
await this.db.answers.add(question.answer);
}
})).pipe(
map(() => void 0)
);
}
private replaceQuestionsInBank(bankId: string, questions: Question[]): Observable<void> {
return from(this.db.transaction('rw', this.db.questions, this.db.answers, async () => {
// Delete existing questions and answers
const oldQuestions = await this.db.questions.where('bankId').equals(bankId).toArray();
const oldQuestionIds = oldQuestions.map(q => q.id);
await this.db.answers.where('questionId').anyOf(oldQuestionIds).delete();
await this.db.questions.where('bankId').equals(bankId).delete();
// Add new questions
for (const question of questions) {
(question as any).bankId = bankId;
await this.db.questions.add(question);
await this.db.answers.add(question.answer);
}
})).pipe(
map(() => void 0)
);
}
private loadQuestionsForBank(bankId: string): Observable<Question[]> {
return from(this.db.questions.where('bankId').equals(bankId).toArray()).pipe(
switchMap(questions => {
if (questions.length === 0) return of([]);
const questionIds = questions.map(q => q.id);
return from(this.db.answers.where('questionId').anyOf(questionIds).toArray()).pipe(
map(answers => {
const answerMap = new Map(answers.map(a => [a.questionId || a.id, a]));
return questions.map(q => ({
...q,
answer: answerMap.get(q.id) || q.answer
}));
})
);
})
);
}
private updateQuestionBankMetadata(bankId: string): Observable<void> {
return this.loadQuestionsForBank(bankId).pipe(
switchMap(questions => {
const categoriesDistribution: { [key: string]: number } = {};
let totalConfidence = 0;
questions.forEach(q => {
categoriesDistribution[q.category] = (categoriesDistribution[q.category] || 0) + 1;
totalConfidence += q.confidence;
});
const metadata = {
totalQuestions: questions.length,
categoriesDistribution,
averageConfidence: questions.length > 0 ? totalConfidence / questions.length : 0,
lastUpdated: new Date().toISOString()
};
return from(this.db.questionBanks.update(bankId, { metadata }));
}),
map(() => void 0)
);
}
private updateLastUsed(bankId: string): void {
this.db.questionBanks.where('id').equals(bankId).modify({ lastUsed: new Date() }).catch(error => {
console.error('Error updating last used:', error);
});
}
private normalizeText(text: string): string {
return text.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
private calculateTextSimilarity(text1: string, text2: string): number {
const words1 = text1.split(' ');
const words2 = text2.split(' ');
const set1 = new Set(words1);
const set2 = new Set(words2);
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
private determineMatchType(similarity: number): 'exact' | 'high' | 'medium' | 'low' {
if (similarity >= 0.95) return 'exact';
if (similarity >= 0.8) return 'high';
if (similarity >= 0.6) return 'medium';
return 'low';
}
private mergeSimilarQuestions(questions: Question[], threshold: number): Question[] {
const merged: Question[] = [];
const processed = new Set<string>();
questions.forEach(question => {
if (processed.has(question.id)) return;
const similar = questions.filter(q =>
q.id !== question.id &&
!processed.has(q.id) &&
this.calculateTextSimilarity(question.text, q.text) >= threshold
);
if (similar.length > 0) {
// Merge similar questions
const mergedQuestion = {
...question,
confidence: Math.max(question.confidence, ...similar.map(q => q.confidence)),
timesUsed: question.timesUsed + similar.reduce((sum, q) => sum + q.timesUsed, 0),
tags: [...new Set([...question.tags, ...similar.flatMap(q => q.tags)])]
};
merged.push(mergedQuestion);
similar.forEach(q => processed.add(q.id));
} else {
merged.push(question);
}
processed.add(question.id);
});
return merged;
}
private rebalanceQuestionCategories(questions: Question[]): Question[] {
const categoryGroups = questions.reduce((groups, question) => {
if (!groups[question.category]) {
groups[question.category] = [];
}
groups[question.category].push(question);
return groups;
}, {} as { [key: string]: Question[] });
// Keep top questions from each category to maintain balance
const maxPerCategory = Math.max(10, Math.floor(questions.length / Object.keys(categoryGroups).length));
const balanced: Question[] = [];
Object.values(categoryGroups).forEach(categoryQuestions => {
const sorted = categoryQuestions.sort((a, b) => b.confidence - a.confidence);
balanced.push(...sorted.slice(0, maxPerCategory));
});
return balanced;
}
public destroy(): void {
this.db.close();
}
}

View File

@ -0,0 +1,364 @@
import { TestBed } from '@angular/core/testing';
import { SpeechService } from './speech.service';
// Mock Web Speech API
interface MockSpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
abort(): void;
}
interface MockSpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
resultIndex: number;
}
describe('SpeechService - Integration Tests', () => {
let service: SpeechService;
let mockSpeechRecognition: MockSpeechRecognition;
let mockResults: any;
beforeEach(() => {
// Mock SpeechRecognition
mockSpeechRecognition = {
continuous: false,
interimResults: false,
lang: 'en-US',
start: jasmine.createSpy('start'),
stop: jasmine.createSpy('stop'),
abort: jasmine.createSpy('abort'),
addEventListener: jasmine.createSpy('addEventListener'),
removeEventListener: jasmine.createSpy('removeEventListener'),
dispatchEvent: jasmine.createSpy('dispatchEvent')
} as any;
mockResults = {
length: 1,
item: (index: number) => ({
length: 1,
item: (index: number) => ({
transcript: 'What is Angular dependency injection?',
confidence: 0.95
}),
isFinal: true
})
};
// Mock global SpeechRecognition
(window as any).SpeechRecognition = jasmine.createSpy('SpeechRecognition').and.returnValue(mockSpeechRecognition);
(window as any).webkitSpeechRecognition = (window as any).SpeechRecognition;
TestBed.configureTestingModule({
providers: [SpeechService]
});
service = TestBed.inject(SpeechService);
});
afterEach(() => {
delete (window as any).SpeechRecognition;
delete (window as any).webkitSpeechRecognition;
});
describe('T011: Integration test Speech Recognition API', () => {
it('should initialize speech recognition with correct configuration', () => {
// Act
service.initializeSpeechRecognition();
// Assert
expect((window as any).SpeechRecognition).toHaveBeenCalled();
expect(mockSpeechRecognition.continuous).toBe(true);
expect(mockSpeechRecognition.interimResults).toBe(true);
expect(mockSpeechRecognition.lang).toBe('en-US');
});
it('should start listening and configure event handlers', () => {
// Arrange
service.initializeSpeechRecognition();
// Act
service.startListening();
// Assert
expect(mockSpeechRecognition.start).toHaveBeenCalled();
expect(mockSpeechRecognition.addEventListener).toHaveBeenCalledWith('result', jasmine.any(Function));
expect(mockSpeechRecognition.addEventListener).toHaveBeenCalledWith('error', jasmine.any(Function));
expect(mockSpeechRecognition.addEventListener).toHaveBeenCalledWith('end', jasmine.any(Function));
});
it('should stop listening when requested', () => {
// Arrange
service.initializeSpeechRecognition();
service.startListening();
// Act
service.stopListening();
// Assert
expect(mockSpeechRecognition.stop).toHaveBeenCalled();
});
it('should process speech results and emit transcripts', (done) => {
// Arrange
service.initializeSpeechRecognition();
service.speechResults$.subscribe(result => {
// Assert
expect(result.transcript).toBe('What is Angular dependency injection?');
expect(result.confidence).toBe(0.95);
expect(result.isFinal).toBe(true);
expect(result.timestamp).toBeInstanceOf(Date);
done();
});
service.startListening();
// Act - Simulate speech recognition result
const resultEvent = {
type: 'result',
results: mockResults,
resultIndex: 0
} as MockSpeechRecognitionEvent;
service.handleSpeechResult(resultEvent);
});
it('should detect question patterns in speech', (done) => {
// Arrange
const questionTexts = [
'What is Angular dependency injection?',
'How do you implement routing in Angular?',
'Tell me about TypeScript interfaces',
'Can you explain RxJS observables?'
];
let detectedCount = 0;
service.detectedQuestions$.subscribe(question => {
// Assert
expect(question.originalText).toContain(questionTexts[detectedCount]);
expect(question.confidence).toBeGreaterThan(0.8);
expect(question.isQuestion).toBe(true);
expect(question.timestamp).toBeInstanceOf(Date);
detectedCount++;
if (detectedCount === questionTexts.length) {
done();
}
});
service.initializeSpeechRecognition();
service.startListening();
// Act - Simulate multiple question results
questionTexts.forEach((text, index) => {
const mockResult = {
length: 1,
item: () => ({
length: 1,
item: () => ({
transcript: text,
confidence: 0.9
}),
isFinal: true
})
};
const resultEvent = {
type: 'result',
results: mockResult,
resultIndex: 0
} as MockSpeechRecognitionEvent;
setTimeout(() => service.handleSpeechResult(resultEvent), index * 100);
});
});
it('should distinguish between questions and user reading responses', (done) => {
// Arrange
const testInputs = [
{ text: 'What is Angular?', isQuestion: true },
{ text: 'Angular is a platform for building mobile and desktop web applications', isQuestion: false },
{ text: 'How do you create components?', isQuestion: true },
{ text: 'Components are the building blocks of Angular applications', isQuestion: false }
];
let processedCount = 0;
service.speechAnalysis$.subscribe(analysis => {
const expected = testInputs[processedCount];
expect(analysis.isQuestion).toBe(expected.isQuestion);
expect(analysis.originalText).toBe(expected.text);
processedCount++;
if (processedCount === testInputs.length) {
done();
}
});
service.initializeSpeechRecognition();
service.startListening();
// Act
testInputs.forEach((input, index) => {
const mockResult = {
length: 1,
item: () => ({
length: 1,
item: () => ({
transcript: input.text,
confidence: 0.9
}),
isFinal: true
})
};
const resultEvent = {
type: 'result',
results: mockResult,
resultIndex: 0
} as MockSpeechRecognitionEvent;
setTimeout(() => service.handleSpeechResult(resultEvent), index * 150);
});
});
it('should handle speech recognition errors gracefully', (done) => {
// Arrange
service.speechErrors$.subscribe(error => {
// Assert
expect(error.type).toBe('network');
expect(error.message).toContain('Network error');
expect(error.timestamp).toBeInstanceOf(Date);
expect(error.canRetry).toBe(true);
done();
});
service.initializeSpeechRecognition();
service.startListening();
// Act - Simulate speech recognition error
const errorEvent = {
type: 'error',
error: 'network'
} as any;
service.handleSpeechError(errorEvent);
});
it('should detect speech hints for upcoming questions', (done) => {
// Arrange
const hintTexts = [
'Let me ask you about Angular',
'Tell me about your experience with',
'Now I want to know about',
'The next question is about'
];
let hintCount = 0;
service.speechHints$.subscribe(hint => {
// Assert
expect(hint.type).toBe('question_intro');
expect(hint.confidence).toBeGreaterThan(0.7);
expect(hint.detectedText).toContain(hintTexts[hintCount]);
hintCount++;
if (hintCount === hintTexts.length) {
done();
}
});
service.initializeSpeechRecognition();
service.startListening();
// Act
hintTexts.forEach((text, index) => {
const mockResult = {
length: 1,
item: () => ({
length: 1,
item: () => ({
transcript: text,
confidence: 0.85
}),
isFinal: true
})
};
const resultEvent = {
type: 'result',
results: mockResult,
resultIndex: 0
} as MockSpeechRecognitionEvent;
setTimeout(() => service.handleSpeechResult(resultEvent), index * 200);
});
});
it('should handle continuous listening with automatic restart', (done) => {
// Arrange
let restartCount = 0;
service.initializeSpeechRecognition();
// Monitor speech recognition state
service.isListening$.subscribe(isListening => {
if (!isListening && restartCount < 2) {
restartCount++;
// Should automatically restart
setTimeout(() => {
expect(mockSpeechRecognition.start).toHaveBeenCalledTimes(restartCount + 1);
if (restartCount === 2) {
done();
}
}, 100);
}
});
service.startListening();
// Act - Simulate speech recognition ending
setTimeout(() => {
const endEvent = { type: 'end' } as Event;
service.handleSpeechEnd(endEvent);
}, 100);
setTimeout(() => {
const endEvent = { type: 'end' } as Event;
service.handleSpeechEnd(endEvent);
}, 300);
});
it('should handle browser compatibility issues', () => {
// Arrange - Remove speech recognition support
delete (window as any).SpeechRecognition;
delete (window as any).webkitSpeechRecognition;
// Act & Assert
expect(() => service.initializeSpeechRecognition()).toThrow();
service.speechErrors$.subscribe(error => {
expect(error.type).toBe('not_supported');
expect(error.message).toContain('Speech recognition not supported');
expect(error.canRetry).toBe(false);
});
});
it('should manage microphone permissions correctly', (done) => {
// Arrange
service.permissionStatus$.subscribe(status => {
if (status === 'granted') {
expect(service.canStartListening()).toBe(true);
done();
}
});
// Act
service.requestMicrophonePermission();
// Simulate permission granted
setTimeout(() => {
service.handlePermissionGranted();
}, 100);
});
});
});

View File

@ -0,0 +1,382 @@
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { filter, map, debounceTime, takeUntil } from 'rxjs/operators';
import {
SpeechResult,
SpeechAnalysis,
SpeechError,
SpeechHint,
DetectedQuestion,
HintType
} from '../models/interview-session.interface';
import { DataSanitizer } from '../models/validation';
import { LanguageConfigService } from './language-config.service';
declare global {
interface Window {
SpeechRecognition: any;
webkitSpeechRecognition: any;
}
}
interface MockSpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
resultIndex: number;
}
@Injectable({
providedIn: 'root'
})
export class SpeechService {
private languageConfigService = inject(LanguageConfigService);
private recognition: any;
private isInitialized = false;
private isCurrentlyListening = false;
private currentLanguage = 'en-US';
private destroy$ = new Subject<void>();
// Observables for speech data streams
private speechResultsSubject = new Subject<SpeechResult>();
private speechAnalysisSubject = new Subject<SpeechAnalysis>();
private speechErrorsSubject = new Subject<SpeechError>();
private speechHintsSubject = new Subject<SpeechHint>();
private detectedQuestionsSubject = new Subject<DetectedQuestion>();
private isListeningSubject = new BehaviorSubject<boolean>(false);
private permissionStatusSubject = new BehaviorSubject<string>('prompt');
private languageSubject = new BehaviorSubject<string>('en-US');
// Public observables
public speechResults$ = this.speechResultsSubject.asObservable();
public speechAnalysis$ = this.speechAnalysisSubject.asObservable();
public speechErrors$ = this.speechErrorsSubject.asObservable();
public speechHints$ = this.speechHintsSubject.asObservable();
public detectedQuestions$ = this.detectedQuestionsSubject.asObservable();
public isListening$ = this.isListeningSubject.asObservable();
public permissionStatus$ = this.permissionStatusSubject.asObservable();
public currentLanguage$ = this.languageSubject.asObservable();
constructor() {
this.setupLanguageSubscription();
this.setupSpeechAnalysisDebouncing();
}
public initializeSpeechRecognition(): void {
if (this.isInitialized) {
return;
}
// Check browser support
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
const error: SpeechError = {
type: 'not_supported',
message: 'Speech recognition not supported in this browser',
timestamp: new Date(),
canRetry: false
};
this.speechErrorsSubject.next(error);
throw new Error('Speech recognition not supported');
}
this.recognition = new SpeechRecognition();
this.configureRecognition();
this.isInitialized = true;
}
private configureRecognition(): void {
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.recognition.lang = this.currentLanguage;
this.recognition.addEventListener('result', this.handleSpeechResult.bind(this));
this.recognition.addEventListener('error', this.handleSpeechError.bind(this));
this.recognition.addEventListener('end', this.handleSpeechEnd.bind(this));
this.recognition.addEventListener('start', () => {
this.isCurrentlyListening = true;
this.isListeningSubject.next(true);
console.log(`Speech recognition started with language: ${this.currentLanguage}`);
});
}
public startListening(): void {
if (!this.isInitialized) {
this.initializeSpeechRecognition();
}
if (!this.isCurrentlyListening) {
try {
this.recognition.start();
} catch (error) {
this.handleSpeechError({
type: 'error',
error: 'start_failed'
} as any);
}
}
}
public stopListening(): void {
if (this.recognition && this.isCurrentlyListening) {
this.recognition.stop();
this.isCurrentlyListening = false;
this.isListeningSubject.next(false);
}
}
public handleSpeechResult(event: MockSpeechRecognitionEvent): void {
if (!event.results) return;
const result = event.results[event.resultIndex];
if (!result || !result.item(0)) return;
const transcript = result.item(0).transcript;
const confidence = result.item(0).confidence || 0.9;
const isFinal = result.isFinal;
// Emit speech result
const speechResult: SpeechResult = {
transcript,
confidence,
isFinal,
timestamp: new Date()
};
this.speechResultsSubject.next(speechResult);
// Only process final results for analysis
if (isFinal) {
this.analyzeSpeech(transcript, confidence);
this.detectSpeechHints(transcript, confidence);
}
}
public handleSpeechError(event: any): void {
const errorType = event.error || 'unknown';
let canRetry = true;
let message = this.languageConfigService.getLocalizedErrorMessage(errorType);
switch (errorType) {
case 'network':
canRetry = true;
break;
case 'not-allowed':
canRetry = false;
this.permissionStatusSubject.next('denied');
break;
case 'no-speech':
canRetry = true;
break;
case 'audio-capture':
canRetry = true;
break;
default:
message = `Speech recognition error: ${errorType}`;
}
const error: SpeechError = {
type: errorType,
message,
timestamp: new Date(),
canRetry
};
this.speechErrorsSubject.next(error);
this.isCurrentlyListening = false;
this.isListeningSubject.next(false);
}
public handleSpeechEnd(event: Event): void {
this.isCurrentlyListening = false;
this.isListeningSubject.next(false);
// Auto-restart for continuous listening
setTimeout(() => {
if (!this.isCurrentlyListening) {
this.startListening();
}
}, 100);
}
private analyzeSpeech(transcript: string, confidence: number): void {
const normalizedText = transcript.toLowerCase().trim();
const isQuestion = this.isQuestionPattern(normalizedText);
const analysis: SpeechAnalysis = {
originalText: transcript,
isQuestion,
confidence,
detectedPatterns: this.getMatchedPatterns(normalizedText)
};
this.speechAnalysisSubject.next(analysis);
// If it's a question, emit as detected question
if (isQuestion) {
const detectedQuestion: DetectedQuestion = {
id: DataSanitizer.generateUUID(),
timestamp: new Date(),
originalText: transcript,
normalizedText,
confidence,
responseTime: 0, // Will be updated when response is provided
wasHelpRequested: true, // Assume help is requested for detected questions
isQuestion: true
};
this.detectedQuestionsSubject.next(detectedQuestion);
}
}
private detectSpeechHints(transcript: string, confidence: number): void {
const normalizedText = transcript.toLowerCase().trim();
const hintPatterns = this.languageConfigService.getCurrentHintPatterns();
for (const hintPattern of hintPatterns) {
if (hintPattern.pattern.test(normalizedText)) {
const hint: SpeechHint = {
id: DataSanitizer.generateUUID(),
timestamp: new Date(),
text: transcript,
hintType: HintType.QUESTION_INTRO, // Default type, could be mapped better
confidence: confidence * 0.8, // Slightly lower confidence for hints
preparedAnswers: [], // Will be populated by question bank service
type: 'question_intro', // Alternative naming for compatibility
detectedText: transcript // Alternative naming for compatibility
};
this.speechHintsSubject.next(hint);
break; // Only emit one hint per transcript
}
}
}
private isQuestionPattern(text: string): boolean {
const patterns = this.languageConfigService.getCurrentQuestionPatterns();
return patterns.some(pattern => pattern.test(text));
}
private getMatchedPatterns(text: string): string[] {
const patterns: string[] = [];
const questionPatterns = this.languageConfigService.getCurrentQuestionPatterns();
const hintPatterns = this.languageConfigService.getCurrentHintPatterns();
questionPatterns.forEach((pattern, index) => {
if (pattern.test(text)) {
patterns.push(`question_pattern_${index}`);
}
});
hintPatterns.forEach((hintPattern, index) => {
if (hintPattern.pattern.test(text)) {
patterns.push(`hint_pattern_${index}_${hintPattern.description}`);
}
});
return patterns;
}
private setupSpeechAnalysisDebouncing(): void {
// Debounce speech analysis to avoid processing too frequently
this.speechResults$
.pipe(
filter(result => result.isFinal),
debounceTime(500), // Wait 500ms after last result
map(result => result.transcript)
)
.subscribe(transcript => {
// Additional processing can be added here
});
}
// Permission management
public async requestMicrophonePermission(): Promise<void> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop()); // Stop immediately, we just needed permission
this.permissionStatusSubject.next('granted');
} catch (error) {
this.permissionStatusSubject.next('denied');
throw error;
}
}
public handlePermissionGranted(): void {
this.permissionStatusSubject.next('granted');
}
public canStartListening(): boolean {
return this.permissionStatusSubject.value === 'granted' && this.isInitialized;
}
// Language management methods
public setLanguage(languageCode: string): void {
const wasListening = this.isCurrentlyListening;
// Stop current recognition if running
if (wasListening) {
this.stopListening();
}
// Update language
this.currentLanguage = languageCode;
this.languageSubject.next(languageCode);
// Update language config service
this.languageConfigService.setLanguage(languageCode);
// Reinitialize recognition with new language
if (this.isInitialized) {
this.recognition.lang = languageCode;
console.log(`Speech recognition language changed to: ${languageCode}`);
}
// Restart listening if it was active
if (wasListening) {
setTimeout(() => {
this.startListening();
}, 100);
}
}
public getCurrentLanguage(): string {
return this.currentLanguage;
}
public getSupportedLanguages() {
return this.languageConfigService.getSupportedLanguages();
}
public autoDetectLanguage(): string {
const detectedLanguage = this.languageConfigService.autoDetectLanguage();
this.setLanguage(detectedLanguage);
return detectedLanguage;
}
private setupLanguageSubscription(): void {
// Subscribe to language changes from the config service
this.languageConfigService.currentLanguage$
.pipe(takeUntil(this.destroy$))
.subscribe(language => {
if (language !== this.currentLanguage) {
this.setLanguage(language);
}
});
// Initialize with current language
const currentLang = this.languageConfigService.getCurrentLanguage();
this.currentLanguage = currentLang;
this.languageSubject.next(currentLang);
}
// Cleanup
public destroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.stopListening();
if (this.recognition) {
this.recognition = null;
}
this.isInitialized = false;
}
}

View File

@ -0,0 +1,730 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, from, of, throwError } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';
import Dexie, { Table } from 'dexie';
import { CVProfile } from '../models/cv-profile.interface';
import { QuestionBank } from '../models/question-bank.interface';
import { InterviewSession } from '../models/interview-session.interface';
import { N8nSyncData } from '../models/n8n-sync.interface';
export interface StorageStats {
totalSize: number;
usedSpace: number;
remainingSpace: number;
itemCounts: {
cvProfiles: number;
questionBanks: number;
sessions: number;
syncData: number;
};
lastBackup?: Date;
lastCleanup?: Date;
}
export interface BackupData {
version: string;
timestamp: Date;
cvProfiles: CVProfile[];
questionBanks: QuestionBank[];
sessions: InterviewSession[];
syncData: N8nSyncData[];
metadata: {
appVersion: string;
exportDate: string;
totalItems: number;
};
}
export interface StorageConfig {
maxCVProfiles: number;
maxQuestionBanks: number;
maxSessions: number;
maxSyncData: number;
autoCleanupDays: number;
compressionEnabled: boolean;
}
class InterviewAssistantDatabase extends Dexie {
cvProfiles!: Table<CVProfile>;
questionBanks!: Table<QuestionBank>;
sessions!: Table<InterviewSession>;
syncData!: Table<N8nSyncData>;
metadata!: Table<any>;
constructor() {
super('InterviewAssistantMainDB');
this.version(1).stores({
cvProfiles: 'id, fileName, uploadDate, lastModified',
questionBanks: 'id, cvProfileId, generatedDate, lastUsed, accuracy',
sessions: 'id, cvProfileId, questionBankId, startTime, endTime, status',
syncData: 'id, sessionId, lastSyncTime, syncStatus',
metadata: 'key, value, timestamp'
});
this.version(2).stores({
cvProfiles: 'id, fileName, uploadDate, lastModified, fileSize',
questionBanks: 'id, cvProfileId, generatedDate, lastUsed, accuracy, version',
sessions: 'id, cvProfileId, questionBankId, startTime, endTime, status, duration',
syncData: 'id, sessionId, lastSyncTime, syncStatus, retryCount',
metadata: 'key, value, timestamp, category'
}).upgrade(async tx => {
// Migration logic for version 2
await tx.table('cvProfiles').toCollection().modify((profile: any) => {
if (!profile.fileSize) {
profile.fileSize = 0;
}
});
});
}
}
@Injectable({
providedIn: 'root'
})
export class StorageService {
private db = new InterviewAssistantDatabase();
private storageStatsSubject = new BehaviorSubject<StorageStats | null>(null);
private isInitializedSubject = new BehaviorSubject<boolean>(false);
private readonly defaultConfig: StorageConfig = {
maxCVProfiles: 50,
maxQuestionBanks: 100,
maxSessions: 200,
maxSyncData: 500,
autoCleanupDays: 30,
compressionEnabled: true
};
private config: StorageConfig = { ...this.defaultConfig };
public storageStats$ = this.storageStatsSubject.asObservable();
public isInitialized$ = this.isInitializedSubject.asObservable();
constructor() {
this.initializeDatabase();
}
// Initialization
private async initializeDatabase(): Promise<void> {
try {
await this.db.open();
await this.loadConfiguration();
await this.updateStorageStats();
await this.performMaintenanceCheck();
this.isInitializedSubject.next(true);
console.log('Storage service initialized successfully');
} catch (error) {
console.error('Failed to initialize storage service:', error);
this.isInitializedSubject.next(false);
}
}
// Configuration Management
public async updateConfiguration(newConfig: Partial<StorageConfig>): Promise<void> {
this.config = { ...this.config, ...newConfig };
await this.storeMetadata('storage_config', this.config);
}
public getConfiguration(): StorageConfig {
return { ...this.config };
}
private async loadConfiguration(): Promise<void> {
try {
const stored = await this.getMetadata('storage_config');
if (stored) {
this.config = { ...this.defaultConfig, ...stored };
}
} catch (error) {
console.warn('Failed to load storage configuration, using defaults:', error);
}
}
// CV Profile Storage
public storeCVProfile(profile: CVProfile): Observable<string> {
return from(this.db.transaction('rw', this.db.cvProfiles, async () => {
// Check if we need to clean up old profiles
const count = await this.db.cvProfiles.count();
if (count >= this.config.maxCVProfiles) {
await this.cleanupOldCVProfiles();
}
// Store the profile
await this.db.cvProfiles.put(profile);
await this.updateStorageStats();
return profile.id;
})).pipe(
catchError(error => {
console.error('Error storing CV profile:', error);
return throwError(error);
})
);
}
public getCVProfile(id: string): Observable<CVProfile | null> {
return from(this.db.cvProfiles.get(id)).pipe(
map(profile => profile || null),
catchError(error => {
console.error('Error getting CV profile:', error);
return of(null);
})
);
}
public getAllCVProfiles(): Observable<CVProfile[]> {
return from(this.db.cvProfiles.orderBy('uploadDate').reverse().toArray()).pipe(
catchError(error => {
console.error('Error getting all CV profiles:', error);
return of([]);
})
);
}
public deleteCVProfile(id: string): Observable<void> {
return from(this.db.transaction('rw', this.db.cvProfiles, this.db.questionBanks, this.db.sessions, async () => {
// Delete related question banks and sessions
await this.db.questionBanks.where('cvProfileId').equals(id).delete();
await this.db.sessions.where('cvProfileId').equals(id).delete();
// Delete the profile
await this.db.cvProfiles.delete(id);
await this.updateStorageStats();
})).pipe(
map(() => void 0),
catchError(error => {
console.error('Error deleting CV profile:', error);
return throwError(error);
})
);
}
// Question Bank Storage
public storeQuestionBank(questionBank: QuestionBank): Observable<string> {
return from(this.db.transaction('rw', this.db.questionBanks, async () => {
// Check if we need to clean up old question banks
const count = await this.db.questionBanks.count();
if (count >= this.config.maxQuestionBanks) {
await this.cleanupOldQuestionBanks();
}
await this.db.questionBanks.put(questionBank);
await this.updateStorageStats();
return questionBank.id;
})).pipe(
catchError(error => {
console.error('Error storing question bank:', error);
return throwError(error);
})
);
}
public getQuestionBank(id: string): Observable<QuestionBank | null> {
return from(this.db.questionBanks.get(id)).pipe(
map(bank => bank || null),
catchError(error => {
console.error('Error getting question bank:', error);
return of(null);
})
);
}
public getQuestionBanksByProfile(cvProfileId: string): Observable<QuestionBank[]> {
return from(this.db.questionBanks.where('cvProfileId').equals(cvProfileId).toArray()).pipe(
catchError(error => {
console.error('Error getting question banks by profile:', error);
return of([]);
})
);
}
public deleteQuestionBank(id: string): Observable<void> {
return from(this.db.transaction('rw', this.db.questionBanks, this.db.sessions, async () => {
// Update sessions that reference this question bank
await this.db.sessions.where('questionBankId').equals(id).modify({ questionBankId: '' });
// Delete the question bank
await this.db.questionBanks.delete(id);
await this.updateStorageStats();
})).pipe(
map(() => void 0),
catchError(error => {
console.error('Error deleting question bank:', error);
return throwError(error);
})
);
}
// Session Storage
public storeSession(session: InterviewSession): Observable<string> {
return from(this.db.transaction('rw', this.db.sessions, async () => {
// Check if we need to clean up old sessions
const count = await this.db.sessions.count();
if (count >= this.config.maxSessions) {
await this.cleanupOldSessions();
}
await this.db.sessions.put(session);
await this.updateStorageStats();
return session.id;
})).pipe(
catchError(error => {
console.error('Error storing session:', error);
return throwError(error);
})
);
}
public getSession(id: string): Observable<InterviewSession | null> {
return from(this.db.sessions.get(id)).pipe(
map(session => session || null),
catchError(error => {
console.error('Error getting session:', error);
return of(null);
})
);
}
public getAllSessions(): Observable<InterviewSession[]> {
return from(this.db.sessions.orderBy('startTime').reverse().toArray()).pipe(
catchError(error => {
console.error('Error getting all sessions:', error);
return of([]);
})
);
}
public getSessionsByProfile(cvProfileId: string): Observable<InterviewSession[]> {
return from(this.db.sessions.where('cvProfileId').equals(cvProfileId).toArray()).pipe(
catchError(error => {
console.error('Error getting sessions by profile:', error);
return of([]);
})
);
}
public deleteSession(id: string): Observable<void> {
return from(this.db.transaction('rw', this.db.sessions, this.db.syncData, async () => {
// Delete related sync data
await this.db.syncData.where('sessionId').equals(id).delete();
// Delete the session
await this.db.sessions.delete(id);
await this.updateStorageStats();
})).pipe(
map(() => void 0),
catchError(error => {
console.error('Error deleting session:', error);
return throwError(error);
})
);
}
// Sync Data Storage
public storeSyncData(syncData: N8nSyncData): Observable<string> {
return from(this.db.transaction('rw', this.db.syncData, async () => {
// Check if we need to clean up old sync data
const count = await this.db.syncData.count();
if (count >= this.config.maxSyncData) {
await this.cleanupOldSyncData();
}
await this.db.syncData.put(syncData);
await this.updateStorageStats();
return syncData.id;
})).pipe(
catchError(error => {
console.error('Error storing sync data:', error);
return throwError(error);
})
);
}
public getSyncData(id: string): Observable<N8nSyncData | null> {
return from(this.db.syncData.get(id)).pipe(
map(data => data || null),
catchError(error => {
console.error('Error getting sync data:', error);
return of(null);
})
);
}
public getPendingSyncData(): Observable<N8nSyncData[]> {
return from(this.db.syncData.where('syncStatus').equals('pending').toArray()).pipe(
catchError(error => {
console.error('Error getting pending sync data:', error);
return of([]);
})
);
}
public deleteSyncData(id: string): Observable<void> {
return from(this.db.syncData.delete(id)).pipe(
switchMap(() => from(this.updateStorageStats())),
map(() => void 0),
catchError(error => {
console.error('Error deleting sync data:', error);
return throwError(error);
})
);
}
// Metadata Management
public storeMetadata(key: string, value: any, category = 'general'): Observable<void> {
const metadata = {
key,
value,
timestamp: new Date(),
category
};
return from(this.db.metadata.put(metadata)).pipe(
map(() => void 0),
catchError(error => {
console.error('Error storing metadata:', error);
return throwError(error);
})
);
}
public getMetadata(key: string): Observable<any> {
return from(this.db.metadata.get(key)).pipe(
map(metadata => metadata ? metadata.value : null),
catchError(error => {
console.error('Error getting metadata:', error);
return of(null);
})
);
}
// Storage Statistics
private async updateStorageStats(): Promise<void> {
try {
const stats: StorageStats = {
totalSize: await this.calculateTotalSize(),
usedSpace: await this.calculateUsedSpace(),
remainingSpace: 0, // Will be calculated
itemCounts: {
cvProfiles: await this.db.cvProfiles.count(),
questionBanks: await this.db.questionBanks.count(),
sessions: await this.db.sessions.count(),
syncData: await this.db.syncData.count()
},
lastBackup: await this.getMetadata('last_backup_date').toPromise(),
lastCleanup: await this.getMetadata('last_cleanup_date').toPromise()
};
stats.remainingSpace = stats.totalSize - stats.usedSpace;
this.storageStatsSubject.next(stats);
} catch (error) {
console.error('Error updating storage stats:', error);
}
}
private async calculateTotalSize(): Promise<number> {
// Estimate based on IndexedDB quota (simplified)
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
const estimate = await navigator.storage.estimate();
return estimate.quota || 50 * 1024 * 1024; // Default to 50MB
} catch (error) {
console.warn('Could not get storage estimate:', error);
}
}
return 50 * 1024 * 1024; // 50MB default
}
private async calculateUsedSpace(): Promise<number> {
// Simplified calculation - in reality, you'd need to traverse all data
const counts = {
cvProfiles: await this.db.cvProfiles.count(),
questionBanks: await this.db.questionBanks.count(),
sessions: await this.db.sessions.count(),
syncData: await this.db.syncData.count()
};
// Rough estimates in bytes
const estimatedSize =
counts.cvProfiles * 100 * 1024 + // ~100KB per CV profile
counts.questionBanks * 50 * 1024 + // ~50KB per question bank
counts.sessions * 20 * 1024 + // ~20KB per session
counts.syncData * 10 * 1024; // ~10KB per sync data
return estimatedSize;
}
// Cleanup Operations
public performCleanup(): Observable<void> {
return from(this.db.transaction('rw',
this.db.cvProfiles,
this.db.questionBanks,
this.db.sessions,
this.db.syncData,
async () => {
await this.cleanupOldCVProfiles();
await this.cleanupOldQuestionBanks();
await this.cleanupOldSessions();
await this.cleanupOldSyncData();
await this.storeMetadata('last_cleanup_date', new Date());
await this.updateStorageStats();
}
)).pipe(
map(() => void 0),
catchError(error => {
console.error('Error performing cleanup:', error);
return throwError(error);
})
);
}
private async cleanupOldCVProfiles(): Promise<void> {
const count = await this.db.cvProfiles.count();
if (count > this.config.maxCVProfiles) {
const excess = count - this.config.maxCVProfiles;
const oldProfiles = await this.db.cvProfiles
.orderBy('uploadDate')
.limit(excess)
.toArray();
for (const profile of oldProfiles) {
await this.deleteCVProfile(profile.id).toPromise();
}
}
}
private async cleanupOldQuestionBanks(): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - this.config.autoCleanupDays);
await this.db.questionBanks
.where('lastUsed')
.below(cutoffDate)
.delete();
}
private async cleanupOldSessions(): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - this.config.autoCleanupDays);
await this.db.sessions
.where('startTime')
.below(cutoffDate)
.delete();
}
private async cleanupOldSyncData(): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - this.config.autoCleanupDays);
await this.db.syncData
.where('lastSyncTime')
.below(cutoffDate)
.delete();
}
// Backup and Restore
public createBackup(): Observable<BackupData> {
return from(this.db.transaction('r',
this.db.cvProfiles,
this.db.questionBanks,
this.db.sessions,
this.db.syncData,
async () => {
const backup: BackupData = {
version: '1.0',
timestamp: new Date(),
cvProfiles: await this.db.cvProfiles.toArray(),
questionBanks: await this.db.questionBanks.toArray(),
sessions: await this.db.sessions.toArray(),
syncData: await this.db.syncData.toArray(),
metadata: {
appVersion: '1.0.0', // Should come from app config
exportDate: new Date().toISOString(),
totalItems: 0
}
};
backup.metadata.totalItems =
backup.cvProfiles.length +
backup.questionBanks.length +
backup.sessions.length +
backup.syncData.length;
await this.storeMetadata('last_backup_date', new Date());
return backup;
}
)).pipe(
catchError(error => {
console.error('Error creating backup:', error);
return throwError(error);
})
);
}
public restoreFromBackup(backup: BackupData): Observable<void> {
return from(this.db.transaction('rw',
this.db.cvProfiles,
this.db.questionBanks,
this.db.sessions,
this.db.syncData,
async () => {
// Clear existing data
await this.db.cvProfiles.clear();
await this.db.questionBanks.clear();
await this.db.sessions.clear();
await this.db.syncData.clear();
// Restore data
await this.db.cvProfiles.bulkAdd(backup.cvProfiles);
await this.db.questionBanks.bulkAdd(backup.questionBanks);
await this.db.sessions.bulkAdd(backup.sessions);
await this.db.syncData.bulkAdd(backup.syncData);
await this.updateStorageStats();
}
)).pipe(
map(() => void 0),
catchError(error => {
console.error('Error restoring from backup:', error);
return throwError(error);
})
);
}
// Maintenance
private async performMaintenanceCheck(): Promise<void> {
const lastCleanup = await this.getMetadata('last_cleanup_date').toPromise();
const daysSinceCleanup = lastCleanup ?
(Date.now() - new Date(lastCleanup).getTime()) / (1000 * 60 * 60 * 24) :
Infinity;
if (daysSinceCleanup > 7) { // Run cleanup weekly
this.performCleanup().subscribe({
next: () => console.log('Automatic cleanup completed'),
error: (error) => console.error('Automatic cleanup failed:', error)
});
}
}
// Utility Methods
public exportToJson(): Observable<string> {
return this.createBackup().pipe(
map(backup => JSON.stringify(backup, null, 2))
);
}
public importFromJson(jsonData: string): Observable<void> {
return new Observable(observer => {
try {
const backup: BackupData = JSON.parse(jsonData);
this.restoreFromBackup(backup).subscribe({
next: () => {
observer.next();
observer.complete();
},
error: (error) => observer.error(error)
});
} catch (error) {
observer.error(new Error('Invalid backup format'));
}
});
}
public clearAllData(): Observable<void> {
return from(this.db.transaction('rw', [
this.db.cvProfiles,
this.db.questionBanks,
this.db.sessions,
this.db.syncData,
this.db.metadata
], async () => {
await this.db.cvProfiles.clear();
await this.db.questionBanks.clear();
await this.db.sessions.clear();
await this.db.syncData.clear();
await this.db.metadata.clear();
await this.updateStorageStats();
})).pipe(
map(() => void 0),
catchError(error => {
console.error('Error clearing all data:', error);
return throwError(error);
})
);
}
// Health Check
public checkStorageHealth(): Observable<any> {
return from(this.db.transaction('r',
this.db.cvProfiles,
this.db.questionBanks,
this.db.sessions,
this.db.syncData,
async () => {
const health = {
isHealthy: true,
issues: [] as string[],
counts: {
cvProfiles: await this.db.cvProfiles.count(),
questionBanks: await this.db.questionBanks.count(),
sessions: await this.db.sessions.count(),
syncData: await this.db.syncData.count()
},
orphanedData: {
questionBanks: 0,
sessions: 0
}
};
// Check for orphaned question banks
const allProfiles = await this.db.cvProfiles.toArray();
const profileIds = new Set(allProfiles.map(p => p.id));
const orphanedBanks = await this.db.questionBanks
.filter(bank => !profileIds.has(bank.cvProfileId))
.count();
health.orphanedData.questionBanks = orphanedBanks;
if (orphanedBanks > 0) {
health.issues.push(`${orphanedBanks} orphaned question banks found`);
health.isHealthy = false;
}
// Check for orphaned sessions
const orphanedSessions = await this.db.sessions
.filter(session => !profileIds.has(session.cvProfileId))
.count();
health.orphanedData.sessions = orphanedSessions;
if (orphanedSessions > 0) {
health.issues.push(`${orphanedSessions} orphaned sessions found`);
health.isHealthy = false;
}
return health;
}
)).pipe(
catchError(error => {
console.error('Error checking storage health:', error);
return of({
isHealthy: false,
issues: ['Failed to check storage health'],
error: error.message
});
})
);
}
// Cleanup
public destroy(): void {
this.db.close();
}
}

View File

@ -0,0 +1,27 @@
export const environment = {
production: true,
n8nWebhookUrl: 'https://n8n.gm-tech.org/webhook/cv-analysis',
authelia: {
baseUrl: 'https://auth.gm-tech.org',
loginPath: '/api/firstfactor',
verifyPath: '/api/verify',
logoutPath: '/api/logout'
},
apiEndpoints: {
cvAnalysis: 'https://n8n.gm-tech.org/webhook/cv-analysis',
questionGeneration: 'https://n8n.gm-tech.org/webhook/cv-analysis',
interviewSession: 'https://n8n.gm-tech.org/webhook/interview-session'
},
features: {
speechRecognition: true,
multiLanguageSupport: true,
n8nIntegration: true,
autheliaAuth: true,
offlineMode: false
},
app: {
name: 'Interview Assistant',
version: '1.0.0',
debug: false
}
};

View File

@ -0,0 +1,27 @@
export const environment = {
production: false,
n8nWebhookUrl: 'https://n8n.gm-tech.org/webhook/cv-analysis',
authelia: {
baseUrl: 'https://auth.gm-tech.org',
loginPath: '/api/firstfactor',
verifyPath: '/api/verify',
logoutPath: '/api/logout'
},
apiEndpoints: {
cvAnalysis: 'https://n8n.gm-tech.org/webhook/cv-analysis',
questionGeneration: 'https://n8n.gm-tech.org/webhook/cv-analysis',
interviewSession: 'https://n8n.gm-tech.org/webhook/interview-session'
},
features: {
speechRecognition: true,
multiLanguageSupport: true,
n8nIntegration: true,
autheliaAuth: true,
offlineMode: false
},
app: {
name: 'Interview Assistant',
version: '1.0.0',
debug: true
}
};

13
src/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>InterviewAssistant</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

1
src/styles.scss Normal file
View File

@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

15
tsconfig.app.json Normal file
View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["dom-speech-recognition"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

14
tsconfig.spec.json Normal file
View File

@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}