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:
commit
326a4aaa29
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
45
.eslintrc.json
Normal 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
72
.gitignore
vendored
Normal 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
10
.prettierrc
Normal 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
4
.vscode/extensions.json
vendored
Normal 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
20
.vscode/launch.json
vendored
Normal 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
42
.vscode/tasks.json
vendored
Normal 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
59
README.md
Normal 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
149
angular.json
Normal 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
18
cypress.config.ts
Normal 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
6
cypress/e2e/spec.cy.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
describe('My First Test', () => {
|
||||||
|
it('Visits the initial project page', () => {
|
||||||
|
cy.visit('/')
|
||||||
|
cy.contains('app is running')
|
||||||
|
})
|
||||||
|
})
|
||||||
5
cypress/fixtures/example.json
Normal file
5
cypress/fixtures/example.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Using fixtures to represent data",
|
||||||
|
"email": "hello@cypress.io"
|
||||||
|
}
|
||||||
|
|
||||||
43
cypress/support/commands.ts
Normal file
43
cypress/support/commands.ts
Normal 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) => { ... })
|
||||||
12
cypress/support/component-index.html
Normal file
12
cypress/support/component-index.html
Normal 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>
|
||||||
39
cypress/support/component.ts
Normal file
39
cypress/support/component.ts
Normal 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
17
cypress/support/e2e.ts
Normal 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
8
cypress/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"include": ["**/*.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": false,
|
||||||
|
"types": ["cypress"]
|
||||||
|
}
|
||||||
|
}
|
||||||
114
docs/README.md
Normal file
114
docs/README.md
Normal 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
|
||||||
189
docs/n8n-cv-analysis-workflow.json
Normal file
189
docs/n8n-cv-analysis-workflow.json
Normal 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
60
karma.conf.js
Normal 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
13455
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
65
package.json
Normal file
65
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
14
src/app/app.config.ts
Normal file
14
src/app/app.config.ts
Normal 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
45
src/app/app.html
Normal 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>© 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
10
src/app/app.routes.ts
Normal 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
315
src/app/app.scss
Normal 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
23
src/app/app.spec.ts
Normal 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
24
src/app/app.ts
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/app/components/auth-login/auth-login.component.ts
Normal file
350
src/app/components/auth-login/auth-login.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
349
src/app/components/comment-session/comment-session.component.ts
Normal file
349
src/app/components/comment-session/comment-session.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/app/components/cv-upload/cv-upload.component.scss
Normal file
305
src/app/components/cv-upload/cv-upload.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
437
src/app/components/cv-upload/cv-upload.component.ts
Normal file
437
src/app/components/cv-upload/cv-upload.component.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/app/models/cv-profile.interface.ts
Normal file
121
src/app/models/cv-profile.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
166
src/app/models/interview-session.interface.ts
Normal file
166
src/app/models/interview-session.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
241
src/app/models/n8n-sync.interface.ts
Normal file
241
src/app/models/n8n-sync.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
141
src/app/models/question-bank.interface.ts
Normal file
141
src/app/models/question-bank.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
460
src/app/models/validation.ts
Normal file
460
src/app/models/validation.ts
Normal 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);
|
||||||
630
src/app/services/analytics.service.ts
Normal file
630
src/app/services/analytics.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
585
src/app/services/auth.service.ts
Normal file
585
src/app/services/auth.service.ts
Normal 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')
|
||||||
|
};
|
||||||
238
src/app/services/authelia-auth.service.ts
Normal file
238
src/app/services/authelia-auth.service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/app/services/cv-parser.service.spec.ts
Normal file
455
src/app/services/cv-parser.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
540
src/app/services/cv-parser.service.ts
Normal file
540
src/app/services/cv-parser.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/app/services/language-config.service.ts
Normal file
276
src/app/services/language-config.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
587
src/app/services/logging.service.ts
Normal file
587
src/app/services/logging.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/app/services/n8n-sync.service.spec.ts
Normal file
371
src/app/services/n8n-sync.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
604
src/app/services/n8n-sync.service.ts
Normal file
604
src/app/services/n8n-sync.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
425
src/app/services/question-bank.service.spec.ts
Normal file
425
src/app/services/question-bank.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
705
src/app/services/question-bank.service.ts
Normal file
705
src/app/services/question-bank.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
364
src/app/services/speech.service.spec.ts
Normal file
364
src/app/services/speech.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
382
src/app/services/speech.service.ts
Normal file
382
src/app/services/speech.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
730
src/app/services/storage.service.ts
Normal file
730
src/app/services/storage.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/environments/environment.prod.ts
Normal file
27
src/environments/environment.prod.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
27
src/environments/environment.ts
Normal file
27
src/environments/environment.ts
Normal 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
13
src/index.html
Normal 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
6
src/main.ts
Normal 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
1
src/styles.scss
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal 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
36
tsconfig.json
Normal 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
14
tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user