From f470f2b7528d5abc9f0919a763f544a4c56c6dcb Mon Sep 17 00:00:00 2001 From: Maxi Date: Tue, 15 Apr 2025 19:02:58 +0200 Subject: [PATCH 01/17] init --- src/competencies-matcher/package.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/competencies-matcher/package.json diff --git a/src/competencies-matcher/package.json b/src/competencies-matcher/package.json new file mode 100644 index 000000000..13de5cc4d --- /dev/null +++ b/src/competencies-matcher/package.json @@ -0,0 +1,23 @@ +{ + "name": "competencies-matcher", + "version": "0.0.1", + "description": "Matching microservice that allows to allows to define and match on data criteria", + "main": "server.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/PROCEED-Labs/proceed.git" + }, + "keywords": [ + "embedding", + "matching" + ], + "author": "PROCEED Project", + "license": "MIT", + "bugs": { + "url": "https://github.com/PROCEED-Labs/proceed/issues" + }, + "homepage": "https://github.com/PROCEED-Labs/proceed#readme" +} From 7ffca0d4e24edbc21f7924bf3d59bb84a64ba828 Mon Sep 17 00:00:00 2001 From: Maxi Date: Tue, 13 May 2025 17:06:51 +0200 Subject: [PATCH 02/17] Add initial competence page layout --- .../package.json | 4 +- .../competences-container.module.scss | 8 ++ .../competences/competences-container.tsx | 42 ++++++++ .../competences/competences-table.module.scss | 20 ++++ .../competences/competences-table.tsx | 73 ++++++++++++++ .../competences-viewer.module.scss | 23 +++++ .../competences/competences-viewer.tsx | 95 +++++++++++++++++++ .../[environmentId]/competences/page.tsx | 20 ++++ .../(dashboard)/[environmentId]/layout.tsx | 7 ++ .../lib/user-preferences.ts | 2 + src/management-system-v2/next.config.js | 1 + 11 files changed, 293 insertions(+), 2 deletions(-) rename src/{competencies-matcher => competence-matcher}/package.json (94%) create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.module.scss create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx diff --git a/src/competencies-matcher/package.json b/src/competence-matcher/package.json similarity index 94% rename from src/competencies-matcher/package.json rename to src/competence-matcher/package.json index 13de5cc4d..13807d1c9 100644 --- a/src/competencies-matcher/package.json +++ b/src/competence-matcher/package.json @@ -1,5 +1,5 @@ { - "name": "competencies-matcher", + "name": "competence-matcher", "version": "0.0.1", "description": "Matching microservice that allows to allows to define and match on data criteria", "main": "server.ts", @@ -20,4 +20,4 @@ "url": "https://github.com/PROCEED-Labs/proceed/issues" }, "homepage": "https://github.com/PROCEED-Labs/proceed#readme" -} +} \ No newline at end of file diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.module.scss b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.module.scss new file mode 100644 index 000000000..0630a7823 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.module.scss @@ -0,0 +1,8 @@ +.container { + width: 100%; + height: 100%; + display: flex; + gap: 1rem; + padding: 1rem; + // background-color: rgba(0, 255, 0, 0.219); +} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx new file mode 100644 index 000000000..1e7c17977 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx @@ -0,0 +1,42 @@ +'use client'; +import style from './competences-container.module.scss'; +import { Space } from 'antd'; +import { FC, useLayoutEffect, useRef, useState } from 'react'; +import CompetencesTable from './competences-table'; +import CompetencesViewer from './competences-viewer'; + +type CompentencesContainerProps = React.PropsWithChildren<{}>; + +const CompentencesContainer: FC = ({ children }) => { + const [containerWidth, setContainerWidth] = useState(0); + const containerRef = useRef(null); + // console.log(containerWidth); + + useLayoutEffect(() => { + if (!window) return; + + const handleResize = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.clientWidth); + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [containerRef]); + + return ( + <> +
+ + +
+ + ); +}; + +export default CompentencesContainer; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss new file mode 100644 index 000000000..f183b2f90 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss @@ -0,0 +1,20 @@ +.outerHandle { + width: 4px; + cursor: ew-resize; + display: flex; + justify-content: center; +} + +.innerHandle { + width: 2px; + background-color: #a6adb5; + height: 100%; + border-radius: 4px; +} +.card { + flex: auto; +} + +.table { + height: 100%; +} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx new file mode 100644 index 000000000..64b11043b --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx @@ -0,0 +1,73 @@ +'use client'; +import { useUserPreferences } from '@/lib/user-preferences'; +import styles from './competences-table.module.scss'; +import { Card, Space, Table } from 'antd'; +import { FC, useState, useRef, ReactNode, useEffect } from 'react'; +import { ResizableBox } from 'react-resizable'; + +type CompetencesTableProps = React.PropsWithChildren<{ + containerWidth: number; +}>; + +const CompetencesTable: FC = ({ children, containerWidth }) => { + const addPreferences = useUserPreferences.use.addPreferences(); + const { cardWidth: width } = useUserPreferences.use['competences-table'](); + const preferencesHydrated = useUserPreferences.use._hydrated(); + + const maxWidth = Math.round(containerWidth * 0.7), + minWidth = Math.round(containerWidth * 0.3), + intitalWidth = Math.round(containerWidth * 0.5); + + const setWidth = (newWidth: number) => { + if (!preferencesHydrated) return; + addPreferences({ + 'competences-table': { cardWidth: Math.max(Math.min(newWidth, maxWidth), minWidth) }, + }); + }; + const oldContainerWidth = useRef(containerWidth); + + /* Handle updates of container width */ + useEffect(() => { + /* Set new width */ + /* Edge-Case: Initial render (parent has not figuered out the actual width in px, yet) */ + if (oldContainerWidth.current === 0) { + oldContainerWidth.current = containerWidth; + /* Check whther Preferences yield any value */ + if (preferencesHydrated && Number.isNaN(width)) setWidth(intitalWidth); + return; + } + /* Other resize: */ + /* Get old width in % */ + const ratio = width / oldContainerWidth.current; + /* Set old width to new width */ + oldContainerWidth.current = containerWidth; + setWidth(Math.round(containerWidth * ratio)); + }, [containerWidth]); + + return ( + <> + setWidth(size.width)} + handle={ +
+
+
+ } + > + + + + + + ); +}; + +export default CompetencesTable; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss new file mode 100644 index 000000000..e99770e41 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss @@ -0,0 +1,23 @@ +.container { + flex: auto; + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; +} +.outerHandle { + height: 4px; + width: 100%; + cursor: ns-resize; + display: flex; + align-items: center; +} +.innerHandle { + height: 2px; + background-color: #a6adb5; + width: 100%; + border-radius: 4px; +} +.card { + flex: auto; +} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx new file mode 100644 index 000000000..c74094248 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx @@ -0,0 +1,95 @@ +import { useUserPreferences } from '@/lib/user-preferences'; +import style from './competences-viewer.module.scss'; +import { Button, Card, Descriptions, DescriptionsProps, Input, List, Space } from 'antd'; +import { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { ResizableBox } from 'react-resizable'; + +type CompetencesViewerProps = React.PropsWithChildren<{}>; + +const CompetencesViewer: FC = ({ children }) => { + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = useRef(null); + + useLayoutEffect(() => { + if (!window) return; + + const handleResize = () => { + if (containerRef.current) { + setContainerHeight(containerRef.current.clientHeight); + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [containerRef]); + + const addPreferences = useUserPreferences.use.addPreferences(); + const { upperCardHeight: height } = useUserPreferences.use['competences-viewer'](); + const preferencesHydrated = useUserPreferences.use._hydrated(); + + const maxHeight = Math.round(containerHeight * 0.7), + minHeight = Math.round(containerHeight * 0.3), + intitalHeight = Math.round(containerHeight * 0.5); + + const setHeight = (newHeight: number) => { + if (!preferencesHydrated) return; + addPreferences({ + 'competences-viewer': { + upperCardHeight: Math.max(Math.min(newHeight, maxHeight), minHeight), + }, + }); + }; + const oldContainerHeight = useRef(containerHeight); + + /* Handle updates of container height */ + useEffect(() => { + /* Set new height */ + if (oldContainerHeight.current === 0) { + oldContainerHeight.current = containerHeight; + /* Check whther Preferences yield any value */ + if (preferencesHydrated && Number.isNaN(height)) setHeight(intitalHeight); + return; + } + /* Other resize: */ + /* Get old height in % */ + const ratio = height / oldContainerHeight.current; + /* Set old height to new height */ + oldContainerHeight.current = containerHeight; + setHeight(Math.round(containerHeight * ratio)); + }, [containerHeight]); + + return ( + <> +
+ setHeight(size.height)} + handle={ +
+
+
+ } + > + + + + + + + +
+ + ); +}; + +export default CompetencesViewer; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx new file mode 100644 index 000000000..3f000ee76 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx @@ -0,0 +1,20 @@ +import Content from '@/components/content'; +import { env } from 'process'; +import FeatureFlags from 'FeatureFlags'; +import CompentencesContainer from './competences-container'; +import CompetencesTable from './competences-table'; +import CompetencesViewer from './competences-viewer'; + +const CompetencesPage = async ({ + params: { environmentId }, +}: { + params: { environmentId: string }; +}) => { + return ( + + + + ); +}; + +export default CompetencesPage; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx index fdea5c597..6e4701145 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx @@ -22,6 +22,7 @@ import { ToolOutlined, SolutionOutlined, HomeOutlined, + OrderedListOutlined, } from '@ant-design/icons'; import Link from 'next/link'; @@ -103,6 +104,12 @@ const DashboardLayout = async ({ icon: , }); + children.push({ + key: 'competences', + label: Competences, + icon: , + }); + layoutMenuItems = [ { key: 'tasklist', diff --git a/src/management-system-v2/lib/user-preferences.ts b/src/management-system-v2/lib/user-preferences.ts index bc463ed4b..84ca60624 100644 --- a/src/management-system-v2/lib/user-preferences.ts +++ b/src/management-system-v2/lib/user-preferences.ts @@ -43,6 +43,8 @@ const defaultPreferences = { 'process-meta-data': { open: false, width: 300 }, 'tech-data-open-tree-items': [] as { id: string; open: string[] }[], 'tech-data-editor': { siderOpen: true, siderWidth: 300 }, + 'competences-table': { cardWidth: NaN }, + 'competences-viewer': { upperCardHeight: NaN }, }; /* as const */ /* Does not work for strings */ const partialUpdate = ( diff --git a/src/management-system-v2/next.config.js b/src/management-system-v2/next.config.js index b42965cb4..3640e2b76 100644 --- a/src/management-system-v2/next.config.js +++ b/src/management-system-v2/next.config.js @@ -52,6 +52,7 @@ const nextConfig = { 'spaces', 'executions', 'engines', + 'competences', 'tasklist', 'general-settings', 'iam', From 7a42089f1579050de22e5e0061317382ea0970d4 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Fri, 16 May 2025 09:30:47 +0200 Subject: [PATCH 03/17] Add competences to db --- .../lib/data/competence-schema.ts | 21 ++ .../lib/data/db/competence.ts | 297 ++++++++++++++++++ .../migration.sql | 34 ++ .../migration.sql | 26 ++ src/management-system-v2/prisma/schema.prisma | 37 +++ 5 files changed, 415 insertions(+) create mode 100644 src/management-system-v2/lib/data/competence-schema.ts create mode 100644 src/management-system-v2/lib/data/db/competence.ts create mode 100644 src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql create mode 100644 src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql diff --git a/src/management-system-v2/lib/data/competence-schema.ts b/src/management-system-v2/lib/data/competence-schema.ts new file mode 100644 index 000000000..8c76d8b9f --- /dev/null +++ b/src/management-system-v2/lib/data/competence-schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const CompetenceAttributeInputSchema = z.object({ + type: z.enum(['PLAIN_TEXT', 'SHORT_TEXT']), + text: z.string(), +}); + +export type CompetenceAttributeInput = z.infer; + +export const CompetenceInputSchema = z.array(CompetenceAttributeInputSchema); + +export type CompetenceInput = z.infer; + +export const CompetenceAttributeTypes = { plain: 'PLAIN_TEXT', short: 'SHORT_TEXT' } as const; + +export type Competence = { + id: string; + userId: string | null; + ownerType: 'USER' | 'SPACE'; + spaceId: string | null; +}; diff --git a/src/management-system-v2/lib/data/db/competence.ts b/src/management-system-v2/lib/data/db/competence.ts new file mode 100644 index 000000000..cb6d0d7fb --- /dev/null +++ b/src/management-system-v2/lib/data/db/competence.ts @@ -0,0 +1,297 @@ +import { Prisma } from '@prisma/client'; +import db from '@/lib/data/db'; +import { CompetenceAttributeInput, CompetenceInput } from '@/lib/data/competence-schema'; + +async function userOrSpaceID(environmentIdOrUserId: string) { + const user = await db.user.findUnique({ + where: { + id: environmentIdOrUserId, + }, + }); + + if (user) { + return 'USER'; + } + + const space = await db.space.findUnique({ + where: { + id: environmentIdOrUserId, + }, + }); + + if (space) { + return 'SPACE'; + } + + throw new Error(`Invalid ID (${environmentIdOrUserId}) passed to competence function`); +} + +/** + * Retrieves all competences for a given environment. + * @param {string} environmentIdOrUserId - The ID of the environment.or User the competence is associated with + * @param {boolean} includeAttributes - Whether to include attributes in the response. + */ +export async function getAllCompetences(environmentIdOrUserId: string, includeAttributes = true) { + const type = await userOrSpaceID(environmentIdOrUserId); + + /* Get all competences */ + const competences = await db.competence.findMany({ + where: type === 'USER' ? { userId: environmentIdOrUserId } : { spaceId: environmentIdOrUserId }, + }); + + if (!includeAttributes) { + return competences; + } + + /* Get all respective Attributes */ + const competencesWithAttributes = await Promise.all( + competences.map(async (competence) => { + const attributes = await db.competenceAttribute.findMany({ + where: { + competenceId: competence.id, + }, + }); + return { + ...competence, + attributes: attributes, + }; + }), + ); + + return competencesWithAttributes; +} + +/** + * Retrieves competence by ID + * @param {string} competenceId - The ID of the competence. + */ +export async function getCompetence(competenceId: string) { + /* Get competence */ + const competence = await db.competence.findUnique({ + where: { + id: competenceId, + }, + }); + + if (!competence) { + throw new Error(`Competence ${competenceId} not found`); + } + + /* Get attributes of competence */ + const attributes = await db.competenceAttribute.findMany({ + where: { + competenceId: competenceId, + }, + }); + + return { + ...competence, + attributes: attributes, + }; +} + +/** + * Creates a new competence with the given attributes. + * @param {string} environmentOrUserId - The ID of the environment or user. + * @param {CompetenceInput} attributes - The attributes of the competence. + */ +export async function addCompetence(environmentOrUserId: string, attributes: CompetenceInput = []) { + const type = await userOrSpaceID(environmentOrUserId); + + /* Create Competence */ + const data: Prisma.CompetenceCreateInput = { + ownerType: type, + ...(type === 'USER' ? { userId: environmentOrUserId } : { spaceId: environmentOrUserId }), + }; + const competence = await db.competence.create({ data }); + + /* Create Attributes */ + const competenceAttributes = await db.competenceAttribute.createMany({ + data: attributes.map((attribute) => ({ + competenceId: competence.id, + type: attribute.type, + text: attribute.text, + })), + }); + + return { + ...competence, + attributes: competenceAttributes, + }; +} + +/** + * Adds a new competence attribute to an existing competence. + * @param {string} competenceId - The ID of the competence. + * @param {CompetenceAttributeInput} attribute - The attribute to add. + */ +export async function addCompetenceAttribute( + competenceId: string, + attribute: CompetenceAttributeInput, +) { + const competence = await db.competence.findUnique({ + where: { + id: competenceId, + }, + }); + + if (!competence) { + throw new Error(`Competence ${competenceId} not found`); + } + + return await db.competenceAttribute.create({ + data: { + competenceId: competence.id, + type: attribute.type, + text: attribute.text, + }, + }); +} + +/** + * Updates a competence by ID + * i.e. overrides all attributes + * @param {string} competenceId - The ID of the competence. + * @param {CompetenceInput} attributes - The attributes to update. + */ +export async function updateCompetence(competenceId: string, attributes: CompetenceInput) { + const competence = await db.competence.findUnique({ + where: { + id: competenceId, + }, + }); + if (!competence) { + throw new Error(`Competence ${competenceId} not found`); + } + + await db.competenceAttribute.deleteMany({ + where: { + competenceId: competence.id, + }, + }); + + await db.competenceAttribute.createMany({ + data: attributes.map((attribute) => ({ + competenceId: competence.id, + type: attribute.type, + text: attribute.text, + })), + }); +} + +/** + * Updates a competence attribute by ID + * @param {string} competenceAttributeId - The ID of the competence attribute. + * @param {CompetenceAttributeInput} attribute - The updated attribute. + */ +export async function updateCompetenceAttribute( + competenceAttributeId: string, + attribute: CompetenceAttributeInput, +) { + const competenceAttribute = await db.competenceAttribute.findUnique({ + where: { + id: competenceAttributeId, + }, + }); + + if (!competenceAttribute) { + throw new Error(`Competence attribute ${competenceAttributeId} not found`); + } + + return await db.competenceAttribute.update({ + where: { + id: competenceAttribute.id, + }, + data: { + type: attribute.type, + text: attribute.text, + }, + }); +} + +/** + * Deletes a competence by ID + * @param {string} competenceId - The ID of the competence. + */ +export async function deleteCompetence(competenceId: string) { + const competence = await db.competence.findUnique({ + where: { + id: competenceId, + }, + }); + + if (!competence) { + throw new Error(`Competence ${competenceId} not found`); + } + + // Should not be necessary, as delete of competences should cascade: + + // await db.competenceAttribute.deleteMany({ + // where: { + // competenceId: competence.id, + // }, + // }); + + await db.competence.delete({ + where: { + id: competence.id, + }, + }); +} + +/** + * Deletes a competence attribute by ID + * @param {string} competenceAttributeId - The ID of the competence attribute. + */ +export async function deleteCompetenceAttribute(competenceAttributeId: string) { + const competenceAttribute = await db.competenceAttribute.findUnique({ + where: { + id: competenceAttributeId, + }, + }); + + if (!competenceAttribute) { + throw new Error(`Competence attribute ${competenceAttributeId} not found`); + } + + await db.competenceAttribute.delete({ + where: { + id: competenceAttribute.id, + }, + }); +} + +/** + * Deletes all competences for a given environment. + * @param {string} environmentIdOrUserId - The ID of the environment or user. + */ +export async function deleteAllCompetences(environmentIdOrUserId: string) { + const type = await userOrSpaceID(environmentIdOrUserId); + + /* Get all competences */ + const competences = await db.competence.findMany({ + where: type === 'USER' ? { userId: environmentIdOrUserId } : { spaceId: environmentIdOrUserId }, + }); + + if (competences.length === 0) return; + + // Should not be necessary, as delete of competences should cascade: + + /* Delete all attributes */ + // await db.competenceAttribute.deleteMany({ + // where: { + // competenceId: { + // in: competences.map((competence) => competence.id), + // }, + // }, + // }); + + /* Delete all competences */ + await db.competence.deleteMany({ + where: { + id: { + in: competences.map((competence) => competence.id), + }, + }, + }); +} diff --git a/src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql b/src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql new file mode 100644 index 000000000..8e1deb0cc --- /dev/null +++ b/src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "CompetenceAttributeType" AS ENUM ('PLAIN_TEXT', 'SHORT_TEXT'); + +-- CreateEnum +CREATE TYPE "CompetenceOwnerType" AS ENUM ('SPACE', 'USER'); + +-- CreateTable +CREATE TABLE "competence" ( + "id" TEXT NOT NULL, + "ownerType" "CompetenceOwnerType" NOT NULL, + "spaceId" TEXT, + "userId" TEXT, + + CONSTRAINT "competence_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "competence_attribute" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "type" "CompetenceAttributeType" NOT NULL, + "competenceId" TEXT NOT NULL, + + CONSTRAINT "competence_attribute_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "competence" ADD CONSTRAINT "competence_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "space"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "competence" ADD CONSTRAINT "competence_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "competence_attribute" ADD CONSTRAINT "competence_attribute_competenceId_fkey" FOREIGN KEY ("competenceId") REFERENCES "competence"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql b/src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql new file mode 100644 index 000000000..e83d76bbc --- /dev/null +++ b/src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql @@ -0,0 +1,26 @@ +ALTER TABLE "competence" + ADD CONSTRAINT user_or_space_id_and_owner_type + CHECK ( + -- exactly one of spaceId / userId is non-empty + ( + (nullif("spaceId", '') IS NOT NULL)::int + + (nullif("userId", '') IS NOT NULL)::int + ) = 1 + -- AND ensure ownerType lines up + AND + ( + -- if it’s a SPACE competence, spaceId must be non-empty and userId empty + ("ownerType" = 'SPACE' + AND nullif("spaceId", '') IS NOT NULL + AND nullif("userId", '') IS NULL + ) + OR + -- if it’s a USER competence, userId must be non-empty and spaceId empty + ("ownerType" = 'USER' + AND nullif("userId", '') IS NOT NULL + AND nullif("spaceId", '') IS NULL + ) + ) + ); +-- adapted after +-- https://dba.stackexchange.com/questions/190505/create-a-constraint-such-that-only-one-of-two-fields-must-be-filled diff --git a/src/management-system-v2/prisma/schema.prisma b/src/management-system-v2/prisma/schema.prisma index 8e9d36a70..b8d06407f 100644 --- a/src/management-system-v2/prisma/schema.prisma +++ b/src/management-system-v2/prisma/schema.prisma @@ -45,6 +45,7 @@ model User { systemAdmin SystemAdmin? @relation("systemAdmin") verificationTokens VerificationToken[] @relation("verificationToken") guestSignin GuestSignin? + competences Competence[] @@map("user") } @@ -146,6 +147,7 @@ model Space { roles Role[] engines Engine[] settings SpaceSettings? @relation("spaceSettings") + competences Competence[] @@map("space") } @@ -278,3 +280,38 @@ model GuestSignin { @@map("guest_signin") } + +enum CompetenceAttributeType { + PLAIN_TEXT + SHORT_TEXT +} + +enum CompetenceOwnerType { + SPACE + USER +} + +model Competence { + id String @id @default(uuid()) + ownerType CompetenceOwnerType + + spaceId String? + userId String? + space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + // Check constraint to ensure that either spaceId or userId is set, but not both was set via migration + + competences CompetenceAttribute[] + + @@map("competence") +} + +model CompetenceAttribute { + id String @id @default(uuid()) + text String + type CompetenceAttributeType + competence Competence @relation(fields: [competenceId], references: [id], onDelete: Cascade) + competenceId String + + @@map("competence_attribute") +} \ No newline at end of file From 0902e047e235632b4c4377839cb0e2c227a4ac7a Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Fri, 16 May 2025 09:31:02 +0200 Subject: [PATCH 04/17] Resolve merge conflict --- .../(dashboard)/[environmentId]/layout.tsx | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx index cd8443d89..d08d76d5c 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/layout.tsx @@ -123,6 +123,11 @@ const DashboardLayout = async ({ label: Machines, icon: , }, + automationSettings.automations?.active !== false && { + key: 'competences', + label: Competences, + icon: , + }, ].filter(truthyFilter); if (children.length) @@ -133,41 +138,6 @@ const DashboardLayout = async ({ children, }); - children.push({ - key: 'competences', - label: Competences, - icon: , - }); - - layoutMenuItems = [ - { - key: 'tasklist', - label: My Tasks, - icon: , - }, - ...layoutMenuItems, - ]; - - children = [ - { - key: 'dashboard', - label: Dashboard, - icon: , - }, - { - key: 'projects', - label: Projects, - icon: , - }, - ...children, - ]; - - layoutMenuItems.push({ - key: 'automations-group', - label: 'Automations', - icon: , - children, - }); if (automationSettings.tasklist?.active !== false) { layoutMenuItems = [ { From 300130d57d562d9e28bb2d39d08e82b6e71909d7 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Fri, 16 May 2025 10:34:06 +0200 Subject: [PATCH 05/17] pass competenes to container --- .../competences/competences-container.tsx | 5 ++++- .../(dashboard)/[environmentId]/competences/page.tsx | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx index 1e7c17977..2e7e665bd 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx @@ -4,8 +4,11 @@ import { Space } from 'antd'; import { FC, useLayoutEffect, useRef, useState } from 'react'; import CompetencesTable from './competences-table'; import CompetencesViewer from './competences-viewer'; +import { Competence } from '@/lib/data/competence-schema'; -type CompentencesContainerProps = React.PropsWithChildren<{}>; +type CompentencesContainerProps = React.PropsWithChildren<{ + competences: Competence[]; +}>; const CompentencesContainer: FC = ({ children }) => { const [containerWidth, setContainerWidth] = useState(0); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx index 3f000ee76..aacc6db05 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx @@ -4,15 +4,24 @@ import FeatureFlags from 'FeatureFlags'; import CompentencesContainer from './competences-container'; import CompetencesTable from './competences-table'; import CompetencesViewer from './competences-viewer'; +import { addCompetence, deleteAllCompetences, getAllCompetences } from '@/lib/data/db/competence'; +import { getCurrentEnvironment } from '@/components/auth'; +import { CompetenceAttributeTypes as attType, Competence } from '@/lib/data/competence-schema'; const CompetencesPage = async ({ params: { environmentId }, }: { params: { environmentId: string }; }) => { + const { activeEnvironment, ability } = await getCurrentEnvironment(environmentId); + + const competences = await getAllCompetences(activeEnvironment.spaceId); + + console.log('competences', JSON.stringify(competences)); + return ( - + ); }; From a12e51063deadb29a9b0d2028dc291a73fcae1b6 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Mon, 19 May 2025 18:51:07 +0200 Subject: [PATCH 06/17] Rewrite competences in DB --- .../lib/data/competence-schema.ts | 50 +- .../lib/data/db/competence.ts | 567 ++++++++++++------ .../migration.sql | 34 -- .../migration.sql | 26 - .../migration.sql | 51 ++ .../migration.sql | 15 + src/management-system-v2/prisma/schema.prisma | 53 +- 7 files changed, 520 insertions(+), 276 deletions(-) delete mode 100644 src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql delete mode 100644 src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql create mode 100644 src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql create mode 100644 src/management-system-v2/prisma/migrations/20250519131515_add_competences_constraints/migration.sql diff --git a/src/management-system-v2/lib/data/competence-schema.ts b/src/management-system-v2/lib/data/competence-schema.ts index 8c76d8b9f..8f13e3f69 100644 --- a/src/management-system-v2/lib/data/competence-schema.ts +++ b/src/management-system-v2/lib/data/competence-schema.ts @@ -1,21 +1,41 @@ import { z } from 'zod'; -export const CompetenceAttributeInputSchema = z.object({ - type: z.enum(['PLAIN_TEXT', 'SHORT_TEXT']), - text: z.string(), -}); +export const CompetenceTypes = z.enum(['USER', 'SPACE']); +export type CompetenceType = z.infer; -export type CompetenceAttributeInput = z.infer; - -export const CompetenceInputSchema = z.array(CompetenceAttributeInputSchema); - -export type CompetenceInput = z.infer; - -export const CompetenceAttributeTypes = { plain: 'PLAIN_TEXT', short: 'SHORT_TEXT' } as const; - -export type Competence = { +export type SpaceCompetence = { + type: CompetenceType; + name: string; id: string; - userId: string | null; - ownerType: 'USER' | 'SPACE'; + description: string; spaceId: string | null; + creatorUserId: string | null; + externalQualitficationNeeded: boolean; + renewalTimeInterval: number | null; + claimedBy: { + userId: string; + competenceId: string; + proficiency: string | null; + qualificationDate: Date | null; + lastUsage: Date | null; + }[]; +}; + +export type UserCompetence = { + userId: string; + competenceId: string; + proficiency: string | null; + qualificationDate: Date | null; + lastUsage: Date | null; + competence: { + type: CompetenceType; + + name: string; + id: string; + description: string; + spaceId: string | null; + creatorUserId: string | null; + externalQualitficationNeeded: boolean; + renewalTimeInterval: number | null; + }; }; diff --git a/src/management-system-v2/lib/data/db/competence.ts b/src/management-system-v2/lib/data/db/competence.ts index cb6d0d7fb..a136f0908 100644 --- a/src/management-system-v2/lib/data/db/competence.ts +++ b/src/management-system-v2/lib/data/db/competence.ts @@ -1,297 +1,500 @@ import { Prisma } from '@prisma/client'; import db from '@/lib/data/db'; -import { CompetenceAttributeInput, CompetenceInput } from '@/lib/data/competence-schema'; +import { CompetenceTypes, SpaceCompetence, UserCompetence } from '@/lib/data/competence-schema'; + +/* Space Competences */ + +/* Helper that ensures only allowed columns are updateable */ +function spaceCompetnceUpdateChecker({ + spaceId, + creatorId, + name, + description, + externalQualificationNeeded, + renewalTimeInterval, + ..._ +}: { + spaceId?: string; + creatorId?: string; + name?: string; + description?: string; + externalQualificationNeeded?: boolean; + renewalTimeInterval?: number | null | undefined; + [key: string]: unknown; +}) { + /* renewalTimeInterval has to be an int or undefined */ + let renewalTimeIntervalInt = renewalTimeInterval; + if (renewalTimeInterval) renewalTimeIntervalInt = parseInt(renewalTimeInterval.toString(), 10); -async function userOrSpaceID(environmentIdOrUserId: string) { - const user = await db.user.findUnique({ - where: { - id: environmentIdOrUserId, - }, - }); - - if (user) { - return 'USER'; - } - - const space = await db.space.findUnique({ - where: { - id: environmentIdOrUserId, - }, - }); - - if (space) { - return 'SPACE'; - } - - throw new Error(`Invalid ID (${environmentIdOrUserId}) passed to competence function`); + return { + /* Not updateable: */ + // type: CompetenceTypes.enum.SPACE, + // spaceId, + // creatorId, + + name, + description, + externalQualificationNeeded, + renewalTimeInterval: renewalTimeIntervalInt, + }; } /** - * Retrieves all competences for a given environment. - * @param {string} environmentIdOrUserId - The ID of the environment.or User the competence is associated with - * @param {boolean} includeAttributes - Whether to include attributes in the response. + * Retrieves all competences for a given space. + * + * @param {string} spaceId - The unique identifier of the space to fetch competences for. + * @param {Object} options - Optional settings. + * @param {boolean} [options.includeUserClaims] - If true, includes a list of users who have claimed each competence. + * @returns {Promise} A promise that resolves to an array of competences. If `includeUserClaims` is true, + * each competence will include a `claimedBy` property listing the users who claimed it. */ -export async function getAllCompetences(environmentIdOrUserId: string, includeAttributes = true) { - const type = await userOrSpaceID(environmentIdOrUserId); - - /* Get all competences */ +export async function getAllSpaceCompetences( + spaceId: string, + { includeUserClaims = true }: { includeUserClaims?: boolean } = {}, +): Promise< + typeof includeUserClaims extends true ? SpaceCompetence[] : Prisma.CompetenceGetPayload<{}>[] +> { const competences = await db.competence.findMany({ - where: type === 'USER' ? { userId: environmentIdOrUserId } : { spaceId: environmentIdOrUserId }, + where: { + spaceId, + type: CompetenceTypes.enum.SPACE, + }, }); - if (!includeAttributes) { - return competences; - } + if (!includeUserClaims) return competences; - /* Get all respective Attributes */ - const competencesWithAttributes = await Promise.all( + return await Promise.all( competences.map(async (competence) => { - const attributes = await db.competenceAttribute.findMany({ + const user = await db.userCompetence.findMany({ where: { competenceId: competence.id, }, }); + return { ...competence, - attributes: attributes, + claimedBy: user, }; }), ); - - return competencesWithAttributes; } /** - * Retrieves competence by ID - * @param {string} competenceId - The ID of the competence. + * Retrieves a competence of type SPACE by its unique identifier. + * + * @param {string} competenceId - The unique identifier of the competence to retrieve. + * @returns {Promise} A promise that resolves to the competence object if found, or null otherwise. */ -export async function getCompetence(competenceId: string) { - /* Get competence */ +export async function getSpaceCompetence(competenceId: string): Promise { const competence = await db.competence.findUnique({ where: { id: competenceId, + type: CompetenceTypes.enum.SPACE, }, }); - if (!competence) { - throw new Error(`Competence ${competenceId} not found`); - } - - /* Get attributes of competence */ - const attributes = await db.competenceAttribute.findMany({ - where: { - competenceId: competenceId, - }, - }); + if (!competence) return null; return { ...competence, - attributes: attributes, + claimedBy: await db.userCompetence.findMany({ + where: { + competenceId: competence.id, + }, + }), }; } /** - * Creates a new competence with the given attributes. - * @param {string} environmentOrUserId - The ID of the environment or user. - * @param {CompetenceInput} attributes - The attributes of the competence. + * Adds a new competence to a specific space in the database. + * + * @param {string} spaceId - The unique identifier of the space to which the competence will be added. + * @param {string} creatorId - The unique identifier of the user creating the competence. + * @param {Object} competence - The data for the competence to be created. + * @returns {Promise} A promise that resolves to the created competence object with an empty `claimedBy` array. */ -export async function addCompetence(environmentOrUserId: string, attributes: CompetenceInput = []) { - const type = await userOrSpaceID(environmentOrUserId); - - /* Create Competence */ - const data: Prisma.CompetenceCreateInput = { - ownerType: type, - ...(type === 'USER' ? { userId: environmentOrUserId } : { spaceId: environmentOrUserId }), - }; - const competence = await db.competence.create({ data }); - - /* Create Attributes */ - const competenceAttributes = await db.competenceAttribute.createMany({ - data: attributes.map((attribute) => ({ - competenceId: competence.id, - type: attribute.type, - text: attribute.text, - })), - }); +export async function addSpaceCompetence( + spaceId: string, + creatorId: string, + competence: Omit, +): Promise { + const data = spaceCompetnceUpdateChecker(competence); return { - ...competence, - attributes: competenceAttributes, + ...(await db.competence.create({ + data: { + ...data, + spaceId, + creatorUserId: creatorId, + type: CompetenceTypes.enum.SPACE, + }, + })), + claimedBy: [] as Prisma.UserCompetenceGetPayload<{}>[], }; } /** - * Adds a new competence attribute to an existing competence. - * @param {string} competenceId - The ID of the competence. - * @param {CompetenceAttributeInput} attribute - The attribute to add. + * Updates a competence within a specific space. + * + * @param {string} competenceId - The ID of the competence to update. + * @param {Object} competence - The updated competence data. + * @returns {Promise} The updated competence object, including a list of users who have claimed it. */ -export async function addCompetenceAttribute( +export async function updateSpaceCompetence( + // spaceId: string, + // @param {string} spaceId - The ID of the space where the competence belongs. competenceId: string, - attribute: CompetenceAttributeInput, -) { - const competence = await db.competence.findUnique({ + competence: Prisma.CompetenceUpdateInput, +): Promise { + // @ts-ignore + const data = spaceCompetnceUpdateChecker({ + ...competence, + }); + + const updatedCompetence = await db.competence.update({ where: { id: competenceId, + // spaceId }, + data, }); - if (!competence) { - throw new Error(`Competence ${competenceId} not found`); - } - - return await db.competenceAttribute.create({ - data: { - competenceId: competence.id, - type: attribute.type, - text: attribute.text, - }, - }); + return { + ...updatedCompetence, + claimedBy: await db.userCompetence.findMany({ + where: { + competenceId: updatedCompetence.id, + }, + }), + }; } /** - * Updates a competence by ID - * i.e. overrides all attributes - * @param {string} competenceId - The ID of the competence. - * @param {CompetenceInput} attributes - The attributes to update. + * Deletes a competence from the database. + * + * @param {string} competenceId - The unique identifier of the competence to delete. + * @returns {Promise} A promise that resolves to the deleted competence object. */ -export async function updateCompetence(competenceId: string, attributes: CompetenceInput) { - const competence = await db.competence.findUnique({ +export async function deleteSpaceCompetence(competenceId: string): Promise { + const claimedBy = await db.userCompetence.findMany({ where: { - id: competenceId, + competenceId, }, }); - if (!competence) { - throw new Error(`Competence ${competenceId} not found`); - } - await db.competenceAttribute.deleteMany({ + return { + ...(await db.competence.delete({ + where: { + id: competenceId, + type: CompetenceTypes.enum.SPACE, + }, + })), + claimedBy, + }; +} + +/** + * Deletes all competences of type SPACE for a given space. + * + * @param {string} spaceId - The unique identifier of the space whose competences will be deleted. + * @returns {Promise} A promise that resolves to the result of the delete operation. + */ +export async function deleteAllSpaceCompetences(spaceId: string): Promise { + const competences = await getAllSpaceCompetences(spaceId); + + await db.competence.deleteMany({ where: { - competenceId: competence.id, + spaceId, + type: CompetenceTypes.enum.SPACE, }, }); - await db.competenceAttribute.createMany({ - data: attributes.map((attribute) => ({ - competenceId: competence.id, - type: attribute.type, - text: attribute.text, - })), - }); + return competences as SpaceCompetence[]; +} + +/* ------------------------------------------------------------------ */ +/* User Competences */ + +/* Helper that ensures only allowed columns are updateable */ +function userCompetenceUpdateChecker({ + competenceId, + userId, + proficiency, + qualificationDate, + lastUsage, + ..._ +}: { + competenceId?: string; + userId?: string; + proficiency?: string | null; + qualificationDate?: Date | string | null; + lastUsage?: Date | string | null; + [key: string]: unknown; +}) { + /* Check if dates are valid */ + let qualificationDateParsed = qualificationDate; + if (qualificationDate) qualificationDateParsed = new Date(qualificationDate).toISOString(); + let lastUsageParsed = lastUsage; + if (lastUsage) lastUsageParsed = new Date(lastUsage).toISOString(); + + return { + /* Not updateable: */ + // type: CompetenceTypes.enum.USER, + // competenceId, + // userId, + + proficiency, + qualificationDate: qualificationDateParsed, + lastUsage: lastUsageParsed, + }; } /** - * Updates a competence attribute by ID - * @param {string} competenceAttributeId - The ID of the competence attribute. - * @param {CompetenceAttributeInput} attribute - The updated attribute. + * Retrieves all competences for a given user. + * + * @param {string} userId - The unique identifier of the user to fetch competences for. + * @returns {Promise} A promise that resolves to an array of user-competences, where the property `competence` + * contains the competence details. */ -export async function updateCompetenceAttribute( - competenceAttributeId: string, - attribute: CompetenceAttributeInput, -) { - const competenceAttribute = await db.competenceAttribute.findUnique({ +export async function getAllCompetencesOfUser(userId: string): Promise { + const userCompetences = await db.userCompetence.findMany({ where: { - id: competenceAttributeId, + userId, }, }); - if (!competenceAttribute) { - throw new Error(`Competence attribute ${competenceAttributeId} not found`); + /* Empty check */ + if (userCompetences.length === 0) { + return []; } - return await db.competenceAttribute.update({ + return await Promise.all( + userCompetences.map(async (userCompetence) => { + const competence = await db.competence.findUnique({ + where: { + id: userCompetence.competenceId, + }, + }); + + // competence should never be null because of db integrity + if (!competence) + throw new Error( + `Inconsistent Database: Competence not found for ID: ${userCompetence.competenceId}`, + ); + + return { + ...userCompetence, + competence, + }; + }), + ); +} + +/** + * Retrieves a specific competence for a given user. + * + * @param {string} userId - The unique identifier of the user. + * @param {string} competenceId - The unique identifier of the competence to retrieve. + * @returns {Promise} A promise that resolves to the user-competence object, including the competence details. + */ +export async function getUserCompetence( + userId: string, + competenceId: string, +): Promise { + const userCompetence = await db.userCompetence.findUnique({ where: { - id: competenceAttribute.id, - }, - data: { - type: attribute.type, - text: attribute.text, + competenceId_userId: { + competenceId, + userId, + }, }, }); + + if (!userCompetence) return null; + + return { + ...userCompetence, + competence: await db.competence.findUnique({ + where: { + id: userCompetence.competenceId, + }, + }), + } as UserCompetence; } /** - * Deletes a competence by ID - * @param {string} competenceId - The ID of the competence. + * Claims a competence for a user. + * + * @param {string} userId - The unique identifier of the user. + * @param {string} competenceId - The unique identifier of the competence to claim. + * @param {Object} userCompetence - The data for the user-competence to be created. + * @returns {Promise} A promise that resolves to the claimed user-competence object, including the competence details. */ -export async function deleteCompetence(competenceId: string) { +export async function claimUserCompetence( + userId: string, + competenceId: string, + userCompetence: Omit, +): Promise { + /* Check that competence exists */ const competence = await db.competence.findUnique({ where: { id: competenceId, + type: CompetenceTypes.enum.SPACE, // has to be space competence }, }); - if (!competence) { - throw new Error(`Competence ${competenceId} not found`); + throw new Error(`Competence with ID ${competenceId} does not exist or is not of type SPACE.`); } - // Should not be necessary, as delete of competences should cascade: + const data = userCompetenceUpdateChecker(userCompetence); - // await db.competenceAttribute.deleteMany({ - // where: { - // competenceId: competence.id, - // }, - // }); - - await db.competence.delete({ - where: { - id: competence.id, + const _userCompetence = await db.userCompetence.create({ + data: { + ...data, + competenceId, + userId, }, }); + + return { + ..._userCompetence, + competence, + }; } /** - * Deletes a competence attribute by ID - * @param {string} competenceAttributeId - The ID of the competence attribute. + * Adds a new competence for a user. + * + * @param {string} userId - The unique identifier of the user. + * @param {Object} competence - The data for the competence to be created. + * @param {Object} userCompetence - The data for the user-competence to be created. + * @returns {Promise} A promise that resolves to the created user-competence object, including the competence details. */ -export async function deleteCompetenceAttribute(competenceAttributeId: string) { - const competenceAttribute = await db.competenceAttribute.findUnique({ - where: { - id: competenceAttributeId, +export async function addUserCompetence( + userId: string, + competence: Omit, + userCompetence: Prisma.UserCompetenceCreateInput, +): Promise { + /* Create the competence */ + const spaceData = spaceCompetnceUpdateChecker(competence); + const newCompetence = await db.competence.create({ + data: { + ...spaceData, + type: CompetenceTypes.enum.USER, + creatorUserId: userId, }, }); - if (!competenceAttribute) { - throw new Error(`Competence attribute ${competenceAttributeId} not found`); - } - - await db.competenceAttribute.delete({ - where: { - id: competenceAttribute.id, + /* Create the user competence */ + const userData = userCompetenceUpdateChecker(userCompetence); + const _userCompetence = await db.userCompetence.create({ + data: { + ...userData, + competenceId: newCompetence.id, + userId, }, }); + + return { + ..._userCompetence, + competence: newCompetence, + }; } /** - * Deletes all competences for a given environment. - * @param {string} environmentIdOrUserId - The ID of the environment or user. + * Updates a user's competence. + * + * @param {string} userId - The unique identifier of the user. + * @param {string} competenceId - The unique identifier of the competence to update. + * @param {Object} userCompetence - The updated user-competence data. + * @returns {Promise} A promise that resolves to the updated user-competence object, including the competence details. */ -export async function deleteAllCompetences(environmentIdOrUserId: string) { - const type = await userOrSpaceID(environmentIdOrUserId); +export async function updateUserCompetence( + userId: string, + competenceId: string, + userCompetence: Prisma.UserCompetenceUpdateInput, +): Promise { + // @ts-ignore + const data = userCompetenceUpdateChecker(userCompetence); - /* Get all competences */ - const competences = await db.competence.findMany({ - where: type === 'USER' ? { userId: environmentIdOrUserId } : { spaceId: environmentIdOrUserId }, + const updatedUserCompetence = await db.userCompetence.update({ + where: { + competenceId_userId: { + competenceId, + userId, + }, + }, + data, }); - if (competences.length === 0) return; + return { + ...updatedUserCompetence, + competence: await db.competence.findUnique({ + where: { + id: updatedUserCompetence.competenceId, + }, + }), + }; +} - // Should not be necessary, as delete of competences should cascade: +/** + * Deletes a user's competence. + * + * @param {string} userId - The unique identifier of the user. + * @param {string} competenceId - The unique identifier of the competence to delete. + * @returns {Promise} A promise that resolves to the deleted user-competence object, including the competence details. + */ +export async function deleteUserCompetence( + userId: string, + competenceId: string, +): Promise { + return { + ...(await db.userCompetence.delete({ + where: { + competenceId_userId: { + competenceId, + userId, + }, + }, + })), + competence: await db.competence.findUnique({ + where: { + id: competenceId, + }, + }), + } as UserCompetence; +} - /* Delete all attributes */ - // await db.competenceAttribute.deleteMany({ - // where: { - // competenceId: { - // in: competences.map((competence) => competence.id), - // }, - // }, - // }); +/** + * Deletes all competences of a user. + * + * @param {string} userId - The unique identifier of the user whose competences will be deleted. + * @returns {Promise} A promise that resolves to an array of deleted user-competences. + */ +export async function deleteAllUserCompetences(userId: string): Promise { + const userCompetences = await getAllCompetencesOfUser(userId); - /* Delete all competences */ - await db.competence.deleteMany({ + await db.userCompetence.deleteMany({ where: { - id: { - in: competences.map((competence) => competence.id), - }, + userId, }, }); + + return userCompetences; } + +export default { + getAllSpaceCompetences, + getSpaceCompetence, + addSpaceCompetence, + updateSpaceCompetence, + deleteSpaceCompetence, + deleteAllSpaceCompetences, + getAllCompetencesOfUser, + getUserCompetence, + claimUserCompetence, + addUserCompetence, + updateUserCompetence, + deleteUserCompetence, + deleteAllUserCompetences, +}; diff --git a/src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql b/src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql deleted file mode 100644 index 8e1deb0cc..000000000 --- a/src/management-system-v2/prisma/migrations/20250514163840_add_competence/migration.sql +++ /dev/null @@ -1,34 +0,0 @@ --- CreateEnum -CREATE TYPE "CompetenceAttributeType" AS ENUM ('PLAIN_TEXT', 'SHORT_TEXT'); - --- CreateEnum -CREATE TYPE "CompetenceOwnerType" AS ENUM ('SPACE', 'USER'); - --- CreateTable -CREATE TABLE "competence" ( - "id" TEXT NOT NULL, - "ownerType" "CompetenceOwnerType" NOT NULL, - "spaceId" TEXT, - "userId" TEXT, - - CONSTRAINT "competence_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "competence_attribute" ( - "id" TEXT NOT NULL, - "text" TEXT NOT NULL, - "type" "CompetenceAttributeType" NOT NULL, - "competenceId" TEXT NOT NULL, - - CONSTRAINT "competence_attribute_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "competence" ADD CONSTRAINT "competence_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "space"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "competence" ADD CONSTRAINT "competence_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "competence_attribute" ADD CONSTRAINT "competence_attribute_competenceId_fkey" FOREIGN KEY ("competenceId") REFERENCES "competence"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql b/src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql deleted file mode 100644 index e83d76bbc..000000000 --- a/src/management-system-v2/prisma/migrations/20250514164010_add_constrain_competence/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ -ALTER TABLE "competence" - ADD CONSTRAINT user_or_space_id_and_owner_type - CHECK ( - -- exactly one of spaceId / userId is non-empty - ( - (nullif("spaceId", '') IS NOT NULL)::int - + (nullif("userId", '') IS NOT NULL)::int - ) = 1 - -- AND ensure ownerType lines up - AND - ( - -- if it’s a SPACE competence, spaceId must be non-empty and userId empty - ("ownerType" = 'SPACE' - AND nullif("spaceId", '') IS NOT NULL - AND nullif("userId", '') IS NULL - ) - OR - -- if it’s a USER competence, userId must be non-empty and spaceId empty - ("ownerType" = 'USER' - AND nullif("userId", '') IS NOT NULL - AND nullif("spaceId", '') IS NULL - ) - ) - ); --- adapted after --- https://dba.stackexchange.com/questions/190505/create-a-constraint-such-that-only-one-of-two-fields-must-be-filled diff --git a/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql b/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql new file mode 100644 index 000000000..b0922633b --- /dev/null +++ b/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "CompetenceOwnerType" AS ENUM ('SPACE', 'USER'); + +-- CreateTable +CREATE TABLE "competence" ( + "id" TEXT NOT NULL, + "type" "CompetenceOwnerType" NOT NULL, + "spaceId" TEXT, + "creatorUserId" TEXT, + "name" TEXT NOT NULL DEFAULT '', + "description" TEXT NOT NULL DEFAULT '', + "externalQualitficationNeeded" BOOLEAN NOT NULL DEFAULT false, + "renewalTimeInterval" INTEGER, + + CONSTRAINT "competence_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_competence" ( + "competenceId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "proficiency" TEXT, + "qualificationDate" TIMESTAMP(3), + "lastUsage" TIMESTAMP(3), + + CONSTRAINT "user_competence_pkey" PRIMARY KEY ("competenceId","userId") +); + +-- CreateIndex +CREATE INDEX "competence_spaceId_idx" ON "competence"("spaceId"); + +-- CreateIndex +CREATE INDEX "competence_creatorUserId_idx" ON "competence"("creatorUserId"); + +-- CreateIndex +CREATE INDEX "user_competence_competenceId_idx" ON "user_competence"("competenceId"); + +-- CreateIndex +CREATE INDEX "user_competence_userId_idx" ON "user_competence"("userId"); + +-- AddForeignKey +ALTER TABLE "competence" ADD CONSTRAINT "competence_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "space"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "competence" ADD CONSTRAINT "competence_creatorUserId_fkey" FOREIGN KEY ("creatorUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_competence" ADD CONSTRAINT "user_competence_competenceId_fkey" FOREIGN KEY ("competenceId") REFERENCES "competence"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_competence" ADD CONSTRAINT "user_competence_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/management-system-v2/prisma/migrations/20250519131515_add_competences_constraints/migration.sql b/src/management-system-v2/prisma/migrations/20250519131515_add_competences_constraints/migration.sql new file mode 100644 index 000000000..d66838930 --- /dev/null +++ b/src/management-system-v2/prisma/migrations/20250519131515_add_competences_constraints/migration.sql @@ -0,0 +1,15 @@ + +ALTER TABLE "competence" + ADD CONSTRAINT only_one_of_spaceId_or_userId_is_nullable + CHECK ( + ( + type = 'SPACE' + AND "spaceId" IS NOT NULL + ) + OR + ( + type = 'USER' + AND "creatorUserId" IS NOT NULL + AND "spaceId" IS NULL + ) +); diff --git a/src/management-system-v2/prisma/schema.prisma b/src/management-system-v2/prisma/schema.prisma index b8d06407f..4ada431bb 100644 --- a/src/management-system-v2/prisma/schema.prisma +++ b/src/management-system-v2/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { verificationTokens VerificationToken[] @relation("verificationToken") guestSignin GuestSignin? competences Competence[] + userCompetences UserCompetence[] @@map("user") } @@ -281,10 +282,6 @@ model GuestSignin { @@map("guest_signin") } -enum CompetenceAttributeType { - PLAIN_TEXT - SHORT_TEXT -} enum CompetenceOwnerType { SPACE @@ -293,25 +290,43 @@ enum CompetenceOwnerType { model Competence { id String @id @default(uuid()) - ownerType CompetenceOwnerType + type CompetenceOwnerType // SPACE or USER - spaceId String? - userId String? - space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - // Check constraint to ensure that either spaceId or userId is set, but not both was set via migration - - competences CompetenceAttribute[] + spaceId String? // Nullable, if the competence is a user-competence + space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade) + + creatorUserId String? // Nullable, if the user is deleted, i.e. the user no longer exists + user User? @relation(fields: [creatorUserId], references: [id], onDelete: SetNull) // Check: only eihter is nullable: spaceId or creatorUserId + + name String @default("") // Should this be nullable? + description String @default("") // Should this be nullable? + externalQualitficationNeeded Boolean @default(false) + renewalTimeInterval Int? // in s (?) // Nullable, if competence is valid indefinitely + + userCompetences UserCompetence[] @@map("competence") + + // Querying will be primarily via the spaceId and userId + @@index([spaceId]) + @@index([creatorUserId]) } -model CompetenceAttribute { - id String @id @default(uuid()) - text String - type CompetenceAttributeType - competence Competence @relation(fields: [competenceId], references: [id], onDelete: Cascade) +model UserCompetence { competenceId String + competence Competence @relation(fields: [competenceId], references: [id], onDelete: Cascade) // Not sure if cascade is the right option here (if a spaceCompetene is deleted, it disapears for all users that had claimed it) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + proficiency String? + qualificationDate DateTime? + lastUsage DateTime? - @@map("competence_attribute") -} \ No newline at end of file + @@id([competenceId, userId]) + @@map("user_competence") + + // Querying will be primarily via the competenceId and userId + @@index([competenceId]) + @@index([userId]) +} From 9f4c3100a171af6b3cf873177f31f5876c35be1a Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Fri, 30 May 2025 17:20:57 +0200 Subject: [PATCH 07/17] Add first layout for SpaceCompetences --- .../competences/competences-table.tsx | 73 ---- .../competences/competences-viewer.tsx | 95 ----- .../[environmentId]/competences/page.tsx | 25 +- .../competences/competence-creation-modal.tsx | 122 ++++++ .../competences-container.module.scss | 0 .../competences/competences-container.tsx | 23 +- .../competences/competences-table.module.scss | 1 + .../competences/competences-table.tsx | 271 ++++++++++++ .../competences-viewer.module.scss | 1 + .../competences/competences-viewer.tsx | 396 ++++++++++++++++++ .../lib/data/competence-schema.ts | 24 +- .../lib/data/competences.ts | 178 ++++++++ .../lib/data/db/competence.ts | 97 ++++- .../migration.sql | 2 +- src/management-system-v2/prisma/schema.prisma | 2 +- 15 files changed, 1100 insertions(+), 210 deletions(-) delete mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx delete mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx create mode 100644 src/management-system-v2/components/competences/competence-creation-modal.tsx rename src/management-system-v2/{app/(dashboard)/[environmentId] => components}/competences/competences-container.module.scss (100%) rename src/management-system-v2/{app/(dashboard)/[environmentId] => components}/competences/competences-container.tsx (62%) rename src/management-system-v2/{app/(dashboard)/[environmentId] => components}/competences/competences-table.module.scss (93%) create mode 100644 src/management-system-v2/components/competences/competences-table.tsx rename src/management-system-v2/{app/(dashboard)/[environmentId] => components}/competences/competences-viewer.module.scss (92%) create mode 100644 src/management-system-v2/components/competences/competences-viewer.tsx create mode 100644 src/management-system-v2/lib/data/competences.ts diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx deleted file mode 100644 index 64b11043b..000000000 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client'; -import { useUserPreferences } from '@/lib/user-preferences'; -import styles from './competences-table.module.scss'; -import { Card, Space, Table } from 'antd'; -import { FC, useState, useRef, ReactNode, useEffect } from 'react'; -import { ResizableBox } from 'react-resizable'; - -type CompetencesTableProps = React.PropsWithChildren<{ - containerWidth: number; -}>; - -const CompetencesTable: FC = ({ children, containerWidth }) => { - const addPreferences = useUserPreferences.use.addPreferences(); - const { cardWidth: width } = useUserPreferences.use['competences-table'](); - const preferencesHydrated = useUserPreferences.use._hydrated(); - - const maxWidth = Math.round(containerWidth * 0.7), - minWidth = Math.round(containerWidth * 0.3), - intitalWidth = Math.round(containerWidth * 0.5); - - const setWidth = (newWidth: number) => { - if (!preferencesHydrated) return; - addPreferences({ - 'competences-table': { cardWidth: Math.max(Math.min(newWidth, maxWidth), minWidth) }, - }); - }; - const oldContainerWidth = useRef(containerWidth); - - /* Handle updates of container width */ - useEffect(() => { - /* Set new width */ - /* Edge-Case: Initial render (parent has not figuered out the actual width in px, yet) */ - if (oldContainerWidth.current === 0) { - oldContainerWidth.current = containerWidth; - /* Check whther Preferences yield any value */ - if (preferencesHydrated && Number.isNaN(width)) setWidth(intitalWidth); - return; - } - /* Other resize: */ - /* Get old width in % */ - const ratio = width / oldContainerWidth.current; - /* Set old width to new width */ - oldContainerWidth.current = containerWidth; - setWidth(Math.round(containerWidth * ratio)); - }, [containerWidth]); - - return ( - <> - setWidth(size.width)} - handle={ -
-
-
- } - > - -
- - - - ); -}; - -export default CompetencesTable; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx deleted file mode 100644 index c74094248..000000000 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useUserPreferences } from '@/lib/user-preferences'; -import style from './competences-viewer.module.scss'; -import { Button, Card, Descriptions, DescriptionsProps, Input, List, Space } from 'antd'; -import { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { ResizableBox } from 'react-resizable'; - -type CompetencesViewerProps = React.PropsWithChildren<{}>; - -const CompetencesViewer: FC = ({ children }) => { - const [containerHeight, setContainerHeight] = useState(0); - const containerRef = useRef(null); - - useLayoutEffect(() => { - if (!window) return; - - const handleResize = () => { - if (containerRef.current) { - setContainerHeight(containerRef.current.clientHeight); - } - }; - - handleResize(); - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [containerRef]); - - const addPreferences = useUserPreferences.use.addPreferences(); - const { upperCardHeight: height } = useUserPreferences.use['competences-viewer'](); - const preferencesHydrated = useUserPreferences.use._hydrated(); - - const maxHeight = Math.round(containerHeight * 0.7), - minHeight = Math.round(containerHeight * 0.3), - intitalHeight = Math.round(containerHeight * 0.5); - - const setHeight = (newHeight: number) => { - if (!preferencesHydrated) return; - addPreferences({ - 'competences-viewer': { - upperCardHeight: Math.max(Math.min(newHeight, maxHeight), minHeight), - }, - }); - }; - const oldContainerHeight = useRef(containerHeight); - - /* Handle updates of container height */ - useEffect(() => { - /* Set new height */ - if (oldContainerHeight.current === 0) { - oldContainerHeight.current = containerHeight; - /* Check whther Preferences yield any value */ - if (preferencesHydrated && Number.isNaN(height)) setHeight(intitalHeight); - return; - } - /* Other resize: */ - /* Get old height in % */ - const ratio = height / oldContainerHeight.current; - /* Set old height to new height */ - oldContainerHeight.current = containerHeight; - setHeight(Math.round(containerHeight * ratio)); - }, [containerHeight]); - - return ( - <> -
- setHeight(size.height)} - handle={ -
-
-
- } - > - - - - - - - -
- - ); -}; - -export default CompetencesViewer; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx index aacc6db05..f67c8b2c3 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/competences/page.tsx @@ -1,12 +1,13 @@ import Content from '@/components/content'; import { env } from 'process'; import FeatureFlags from 'FeatureFlags'; -import CompentencesContainer from './competences-container'; -import CompetencesTable from './competences-table'; -import CompetencesViewer from './competences-viewer'; -import { addCompetence, deleteAllCompetences, getAllCompetences } from '@/lib/data/db/competence'; -import { getCurrentEnvironment } from '@/components/auth'; -import { CompetenceAttributeTypes as attType, Competence } from '@/lib/data/competence-schema'; +import CompentencesContainer from '@/components/competences/competences-container'; +import CompetencesTable from '@/components/competences/competences-table'; +import CompetencesViewer from '@/components/competences/competences-viewer'; +import { getAllCompetencesOfUser, getAllSpaceCompetences } from '@/lib/data/competences'; +import { getCurrentEnvironment, getCurrentUser } from '@/components/auth'; +import { CompetenceTypes } from '@/lib/data/competence-schema'; +import type { CompetenceType } from '@/lib/data/competence-schema'; const CompetencesPage = async ({ params: { environmentId }, @@ -14,14 +15,18 @@ const CompetencesPage = async ({ params: { environmentId: string }; }) => { const { activeEnvironment, ability } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + const { userId } = await getCurrentUser(); - const competences = await getAllCompetences(activeEnvironment.spaceId); - - console.log('competences', JSON.stringify(competences)); + const spaceCompetences = await getAllSpaceCompetences(environmentId); + const userCompetences = await getAllCompetencesOfUser(environmentId); return ( - + ); }; diff --git a/src/management-system-v2/components/competences/competence-creation-modal.tsx b/src/management-system-v2/components/competences/competence-creation-modal.tsx new file mode 100644 index 000000000..15bc46a5f --- /dev/null +++ b/src/management-system-v2/components/competences/competence-creation-modal.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { Button, Input, message, Modal, Row, Space, Switch, Typography } from 'antd'; +import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { FC, useState } from 'react'; +import { set } from 'zod'; +import { wrapServerCall } from '@/lib/wrap-server-call'; +import { useRouter } from 'next/navigation'; +import { addSpaceCompetence } from '@/lib/data/competences'; +import { useEnvironment } from '../auth-can'; +import { useSession } from 'next-auth/react'; + +type CompetenceCreationModalProps = React.PropsWithChildren<{ + open: boolean; + onClose?: () => void; + environmentId: string; +}>; + +const CompetenceCreationModal: FC = ({ + children, + open, + onClose, + environmentId, +}) => { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + const { spaceId } = useEnvironment(); + const session = useSession(); + const userId = session.data?.user.id; + + // User Input + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [externalQualificationNeeded, setExternalQualificationNeeded] = useState(false); + const [renewalTimeInterval, setRenewalTimeInterval] = useState(undefined); + + const createSpaceCompetence = async () => { + setLoading(true); + await wrapServerCall({ + fn: async () => { + if (!spaceId || !userId) { + message.error(' creating competence'); + setLoading(false); + return; + } + await addSpaceCompetence(environmentId, { + name, + description, + externalQualificationNeeded, + renewalTimeInterval, + }); + }, + onSuccess: () => { + setLoading(false); + + setName(''); + setDescription(''); + setExternalQualificationNeeded(false); + setRenewalTimeInterval(undefined); + + onClose?.(); + router.refresh(); + }, + onError: (err) => { + console.error('Error creating competence', err); + message.error('Error creating competence'); + setLoading(false); + }, + }); + }; + return ( + <> + + Create + , + ]} + > + + {/* Name */} + setName(e.target.value)} /> + {/* Description */} + setDescription(e.target.value)} + autoSize={{ minRows: 3, maxRows: 5 }} + /> + {/* Requires External Qualification */} +
+ Requires External Qualification: +
+ } + unCheckedChildren={} + checked={externalQualificationNeeded} + onChange={(checked) => setExternalQualificationNeeded(checked)} + /> +
+ {/* Renewal Time */} +
+ Renewal Time Interval: +
+
TIME
+
+ + + + ); +}; + +export default CompetenceCreationModal; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.module.scss b/src/management-system-v2/components/competences/competences-container.module.scss similarity index 100% rename from src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.module.scss rename to src/management-system-v2/components/competences/competences-container.module.scss diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx b/src/management-system-v2/components/competences/competences-container.tsx similarity index 62% rename from src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx rename to src/management-system-v2/components/competences/competences-container.tsx index 2e7e665bd..ff923a4a4 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-container.tsx +++ b/src/management-system-v2/components/competences/competences-container.tsx @@ -4,13 +4,18 @@ import { Space } from 'antd'; import { FC, useLayoutEffect, useRef, useState } from 'react'; import CompetencesTable from './competences-table'; import CompetencesViewer from './competences-viewer'; -import { Competence } from '@/lib/data/competence-schema'; +import { SpaceCompetence } from '@/lib/data/competence-schema'; type CompentencesContainerProps = React.PropsWithChildren<{ - competences: Competence[]; + competences: SpaceCompetence[]; + environmentId: string; }>; -const CompentencesContainer: FC = ({ children }) => { +const CompentencesContainer: FC = ({ + children, + competences, + environmentId, +}) => { const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); // console.log(containerWidth); @@ -32,11 +37,19 @@ const CompentencesContainer: FC = ({ children }) => }; }, [containerRef]); + const [selectedCompetence, setSelectedCompetence] = useState(null); + return ( <>
- - + +
); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss b/src/management-system-v2/components/competences/competences-table.module.scss similarity index 93% rename from src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss rename to src/management-system-v2/components/competences/competences-table.module.scss index f183b2f90..b603d8e51 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-table.module.scss +++ b/src/management-system-v2/components/competences/competences-table.module.scss @@ -13,6 +13,7 @@ } .card { flex: auto; + height: 100%; } .table { diff --git a/src/management-system-v2/components/competences/competences-table.tsx b/src/management-system-v2/components/competences/competences-table.tsx new file mode 100644 index 000000000..48841c008 --- /dev/null +++ b/src/management-system-v2/components/competences/competences-table.tsx @@ -0,0 +1,271 @@ +'use client'; +import { useUserPreferences } from '@/lib/user-preferences'; +import styles from './competences-table.module.scss'; +import { Button, Card, message, Space, Table, TableProps, Tag } from 'antd'; +import { CheckOutlined, CloseOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { FC, useState, useRef, ReactNode, useEffect, useLayoutEffect } from 'react'; +import { ResizableBox } from 'react-resizable'; +import { SpaceCompetence, User } from '@/lib/data/competence-schema'; +import { ResizeableTitle, useResizeableColumnWidth } from '@/lib/useColumnWidth'; +import { set } from 'zod'; +import ConfirmationButton from '@/components/confirmation-button'; +import CompetenceCreationModal from './competence-creation-modal'; +import { wrapServerCall } from '@/lib/wrap-server-call'; +import { deleteSpaceCompetence } from '@/lib/data/competences'; +import { useRouter } from 'next/navigation'; + +type CompetencesTableProps = React.PropsWithChildren<{ + containerWidth: number; + competences: SpaceCompetence[]; + selectedCompetence: SpaceCompetence | null; + setSelectedSpaceCompetence: (competence: SpaceCompetence | null) => void; + environmentId: string; +}>; + +const CompetencesTable: FC = ({ + children, + containerWidth, + competences, + selectedCompetence, + setSelectedSpaceCompetence, + environmentId, +}) => { + const addPreferences = useUserPreferences.use.addPreferences(); + const { cardWidth: width } = useUserPreferences.use['competences-table'](); + const preferencesHydrated = useUserPreferences.use._hydrated(); + + const router = useRouter(); + + const [maxTableHeigth, setMaxTableHeigth] = useState(0); + const cardRef = useRef(null); + useLayoutEffect(() => { + if (!cardRef.current) return; + const handleResize = () => { + setMaxTableHeigth(cardRef.current?.clientHeight || 0); + }; + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [cardRef]); + + const [creationModalOpen, setCreationModalOpen] = useState(false); + + const maxWidth = Math.round(containerWidth * 0.7), + minWidth = Math.round(containerWidth * 0.4), + intitalWidth = Math.round(containerWidth * 0.6); + + const setWidth = (newWidth: number) => { + if (!preferencesHydrated) return; + addPreferences({ + 'competences-table': { cardWidth: Math.max(Math.min(newWidth, maxWidth), minWidth) }, + }); + }; + const oldContainerWidth = useRef(containerWidth); + + /* Handle updates of container width */ + useEffect(() => { + /* Set new width */ + /* Edge-Case: Initial render (parent has not figuered out the actual width in px, yet) */ + if (oldContainerWidth.current === 0) { + oldContainerWidth.current = containerWidth; + /* Check whther Preferences yield any value */ + if (preferencesHydrated && Number.isNaN(width)) setWidth(intitalWidth); + return; + } + /* Other resize: */ + /* Get old width in % */ + const ratio = width / oldContainerWidth.current; + /* Set old width to new width */ + oldContainerWidth.current = containerWidth; + setWidth(Math.round(containerWidth * ratio)); + }, [containerWidth]); + + let columns: TableProps['columns'] = [ + { + title: 'Name', + dataIndex: 'name', + render: (value, record, index) => <>{value}, + }, + // { + // title: 'Description', + // dataIndex: 'description', + // render: (text) => <>TEST, + // }, + { + title: 'Creatd By', + dataIndex: 'creatorUserId', + /* TODO: Use ID to user mapping */ + render: (value, record, index) => <>{value}, + }, + { + title: 'Requires Qualification', + dataIndex: 'externalQualificationNeeded', + render: (value, record, index) => ( + <> +
+ {value ? ( + + ) : ( + + )} +
+ + ), + }, + { + title: 'Renewal Time', + dataIndex: 'renewalTimeInterval', + render: (value, record, index) => <>{value}, + }, + { + title: 'Claimed By', + dataIndex: ['claimedBy'], + render: (value, record, index) => ( + <> + {value.map((user: User) => ( + {user.userId} + ))} + + ), + }, + { + title: '', + key: 'delete-entry', + width: 40, + render: (value, record, index) => ( + <> + + {/*
{ + setSelectedSpaceCompetence(selectedRows[0]); + }, + selectedRowKeys: tableData + .filter((competence) => competence.id === selectedCompetence?.id) + .map((competence) => competence.id), + }} + onRow={(record) => ({ + onClick: () => { + setSelectedSpaceCompetence(record); + }, + })} + scroll={{ + y: maxTableHeigth - 250, + }} + pagination={{ + pageSize: 20, + position: ['bottomCenter'], + }} + // components={{ + // header: { + // cell: ResizeableTitle, + // }, + // }} + footer={() => ( +
+ +
+ )} + /> + + + + setCreationModalOpen(false)} + environmentId={environmentId} + /> + + ); +}; + +export default CompetencesTable; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss b/src/management-system-v2/components/competences/competences-viewer.module.scss similarity index 92% rename from src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss rename to src/management-system-v2/components/competences/competences-viewer.module.scss index e99770e41..676814d99 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/competences/competences-viewer.module.scss +++ b/src/management-system-v2/components/competences/competences-viewer.module.scss @@ -20,4 +20,5 @@ } .card { flex: auto; + // overflow-y: scroll; } diff --git a/src/management-system-v2/components/competences/competences-viewer.tsx b/src/management-system-v2/components/competences/competences-viewer.tsx new file mode 100644 index 000000000..e05c9b782 --- /dev/null +++ b/src/management-system-v2/components/competences/competences-viewer.tsx @@ -0,0 +1,396 @@ +import { useUserPreferences } from '@/lib/user-preferences'; +import style from './competences-viewer.module.scss'; +import { + Button, + Card, + Descriptions, + DescriptionsProps, + Input, + List, + message, + Space, + Switch, + Typography, +} from 'antd'; +import { CheckOutlined, CloseOutlined, EditOutlined, SaveOutlined } from '@ant-design/icons'; +import { FC, ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { ResizableBox } from 'react-resizable'; +import { SpaceCompetence } from '@/lib/data/competence-schema'; +import { useRouter } from 'next/navigation'; +import { updateSpaceCompetence } from '@/lib/data/competences'; +import { wrapServerCall } from '@/lib/wrap-server-call'; + +type CompetencesViewerProps = React.PropsWithChildren<{ + selectedCompetence: SpaceCompetence | null; + environmentId: string; +}>; + +const CompetencesViewer: FC = ({ + children, + selectedCompetence, + environmentId, +}) => { + console.log('CompetencesViewer', selectedCompetence); + const router = useRouter(); + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = useRef(null); + + const [descriptionIsEdited, setDescriptionIsEdited] = useState(false); + const [attributesAreEdited, setAttributesAreEdited] = useState(false); + + const [name, setName] = useState(selectedCompetence?.name || ''); + const [description, setDescription] = useState(selectedCompetence?.description || ''); + const [externalQualificationNeeded, setExternalQualificationNeeded] = useState( + selectedCompetence?.externalQualificationNeeded || false, + ); + const [renewalTimeInterval, setRenewalTimeInterval] = useState( + selectedCompetence?.renewalTimeInterval || undefined, + ); + + /* Card width - Updates card (content) width - if necessary */ + const [cardWidth, setCardWidth] = useState(0); + const cardRef = useRef(null); + useLayoutEffect(() => { + if (!cardRef.current) return; + const observer = new ResizeObserver((entries) => { + for (let entry of entries) { + const w = entry.contentRect.width; + setCardWidth((prev) => (prev === w ? prev : w - 80)); + } + }); + + observer.observe(cardRef.current); + + return () => { + observer.disconnect(); + }; + }, []); + + useLayoutEffect(() => { + if (!window) return; + + const handleResize = () => { + if (containerRef.current) { + setContainerHeight(containerRef.current.clientHeight); + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [containerRef]); + + const addPreferences = useUserPreferences.use.addPreferences(); + const { upperCardHeight: height } = useUserPreferences.use['competences-viewer'](); + const preferencesHydrated = useUserPreferences.use._hydrated(); + + const maxHeight = Math.round(containerHeight * 0.7), + minHeight = Math.round(containerHeight * 0.3), + intitalHeight = Math.round(containerHeight * 0.5); + + const setHeight = (newHeight: number) => { + if (!preferencesHydrated) return; + addPreferences({ + 'competences-viewer': { + upperCardHeight: Math.max(Math.min(newHeight, maxHeight), minHeight), + }, + }); + }; + const oldContainerHeight = useRef(containerHeight); + + /* Handle updates of container height */ + useEffect(() => { + /* Set new height */ + if (oldContainerHeight.current === 0) { + oldContainerHeight.current = containerHeight; + /* Check whther Preferences yield any value */ + if (preferencesHydrated && Number.isNaN(height)) setHeight(intitalHeight); + return; + } + /* Other resize: */ + /* Get old height in % */ + const ratio = height / oldContainerHeight.current; + /* Set old height to new height */ + oldContainerHeight.current = containerHeight; + setHeight(Math.round(containerHeight * ratio)); + }, [containerHeight]); + + /* Reset when selection changes */ + useEffect(() => { + if (!selectedCompetence) return; + /* Values */ + setName(selectedCompetence.name); + setDescription(selectedCompetence.description); + setExternalQualificationNeeded(selectedCompetence.externalQualificationNeeded || false); + setRenewalTimeInterval(selectedCompetence.renewalTimeInterval || undefined); + + /* Edits */ + setDescriptionIsEdited(false); + setAttributesAreEdited(false); + }, [selectedCompetence]); + + const resetState = () => { + // setDescriptionIsEdited(false); + // setAttributesAreEdited(false); + setName(selectedCompetence?.name || ''); + setDescription(selectedCompetence?.description || ''); + setExternalQualificationNeeded(selectedCompetence?.externalQualificationNeeded || false); + setRenewalTimeInterval(selectedCompetence?.renewalTimeInterval || undefined); + }; + + const updateCmpetence = async () => { + if (!selectedCompetence) return; + + await wrapServerCall({ + fn: async () => { + await updateSpaceCompetence(environmentId, selectedCompetence.id, { + name, + description, + externalQualificationNeeded, + renewalTimeInterval, + }); + }, + onSuccess: () => { + setDescriptionIsEdited(false); + router.refresh(); + message.success('Competence updated successfully'); + }, + onError: (err) => { + message.error('Error updating competence'); + resetState(); + console.error('Error updating competence', err); + }, + }); + }; + + const attributeListData = [ + { + title: 'Name', + value: name, + isEdited: attributesAreEdited, + onChange: (e: React.ChangeEvent) => setName(e.target.value), + render: (value: string) => {name}, + editRender: (value: string) => ( + setName(e.target.value)} + style={{ width: '100%', textAlign: 'left' }} + /> + ), + }, + { + title: 'Additional Qualification Needed', + value: externalQualificationNeeded ? 'Yes' : 'No', + isEdited: attributesAreEdited, + onChange: (e: React.ChangeEvent) => + setExternalQualificationNeeded(e.target.checked), + render: (value: boolean) => {value}, + editRender: (value: boolean) => ( + <> + {externalQualificationNeeded ? 'Yes' : 'No'} + } + unCheckedChildren={} + checked={externalQualificationNeeded} + onChange={(checked) => setExternalQualificationNeeded(checked)} + /> + + ), + }, + { + title: 'Renewal Time Interval', + value: renewalTimeInterval ? `${renewalTimeInterval} days` : 'Not set', + isEdited: attributesAreEdited, + onChange: (e: React.ChangeEvent) => + setRenewalTimeInterval(Number(e.target.value)), + render: (value: ReactNode) => {value}, + editRender: (value: ReactNode) => ( + <> + + +
TEST
+ + ), + }, + ]; + + return ( + <> +
+ setHeight(size.height)} + handle={ +
+
+
+ } + > + } + onClick={() => { + setDescriptionIsEdited(true); + }} + > + ) : ( + + )) + } + > + {selectedCompetence ? ( +
+ {descriptionIsEdited ? ( + { + setDescription(e.target.value); + }} + /> + ) : ( + + {description.split('\n').map((text, index) => { + if (index === 0) return text; + return ( + +
+ {text} +
+ ); + }) || ''} +
+ )} +
+ ) : ( + + No competence selected. + + )} +
+ + } + onClick={() => { + setAttributesAreEdited(true); + }} + > + ) : ( + + )) + } + > + { + return ( + <> + {selectedCompetence ? ( + // + // + // {item.title}: + // { + // // @ts-ignore + // item.isEdited + // ? item?.editRender(item.value) + // : item?.render(item.value) + // // ( + // // + // // ) : ( + // // {item.value} + // // ) + // } + // + // } + // /> + // + + } + title={`${item.title}:`} + description={ + item.isEdited ? ( +
+ {item.editRender(item.value)} +
+ ) : ( + item.render(item.value) + ) + } + /> +
+ ) : ( + index === 0 && ( + + + No competence selected. + + } + /> + + ) + )} + + ); + }} + /> +
+
+ + ); +}; + +export default CompetencesViewer; diff --git a/src/management-system-v2/lib/data/competence-schema.ts b/src/management-system-v2/lib/data/competence-schema.ts index 8f13e3f69..8558539f8 100644 --- a/src/management-system-v2/lib/data/competence-schema.ts +++ b/src/management-system-v2/lib/data/competence-schema.ts @@ -10,7 +10,7 @@ export type SpaceCompetence = { description: string; spaceId: string | null; creatorUserId: string | null; - externalQualitficationNeeded: boolean; + externalQualificationNeeded: boolean; renewalTimeInterval: number | null; claimedBy: { userId: string; @@ -29,13 +29,31 @@ export type UserCompetence = { lastUsage: Date | null; competence: { type: CompetenceType; - name: string; id: string; description: string; spaceId: string | null; creatorUserId: string | null; - externalQualitficationNeeded: boolean; + externalQualificationNeeded: boolean; renewalTimeInterval: number | null; }; }; + +export type User = { + userId: string; + competenceId: string; + proficiency: string | null | undefined; + qualificationDate: Date | null | undefined; + lastUsage: Date | null | undefined; +}; + +export type Competence = { + type: CompetenceType; + name: string; + id: string; + description: string; + spaceId: string | null | undefined; + creatorUserId: string | null | undefined; + externalQualificationNeeded: boolean | undefined; + renewalTimeInterval: number | null | undefined; +}; diff --git a/src/management-system-v2/lib/data/competences.ts b/src/management-system-v2/lib/data/competences.ts new file mode 100644 index 000000000..e7767edff --- /dev/null +++ b/src/management-system-v2/lib/data/competences.ts @@ -0,0 +1,178 @@ +'use server'; + +import Competences from '@/lib/data/db/competence'; +import type { + SpaceCompetence, + UserCompetence, + User, + Competence, + CompetenceType, +} from './competence-schema'; +import { CompetenceTypes } from './competence-schema'; +import { getCurrentEnvironment, getCurrentUser } from '@/components/auth'; + +/* TODO: Ability checks */ + +export async function getAllSpaceCompetences( + environmentId: string, +): ReturnType { + const { activeEnvironment, ability } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + + return Competences.getAllSpaceCompetences(spaceId); +} + +export async function getSpaceCompetence( + environmentId: string, + competenceId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + return Competences.getSpaceCompetence(competenceId); +} + +export async function addSpaceCompetence( + environmentId: string, + competence: Omit, +): ReturnType { + const { activeEnvironment, ability } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + const { userId } = await getCurrentUser(); + + return Competences.addSpaceCompetence(spaceId, userId, competence); +} + +export async function updateSpaceCompetence( + environmentId: string, + competenceId: string, + competence: Omit, +) { + const { ability } = await getCurrentEnvironment(environmentId); + + return Competences.updateSpaceCompetence(competenceId, competence); +} + +export async function deleteSpaceCompetence( + environmentId: string, + competenceId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + + return Competences.deleteSpaceCompetence(competenceId); +} + +export async function deleteAllSpaceCompetences( + environmentId: string, +): ReturnType { + const { activeEnvironment, ability } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + + return Competences.deleteAllSpaceCompetences(spaceId); +} + +export async function getAllCompetencesOfUser( + environmentId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + + const { userId } = await getCurrentUser(); + + return Competences.getAllCompetencesOfUser(userId); +} + +export async function getUserCompetence( + environmentId: string, + competenceId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + const { userId } = await getCurrentUser(); + + return Competences.getUserCompetence(userId, competenceId); +} + +export async function claimSpaceCompetence( + environmentId: string, + competenceId: string, + { proficiency, qualificationDate, lastUsage } = {} as Partial, +): ReturnType { + const { activeEnvironment, ability } = await getCurrentEnvironment(environmentId); + const { spaceId } = activeEnvironment; + + const { userId } = await getCurrentUser(); + + return Competences.claimSpaceCompetence(userId, competenceId, { + proficiency: proficiency || undefined, + qualificationDate: qualificationDate || undefined, + lastUsage: lastUsage || undefined, + }); +} + +export async function unclaimSpaceCompetence( + environmentId: string, + competenceId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + const { userId } = await getCurrentUser(); + + return Competences.unclaimSpaceCompetence(userId, competenceId); +} + +export async function addUserCompetence( + environmentId: string, + { name, description, externalQualificationNeeded, renewalTimeInterval } = {} as { + name: string; + description: string; + } & Partial, + { proficiency, qualificationDate, lastUsage } = {} as Partial, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + const { userId } = await getCurrentUser(); + + return Competences.addUserCompetence( + userId, + { + name, + description, + externalQualificationNeeded, + renewalTimeInterval, + }, + { + proficiency, + qualificationDate, + lastUsage, + }, + ); +} + +export async function updateUserCompetence( + environmentId: string, + competenceId: string, + { proficiency, qualificationDate, lastUsage } = {} as Partial, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + const { userId } = await getCurrentUser(); + + return Competences.updateUserCompetence(userId, competenceId, { + proficiency, + qualificationDate, + lastUsage, + }); +} + +export async function deleteUserCompetence( + environmentId: string, + competenceId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + const { userId } = await getCurrentUser(); + + return Competences.deleteUserCompetence(userId, competenceId); +} + +export async function deleteAllUserCompetences( + environmentId: string, +): ReturnType { + const { ability } = await getCurrentEnvironment(environmentId); + const { userId } = await getCurrentUser(); + + return Competences.deleteAllUserCompetences(userId); +} diff --git a/src/management-system-v2/lib/data/db/competence.ts b/src/management-system-v2/lib/data/db/competence.ts index a136f0908..f588e567a 100644 --- a/src/management-system-v2/lib/data/db/competence.ts +++ b/src/management-system-v2/lib/data/db/competence.ts @@ -1,11 +1,18 @@ import { Prisma } from '@prisma/client'; import db from '@/lib/data/db'; -import { CompetenceTypes, SpaceCompetence, UserCompetence } from '@/lib/data/competence-schema'; +import { + Competence, + CompetenceType, + CompetenceTypes, + SpaceCompetence, + User, + UserCompetence, +} from '@/lib/data/competence-schema'; /* Space Competences */ /* Helper that ensures only allowed columns are updateable */ -function spaceCompetnceUpdateChecker({ +function spaceCompetenceUpdateChecker({ spaceId, creatorId, name, @@ -48,12 +55,7 @@ function spaceCompetnceUpdateChecker({ * @returns {Promise} A promise that resolves to an array of competences. If `includeUserClaims` is true, * each competence will include a `claimedBy` property listing the users who claimed it. */ -export async function getAllSpaceCompetences( - spaceId: string, - { includeUserClaims = true }: { includeUserClaims?: boolean } = {}, -): Promise< - typeof includeUserClaims extends true ? SpaceCompetence[] : Prisma.CompetenceGetPayload<{}>[] -> { +export async function getAllSpaceCompetences(spaceId: string): Promise { const competences = await db.competence.findMany({ where: { spaceId, @@ -61,8 +63,6 @@ export async function getAllSpaceCompetences( }, }); - if (!includeUserClaims) return competences; - return await Promise.all( competences.map(async (competence) => { const user = await db.userCompetence.findMany({ @@ -118,7 +118,7 @@ export async function addSpaceCompetence( creatorId: string, competence: Omit, ): Promise { - const data = spaceCompetnceUpdateChecker(competence); + const data = spaceCompetenceUpdateChecker(competence); return { ...(await db.competence.create({ @@ -147,7 +147,7 @@ export async function updateSpaceCompetence( competence: Prisma.CompetenceUpdateInput, ): Promise { // @ts-ignore - const data = spaceCompetnceUpdateChecker({ + const data = spaceCompetenceUpdateChecker({ ...competence, }); @@ -323,17 +323,17 @@ export async function getUserCompetence( } /** - * Claims a competence for a user. + * Claims a space-competence for a user. * * @param {string} userId - The unique identifier of the user. * @param {string} competenceId - The unique identifier of the competence to claim. * @param {Object} userCompetence - The data for the user-competence to be created. * @returns {Promise} A promise that resolves to the claimed user-competence object, including the competence details. */ -export async function claimUserCompetence( +export async function claimSpaceCompetence( userId: string, competenceId: string, - userCompetence: Omit, + userCompetence: Omit, ): Promise { /* Check that competence exists */ const competence = await db.competence.findUnique({ @@ -362,6 +362,48 @@ export async function claimUserCompetence( }; } +/** + * Unclaims a space-competence for a user. + * + * @param {string} userId - The unique identifier of the user. + * @param {string} competenceId - The unique identifier of the competence to unclaim. + * @returns {Promise} A promise that resolves to the unclaimed user-competence object, including the competence details. + */ +export async function unclaimSpaceCompetence( + userId: string, + competenceId: string, +): Promise { + const userCompetence = await db.userCompetence.findUnique({ + where: { + competenceId_userId: { + competenceId, + userId, + }, + }, + }); + + if (!userCompetence) { + throw new Error(`User with ID ${userId} has not claimed competence with ID ${competenceId}.`); + } + + return { + ...(await db.userCompetence.delete({ + where: { + competenceId_userId: { + competenceId, + userId, + }, + }, + })), + competence: + (await db.competence.findUnique({ + where: { + id: competenceId, + }, + })) || ({} as Competence), + }; +} + /** * Adds a new competence for a user. * @@ -372,11 +414,11 @@ export async function claimUserCompetence( */ export async function addUserCompetence( userId: string, - competence: Omit, - userCompetence: Prisma.UserCompetenceCreateInput, + competence: Omit, + userCompetence: Omit, ): Promise { /* Create the competence */ - const spaceData = spaceCompetnceUpdateChecker(competence); + const spaceData = spaceCompetenceUpdateChecker(competence); const newCompetence = await db.competence.create({ data: { ...spaceData, @@ -448,7 +490,7 @@ export async function deleteUserCompetence( userId: string, competenceId: string, ): Promise { - return { + const result = { ...(await db.userCompetence.delete({ where: { competenceId_userId: { @@ -457,12 +499,22 @@ export async function deleteUserCompetence( }, }, })), - competence: await db.competence.findUnique({ + /* Delete competence */ + competence: await db.competence.delete({ where: { id: competenceId, + type: CompetenceTypes.enum.USER, }, }), - } as UserCompetence; + }; + + if (!result) { + throw new Error(`User with ID ${userId} has not claimed competence with ID ${competenceId}.`); + } + + // No check for competence (assuming db integrity) + + return result; } /** @@ -492,7 +544,8 @@ export default { deleteAllSpaceCompetences, getAllCompetencesOfUser, getUserCompetence, - claimUserCompetence, + claimSpaceCompetence, + unclaimSpaceCompetence, addUserCompetence, updateUserCompetence, deleteUserCompetence, diff --git a/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql b/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql index b0922633b..507ace1d9 100644 --- a/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql +++ b/src/management-system-v2/prisma/migrations/20250519131312_add_competences/migration.sql @@ -9,7 +9,7 @@ CREATE TABLE "competence" ( "creatorUserId" TEXT, "name" TEXT NOT NULL DEFAULT '', "description" TEXT NOT NULL DEFAULT '', - "externalQualitficationNeeded" BOOLEAN NOT NULL DEFAULT false, + "externalQualificationNeeded" BOOLEAN NOT NULL DEFAULT false, "renewalTimeInterval" INTEGER, CONSTRAINT "competence_pkey" PRIMARY KEY ("id") diff --git a/src/management-system-v2/prisma/schema.prisma b/src/management-system-v2/prisma/schema.prisma index 4ada431bb..8a931968c 100644 --- a/src/management-system-v2/prisma/schema.prisma +++ b/src/management-system-v2/prisma/schema.prisma @@ -300,7 +300,7 @@ model Competence { name String @default("") // Should this be nullable? description String @default("") // Should this be nullable? - externalQualitficationNeeded Boolean @default(false) + externalQualificationNeeded Boolean @default(false) renewalTimeInterval Int? // in s (?) // Nullable, if competence is valid indefinitely userCompetences UserCompetence[] From db7dfebf54b357084adbf0c5eaecde83d4704f22 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Mon, 2 Jun 2025 16:58:02 +0200 Subject: [PATCH 08/17] ran prettier --- src/competence-matcher/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/competence-matcher/package.json b/src/competence-matcher/package.json index 13807d1c9..8783bafca 100644 --- a/src/competence-matcher/package.json +++ b/src/competence-matcher/package.json @@ -20,4 +20,4 @@ "url": "https://github.com/PROCEED-Labs/proceed/issues" }, "homepage": "https://github.com/PROCEED-Labs/proceed#readme" -} \ No newline at end of file +} From c656728f2e5fe1b54e0cb525558c7edda34f586e Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Thu, 26 Jun 2025 01:32:33 +0200 Subject: [PATCH 09/17] Add paths to ignore for competence matcher database and models --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 53607c0a5..cf49f52a0 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ dataEval.json # Ignore generated credentials from google-github-actions/auth gha-creds-*.json + +./src/competence-matcher/src/db/dbs/ +./src/competence-matcher/src/models/ From c980b198aeb53b435fa9f7d1928bb6bde27e6f46 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Thu, 26 Jun 2025 01:33:33 +0200 Subject: [PATCH 10/17] Add section header for Matching Service in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cf49f52a0..9e95cf440 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,6 @@ dataEval.json # Ignore generated credentials from google-github-actions/auth gha-creds-*.json +# Matching Service (models and dbs) ./src/competence-matcher/src/db/dbs/ ./src/competence-matcher/src/models/ From f7edd690f82bfbb5fd96ebdf073413fb3dc5c4a7 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Thu, 26 Jun 2025 01:37:16 +0200 Subject: [PATCH 11/17] feat: Implement middleware for database selection and logging - Added dbHeader middleware to handle multiple databases based on request header. - Introduced requestLogger middleware to log incoming requests with detailed information. feat: Create resource management routes and middleware - Developed resource-related middleware functions for creating and retrieving resource lists. - Established routes for resource management, including job status and resource list retrieval. feat: Integrate embedding functionality with Hugging Face Transformers - Implemented Embedding class to handle model loading and text embedding. - Created worker for processing embedding jobs in a separate thread. chore: Add TypeScript configuration and utility types - Introduced tsconfig.json for TypeScript compilation settings. - Defined various types for competencies, resources, and embedding jobs to enhance type safety. feat: Add ONNX model configuration for embedding - Included configuration file for Qwen3-Embedding model in ONNX format. chore: Set up server with Express and middleware - Configured Express server with routes for resource and matching tasks. - Integrated middleware for database selection and request logging. --- src/competence-matcher/.env | 0 src/competence-matcher/package.json | 21 +- src/competence-matcher/src/config.ts | 8 + src/competence-matcher/src/db/db-manager.ts | 162 ++++ src/competence-matcher/src/db/db.ts | 757 ++++++++++++++++++ .../src/middleware/db-locator.ts | 22 + .../src/middleware/logging.ts | 17 + .../src/middleware/match.ts | 0 .../src/middleware/resource.ts | 201 +++++ .../Qwen3-Embedding-0.6B-ONNX/config.json | 70 ++ src/competence-matcher/src/routes/match.ts | 13 + src/competence-matcher/src/routes/resource.ts | 29 + src/competence-matcher/src/server.ts | 49 ++ src/competence-matcher/src/tasks/embedding.ts | 91 +++ .../src/tasks/reason-llm.ts | 0 src/competence-matcher/src/utils/types.ts | 92 +++ src/competence-matcher/src/worker/embedder.ts | 40 + src/competence-matcher/src/worker/matcher.ts | 0 src/competence-matcher/tsconfig.json | 13 + 19 files changed, 1582 insertions(+), 3 deletions(-) create mode 100644 src/competence-matcher/.env create mode 100644 src/competence-matcher/src/config.ts create mode 100644 src/competence-matcher/src/db/db-manager.ts create mode 100644 src/competence-matcher/src/db/db.ts create mode 100644 src/competence-matcher/src/middleware/db-locator.ts create mode 100644 src/competence-matcher/src/middleware/logging.ts create mode 100644 src/competence-matcher/src/middleware/match.ts create mode 100644 src/competence-matcher/src/middleware/resource.ts create mode 100644 src/competence-matcher/src/models/onnx-community/Qwen3-Embedding-0.6B-ONNX/config.json create mode 100644 src/competence-matcher/src/routes/match.ts create mode 100644 src/competence-matcher/src/routes/resource.ts create mode 100644 src/competence-matcher/src/server.ts create mode 100644 src/competence-matcher/src/tasks/embedding.ts create mode 100644 src/competence-matcher/src/tasks/reason-llm.ts create mode 100644 src/competence-matcher/src/utils/types.ts create mode 100644 src/competence-matcher/src/worker/embedder.ts create mode 100644 src/competence-matcher/src/worker/matcher.ts create mode 100644 src/competence-matcher/tsconfig.json diff --git a/src/competence-matcher/.env b/src/competence-matcher/.env new file mode 100644 index 000000000..e69de29bb diff --git a/src/competence-matcher/package.json b/src/competence-matcher/package.json index 8783bafca..0cbdaf8e3 100644 --- a/src/competence-matcher/package.json +++ b/src/competence-matcher/package.json @@ -2,9 +2,10 @@ "name": "competence-matcher", "version": "0.0.1", "description": "Matching microservice that allows to allows to define and match on data criteria", - "main": "server.ts", + "main": "dist/server.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "build": "tsc" }, "repository": { "type": "git", @@ -19,5 +20,19 @@ "bugs": { "url": "https://github.com/PROCEED-Labs/proceed/issues" }, - "homepage": "https://github.com/PROCEED-Labs/proceed#readme" + "homepage": "https://github.com/PROCEED-Labs/proceed#readme", + "dependencies": { + "@huggingface/transformers": "^3.5.2", + "express": "^5.1.0", + "sqlite-vec": "^0.1.7-alpha.2" + }, + "devDependencies": { + "@types/express": "^5.0.2", + "@types/node": "^22.15.30", + "ts-node-dev": "^2.0.0", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=23.5.0" + } } diff --git a/src/competence-matcher/src/config.ts b/src/competence-matcher/src/config.ts new file mode 100644 index 000000000..10355e697 --- /dev/null +++ b/src/competence-matcher/src/config.ts @@ -0,0 +1,8 @@ +export const config = { + dbPath: process.env.DB_PATH || 'src/db/dbs/', + embeddingDim: parseInt(process.env.EMBEDDING_DIM || '1024', 10), + model: process.env.MODEL || 'onnx-community/Qwen3-Embedding-0.6B-ONNX', + modelCache: process.env.MODEL_CACHE || 'src/models/', + port: parseInt(process.env.PORT || '8501', 10), + multipleDBs: process.env.MULTIPLE_DBS === 'true' || false, +}; diff --git a/src/competence-matcher/src/db/db-manager.ts b/src/competence-matcher/src/db/db-manager.ts new file mode 100644 index 000000000..d5ab99d4f --- /dev/null +++ b/src/competence-matcher/src/db/db-manager.ts @@ -0,0 +1,162 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import VectorDataBase from './db'; +import { config } from '../config'; + +const { dbPath: rawDbPath, embeddingDim } = config; + +/** + * DBManager: Singleton that manages multiple VectorDataBase instances keyed by name. + * + */ +class DBManager { + private static managerInstance: DBManager; + private dbInstances = new Map(); + private static activeDB: VectorDataBase | null = null; + private dbPath: string; + private embeddingDim: number; + + private constructor() { + this.embeddingDim = embeddingDim; + + // Resolve absolute path for storage directory + this.dbPath = path.resolve(rawDbPath); + // Ensure directory exists + if (!fs.existsSync(this.dbPath)) { + fs.mkdirSync(this.dbPath, { recursive: true }); + } + // Load existing databases + this.loadSavedDBs(); + } + + /** + * Retrieve the singleton DBManager instance, initialising if necessary. + * @returns DBManager singleton + */ + public static getInstance(): DBManager { + if (!DBManager.managerInstance) { + DBManager.managerInstance = new DBManager(); + } + return DBManager.managerInstance; + } + + /** + * Initialise the DBManager and load any existing databases. + */ + private loadSavedDBs(): void { + // Load existing databases from the storage directory + const files = fs.readdirSync(this.dbPath); + files.forEach((file) => { + if (file.endsWith('.db')) { + const dbName = path.basename(file, '.db'); + this.addDBInstance(dbName); + } + }); + } + + /** + * Normalise a database name by stripping any extension and enforcing `.db`. + * @param dbName Name provided; may include extension. + * @returns Normalised filename ending with `.db`. + */ + private normaliseDBName(dbName: string): string { + const base = path.basename(dbName, path.extname(dbName)); + return `${base}.db`; + } + + /** + * Resolve full absolute path to the DB file under storage directory. + * @param dbName Name provided by user; normalised to `.db` and joined with storage dir. + * @returns Absolute file path for the database. + */ + private resolveDbPath(dbName: string): string { + const normalisedDBName = this.normaliseDBName(dbName); + return path.join(this.dbPath, normalisedDBName); + } + + /** + * Internal: create and cache a new VectorDataBase instance for the given name. + * Uses resolveDbPath to obtain the absolute file path. + * @param dbName Name provided by user; normalised internally. + * @returns Newly created VectorDataBase instance. + */ + private addDBInstance(dbName: string): VectorDataBase { + const normalisedDBName = this.normaliseDBName(dbName); + const filePath = this.resolveDbPath(normalisedDBName); + const db = new VectorDataBase({ filePath, embeddingDim: this.embeddingDim }); + this.dbInstances.set(normalisedDBName, db); + return db; + } + + /** + * Get the currently active VectorDataBase instance, if set via setActiveDB. + * @returns Active VectorDataBase or null if none is set. + */ + public static getActiveDB(): VectorDataBase | null { + return DBManager.activeDB; + } + + /** + * Set the active database by name. Creates the instance if it does not exist. + * @param dbName Name of the database (without extension or with any extension). + */ + public static setActiveDB(dbName: string): void { + const manager = DBManager.getInstance(); + const normalisedDBName = manager.normaliseDBName(dbName); + if (!manager.dbInstances.has(normalisedDBName)) { + manager.addDBInstance(normalisedDBName); + } + DBManager.activeDB = manager.dbInstances.get(normalisedDBName)!; + } + + /** + * Retrieve (or create) the VectorDataBase instance for given name. + * @param dbName Name of the database (without extension or with any extension). + * @returns VectorDataBase instance corresponding to the name. + */ + public getDB(dbName: string): VectorDataBase { + const normalisedDBName = this.normaliseDBName(dbName); + if (this.dbInstances.has(normalisedDBName)) { + return this.dbInstances.get(normalisedDBName)!; + } + return this.addDBInstance(normalisedDBName); + } + + /** + * Close and remove the VectorDataBase instance for given name. + * @param dbName Name of the database to close. + * @returns True if instance existed and was closed; false otherwise. + */ + public closeDB(dbName: string): boolean { + const normalisedDBName = this.normaliseDBName(dbName); + const db = this.dbInstances.get(normalisedDBName); + if (db) { + db.close(); + this.dbInstances.delete(normalisedDBName); + if (DBManager.activeDB === db) { + DBManager.activeDB = null; + } + return true; + } + return false; + } + + /** + * Close and remove all managed VectorDataBase instances. + */ + public closeAllDBs(): void { + this.dbInstances.forEach((db) => db.close()); + this.dbInstances.clear(); + DBManager.activeDB = null; + } + + /** + * List the names (normalised) of all managed databases. + * @returns Array of database filenames (e.g. ['tenant1.db', 'other.db']). + */ + public listDBs(): string[] { + return Array.from(this.dbInstances.keys()); + } +} + +export default DBManager; diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts new file mode 100644 index 000000000..13e6d81ce --- /dev/null +++ b/src/competence-matcher/src/db/db.ts @@ -0,0 +1,757 @@ +// db.ts +import { DatabaseSync } from 'node:sqlite'; +import * as path from 'node:path'; +import * as sqliteVec from 'sqlite-vec'; +import { v4 as uuid } from 'uuid'; + +export interface VectorDBOptions { + filePath?: string; // file path or ":memory:" + embeddingDim: number; // dimension of each embedding +} + +class VectorDataBase { + private db: DatabaseSync; + private embeddingDim: number; + private transactionInProgress = false; + + /** + * Opens (or creates) the SQLite DB, enables FKs, loads sqlite-vec, and sets up schema. + */ + constructor(opts: VectorDBOptions) { + this.embeddingDim = opts.embeddingDim; + const dbPath = + !opts.filePath || opts.filePath === ':memory:' + ? ':memory:' + : path.isAbsolute(opts.filePath) + ? opts.filePath + : path.join(process.cwd(), opts.filePath); + + this.db = new DatabaseSync(dbPath, { allowExtension: true }); + this.db.exec(`PRAGMA foreign_keys = ON;`); + sqliteVec.load(this.db); + this.initSchema(); + } + + /** Close the database connection */ + public close(): void { + this.db.close(); + } + + /** Run a set of operations atomically (in a transaction) */ + public atomicStep(cb: () => void): void { + if (this.transactionInProgress) throw new Error('Transaction already in progress'); + this.transactionInProgress = true; + this.db.exec('BEGIN'); + try { + cb(); + this.db.exec('COMMIT'); + } catch (e) { + this.db.exec('ROLLBACK'); + throw e; + } finally { + this.transactionInProgress = false; + } + } + + /** Set up all tables, indexes and virtual tables */ + private initSchema() { + // jobs + this.db.exec(` + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + reference_id TEXT + ); + `); + + // resource_list + this.db.exec(` + CREATE TABLE IF NOT EXISTS resource_list ( + id TEXT PRIMARY KEY + ); + `); + + // resources (internal PK + user‐facing ID) + this.db.exec(` + CREATE TABLE IF NOT EXISTS resource ( + _rid INTEGER PRIMARY KEY AUTOINCREMENT, + resource_id TEXT NOT NULL, + list_id TEXT NOT NULL REFERENCES resource_list(id) ON DELETE CASCADE + ); + `); + this.db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_resource_list_resid + ON resource(list_id, resource_id); + `); + + // competences (internal PK + user‐facing ID) + this.db.exec(` + CREATE TABLE IF NOT EXISTS competence ( + _cid INTEGER PRIMARY KEY AUTOINCREMENT, + competence_id TEXT NOT NULL, + resource__rid INTEGER NOT NULL REFERENCES resource(_rid) ON DELETE CASCADE, + competence_name TEXT, + competence_description TEXT, + external_qualification_needed BOOLEAN DEFAULT FALSE, + renew_time INTEGER, + proficiency_level TEXT, + qualification_dates TEXT, + last_usages TEXT + ); + `); + this.db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_competence_rescid + ON competence(resource__rid, competence_id); + `); + + // embeddings (virtual vec0 table; explicit deletes required) + this.db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS competence_embedding + USING vec0( + cid INTEGER NOT NULL REFERENCES competence(_cid) ON DELETE CASCADE, + text TEXT, + type TEXT, + embedding FLOAT32[${this.embeddingDim}] + ); + `); + } + + /*-------------------------------------------------------------------- + * Helper Lookups + *------------------------------------------------------------------*/ + + /** Get the internal `_rid` for a given user‐facing resourceId + listId */ + private getResourceRid(resourceId: string, listId: string): number { + const row = this.db + .prepare(`SELECT _rid FROM resource WHERE resource_id = ? AND list_id = ?`) + .get(resourceId, listId); + if (!row) throw new Error(`Resource '${resourceId}' not found in list '${listId}'`); + return row._rid as number; + } + + /** Get the internal `_cid` for a given user‐facing competenceId */ + private getCompetenceCidByCompetenceId( + listId: string, + resourceId: string, + competenceId: string, + ): number { + const _rid = this.getResourceRid(resourceId, listId); + const row = this.db + .prepare( + ` + SELECT _cid + FROM competence + WHERE competence_id = ? + AND resource__rid = ? + `, + ) + .get(competenceId, _rid); + if (!row) + throw new Error( + `Competence '${competenceId}' in List ${listId} in Resource ${resourceId} not found`, + ); + return row._cid as number; + } + + /*-------------------------------------------------------------------- + * Job Methods + *------------------------------------------------------------------*/ + + /** + * Create a new background‐job record. + * @param referenceId Optionally point back to a resource-list, resource, or competence ID. + * @returns the new job’s UUID. + */ + public createJob(referenceId?: string): string { + const jobId = uuid(); + this.db + .prepare(`INSERT INTO jobs(id, reference_id) VALUES (?, ?)`) + .run(jobId, referenceId ?? null); + return jobId; + } + + /** + * Change a job’s status. + * @throws if no such job exists. + */ + public updateJobStatus( + jobId: string, + status: 'pending' | 'running' | 'completed' | 'failed', + ): void { + const result = this.db.prepare(`UPDATE jobs SET status = ? WHERE id = ?`).run(status, jobId); + if (result.changes === 0) throw new Error(`Job with id ${jobId} not found`); + } + + /** + * Look up a job’s current status and its referenceId. + * @throws if no such job exists. + */ + public getJob(jobId: string): { jobId: string; status: string; referenceId?: string } { + const row = this.db + .prepare(`SELECT id, status, reference_id FROM jobs WHERE id = ?`) + .get(jobId) as { id: string; status: string; reference_id: string } | undefined; + if (!row) throw new Error(`Job with id ${jobId} not found`); + return { jobId: row.id, status: row.status, referenceId: row.reference_id ?? undefined }; + } + + /*-------------------------------------------------------------------- + * ResourceList Methods + *------------------------------------------------------------------*/ + + /** Create a fresh, empty resource‐list and return its UUID */ + public createResourceList(): string { + const listId = uuid(); + this.db.prepare(`INSERT INTO resource_list(id) VALUES (?)`).run(listId); + return listId; + } + + /** + * Delete an entire list—this cascades down to resources, competences, + * explicitly wipes embeddings. + */ + public deleteResourceList(listId: string): void { + this.atomicStep(() => { + this.db + .prepare( + ` + DELETE FROM competence_embedding + WHERE cid IN ( + SELECT c._cid + FROM competence c + JOIN resource r ON c.resource__rid = r._rid + WHERE r.list_id = ? + ) + `, + ) + .run(listId); + this.db.prepare(`DELETE FROM resource_list WHERE id = ?`).run(listId); + }); + } + + /** Enumerate all list IDs */ + public getAvailableResourceLists(): string[] { + return this.db + .prepare(`SELECT id FROM resource_list`) + .all() + .map((r) => (r as any).id); + } + + /** + * Fetch a list plus all its resources and each resource’s competences. + * @throws if listId doesn’t exist. + */ + public getResourceList(listId: string): { + listId: string; + resources: Array<{ + resourceId: string; + competencies: Array<{ + competenceId: string; + name?: string; + description?: string; + externalQualificationNeeded: boolean; + renewTime?: number; + proficiencyLevel?: string; + qualificationDates: string[]; + lastUsages: string[]; + }>; + }>; + } { + const exists = this.db.prepare(`SELECT 1 FROM resource_list WHERE id = ?`).get(listId); + if (!exists) throw new Error(`Resource list '${listId}' not found`); + + const resources = this.db + .prepare(`SELECT _rid, resource_id FROM resource WHERE list_id = ?`) + .all(listId) as Array<{ _rid: number; resource_id: string }>; + + return { + listId, + resources: resources.map(({ _rid, resource_id }) => { + const comps = this.db + .prepare( + ` + SELECT competence_id, competence_name, competence_description, + external_qualification_needed, renew_time, + proficiency_level, qualification_dates, last_usages + FROM competence + WHERE resource__rid = ? + `, + ) + .all(_rid) as Array<{ + competence_id: string; + competence_name: string | null; + competence_description: string | null; + external_qualification_needed: number; + renew_time: number | null; + proficiency_level: string | null; + qualification_dates: string | null; + last_usages: string | null; + }>; + return { + resourceId: resource_id, + competencies: comps.map((c) => ({ + competenceId: c.competence_id, + name: c.competence_name ?? undefined, + description: c.competence_description ?? undefined, + externalQualificationNeeded: Boolean(c.external_qualification_needed), + renewTime: c.renew_time ?? undefined, + proficiencyLevel: c.proficiency_level ?? undefined, + qualificationDates: c.qualification_dates ? JSON.parse(c.qualification_dates) : [], + lastUsages: c.last_usages ? JSON.parse(c.last_usages) : [], + })), + }; + }), + }; + } + + /*-------------------------------------------------------------------- + * Resource Methods + *------------------------------------------------------------------*/ + + /** + * Add a resource (user‐facing ID) into a list. + * Returns the user‐facing resourceId. + */ + public addResource(listId: string, resourceId?: string): string { + const rid = resourceId ?? uuid(); + this.db.prepare(`INSERT INTO resource(resource_id, list_id) VALUES (?, ?)`).run(rid, listId); + return rid; + } + + /** + * Move a resource from one list to another. + * @param oldListId current list + * @param resourceId user‐facing ID + * @param newListId target list + */ + public updateResource(oldListId: string, resourceId: string, newListId: string): void { + const _rid = this.getResourceRid(resourceId, oldListId); + this.db.prepare(`UPDATE resource SET list_id = ? WHERE _rid = ?`).run(newListId, _rid); + } + + /** + * Delete a single resource (and its subtree) by user‐facing ID + list. + */ + public deleteResource(listId: string, resourceId: string): void { + this.atomicStep(() => { + const _rid = this.getResourceRid(resourceId, listId); + this.db + .prepare( + ` + DELETE FROM competence_embedding + WHERE cid IN (SELECT _cid FROM competence WHERE resource__rid = ?) + `, + ) + .run(_rid); + this.db.prepare(`DELETE FROM resource WHERE _rid = ?`).run(_rid); + }); + } + + /** + * Fetch one resource + * @param listId user‐facing ID of the resource list + * @param resourceId user‐facing ID of the resource + * @returns an object with the resource’s metadata. + * @throws if not found. + */ + public getResource( + listId: string, + resourceId: string, + ): { + listId: string; + resourceId: string; + competencies: Array<{ + competenceId: string; + name?: string; + description?: string; + externalQualificationNeeded: boolean; + renewTime?: number; + proficiencyLevel?: string; + qualificationDates: string[]; + lastUsages: string[]; + }>; + } { + const row = this.db + .prepare( + ` + SELECT _rid, resource_id, list_id + FROM resource + WHERE resource_id = ? + AND list_id = ? + `, + ) + .get(resourceId, listId) as + | { _rid: number; resource_id: string; list_id: string } + | undefined; + if (!row) throw new Error(`Resource '${resourceId}' in List '${listId}' not found`); + + const comps = this.db + .prepare( + ` + SELECT competence_id, competence_name, competence_description, + external_qualification_needed, renew_time, + proficiency_level, qualification_dates, last_usages + FROM competence + WHERE resource__rid = ? + `, + ) + .all(row._rid) as Array<{ + competence_id: string; + competence_name: string | null; + competence_description: string | null; + external_qualification_needed: number; + renew_time: number | null; + proficiency_level: string | null; + qualification_dates: string | null; + last_usages: string | null; + }>; + + return { + listId: row.list_id, + resourceId, + competencies: comps.map((c) => ({ + competenceId: c.competence_id, + name: c.competence_name ?? undefined, + description: c.competence_description ?? undefined, + externalQualificationNeeded: Boolean(c.external_qualification_needed), + renewTime: c.renew_time ?? undefined, + proficiencyLevel: c.proficiency_level ?? undefined, + qualificationDates: c.qualification_dates ? JSON.parse(c.qualification_dates) : [], + lastUsages: c.last_usages ? JSON.parse(c.last_usages) : [], + })), + }; + } + + /*-------------------------------------------------------------------- + * Competence Methods + *------------------------------------------------------------------*/ + + /** + * Add a competence under a given resource. + * @param listId user‐facing ID of the resource list + * @param resourceId user‐facing + * @param competence Input object (may supply its own competenceId) + * @returns the user‐facing competenceId + */ + public addCompetence( + listId: string, + resourceId: string, + competence: { + competenceId?: string; + name?: string; + description?: string; + externalQualificationNeeded?: boolean; + renewTime?: number; + proficiencyLevel?: string; + qualificationDates?: string[]; + lastUsages?: string[]; + }, + ): string { + const cidUser = competence.competenceId ?? uuid(); + const _rid = this.getResourceRid(resourceId, listId); + + this.db + .prepare( + ` + INSERT INTO competence + (competence_id, resource__rid, + competence_name, competence_description, + external_qualification_needed, renew_time, + proficiency_level, qualification_dates, last_usages) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + cidUser, + _rid, + competence.name ?? null, + competence.description ?? null, + competence.externalQualificationNeeded ? 1 : 0, + competence.renewTime ?? null, + competence.proficiencyLevel ?? null, + competence.qualificationDates ? JSON.stringify(competence.qualificationDates) : null, + competence.lastUsages ? JSON.stringify(competence.lastUsages) : null, + ); + + return cidUser; + } + + /** + * Update a competence’s metadata. + * @param listId user‐facing ID of the resource list + * @param resourceId user‐facing ID of the resource + * @param competenceId user‐facing ID of the competence to update + * @param fields object with fields to update; only those provided will be changed + * @returns nothing, but throws if the competence does not exist. + * @throws if no such competence exists on the resource. + */ + public updateCompetence( + listId: string, + resourceId: string, + competenceId: string, + fields: { + name?: string; + description?: string; + externalQualificationNeeded?: boolean; + renewTime?: number; + proficiencyLevel?: string; + qualificationDates?: string[]; + lastUsages?: string[]; + }, + ): void { + const _rid = this.getResourceRid(resourceId, listId); + const _cid = this.db + .prepare(`SELECT _cid FROM competence WHERE competence_id = ? AND resource__rid = ?`) + .get(competenceId, _rid)?.['_cid']; + if (!_cid) throw new Error(`Competence '${competenceId}' not on resource '${resourceId}'`); + + const sets: string[] = []; + const params: any[] = []; + if (fields.name !== undefined) { + sets.push(`competence_name = ?`); + params.push(fields.name); + } + if (fields.description !== undefined) { + sets.push(`competence_description = ?`); + params.push(fields.description); + } + if (fields.externalQualificationNeeded !== undefined) { + sets.push(`external_qualification_needed = ?`); + params.push(fields.externalQualificationNeeded ? 1 : 0); + } + if (fields.renewTime !== undefined) { + sets.push(`renew_time = ?`); + params.push(fields.renewTime); + } + if (fields.proficiencyLevel !== undefined) { + sets.push(`proficiency_level = ?`); + params.push(fields.proficiencyLevel); + } + if (fields.qualificationDates !== undefined) { + sets.push(`qualification_dates = ?`); + params.push(JSON.stringify(fields.qualificationDates)); + } + if (fields.lastUsages !== undefined) { + sets.push(`last_usages = ?`); + params.push(JSON.stringify(fields.lastUsages)); + } + + if (sets.length > 0) { + params.push(_cid); + this.db.prepare(`UPDATE competence SET ${sets.join(', ')} WHERE _cid = ?`).run(...params); + } + } + + /** + * Delete a competence from a resource. + * @param resourceId user‐facing ID + * @param listId user‐facing ID of the resource list + * @param competenceId user‐facing ID of the competence to delete + * @throws if no such competence exists on the resource. + */ + public deleteCompetence(listId: string, resourceId: string, competenceId: string): void { + this.atomicStep(() => { + const _rid = this.getResourceRid(resourceId, listId); + const _cid = this.db + .prepare(`SELECT _cid FROM competence WHERE competence_id = ? AND resource__rid = ?`) + .get(competenceId, _rid)?._cid; + if (!_cid) throw new Error(`Competence '${competenceId}' not on resource '${resourceId}'`); + + // explicitly delete embeddings + this.db.prepare(`DELETE FROM competence_embedding WHERE cid = ?`).run(_cid); + // then delete competence row + this.db.prepare(`DELETE FROM competence WHERE _cid = ?`).run(_cid); + }); + } + + /** + * Fetch one competence’s metadata (including listId + resourceId). + * @param listId user‐facing ID of the resource list + * @param resourceId user‐facing ID of the resource + * @param competenceId user‐facing ID of the competence to fetch + * @returns an object with the competence’s metadata. + * @throws if no such competence exists on the resource. + * @throws if the competenceId is not found on the resource. + */ + public getCompetence( + listId: string, + resourceId: string, + competenceId: string, + ): { + listId: string; + resourceId: string; + competenceId: string; + name?: string; + description?: string; + externalQualificationNeeded: boolean; + renewTime?: number; + proficiencyLevel?: string; + qualificationDates: string[]; + lastUsages: string[]; + } { + const _rid = this.getResourceRid(resourceId, listId); + const row = this.db + .prepare( + ` + SELECT c.competence_id, c.competence_name, c.competence_description, + c.external_qualification_needed, c.renew_time, + c.proficiency_level, c.qualification_dates, c.last_usages + FROM competence c + WHERE c.competence_id = ? AND c.resource__rid = ? + `, + ) + .get(competenceId, _rid) as + | { + competence_id: string; + competence_name: string | null; + competence_description: string | null; + external_qualification_needed: number; + renew_time: number | null; + proficiency_level: string | null; + qualification_dates: string | null; + last_usages: string | null; + } + | undefined; + if (!row) throw new Error(`Competence '${competenceId}' not on resource '${resourceId}'`); + + return { + listId, + resourceId, + competenceId: row.competence_id, + name: row.competence_name ?? undefined, + description: row.competence_description ?? undefined, + externalQualificationNeeded: Boolean(row.external_qualification_needed), + renewTime: row.renew_time ?? undefined, + proficiencyLevel: row.proficiency_level ?? undefined, + qualificationDates: row.qualification_dates ? JSON.parse(row.qualification_dates) : [], + lastUsages: row.last_usages ? JSON.parse(row.last_usages) : [], + }; + } + + /*-------------------------------------------------------------------- + * Embedding Methods + *------------------------------------------------------------------*/ + + /** + * Insert or replace a text embedding for a competence. + * This will overwrite any existing embedding for the same text and type. + * @param embeddingInput object with competenceId, text, type, and embedding vector. + * @throws if the embedding vector does not match the configured dimension. + */ + public upsertEmbedding(embeddingInput: { + listId: string; + resourceId: string; + competenceId: string; + text: string; + type: 'name' | 'description' | 'proficiencyLevel'; + embedding: number[]; + }): void { + const { listId, resourceId, competenceId, text, type, embedding } = embeddingInput; + if (embedding.length !== this.embeddingDim) { + throw new Error(`Embedding must have length ${this.embeddingDim}`); + } + const cid = this.getCompetenceCidByCompetenceId(listId, resourceId, competenceId); + // console.log(`Upserting embedding for competence ${competenceId} (${cid}) with text "${text}"`); + + const cidInt = `${Math.floor(cid)}`; + + this.db + .prepare( + ` + INSERT OR REPLACE INTO competence_embedding + (cid, text, type, embedding) + VALUES (CAST(? AS INTEGER), ?, ?, vec_f32(?)) + `, + ) + .run(cidInt, text, type, new Float32Array(embedding)); + } + + /** Delete all embeddings for one competence + * @param listId user‐facing ID of the resource list + * @param resourceId user‐facing ID of the resource + * @param competenceId user‐facing ID of the competence + */ + public deleteEmbeddingsForCompetence( + listId: string, + resourceId: string, + competenceId: string, + ): void { + const cid = this.getCompetenceCidByCompetenceId(listId, resourceId, competenceId); + this.db.prepare(`DELETE FROM competence_embedding WHERE cid = ?`).run(cid); + } + + /** + * kNN‐search over embeddings, returning user‐facing competenceIds + distances. + * + * @param embedding the query vector to search for + * @param options optional parameters: + * - k: number of nearest neighbors to return (default: all) + * - filter: optional filter by resourceId and listId + * - similarityMetric: 'cosine', 'hamming', or 'euclidean' (default: 'cosine') + * @returns an array of objects with competenceId, text, type, and distance. + * @throws if the embedding length does not match the configured dimension. + * @throws if the metric is unsupported or k is not a positive integer. + */ + public searchEmbedding( + embedding: number[], + options?: { + k?: number; + filter?: { resourceId?: string; listId?: string }; + similarityMetric?: 'cosine' | 'hamming' | 'euclidean'; + }, + ): Array<{ + competenceId: string; + text: string; + type: string; + distance: number; + }> { + const { k, filter, similarityMetric } = options || {}; + const metrics = { + cosine: 'vec_distance_cosine', + hamming: 'vec_distance_hamming', + euclidean: 'vec_distance_L2', + }; + const metric = metrics[similarityMetric || 'cosine']; + if (!metric) throw new Error(`Unsupported metric: ${similarityMetric}`); + if (embedding.length !== this.embeddingDim) throw new Error(`Embedding length mismatch`); + if (k !== undefined && k <= 0) throw new Error('k must be > 0'); + + let sql = ` + SELECT c.competence_id, ce.text, ce.type, + ${metric}(ce.embedding, vec_f32(?)) AS distance + FROM competence_embedding ce + JOIN competence c ON ce.cid = c._cid + JOIN resource r ON c.resource__rid = r._rid + `; + const params: any[] = [new Float32Array(embedding)]; + + const whereClauses: string[] = []; + if (filter?.resourceId) { + whereClauses.push(`r.resource_id = ?`); + params.push(filter.resourceId); + } + if (filter?.listId) { + whereClauses.push(`r.list_id = ?`); + params.push(filter.listId); + } + if (whereClauses.length > 0) { + sql += ` WHERE ` + whereClauses.join(' AND '); + } + + sql += ` ORDER BY distance ASC`; + + if (k) { + sql += ` LIMIT ?`; + params.push(k); + } + + const rows = this.db.prepare(sql).all(...params) as Array; + return rows.map((r) => ({ + competenceId: r.competence_id, + text: r.text, + type: r.type, + distance: r.distance, + })); + } +} + +export default VectorDataBase; diff --git a/src/competence-matcher/src/middleware/db-locator.ts b/src/competence-matcher/src/middleware/db-locator.ts new file mode 100644 index 000000000..3b65c0cd6 --- /dev/null +++ b/src/competence-matcher/src/middleware/db-locator.ts @@ -0,0 +1,22 @@ +import { Request, Response, NextFunction } from 'express'; +import { config } from '../config'; + +const { multipleDBs } = config; + +export function dbHeader(req: Request, res: Response, next: NextFunction): void { + // This middleware allows for the use f multiple databases instead of a single one. + // 'x-proceed-db-id' is a custom header that should be included in the request, which specifies the database name to use. + if (multipleDBs) { + const dbName = req.header('x-proceed-db-id'); + if (!dbName || typeof dbName !== 'string' || dbName.trim() === '') { + res.status(400).json({ error: 'Missing x-proceed-db-id header' }); + return; + } + req.dbName = dbName; + } else { + // For now, we use a single database, so we can just set a default name. + req.dbName = 'PROCEED-Matching.db'; + } + + next(); +} diff --git a/src/competence-matcher/src/middleware/logging.ts b/src/competence-matcher/src/middleware/logging.ts new file mode 100644 index 000000000..af93af303 --- /dev/null +++ b/src/competence-matcher/src/middleware/logging.ts @@ -0,0 +1,17 @@ +import { Request, Response, NextFunction } from 'express'; + +export function requestLogger(req: Request, res: Response, next: NextFunction): void { + const { method, query, body, headers, params } = req; + const logData = { + time: new Date().toISOString(), + method, + path: req.path, + query: JSON.stringify(query, null, 2), + body, + headers: JSON.stringify(headers, null, 2), + params: JSON.stringify(params, null, 2), + ip: req.ip, + }; + console.table([logData]); + next(); +} diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/competence-matcher/src/middleware/resource.ts b/src/competence-matcher/src/middleware/resource.ts new file mode 100644 index 000000000..685804958 --- /dev/null +++ b/src/competence-matcher/src/middleware/resource.ts @@ -0,0 +1,201 @@ +import { Request, Response, NextFunction } from 'express'; +import DBManager from '../db/db-manager'; +import { PATHS } from '../server'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Worker } from 'worker_threads'; + +function getDB(name: string) { + const dbManager = DBManager.getInstance(); + DBManager.setActiveDB(name); + return dbManager.getDB(name); +} + +export function getResourceLists(req: Request, res: Response, next: NextFunction): void { + try { + const db = getDB(req.dbName!); + + const availableResourceLists = db.getAvailableResourceLists(); + + res.status(200).json(availableResourceLists); + } catch (error) { + console.error('Error retrieving resource lists:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +} + +export function getResourceList(req: Request, res: Response, next: NextFunction): void { + try { + const db = getDB(req.dbName!); + const resourceListId = req.params.resourceListId; + + if (!resourceListId) { + res.status(400).json({ error: 'Resource list ID is required' }); + return; + } + + const resourceList = db.getResourceList(resourceListId); + + if (!resourceList) { + res.status(404).json({ error: 'Resource list not found' }); + return; + } + + res.status(200).json(resourceList); + } catch (error) { + console.error('Error retrieving resource list:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +} + +export function createResourceList(req: Request, res: Response, next: NextFunction): void { + // Check if the request body contains the necessary data + let resourceIds: string[] = []; + let competences: CompetenceInput /* resourceIndex */[] /* competenceIndex */[] = []; + try { + if (!Array.isArray(req.body) || req.body.length === 0) { + res.status(400).json({ error: 'Invalid request body. Expected an array of resources.' }); + } + req.body.forEach(({ resourceId, competencies }: ResourceInput) => { + if (!resourceId || typeof resourceId !== 'string') { + throw new Error('Invalid resourceId in request body'); + } + if (!Array.isArray(competencies) /* || competencies.length === 0 */) { + throw new Error('Invalid competencies in request body'); + } + resourceIds.push(resourceId); + const checkedCompetences = competencies.map((c: CompetenceInput) => { + if (!c.competenceId || typeof c.competenceId !== 'string') { + throw new Error('Invalid competenceId in request body'); + } + return { + competenceId: c.competenceId, + name: c.name, + description: c.description, + externalQualificationNeeded: c.externalQualificationNeeded, + renewTime: c.renewTime, + proficiencyLevel: c.proficiencyLevel, + qualificationDates: c.qualificationDates, + lastUsages: c.lastUsages, + }; + }); + + competences.push(checkedCompetences); + }); + } catch (error) { + console.error('Error processing request body:', error); + res.status(400).json({ error: 'Invalid request body format' }); + return; + } + /* ------------------------- */ + let listId: string; + let jobId: string; + try { + const db = getDB(req.dbName!); + // TODO: Should we blindly trust that client only send integrity data? -> Maybe add a check for id duplicates? + db.atomicStep(() => { + // ResourceList + listId = db.createResourceList(); + // Resources + resourceIds.forEach((resourceId) => { + db.addResource(listId, resourceId); + }); + // Competences + competences.forEach((competenceArray, resourceIndex) => { + competenceArray.forEach((competence) => { + db.addCompetence(listId, resourceIds[resourceIndex], competence); + }); + }); + // Embeddings is offloaded to worker -> Just create a job + jobId = db.createJob(listId); + }); + } catch (error) { + console.error('Error adding resource list:', error); + res.status(500).json({ error: 'Internal Server Error' }); + return; + } + + // Start Embedding Worker + const descriptionEmbeddingInput = competences + .map((competenceArray, resourceIndex) => { + return competenceArray.map((competence) => { + return { + listId: listId!, + resourceId: resourceIds[resourceIndex], + competenceId: competence.competenceId, + text: competence.description, + type: 'description', + }; + }); + }) + .flat(); + + const job: EmbeddingJob = { + jobId: jobId!, + dbName: req.dbName!, + // @ts-ignore (Checked above) + tasks: descriptionEmbeddingInput, + }; + + const tsPath = path.resolve(__dirname, '../worker/embedder.ts'); + const jsPath = path.resolve(__dirname, '../worker/embedder.js'); + const isTs = fs.existsSync(tsPath); + + const workerFile = isTs ? tsPath : jsPath; + + const execArgv = isTs + ? [...process.execArgv, '-r', 'ts-node/register/transpile-only'] + : process.execArgv; + + const worker = new Worker(workerFile, { execArgv }); + + worker.on('error', (err) => { + console.error('Embedding worker crashed:', err); + }); + worker.on('exit', (code) => { + if (code !== 0) { + console.error(`Worker for job ${jobId} exited with code ${code}`); + } + }); + + // Send the job + worker.postMessage(job); + + // Respond with listId in location header + res + .setHeader('Location', `${PATHS.resource}/jobs/${jobId!}`) + // Rspond with accepted status and jobId + .status(202) + .json({ jobId: jobId!, status: 'pending' }); +} + +export function getJobStatus(req: Request, res: Response) { + try { + const db = getDB(req.dbName!); + const job = db.getJob(req.params.jobId); + + switch (job.status) { + case 'pending': + res.status(202).json({ jobId: job.jobId, status: job.status }); + return; + case 'running': + res.status(202).json({ jobId: job.jobId, status: job.status }); + return; + case 'completed': + res + .status(201) + .setHeader('Location', `${PATHS.resource}/${job.referenceId}`) + .json({ jobId: job.jobId, status: job.status, id: job.referenceId }); + return; + case 'failed': + res.status(500).json({ jobId: job.jobId, status: job.status }); + return; + default: + res.status(500).json({ error: 'Internal Server Error' }); + return; + } + } catch (err) { + // console.error(err); + res.status(404).json({ error: 'Job not found' }); + } +} diff --git a/src/competence-matcher/src/models/onnx-community/Qwen3-Embedding-0.6B-ONNX/config.json b/src/competence-matcher/src/models/onnx-community/Qwen3-Embedding-0.6B-ONNX/config.json new file mode 100644 index 000000000..1b0d51dce --- /dev/null +++ b/src/competence-matcher/src/models/onnx-community/Qwen3-Embedding-0.6B-ONNX/config.json @@ -0,0 +1,70 @@ +{ + "architectures": [ + "Qwen3ForCausalLM" + ], + "attention_bias": false, + "attention_dropout": 0.0, + "bos_token_id": 151643, + "eos_token_id": 151643, + "head_dim": 128, + "hidden_act": "silu", + "hidden_size": 1024, + "initializer_range": 0.02, + "intermediate_size": 3072, + "layer_types": [ + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention" + ], + "max_position_embeddings": 32768, + "max_window_layers": 28, + "model_type": "qwen3", + "num_attention_heads": 16, + "num_hidden_layers": 28, + "num_key_value_heads": 8, + "rms_norm_eps": 1e-06, + "rope_scaling": null, + "rope_theta": 1000000, + "sliding_window": null, + "tie_word_embeddings": true, + "torch_dtype": "bfloat16", + "transformers_version": "4.53.0.dev0", + "use_cache": true, + "use_sliding_window": false, + "vocab_size": 151669, + "transformers.js_config": { + "kv_cache_dtype": { + "q4f16": "float16", + "fp16": "float16" + }, + "use_external_data_format": { + "model.onnx": true, + "model_fp16.onnx": true + } + } +} \ No newline at end of file diff --git a/src/competence-matcher/src/routes/match.ts b/src/competence-matcher/src/routes/match.ts new file mode 100644 index 000000000..f0eeea6c4 --- /dev/null +++ b/src/competence-matcher/src/routes/match.ts @@ -0,0 +1,13 @@ +import express from 'express'; + +const router = express.Router(); + +router + .route('/') + // .all() + // .get() + // .put() + .post(); +// .delete(); + +export default router; diff --git a/src/competence-matcher/src/routes/resource.ts b/src/competence-matcher/src/routes/resource.ts new file mode 100644 index 000000000..c7f141a90 --- /dev/null +++ b/src/competence-matcher/src/routes/resource.ts @@ -0,0 +1,29 @@ +import express from 'express'; +import { + createResourceList, + getJobStatus, + getResourceList, + getResourceLists, +} from '../middleware/resource'; + +const router = express.Router(); + +// .all() +// .get() +// .put() +// .post(); +// .patch(); +// .delete(); + +// '/:resource-list-id' +// '/:resource-list-id/:resource-id' +// '/:resource-list-id/:resource-id/:competence-id' + +router.route('/').get(getResourceLists); + +router.route('/jobs').post(createResourceList); +router.route('/jobs/:jobId').get(getJobStatus); + +router.route('/:resourceListId').get(getResourceList); + +export default router; diff --git a/src/competence-matcher/src/server.ts b/src/competence-matcher/src/server.ts new file mode 100644 index 000000000..79c3f0c92 --- /dev/null +++ b/src/competence-matcher/src/server.ts @@ -0,0 +1,49 @@ +import express from 'express'; + +import ResourceRouter from './routes/resource'; +import MatchRouter from './routes/match'; +import { config } from './config'; +import { dbHeader } from './middleware/db-locator'; +import { requestLogger } from './middleware/logging'; +import Embedding from './tasks/embedding'; + +const { port: PORT } = config; +export const PATHS = { + resource: '/resource-competence-list', + match: '/matching-task-to-resource', +}; + +const app = express(); + +// Ensure embedding model is loaded +Embedding.getInstance(); + +// Extend Express Request interface +declare module 'express-serve-static-core' { + interface Request { + dbName?: string; + } +} + +// Parse JSON +app.use(express.json()); +// Parse URL-encoded data +app.use(express.urlencoded({ extended: true })); +// Middleware to handle database header +app.use(dbHeader); +// Logging middleware +// app.use(requestLogger); + +// Hello World +app.get('/', (req, res, next) => { + console.log('Received a GET request on /'); + res.status(200).send('Welcome to the Matching Server'); +}); + +// Routes +app.use(PATHS.resource, ResourceRouter); +// app.use('/match', MatchRouter); + +app.listen(PORT, () => { + console.log(`Matching-Server is running on http://localhost:${PORT}`); +}); diff --git a/src/competence-matcher/src/tasks/embedding.ts b/src/competence-matcher/src/tasks/embedding.ts new file mode 100644 index 000000000..36a7bf5db --- /dev/null +++ b/src/competence-matcher/src/tasks/embedding.ts @@ -0,0 +1,91 @@ +import { + pipeline, + env as huggingfaceEnv, + PipelineType, + ProgressCallback, + FeatureExtractionPipeline, + FeatureExtractionPipelineOptions, + PretrainedModelOptions, +} from '@huggingface/transformers'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { config } from '../config'; + +const { model, modelCache, embeddingDim } = config; + +class Embedding { + private static task: PipelineType = 'feature-extraction'; + private static model = model; + private static instance: FeatureExtractionPipeline | null = null; + + /** + * Return the singleton pipeline instance, loading if needed. + * @param progressCallback Optional progress callback for model loading. + */ + public static async getInstance( + progressCallback: ProgressCallback | null = (progressInfo) => { + console.log(progressInfo); + }, + ) { + if (Embedding.instance === null) { + Embedding.configureEnv(); + + const opts: PretrainedModelOptions = { + use_external_data_format: true, + }; + // { progress_callback, config, cache_dir, local_files_only, revision, device, dtype, subfolder, use_external_data_format, model_file_name, session_options, } + if (progressCallback) opts.progress_callback = progressCallback; + if (progressCallback) { + opts.progress_callback = progressCallback; + } + + const pipelineResult: any = await pipeline(Embedding.task, Embedding.model, opts); + Embedding.instance = pipelineResult as FeatureExtractionPipeline; + } + return Embedding.instance; + } + + private static configureEnv() { + if (modelCache) { + const absCache = path.resolve(modelCache); + if (!fs.existsSync(absCache)) { + fs.mkdirSync(absCache, { recursive: true }); + } + huggingfaceEnv.cacheDir = absCache; + } + huggingfaceEnv.allowLocalModels = true; + } + + /** + * Compute embeddings (mean-pooled, normalised by default) for one or more texts. + * @param texts Single string or array of strings to embed. + * @param options Pipeline options, e.g. { pooling: 'mean', normalize: true }. + * @returns 2D array [numText][embeddingDim] + */ + public static async embed( + texts: string | string[], + options: FeatureExtractionPipelineOptions = { pooling: 'mean', normalize: true }, + ): Promise { + const pipe = await Embedding.getInstance(); + const inputs = Array.isArray(texts) ? texts : [texts]; + const output = await pipe(inputs, options); + + // The pipeline returns a Tensor or array of Tensors (depending on input) + const raw = Array.isArray(output) ? output : [output]; + + const embeddings = raw.map((tensor) => { + const data = (tensor as any).data as Float32Array; + const arr = Array.from(data); + if (arr.length !== embeddingDim) { + throw new Error( + `Embedding dimension mismatch: expected ${embeddingDim}, got ${arr.length}`, + ); + } + return arr; + }) as number[][]; + + return embeddings; + } +} + +export default Embedding; diff --git a/src/competence-matcher/src/tasks/reason-llm.ts b/src/competence-matcher/src/tasks/reason-llm.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts new file mode 100644 index 000000000..b2b3c84a2 --- /dev/null +++ b/src/competence-matcher/src/utils/types.ts @@ -0,0 +1,92 @@ +type Competence = { + listId: string; // UUIDString + resourceId: string; // UUIDString + competenceId: string; // UUIDString + name?: string; // optional + description?: string; // optional but recommended to have content + externalQualificationNeeded?: boolean; // optional + renewTime?: number; // DaysAsInteger, optional + proficiencyLevel?: string; // optional + qualificationDates?: string[]; // ISO date strings, optional + lastUsages?: string[]; // ISO date strings, optional +}; + +type CompetenceInput = { + competenceId?: string; + name?: string; + description?: string; + externalQualificationNeeded?: boolean; + renewTime?: number; + proficiencyLevel?: string; + qualificationDates?: string[]; + lastUsages?: string[]; +}; + +type CompetenceEmbedding = { + competenceId: string; // UUIDString + embedding: number[]; // array of numbers representing the embedding +}; + +type Resource = { + listId: string; // UUIDString + resourceId: string; // UUIDString + competencies: Competence[]; // array of competencies +}; + +type ResourceInput = { + resourceId?: string; + competencies: CompetenceInput[]; +}; + +type ResourceList = { + listId: string; // UUIDString + resources: { + resourceId: string; // UUIDString + competencies: Competence[]; // array of competencies + }[]; +}; + +type ResourceListInput = ResourceInput[]; + +type Task = { + taskId: string; // UUIDString + taskName?: string; // optional + taskDescription?: string; // optional but recommended to have content + executionInstructions?: string; // optional, e.g. HTML + requiredCompetencies?: string[] | CompetenceInput[]; // either array of competenceIds or array of CompetenceInput +}; + +type Match = { + [resourceId: string]: { + matchingProbability: number; // 0-1 + reason: string; + }; +}; + +interface VectorDBOptions { + filePath?: string; // If undefined or ":memory:", use in-memory; else path to file - Note: memory will not work with workers!! + embeddingDim: number; +} + +type CompetenceDBOutput = { + id: string; + competence_name: string | null; + competence_description: string | null; + external_qualification_needed: number; // 0 or 1 + renew_time: number | null; + proficiency_level: string | null; + qualification_dates: string | null; // JSON string + last_usages: string | null; // JSON string +}; + +interface EmbeddingJob { + jobId: string; + dbName: string; + tasks: Array<{ + listId: string; + resourceId: string; + competenceId: string; + text: string; + type: 'name' | 'description' | 'proficiencyLevel'; + }>; +} diff --git a/src/competence-matcher/src/worker/embedder.ts b/src/competence-matcher/src/worker/embedder.ts new file mode 100644 index 000000000..17b8407b7 --- /dev/null +++ b/src/competence-matcher/src/worker/embedder.ts @@ -0,0 +1,40 @@ +import { parentPort } from 'worker_threads'; +import Embedding from '../tasks/embedding'; +import DBManager from '../db/db-manager'; + +parentPort!.once('message', async (job: EmbeddingJob) => { + const { jobId, dbName, tasks } = job; + + // Open the DB in this thread + // Note: This is another DB instance, not the one used by the main thread + const db = DBManager.getInstance().getDB(dbName); + + try { + // Mark job as running + db.updateJobStatus(jobId, 'running'); + + // For each task: embed & upsert + for (const { listId, resourceId, competenceId, text, type } of tasks) { + const [vector] = await Embedding.embed(text); + // console.log(`Embedded text for job ${jobId}:`, text, '->', vector); + db.upsertEmbedding({ listId, resourceId, competenceId, text, type, embedding: vector }); + } + + // Mark job completed + db.updateJobStatus(jobId, 'completed'); + } catch (err) { + // On any error: mark job as failed + console.error('Worker error for job', jobId, err); + try { + db.updateJobStatus(jobId, 'failed'); + } catch {} + } + + // Notify parent (not really necessary) + // parentPort!.postMessage({ done: true, jobId }); + + // Clean up: close DB and exit + db.close(); + parentPort!.close(); + process.exit(0); +}); diff --git a/src/competence-matcher/src/worker/matcher.ts b/src/competence-matcher/src/worker/matcher.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/competence-matcher/tsconfig.json b/src/competence-matcher/tsconfig.json new file mode 100644 index 000000000..c612291b4 --- /dev/null +++ b/src/competence-matcher/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["esnext"], + "target": "ES2020", + "module": "commonjs", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "moduleResolution": "node" + }, + "include": ["src"] +} From 5468886aff3f62bb2da95ecc849e66960bfb82da Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:31:34 +0200 Subject: [PATCH 12/17] feat: Add matching functionality and semantic splitting for competence tasks --- src/competence-matcher/package.json | 4 +- src/competence-matcher/src/config.ts | 3 + src/competence-matcher/src/db/db.ts | 120 ++++++++---- .../src/middleware/match.ts | 172 ++++++++++++++++++ .../src/middleware/resource.ts | 35 +--- src/competence-matcher/src/routes/match.ts | 20 +- src/competence-matcher/src/server.ts | 2 +- src/competence-matcher/src/tasks/embedding.ts | 2 +- .../src/tasks/semantic-split.ts | 49 +++++ src/competence-matcher/src/utils/db.ts | 7 + src/competence-matcher/src/utils/prompts.ts | 117 ++++++++++++ src/competence-matcher/src/utils/types.ts | 44 +++-- src/competence-matcher/src/utils/worker.ts | 38 ++++ src/competence-matcher/src/worker/embedder.ts | 41 +++-- src/competence-matcher/src/worker/matcher.ts | 69 +++++++ 15 files changed, 623 insertions(+), 100 deletions(-) create mode 100644 src/competence-matcher/src/tasks/semantic-split.ts create mode 100644 src/competence-matcher/src/utils/db.ts create mode 100644 src/competence-matcher/src/utils/prompts.ts create mode 100644 src/competence-matcher/src/utils/worker.ts diff --git a/src/competence-matcher/package.json b/src/competence-matcher/package.json index 0cbdaf8e3..5ee53612d 100644 --- a/src/competence-matcher/package.json +++ b/src/competence-matcher/package.json @@ -5,7 +5,8 @@ "main": "dist/server.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/server.ts", - "build": "tsc" + "build": "tsc", + "run-production": "node dist/server.js" }, "repository": { "type": "git", @@ -24,6 +25,7 @@ "dependencies": { "@huggingface/transformers": "^3.5.2", "express": "^5.1.0", + "ollama": "^0.5.16", "sqlite-vec": "^0.1.7-alpha.2" }, "devDependencies": { diff --git a/src/competence-matcher/src/config.ts b/src/competence-matcher/src/config.ts index 10355e697..9a76543a6 100644 --- a/src/competence-matcher/src/config.ts +++ b/src/competence-matcher/src/config.ts @@ -5,4 +5,7 @@ export const config = { modelCache: process.env.MODEL_CACHE || 'src/models/', port: parseInt(process.env.PORT || '8501', 10), multipleDBs: process.env.MULTIPLE_DBS === 'true' || false, + ollamaPath: process.env.OLLAMA_PATH || 'http://localhost:11434', + ollamaModel: process.env.OLLAMA_MODEL || 'llama3.2:3b', + splittingSymbol: process.env.SPLITTING_SYMBOL || '__________', }; diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts index 13e6d81ce..080c7afe5 100644 --- a/src/competence-matcher/src/db/db.ts +++ b/src/competence-matcher/src/db/db.ts @@ -64,6 +64,23 @@ class VectorDataBase { ); `); + // matches + this.db.exec(` + CREATE TABLE IF NOT EXISTS match_results ( + id TEXT PRIMARY KEY, -- UUID for this match record + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + task_id TEXT NOT NULL, -- task ID this match belongs to + competence_id TEXT NOT NULL, -- matched competence + distance REAL NOT NULL, -- similarity score + text TEXT NOT NULL, -- the matched snippet + type TEXT NOT NULL -- 'name' | 'description' | 'proficiencyLevel' + ); + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS ix_match_results_job + ON match_results(job_id); + `); + // resource_list this.db.exec(` CREATE TABLE IF NOT EXISTS resource_list ( @@ -89,7 +106,7 @@ class VectorDataBase { CREATE TABLE IF NOT EXISTS competence ( _cid INTEGER PRIMARY KEY AUTOINCREMENT, competence_id TEXT NOT NULL, - resource__rid INTEGER NOT NULL REFERENCES resource(_rid) ON DELETE CASCADE, + resource_rid INTEGER NOT NULL REFERENCES resource(_rid) ON DELETE CASCADE, competence_name TEXT, competence_description TEXT, external_qualification_needed BOOLEAN DEFAULT FALSE, @@ -101,7 +118,7 @@ class VectorDataBase { `); this.db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS ux_competence_rescid - ON competence(resource__rid, competence_id); + ON competence(resource_rid, competence_id); `); // embeddings (virtual vec0 table; explicit deletes required) @@ -142,7 +159,7 @@ class VectorDataBase { SELECT _cid FROM competence WHERE competence_id = ? - AND resource__rid = ? + AND resource_rid = ? `, ) .get(competenceId, _rid); @@ -194,6 +211,57 @@ class VectorDataBase { return { jobId: row.id, status: row.status, referenceId: row.reference_id ?? undefined }; } + /*-------------------------------------------------------------------- + * Match Methods + *------------------------------------------------------------------*/ + + public addMatchResult(opts: { + jobId: string; + taskId: string; + competenceId: string; + text: string; + type: 'name' | 'description' | 'proficiencyLevel'; + distance: number; + }): void { + const id = uuid(); + this.db + .prepare( + ` + INSERT INTO match_results + (id, job_id, task_id, competence_id, text, type, distance) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ) + .run(id, opts.jobId, opts.taskId, opts.competenceId, opts.text, opts.type, opts.distance); + } + + public getMatchResults(jobId: string): Array<{ + taskId: string; + competenceId: string; + text: string; + type: string; + distance: number; + }> { + return this.db + .prepare( + ` + SELECT task_id, competence_id, text, type, distance + FROM match_results + WHERE job_id = ? + GROUP BY task_id, competence_id + ORDER BY distance ASC + `, + ) + .all(jobId) + .map((r: any) => ({ + taskId: r.task_id, + competenceId: r.competence_id, + text: r.text, + type: r.type, + distance: r.distance, + })); + } + /*-------------------------------------------------------------------- * ResourceList Methods *------------------------------------------------------------------*/ @@ -218,7 +286,7 @@ class VectorDataBase { WHERE cid IN ( SELECT c._cid FROM competence c - JOIN resource r ON c.resource__rid = r._rid + JOIN resource r ON c.resource_rid = r._rid WHERE r.list_id = ? ) `, @@ -273,19 +341,10 @@ class VectorDataBase { external_qualification_needed, renew_time, proficiency_level, qualification_dates, last_usages FROM competence - WHERE resource__rid = ? + WHERE resource_rid = ? `, ) - .all(_rid) as Array<{ - competence_id: string; - competence_name: string | null; - competence_description: string | null; - external_qualification_needed: number; - renew_time: number | null; - proficiency_level: string | null; - qualification_dates: string | null; - last_usages: string | null; - }>; + .all(_rid) as CompetenceDBOutput[]; return { resourceId: resource_id, competencies: comps.map((c) => ({ @@ -338,7 +397,7 @@ class VectorDataBase { .prepare( ` DELETE FROM competence_embedding - WHERE cid IN (SELECT _cid FROM competence WHERE resource__rid = ?) + WHERE cid IN (SELECT _cid FROM competence WHERE resource_rid = ?) `, ) .run(_rid); @@ -391,19 +450,10 @@ class VectorDataBase { external_qualification_needed, renew_time, proficiency_level, qualification_dates, last_usages FROM competence - WHERE resource__rid = ? + WHERE resource_rid = ? `, ) - .all(row._rid) as Array<{ - competence_id: string; - competence_name: string | null; - competence_description: string | null; - external_qualification_needed: number; - renew_time: number | null; - proficiency_level: string | null; - qualification_dates: string | null; - last_usages: string | null; - }>; + .all(row._rid) as CompetenceDBOutput[]; return { listId: row.list_id, @@ -453,7 +503,7 @@ class VectorDataBase { .prepare( ` INSERT INTO competence - (competence_id, resource__rid, + (competence_id, resource_rid, competence_name, competence_description, external_qualification_needed, renew_time, proficiency_level, qualification_dates, last_usages) @@ -500,7 +550,7 @@ class VectorDataBase { ): void { const _rid = this.getResourceRid(resourceId, listId); const _cid = this.db - .prepare(`SELECT _cid FROM competence WHERE competence_id = ? AND resource__rid = ?`) + .prepare(`SELECT _cid FROM competence WHERE competence_id = ? AND resource_rid = ?`) .get(competenceId, _rid)?.['_cid']; if (!_cid) throw new Error(`Competence '${competenceId}' not on resource '${resourceId}'`); @@ -552,7 +602,7 @@ class VectorDataBase { this.atomicStep(() => { const _rid = this.getResourceRid(resourceId, listId); const _cid = this.db - .prepare(`SELECT _cid FROM competence WHERE competence_id = ? AND resource__rid = ?`) + .prepare(`SELECT _cid FROM competence WHERE competence_id = ? AND resource_rid = ?`) .get(competenceId, _rid)?._cid; if (!_cid) throw new Error(`Competence '${competenceId}' not on resource '${resourceId}'`); @@ -596,7 +646,7 @@ class VectorDataBase { c.external_qualification_needed, c.renew_time, c.proficiency_level, c.qualification_dates, c.last_usages FROM competence c - WHERE c.competence_id = ? AND c.resource__rid = ? + WHERE c.competence_id = ? AND c.resource_rid = ? `, ) .get(competenceId, _rid) as @@ -652,7 +702,7 @@ class VectorDataBase { const cid = this.getCompetenceCidByCompetenceId(listId, resourceId, competenceId); // console.log(`Upserting embedding for competence ${competenceId} (${cid}) with text "${text}"`); - const cidInt = `${Math.floor(cid)}`; + const cidInt = `${Math.floor(cid)}`; // This + the cast is a workaround, sqlite-vec or sqlite read the cid as a float even though it is an integer. (Could be the lib or the fact that it is a virtual table, not sure) this.db .prepare( @@ -685,7 +735,7 @@ class VectorDataBase { * @param embedding the query vector to search for * @param options optional parameters: * - k: number of nearest neighbors to return (default: all) - * - filter: optional filter by resourceId and listId + * - filter: optional filter by resourceId and/or listId * - similarityMetric: 'cosine', 'hamming', or 'euclidean' (default: 'cosine') * @returns an array of objects with competenceId, text, type, and distance. * @throws if the embedding length does not match the configured dimension. @@ -720,7 +770,7 @@ class VectorDataBase { ${metric}(ce.embedding, vec_f32(?)) AS distance FROM competence_embedding ce JOIN competence c ON ce.cid = c._cid - JOIN resource r ON c.resource__rid = r._rid + JOIN resource r ON c.resource_rid = r._rid `; const params: any[] = [new Float32Array(embedding)]; @@ -736,7 +786,7 @@ class VectorDataBase { if (whereClauses.length > 0) { sql += ` WHERE ` + whereClauses.join(' AND '); } - + sql += ` GROUP BY c.competence_id, ce.type`; sql += ` ORDER BY distance ASC`; if (k) { diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts index e69de29bb..d2cd72966 100644 --- a/src/competence-matcher/src/middleware/match.ts +++ b/src/competence-matcher/src/middleware/match.ts @@ -0,0 +1,172 @@ +import { Request, Response, NextFunction } from 'express'; +import { PATHS } from '../server'; +import { getDB } from '../utils/db'; +import { createWorker } from '../utils/worker'; + +export function matchCompetenceList(req: Request, res: Response, next: NextFunction): void { + try { + let listId: string; + let list: ResourceListInput; + let taskInput: MatchingTask[]; + const db = getDB(req.dbName!); + + /**-------------------------------------------- + * Checks + *---------------------------------------------*/ + if ('competenceList' in req.body) { + // Handle case where competenceList is provided + const { competenceList, tasks } = req.body as { + competenceList: ResourceListInput; + tasks: MatchingTask[]; + }; + list = competenceList; + taskInput = tasks; + } else if ('competenceListId' in req.body) { + // Handle case where competenceListId is provided + const { competenceListId, tasks } = req.body as { + competenceListId: string; + tasks: MatchingTask[]; + }; + listId = competenceListId; + taskInput = tasks; + } + + if (!listId! && !list!) { + res.status(400).json({ + error: 'Either competenceListId or competenceList must be provided.', + }); + return; + } + + if (!taskInput! || !Array.isArray(taskInput) || taskInput?.length === 0) { + res.status(400).json({ + error: 'An array of tasks must be provided for matching.', + }); + return; + } + + if (listId! && !(typeof listId === 'string')) { + res.status(400).json({ + error: 'competenceListId must be an UUIDStrings.', + }); + return; + } else if ( + list! && + (!Array.isArray(list) || + !list.every( + (entry) => typeof entry === 'object' && !Array.isArray(entry) && entry !== null, + )) + ) { + res.status(400).json({ + error: 'competenceList must be an array of ResourceInput objects.', + }); + return; + } + + /**-------------------------------------------- + * Case existing competenceListId was passed + *---------------------------------------------*/ + if (listId!) { + // Check if the competence list exists + const competenceLists = db.getAvailableResourceLists(); + if (!competenceLists.includes(listId)) { + res.status(404).json({ + error: `Competence list with ID ${listId} not found.`, + }); + return; + } + + const jobId = db.createJob(listId); + const job: MatchingJob = { + jobId, + dbName: req.dbName!, + listId, + resourceId: undefined, // For now, we don't support matching against a single resource + tasks: taskInput.map((task) => { + return { + taskId: task.taskId, + name: task.name, + description: task.description, + executionInstructions: task.executionInstructions, + requiredCompetencies: (task.requiredCompetencies ?? []).map((competence) => + typeof competence === 'string' + ? (competence as string) + : ({ + competenceId: competence.competenceId, + name: competence.name, + description: competence.description, + externalQualificationNeeded: competence.externalQualificationNeeded, + renewTime: competence.renewTime, + proficiencyLevel: competence.proficiencyLevel, + qualificationDates: competence.qualificationDates, + lastUsages: competence.lastUsages, + } as CompetenceInput), + ) as string[] | CompetenceInput[], + }; + }), + }; + + const worker = createWorker('matcher'); + + worker.postMessage(job); + + // Respond with jobId in location header + res + .setHeader('Location', `${PATHS.match}/jobs/${jobId}`) + // Accepted response + .status(202) + .json({ jobId, status: 'pending' }); + return; + } + + /**-------------------------------------------- + * Case new Competence-List was passed + *---------------------------------------------*/ + res.status(501).json({ + error: + 'Matching with new competence lists is not implemented, yet. For now, please create a competence list first and then match against it.', + }); + return; + } catch (error) { + console.error('Error matching:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +} + +export function getMatchJobResults(req: Request, res: Response, next: NextFunction): void { + const { jobId } = req.params; + const db = getDB(req.dbName!); + + // Check if job exists + const job = db.getJob(jobId); + if (!job) { + res.status(404).json({ error: `Job with ID ${jobId} not found.` }); + return; + } + + // Job can be pending, running, completed, or failed + if (job.status === 'pending' || job.status === 'running') { + res.status(202).json({ + jobId, + status: job.status, + }); + return; + } + if (job.status === 'failed') { + res.status(500).json({ + error: `Job with ID ${jobId} failed. Please check the job logs for more details.`, + }); + return; + } + if (job.status !== 'completed') { + // This should not happen, but just in case + res.status(500).json({ + error: `Job with ID ${jobId} is in an unknown state: ${job.status}.`, + }); + return; + } + + // Return match results + const results = db.getMatchResults(jobId); + res.status(200).json(results); +} diff --git a/src/competence-matcher/src/middleware/resource.ts b/src/competence-matcher/src/middleware/resource.ts index 685804958..1e2bfcbe1 100644 --- a/src/competence-matcher/src/middleware/resource.ts +++ b/src/competence-matcher/src/middleware/resource.ts @@ -1,15 +1,7 @@ import { Request, Response, NextFunction } from 'express'; -import DBManager from '../db/db-manager'; import { PATHS } from '../server'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { Worker } from 'worker_threads'; - -function getDB(name: string) { - const dbManager = DBManager.getInstance(); - DBManager.setActiveDB(name); - return dbManager.getDB(name); -} +import { getDB } from '../utils/db'; +import { createWorker } from '../utils/worker'; export function getResourceLists(req: Request, res: Response, next: NextFunction): void { try { @@ -137,31 +129,12 @@ export function createResourceList(req: Request, res: Response, next: NextFuncti tasks: descriptionEmbeddingInput, }; - const tsPath = path.resolve(__dirname, '../worker/embedder.ts'); - const jsPath = path.resolve(__dirname, '../worker/embedder.js'); - const isTs = fs.existsSync(tsPath); - - const workerFile = isTs ? tsPath : jsPath; - - const execArgv = isTs - ? [...process.execArgv, '-r', 'ts-node/register/transpile-only'] - : process.execArgv; - - const worker = new Worker(workerFile, { execArgv }); - - worker.on('error', (err) => { - console.error('Embedding worker crashed:', err); - }); - worker.on('exit', (code) => { - if (code !== 0) { - console.error(`Worker for job ${jobId} exited with code ${code}`); - } - }); + const worker = createWorker('embedder'); // Send the job worker.postMessage(job); - // Respond with listId in location header + // Respond with jobid in location header res .setHeader('Location', `${PATHS.resource}/jobs/${jobId!}`) // Rspond with accepted status and jobId diff --git a/src/competence-matcher/src/routes/match.ts b/src/competence-matcher/src/routes/match.ts index f0eeea6c4..0cb894f73 100644 --- a/src/competence-matcher/src/routes/match.ts +++ b/src/competence-matcher/src/routes/match.ts @@ -1,13 +1,21 @@ import express from 'express'; +import { getMatchJobResults, matchCompetenceList } from '../middleware/match'; const router = express.Router(); -router - .route('/') - // .all() - // .get() - // .put() - .post(); +// .all() +// .get() +// .put() +// .post(); +// .patch(); // .delete(); +// '/:resource-list-id' +// '/:resource-list-id/:resource-id' +// '/:resource-list-id/:resource-id/:competence-id' + +router.route('/jobs/').post(matchCompetenceList); + +router.route('/jobs/:jobId').get(getMatchJobResults); + export default router; diff --git a/src/competence-matcher/src/server.ts b/src/competence-matcher/src/server.ts index 79c3f0c92..d864c9b57 100644 --- a/src/competence-matcher/src/server.ts +++ b/src/competence-matcher/src/server.ts @@ -42,7 +42,7 @@ app.get('/', (req, res, next) => { // Routes app.use(PATHS.resource, ResourceRouter); -// app.use('/match', MatchRouter); +app.use(PATHS.match, MatchRouter); app.listen(PORT, () => { console.log(`Matching-Server is running on http://localhost:${PORT}`); diff --git a/src/competence-matcher/src/tasks/embedding.ts b/src/competence-matcher/src/tasks/embedding.ts index 36a7bf5db..04af1b8d6 100644 --- a/src/competence-matcher/src/tasks/embedding.ts +++ b/src/competence-matcher/src/tasks/embedding.ts @@ -24,7 +24,7 @@ class Embedding { */ public static async getInstance( progressCallback: ProgressCallback | null = (progressInfo) => { - console.log(progressInfo); + // console.log(progressInfo); }, ) { if (Embedding.instance === null) { diff --git a/src/competence-matcher/src/tasks/semantic-split.ts b/src/competence-matcher/src/tasks/semantic-split.ts new file mode 100644 index 000000000..d7cc461c0 --- /dev/null +++ b/src/competence-matcher/src/tasks/semantic-split.ts @@ -0,0 +1,49 @@ +import ollama from 'ollama'; +import { config } from '../config'; +import { SEMANTIC_SPLITTER as intructPrompt } from '../utils/prompts'; +import type { Message } from 'ollama'; + +const { ollamaPath, ollamaModel, splittingSymbol } = config; + +export async function splitSemantically(tasks: EmbeddingTask[]): Promise { + const splittedTasks: EmbeddingTask[] = []; + + for (const task of tasks) { + const messages: Message[] = [ + ...intructPrompt, + { + role: 'user', + content: task.text, + }, + ]; + + try { + const response = await ollama.chat({ + model: ollamaModel, + messages, + // TODO: use custom url + }); + + // Split @ splittingSymbol + const parts = response.message.content.split(splittingSymbol).map((part) => part.trim()); + + console.log(response); + + parts.forEach((part) => { + if (part !== '') { + splittedTasks.push({ + ...task, + text: part, + }); + } + }); + } catch (error) { + console.error('Error during semantic splitting:', error); + // Skip the splitting for this task + splittedTasks.push(task); + continue; + } + } + + return splittedTasks; +} diff --git a/src/competence-matcher/src/utils/db.ts b/src/competence-matcher/src/utils/db.ts new file mode 100644 index 000000000..5d02224b2 --- /dev/null +++ b/src/competence-matcher/src/utils/db.ts @@ -0,0 +1,7 @@ +import DBManager from '../db/db-manager'; + +export function getDB(name: string) { + const dbManager = DBManager.getInstance(); + DBManager.setActiveDB(name); + return dbManager.getDB(name); +} diff --git a/src/competence-matcher/src/utils/prompts.ts b/src/competence-matcher/src/utils/prompts.ts new file mode 100644 index 000000000..2d5e26133 --- /dev/null +++ b/src/competence-matcher/src/utils/prompts.ts @@ -0,0 +1,117 @@ +import type { Message } from 'ollama'; +import { config } from '../config'; + +const { splittingSymbol } = config; + +const SEMANTIC_SPLITTER_INTRUCT: Message = { + role: 'system', + content: ` + Your task is to segment the following text (i.e. user input such as plain prose, bullet points or listings) into semantically independent parts. + Do not add, remove, or modify any words. + Preserve the original ordering of words within each group—but groups themselves need not follow the original sequence. + Separate each group only by the delimiter + ${splittingSymbol} + (i.e. exactly as shown, on a line by themselves). + If the entire input is already one coherent semantic unit, return it verbatim without any delimiter. + Grouping need do not be adjacent - just semantically related (i.e. two related text parts might be separated by other text parts). + `, +}; +const SEMANTIC_SPLITTER_EXAMPLES: Message[] = [ + { + role: 'user', + content: ` + The job requires welding experience. Tick welding would be preferable if the person is familiar with it. The cage is designed for small pets like rabbits. Therefore, it must not contain any sharp edges that could harm them. Experience with welding small wires could be beneficial. + `, + }, + { + role: 'assistant', + content: ` + The job requires welding experience. Tick welding would be preferable if the person is familiar with it. Experience with welding small wires could be beneficial. + ${splittingSymbol} + The cage is designed for small pets like rabbits. Therefore, it must not contain any sharp edges that could harm them. + `, + }, + { + role: 'user', + content: ` + - Assemble circuit boards according to schematic diagrams + - Test each board for continuity and signal integrity + - Package finished units in protective casing + - Ship completed orders to customers worldwide + `, + }, + { + role: 'assistant', + content: ` + - Assemble circuit boards according to schematic diagrams + - Test each board for continuity and signal integrity + ${splittingSymbol} + - Package finished units in protective casing + - Ship completed orders to customers worldwide + `, + }, + { + role: 'user', + content: ` + 1. Prepare raw materials for production + 2. Record daily output and machine performance. + 3. Clean workstations and restock supplies + 4. Order order new materials when needed. + 5. Calibrate and maintain measurement instruments. + `, + }, + { + role: 'assistant', + content: ` + 1. Prepare raw materials for production + 4. Order order new materials when needed. + ${splittingSymbol} + 2. Record daily output and machine performance. + ${splittingSymbol} + 3. Clean workstations and restock supplies + ${splittingSymbol} + 5. Calibrate and maintain measurement instruments. + `, + }, + { + role: 'user', + content: ` + Operate CNC milling machines to produce precision metal parts. Perform quality inspections using calipers, micrometers, and gauges. Monitor machine operation and adjust feed rates, speeds, and tooling as needed. Maintain a clean and safe workspace, following all OSHA safety guidelines. Collaborate with engineers to troubleshoot design issues and implement improvements. Document production logs, inspection reports, and maintenance records daily. Assist in training new operators on standard operating procedures and best practices. + `, + }, + { + role: 'assistant', + content: ` + Operate CNC milling machines to produce precision metal parts. + Monitor machine operation and adjust feed rates, speeds, and tooling as needed. + ${splittingSymbol} + Perform quality inspections using calipers, micrometers, and gauges. + Collaborate with engineers to troubleshoot design issues and implement improvements. + ${splittingSymbol} + Maintain a clean and safe workspace, following all OSHA safety guidelines. + Document production logs, inspection reports, and maintenance records daily. + Assist in training new operators on standard operating procedures and best practices. + `, + }, +]; + +export const SEMANTIC_SPLITTER: Message[] = [ + SEMANTIC_SPLITTER_INTRUCT, + ...SEMANTIC_SPLITTER_EXAMPLES, +]; + +// """""""""" +// The warehouse must maintain ambient temperatures between 15°C and 25°C to protect sensitive goods. Humidity levels should not exceed 60% to prevent corrosion and mold growth. Inventory audits are scheduled weekly to ensure accuracy and compliance with safety standards. +// """""""""" + +// """""""""" +// Our catering service provides vegetarian, vegan, and gluten-free menu options to accommodate diverse dietary needs. All dishes are prepared fresh daily using locally sourced ingredients whenever possible. Orders must be placed at least 48 hours in advance to guarantee availability. Delivery times range from 8 AM to 6 PM on weekdays. +// """""""""" + +// """""""""" +// Employees are required to complete the annual cybersecurity training module before accessing the new intranet portal. The module covers password hygiene, phishing identification, and secure remote-access procedures. Failure to complete training by the deadline will result in temporary revocation of network privileges. +// """""""""" + +// """""""""" +// The production line must operate continuously in three shifts—morning, afternoon, and night—to meet daily target outputs of 5,000 units. All machinery, including conveyor belts and hydraulic presses, requires a thorough safety inspection at the start of each shift to ensure proper lubrication and guard alignment. Operators are responsible for logging any unusual vibrations, noise anomalies, or temperature spikes immediately in the maintenance ledger. Raw material deliveries arrive twice weekly and must be verified against purchase orders for quantity, grade, and certificate of analysis before being released to the staging area. Finished goods undergo a final quality check where dimensional tolerances, surface finish, and functional performance are recorded. Any nonconforming parts are quarantined and tagged, then reported to quality assurance for root‐cause analysis. At the end of each month, team leads compile production metrics, downtime reasons, and scrap rates into a summarized report for review at the management safety and efficiency meeting. +// """""""""" diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts index b2b3c84a2..1779d4757 100644 --- a/src/competence-matcher/src/utils/types.ts +++ b/src/competence-matcher/src/utils/types.ts @@ -48,17 +48,27 @@ type ResourceList = { type ResourceListInput = ResourceInput[]; -type Task = { +type MatchingTask = { taskId: string; // UUIDString - taskName?: string; // optional - taskDescription?: string; // optional but recommended to have content + name?: string; // optional + description?: string; // optional but recommended to have content executionInstructions?: string; // optional, e.g. HTML requiredCompetencies?: string[] | CompetenceInput[]; // either array of competenceIds or array of CompetenceInput }; +type MatchingTaskInput = + | { + competenceListId: string; // UUIDString + tasks: MatchingTask[]; // array of tasks to match + } + | { + competenceList: ResourceListInput; // array of resources with competencies + tasks: MatchingTask[]; // array of tasks to match + }; + type Match = { [resourceId: string]: { - matchingProbability: number; // 0-1 + matchingConfidence: number; // 0-1 reason: string; }; }; @@ -69,7 +79,7 @@ interface VectorDBOptions { } type CompetenceDBOutput = { - id: string; + competence_id: string; competence_name: string | null; competence_description: string | null; external_qualification_needed: number; // 0 or 1 @@ -79,14 +89,24 @@ type CompetenceDBOutput = { last_usages: string | null; // JSON string }; +type EmbeddingTask = { + listId: string; // UUIDString + resourceId: string; // UUIDString + competenceId: string; // UUIDString + text: string; // Text to embed + type: 'name' | 'description' | 'proficiencyLevel'; // Type of text +}; + interface EmbeddingJob { jobId: string; dbName: string; - tasks: Array<{ - listId: string; - resourceId: string; - competenceId: string; - text: string; - type: 'name' | 'description' | 'proficiencyLevel'; - }>; + tasks: EmbeddingTask[]; +} + +interface MatchingJob { + jobId: string; + dbName: string; + listId?: string; // Which List to match against + resourceId?: string; // Optional: If matching against a single resource + tasks: MatchingTask[]; // Tasks to match } diff --git a/src/competence-matcher/src/utils/worker.ts b/src/competence-matcher/src/utils/worker.ts new file mode 100644 index 000000000..34e98a7fb --- /dev/null +++ b/src/competence-matcher/src/utils/worker.ts @@ -0,0 +1,38 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Worker } from 'worker_threads'; + +export function createWorker(filename: string): Worker { + const tsPath = path.resolve(__dirname, `../worker/${filename}.ts`); + const jsPath = path.resolve(__dirname, `../worker/${filename}.js`); + const isTs = fs.existsSync(tsPath); + + const workerFile = isTs ? tsPath : jsPath; + + const execArgv = isTs + ? [...process.execArgv, '-r', 'ts-node/register/transpile-only'] + : process.execArgv; + + const worker = new Worker(workerFile, { execArgv }); + + worker.on('error', (err) => { + console.error('Embedding worker crashed:', err); + }); + worker.on('exit', (code) => { + if (code !== 0) { + console.error(`Worker exited with code ${code}`); + } + }); + worker.on('message', (message) => { + switch (message.type) { + case 'status': + console.log(`Worker for job ${message.jobId} status:`, message.status); + break; + case 'error': + console.error(`Worker for job ${message.jobId} error:`, message.error); + break; + } + }); + + return worker; +} diff --git a/src/competence-matcher/src/worker/embedder.ts b/src/competence-matcher/src/worker/embedder.ts index 17b8407b7..b48a59ff3 100644 --- a/src/competence-matcher/src/worker/embedder.ts +++ b/src/competence-matcher/src/worker/embedder.ts @@ -1,20 +1,31 @@ import { parentPort } from 'worker_threads'; import Embedding from '../tasks/embedding'; -import DBManager from '../db/db-manager'; +import { getDB } from '../utils/db'; +import { splitSemantically } from '../tasks/semantic-split'; parentPort!.once('message', async (job: EmbeddingJob) => { const { jobId, dbName, tasks } = job; // Open the DB in this thread - // Note: This is another DB instance, not the one used by the main thread - const db = DBManager.getInstance().getDB(dbName); + // Note: This is another DB-Connector instance, not the one used by the main thread + // But it refers to the same database file + const db = getDB(dbName); try { // Mark job as running db.updateJobStatus(jobId, 'running'); + parentPort!.postMessage({ type: 'status', status: 'running', jobId }); + + let work = tasks; + // try { + // work = await splitSemantically(tasks); + // } catch (error) { + // const errorMessage = error instanceof Error ? error.message : String(error); + // parentPort!.postMessage({ type: 'error', error: errorMessage }); + // } // For each task: embed & upsert - for (const { listId, resourceId, competenceId, text, type } of tasks) { + for (const { listId, resourceId, competenceId, text, type } of work) { const [vector] = await Embedding.embed(text); // console.log(`Embedded text for job ${jobId}:`, text, '->', vector); db.upsertEmbedding({ listId, resourceId, competenceId, text, type, embedding: vector }); @@ -22,19 +33,23 @@ parentPort!.once('message', async (job: EmbeddingJob) => { // Mark job completed db.updateJobStatus(jobId, 'completed'); + // Notify parent (not really necessary) + parentPort!.postMessage({ type: 'status', status: 'completed', jobId }); } catch (err) { + // Notify parent about error + !parentPort?.postMessage({ + jobId, + status: 'failed', + error: err instanceof Error ? err.message : 'Unknown error', + }); // On any error: mark job as failed - console.error('Worker error for job', jobId, err); try { db.updateJobStatus(jobId, 'failed'); } catch {} + } finally { + // Clean up: close DB and exit + db.close(); + parentPort!.close(); + process.exit(0); } - - // Notify parent (not really necessary) - // parentPort!.postMessage({ done: true, jobId }); - - // Clean up: close DB and exit - db.close(); - parentPort!.close(); - process.exit(0); }); diff --git a/src/competence-matcher/src/worker/matcher.ts b/src/competence-matcher/src/worker/matcher.ts index e69de29bb..e411c6298 100644 --- a/src/competence-matcher/src/worker/matcher.ts +++ b/src/competence-matcher/src/worker/matcher.ts @@ -0,0 +1,69 @@ +import { parentPort } from 'worker_threads'; +import Embedding from '../tasks/embedding'; +import DBManager from '../db/db-manager'; +import { getDB } from '../utils/db'; + +parentPort!.once('message', async (job: MatchingJob) => { + const { jobId, dbName, listId, resourceId, tasks } = job; + + // Open the DB in this thread + // Note: This is another DB instance, not the one used by the main thread + const db = getDB(dbName); + + try { + // Mark job as running + db.updateJobStatus(jobId, 'running'); + parentPort!.postMessage({ type: 'status', status: 'running', jobId }); + + // For each task: embed text and search for matches + for (const task of tasks) { + const { taskId, name, description, executionInstructions, requiredCompetencies } = task; + if (!description) { + continue; // Skip tasks without description + } + // Embed the task description + const [vector] = await Embedding.embed(description); + + // Search for matches in the competence list (and resource if provided) + const matches = db.searchEmbedding(vector, { + filter: { + listId: listId, + resourceId: resourceId, // Optional: If matching against a single resource + }, + }); + + for (const match of matches) { + db.addMatchResult({ + jobId, + taskId, + competenceId: match.competenceId, + text: match.text, + type: match.type as 'name' | 'description' | 'proficiencyLevel', + distance: match.distance, + }); + } + console.log(matches); + } + + // Mark job completed + db.updateJobStatus(jobId, 'completed'); + // Notify parent (not really necessary) + parentPort!.postMessage({ type: 'status', status: 'completed', jobId }); + } catch (err) { + // On any error: mark job as failed + !parentPort?.postMessage({ + jobId, + status: 'failed', + error: err instanceof Error ? err.message : 'Unknown error', + }); + // Update job status in DB + try { + db.updateJobStatus(jobId, 'failed'); + } catch {} + } finally { + // Clean up: close DB and exit + db.close(); + parentPort!.close(); + process.exit(0); + } +}); From 6e9fc86df281564485c000ed5b896dddbd6b7db4 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Sat, 5 Jul 2025 17:55:52 +0200 Subject: [PATCH 13/17] feat: Enhance competence matcher with new configuration options, improved match result handling, and added reasoning capabilities --- src/competence-matcher/src/config.ts | 11 ++- src/competence-matcher/src/db/db.ts | 61 ++++++++++++--- .../src/middleware/match.ts | 43 ++++++++++- src/competence-matcher/src/routes/resource.ts | 7 +- src/competence-matcher/src/server.ts | 61 +++++++++------ src/competence-matcher/src/tasks/embedding.ts | 13 +++- src/competence-matcher/src/tasks/reason.ts | 42 ++++++++++ .../src/tasks/semantic-split.ts | 67 ++++++++++------ .../{reason-llm.ts => sematic-opposites.ts} | 0 src/competence-matcher/src/utils/ollama.ts | 42 ++++++++++ src/competence-matcher/src/utils/prompts.ts | 77 ++++++++++++++++++- src/competence-matcher/src/utils/types.ts | 52 +++++-------- src/competence-matcher/src/utils/worker.ts | 33 ++++++++ src/competence-matcher/src/worker/embedder.ts | 42 +++------- src/competence-matcher/src/worker/matcher.ts | 38 +++------ .../src/worker/worker-manager.ts | 5 ++ 16 files changed, 423 insertions(+), 171 deletions(-) create mode 100644 src/competence-matcher/src/tasks/reason.ts rename src/competence-matcher/src/tasks/{reason-llm.ts => sematic-opposites.ts} (100%) create mode 100644 src/competence-matcher/src/utils/ollama.ts create mode 100644 src/competence-matcher/src/worker/worker-manager.ts diff --git a/src/competence-matcher/src/config.ts b/src/competence-matcher/src/config.ts index 9a76543a6..5bf330572 100644 --- a/src/competence-matcher/src/config.ts +++ b/src/competence-matcher/src/config.ts @@ -1,11 +1,14 @@ export const config = { dbPath: process.env.DB_PATH || 'src/db/dbs/', + embeddingModel: process.env.EMBEDDING_MODEL || 'onnx-community/Qwen3-Embedding-0.6B-ONNX', embeddingDim: parseInt(process.env.EMBEDDING_DIM || '1024', 10), - model: process.env.MODEL || 'onnx-community/Qwen3-Embedding-0.6B-ONNX', - modelCache: process.env.MODEL_CACHE || 'src/models/', + embeddingModelCache: process.env.MODEL_CACHE || 'src/models/', + useGPU: process.env.USE_GPU === 'true' || false, port: parseInt(process.env.PORT || '8501', 10), multipleDBs: process.env.MULTIPLE_DBS === 'true' || false, ollamaPath: process.env.OLLAMA_PATH || 'http://localhost:11434', - ollamaModel: process.env.OLLAMA_MODEL || 'llama3.2:3b', - splittingSymbol: process.env.SPLITTING_SYMBOL || '__________', + ollamaSplittingModel: process.env.OLLAMA_SPLITTING_MODEL || 'llama3.2', + ollamaReasonModel: process.env.OLLAMA_REASON_MODEL || 'llama3.2', + splittingSymbol: process.env.SPLITTING_SYMBOL || '', + numberOfThreads: parseInt(process.env.NUMBER_OF_THREADS || '10', 10), }; diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts index 080c7afe5..ae67645c0 100644 --- a/src/competence-matcher/src/db/db.ts +++ b/src/competence-matcher/src/db/db.ts @@ -4,11 +4,6 @@ import * as path from 'node:path'; import * as sqliteVec from 'sqlite-vec'; import { v4 as uuid } from 'uuid'; -export interface VectorDBOptions { - filePath?: string; // file path or ":memory:" - embeddingDim: number; // dimension of each embedding -} - class VectorDataBase { private db: DatabaseSync; private embeddingDim: number; @@ -73,7 +68,8 @@ class VectorDataBase { competence_id TEXT NOT NULL, -- matched competence distance REAL NOT NULL, -- similarity score text TEXT NOT NULL, -- the matched snippet - type TEXT NOT NULL -- 'name' | 'description' | 'proficiencyLevel' + type TEXT NOT NULL, -- 'name' | 'description' | 'proficiencyLevel' + reason TEXT -- llm based reason for the match ); `); this.db.exec(` @@ -215,6 +211,12 @@ class VectorDataBase { * Match Methods *------------------------------------------------------------------*/ + /** + * Add a match result for a job, task, and competence. + * + * @param opts Options for adding a match result. + * @throws if the jobId, taskId, or competenceId do not exist. + */ public addMatchResult(opts: { jobId: string; taskId: string; @@ -222,19 +224,33 @@ class VectorDataBase { text: string; type: 'name' | 'description' | 'proficiencyLevel'; distance: number; + reason?: string; // optional reason for the match }): void { const id = uuid(); this.db .prepare( ` INSERT INTO match_results - (id, job_id, task_id, competence_id, text, type, distance) + (id, job_id, task_id, competence_id, text, type, distance, reason) VALUES (?, ?, ?, ?, ?, ?, ?) `, ) - .run(id, opts.jobId, opts.taskId, opts.competenceId, opts.text, opts.type, opts.distance); + .run( + id, + opts.jobId, + opts.taskId, + opts.competenceId, + opts.text, + opts.type, + opts.distance, + opts.reason ?? null, + ); } + /** + * Fetch all match results for a given jobId. + * @returns an array of match results, sorted by taskId and distance. + */ public getMatchResults(jobId: string): Array<{ taskId: string; competenceId: string; @@ -245,11 +261,10 @@ class VectorDataBase { return this.db .prepare( ` - SELECT task_id, competence_id, text, type, distance + SELECT task_id, competence_id, text, type, distance, reason FROM match_results WHERE job_id = ? - GROUP BY task_id, competence_id - ORDER BY distance ASC + ORDER BY task_id, distance `, ) .all(jobId) @@ -259,6 +274,7 @@ class VectorDataBase { text: r.text, type: r.type, distance: r.distance, + reason: r.reason ?? undefined, })); } @@ -795,12 +811,33 @@ class VectorDataBase { } const rows = this.db.prepare(sql).all(...params) as Array; - return rows.map((r) => ({ + + let result = rows.map((r) => ({ competenceId: r.competence_id, text: r.text, type: r.type, distance: r.distance, })); + + // Normalise distances to [0, 1], depending on the metric: + if (similarityMetric === 'cosine') { + // Cosine distance is in [0, 2] + result = result.map((row) => ({ + ...row, + distance: row.distance / 2, + })); + } else if (similarityMetric === 'hamming') { + // Hamming distance is in [0, 1], so we leave it as is + } else if (similarityMetric === 'euclidean') { + // Euclidean distance is in [0, sqrt(embeddingDim)] + const maxDistance = Math.sqrt(this.embeddingDim); + result = result.map((row) => ({ + ...row, + distance: row.distance / maxDistance, + })); + } + + return result; } } diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts index d2cd72966..8321fe135 100644 --- a/src/competence-matcher/src/middleware/match.ts +++ b/src/competence-matcher/src/middleware/match.ts @@ -154,19 +154,56 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti } if (job.status === 'failed') { res.status(500).json({ - error: `Job with ID ${jobId} failed. Please check the job logs for more details.`, + error: `Job with ID ${jobId} failed.`, }); return; } if (job.status !== 'completed') { // This should not happen, but just in case + console.error(`Unexpected job status: ${job.status} for jobId: ${jobId}`); res.status(500).json({ - error: `Job with ID ${jobId} is in an unknown state: ${job.status}.`, + error: `Job with ID ${jobId} failed.`, }); return; } // Return match results const results = db.getMatchResults(jobId); - res.status(200).json(results); + // Group by taskId and within each taskId by competenceId + // where matches are sorted by distance ascending + // and competences are sorted by avgDistance ascending + const groupedResults: GroupedMatchResults = results.reduce((acc, result) => { + const { taskId, competenceId, text, type, distance } = result as { + taskId: string; + competenceId: string; + text: string; + type: 'name' | 'description' | 'proficiencyLevel'; + distance: number; + }; + + let task = acc.find((t) => t.taskId === taskId); + if (!task) { + task = { taskId, competences: [] }; + acc.push(task); + } + + let competence = task.competences.find((c) => c.competenceId === competenceId); + if (!competence) { + competence = { competenceId, matchings: [], avgsimilarity: 0 }; + task.competences.push(competence); + } + + competence.matchings.push({ text, type, similarity: distance }); + competence.matchings.sort((a, b) => a.similarity - b.similarity); + + competence.avgsimilarity = + competence.matchings.reduce((sum, match) => sum + match.similarity, 0) / + competence.matchings.length; + + task.competences.sort((a, b) => a.avgsimilarity - b.avgsimilarity); + + return acc; + }, [] as GroupedMatchResults); + + res.status(200).json(groupedResults); } diff --git a/src/competence-matcher/src/routes/resource.ts b/src/competence-matcher/src/routes/resource.ts index c7f141a90..2096119ec 100644 --- a/src/competence-matcher/src/routes/resource.ts +++ b/src/competence-matcher/src/routes/resource.ts @@ -5,6 +5,9 @@ import { getResourceList, getResourceLists, } from '../middleware/resource'; +import { config } from '../config'; + +const { multipleDBs } = config; const router = express.Router(); @@ -19,7 +22,9 @@ const router = express.Router(); // '/:resource-list-id/:resource-id' // '/:resource-list-id/:resource-id/:competence-id' -router.route('/').get(getResourceLists); +// Getting an overview of all resource lists should be tennant-specific, +// so it should only be available if multipleDBs is true. +if (multipleDBs) router.route('/').get(getResourceLists); router.route('/jobs').post(createResourceList); router.route('/jobs/:jobId').get(getJobStatus); diff --git a/src/competence-matcher/src/server.ts b/src/competence-matcher/src/server.ts index d864c9b57..403991043 100644 --- a/src/competence-matcher/src/server.ts +++ b/src/competence-matcher/src/server.ts @@ -6,18 +6,15 @@ import { config } from './config'; import { dbHeader } from './middleware/db-locator'; import { requestLogger } from './middleware/logging'; import Embedding from './tasks/embedding'; +import { ensureAllModelsAreAvailable } from './utils/ollama'; const { port: PORT } = config; + export const PATHS = { resource: '/resource-competence-list', match: '/matching-task-to-resource', }; -const app = express(); - -// Ensure embedding model is loaded -Embedding.getInstance(); - // Extend Express Request interface declare module 'express-serve-static-core' { interface Request { @@ -25,25 +22,39 @@ declare module 'express-serve-static-core' { } } -// Parse JSON -app.use(express.json()); -// Parse URL-encoded data -app.use(express.urlencoded({ extended: true })); -// Middleware to handle database header -app.use(dbHeader); -// Logging middleware -// app.use(requestLogger); - -// Hello World -app.get('/', (req, res, next) => { - console.log('Received a GET request on /'); - res.status(200).send('Welcome to the Matching Server'); -}); - -// Routes -app.use(PATHS.resource, ResourceRouter); -app.use(PATHS.match, MatchRouter); +async function main() { + const app = express(); + + // Ensure embedding model is loaded + Embedding.getInstance(); + // Ensure all required models are available + await ensureAllModelsAreAvailable(); + + // Parse JSON + app.use(express.json()); + // Parse URL-encoded data + app.use(express.urlencoded({ extended: true })); + // Middleware to handle database header + app.use(dbHeader); + // Logging middleware + // app.use(requestLogger); + + // Hello World + app.get('/', (req, res, next) => { + console.log('Received a GET request on /'); + res.status(200).send('Welcome to the Matching Server'); + }); + + // Routes + app.use(PATHS.resource, ResourceRouter); + app.use(PATHS.match, MatchRouter); + + app.listen(PORT, () => { + console.log(`Matching-Server is running on http://localhost:${PORT}`); + }); +} -app.listen(PORT, () => { - console.log(`Matching-Server is running on http://localhost:${PORT}`); +main().catch((error) => { + console.error('Error starting the server:', error); + process.exit(1); }); diff --git a/src/competence-matcher/src/tasks/embedding.ts b/src/competence-matcher/src/tasks/embedding.ts index 04af1b8d6..9871e8127 100644 --- a/src/competence-matcher/src/tasks/embedding.ts +++ b/src/competence-matcher/src/tasks/embedding.ts @@ -11,11 +11,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { config } from '../config'; -const { model, modelCache, embeddingDim } = config; +const { embeddingModel, embeddingModelCache, embeddingDim, useGPU } = config; class Embedding { private static task: PipelineType = 'feature-extraction'; - private static model = model; + private static model = embeddingModel; private static instance: FeatureExtractionPipeline | null = null; /** @@ -25,6 +25,9 @@ class Embedding { public static async getInstance( progressCallback: ProgressCallback | null = (progressInfo) => { // console.log(progressInfo); + if (progressInfo.status === 'ready') { + console.log('Embedding model loaded successfully.'); + } }, ) { if (Embedding.instance === null) { @@ -32,6 +35,8 @@ class Embedding { const opts: PretrainedModelOptions = { use_external_data_format: true, + dtype: 'fp32', + device: useGPU ? 'cuda' : 'cpu', }; // { progress_callback, config, cache_dir, local_files_only, revision, device, dtype, subfolder, use_external_data_format, model_file_name, session_options, } if (progressCallback) opts.progress_callback = progressCallback; @@ -46,8 +51,8 @@ class Embedding { } private static configureEnv() { - if (modelCache) { - const absCache = path.resolve(modelCache); + if (embeddingModelCache) { + const absCache = path.resolve(embeddingModelCache); if (!fs.existsSync(absCache)) { fs.mkdirSync(absCache, { recursive: true }); } diff --git a/src/competence-matcher/src/tasks/reason.ts b/src/competence-matcher/src/tasks/reason.ts new file mode 100644 index 000000000..f586dabd2 --- /dev/null +++ b/src/competence-matcher/src/tasks/reason.ts @@ -0,0 +1,42 @@ +import { ollama } from '../utils/ollama'; +import { config } from '../config'; +import { MATCH_REASON as intructPrompt } from '../utils/prompts'; +import type { Message } from 'ollama'; + +const { ollamaReasonModel } = config; + +export async function addReason(matches: Match[], targetText: string): Promise { + if (matches.length === 0) { + return matches; // No matches to reason about + } + const reasonMatches: Match[] = await Promise.all( + matches.map(async (match) => { + const messages: Message[] = [ + ...intructPrompt, + { + role: 'user', + content: `Task: ${targetText}\nCompetence: ${match.text}\nSimilarity Score: ${match.distance.toFixed(2)}`, + }, + ]; + try { + const response = await ollama.chat({ + model: ollamaReasonModel, + messages: messages, + }); + + // Extract the reason from the response + const reason = response.message.content.trim(); + return { + ...match, + reason, // Add the reason to the match + }; + } catch (error) { + console.error('Error during reasoning:', error); + // If there's an error, just keep the original match without a reason + return match; + } + }), + ); + + return reasonMatches; +} diff --git a/src/competence-matcher/src/tasks/semantic-split.ts b/src/competence-matcher/src/tasks/semantic-split.ts index d7cc461c0..8111c248f 100644 --- a/src/competence-matcher/src/tasks/semantic-split.ts +++ b/src/competence-matcher/src/tasks/semantic-split.ts @@ -1,12 +1,15 @@ -import ollama from 'ollama'; +import { ollama } from '../utils/ollama'; import { config } from '../config'; import { SEMANTIC_SPLITTER as intructPrompt } from '../utils/prompts'; import type { Message } from 'ollama'; -const { ollamaPath, ollamaModel, splittingSymbol } = config; +const { ollamaSplittingModel, splittingSymbol } = config; + +const MIN_TEXT_LENGTH = 60; // Minimum length of text to consider for splitting (I noticed that text inputs that are too short often lead to errors in the splitting process - and since they are so small, they can be embedded directly without splitting) export async function splitSemantically(tasks: EmbeddingTask[]): Promise { const splittedTasks: EmbeddingTask[] = []; + const promises: Promise[] = []; for (const task of tasks) { const messages: Message[] = [ @@ -17,33 +20,47 @@ export async function splitSemantically(tasks: EmbeddingTask[]): Promise part.trim()); - - console.log(response); + // Filter out empty, whitespace-only and too short messages + const filteredMessages: Message[] = []; + messages.forEach((message) => { + const content = message.content.replace(/\s+/g, ' ').trim(); + if (content.trim() !== '' && content.length > MIN_TEXT_LENGTH) { + filteredMessages.push(message); + } else { + splittedTasks.push({ + ...task, + text: message.content, + }); + } + }); - parts.forEach((part) => { - if (part !== '') { - splittedTasks.push({ - ...task, - text: part, + if (filteredMessages.length > 0) { + const promise = ollama + .chat({ + model: ollamaSplittingModel, + messages: filteredMessages, + }) + .then((response) => { + const parts = response.message.content.split(splittingSymbol).map((part) => part.trim()); + parts.forEach((part) => { + if (part !== '') { + splittedTasks.push({ + ...task, + text: part, + }); + } }); - } - }); - } catch (error) { - console.error('Error during semantic splitting:', error); - // Skip the splitting for this task - splittedTasks.push(task); - continue; + }) + .catch((error) => { + console.error('Error during semantic splitting:', error); + splittedTasks.push(task); + }); + + promises.push(promise); } } + await Promise.all(promises); + return splittedTasks; } diff --git a/src/competence-matcher/src/tasks/reason-llm.ts b/src/competence-matcher/src/tasks/sematic-opposites.ts similarity index 100% rename from src/competence-matcher/src/tasks/reason-llm.ts rename to src/competence-matcher/src/tasks/sematic-opposites.ts diff --git a/src/competence-matcher/src/utils/ollama.ts b/src/competence-matcher/src/utils/ollama.ts new file mode 100644 index 000000000..8b605f2a4 --- /dev/null +++ b/src/competence-matcher/src/utils/ollama.ts @@ -0,0 +1,42 @@ +import { Ollama } from 'ollama'; +import { config } from '../config'; + +const { ollamaPath, ollamaSplittingModel, ollamaReasonModel } = config; + +export const ollama = new Ollama({ + host: ollamaPath, + headers: { + 'User-Agent': 'PROCEED Competence Matcher', + }, +}); + +/** + * Ensures that all required models are available by checking their existence + * in the Ollama server. If a model is not available, it will be downloaded. + * If the model cannot be downloaded or is not available, an error will be thrown. + * (Ensures all needed models are actually available) + */ +export const ensureAllModelsAreAvailable = async () => { + const models = [ollamaSplittingModel, ollamaReasonModel]; + + const availableModels = (await ollama.list()).models.map((model) => model.model); + + for (const model of models) { + if (!availableModels.includes(model)) { + const modelpull = await ollama.pull({ + model, + insecure: false, + stream: false, + }); + + // Check if the model was successfully pulled + if (!modelpull || modelpull.status !== 'success') { + throw new Error( + `Model ${model} could not be pulled: ${modelpull?.status || 'Unknown error'}`, + ); + } + } + } + + console.log('All required models are available in ollama.'); +}; diff --git a/src/competence-matcher/src/utils/prompts.ts b/src/competence-matcher/src/utils/prompts.ts index 2d5e26133..a2bffcb54 100644 --- a/src/competence-matcher/src/utils/prompts.ts +++ b/src/competence-matcher/src/utils/prompts.ts @@ -7,13 +7,18 @@ const SEMANTIC_SPLITTER_INTRUCT: Message = { role: 'system', content: ` Your task is to segment the following text (i.e. user input such as plain prose, bullet points or listings) into semantically independent parts. - Do not add, remove, or modify any words. - Preserve the original ordering of words within each group—but groups themselves need not follow the original sequence. + Do not add, remove, or modify any words - under no circumstances should you ever add any additional text, comments, or explanations. + Preserve the original ordering of words within each group — but groups themselves need not follow the original sequence. Separate each group only by the delimiter ${splittingSymbol} (i.e. exactly as shown, on a line by themselves). If the entire input is already one coherent semantic unit, return it verbatim without any delimiter. Grouping need do not be adjacent - just semantically related (i.e. two related text parts might be separated by other text parts). + If the input is empty, return an empty string. + If the input is a single word, return it verbatim without any delimiter. + If the input is a single sentence, return it verbatim without any delimiter. + If you are unsure about the grouping, return the entire input as a single group without any delimiters. + Under no circumstances should you ever add any additional text, comments, or explanations. `, }; const SEMANTIC_SPLITTER_EXAMPLES: Message[] = [ @@ -100,6 +105,74 @@ export const SEMANTIC_SPLITTER: Message[] = [ ...SEMANTIC_SPLITTER_EXAMPLES, ]; +/** + * ------------------------------------------------------------- + */ + +const MATCH_REASON_INTRUCT: Message = { + role: 'system', + content: ` + You are an expert in generating reasons for matching scores between tasks and competences. + Your task is to generate a reason for the matching score between a task and a competence. + The reason should be one to three short, concise sentence that explain why the task and competence match as well as they did or why they did not match that well. + Do not mention the similarity score in your response. + The reason should be based on the text of the task and the competence and their estimated normalized similarity score. + The similarity score is a number between 0 and 1, where 0 means no similarity and 1 means perfect similarity. + Do not mention the similarity score in your response. + `, +}; + +const MATCH_REASON_EXAMPLES: Message[] = [ + { + role: 'user', + content: ` + Task: Operate CNC milling machines to produce precision metal parts. + Competence: Experience with CNC milling machines and precision machining. + Similarity Score: 0.95 + `, + }, + { + role: 'assistant', + content: ` + The task and competence match very well because the task requires operating CNC milling machines, which is exactly what the competence is about. + `, + }, + { + role: 'user', + content: ` + Task: Assemble circuit boards according to schematic diagrams. + Competence: Basic knowledge of electronics and soldering skills. + Similarity Score: 0.65 + `, + }, + { + role: 'assistant', + content: ` + The task and competence have a moderate match because while assembling circuit boards requires some knowledge of electronics, it does not specifically require advanced soldering skills. + `, + }, + { + role: 'user', + content: ` + Task: Prepare raw materials for production. + Competence: Experience with inventory management and supply chain logistics. + Similarity Score: 0.30 + `, + }, + { + role: 'assistant', + content: ` + The task and competence have a low match because preparing raw materials is a basic task that does not require advanced inventory management or supply chain logistics skills. + `, + }, +]; + +export const MATCH_REASON: Message[] = [MATCH_REASON_INTRUCT, ...MATCH_REASON_EXAMPLES]; + +/** + * ------------------------------------------------------------- + */ + // """""""""" // The warehouse must maintain ambient temperatures between 15°C and 25°C to protect sensitive goods. Humidity levels should not exceed 60% to prevent corrosion and mold growth. Inventory audits are scheduled weekly to ensure accuracy and compliance with safety standards. // """""""""" diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts index 1779d4757..313de6330 100644 --- a/src/competence-matcher/src/utils/types.ts +++ b/src/competence-matcher/src/utils/types.ts @@ -22,30 +22,11 @@ type CompetenceInput = { lastUsages?: string[]; }; -type CompetenceEmbedding = { - competenceId: string; // UUIDString - embedding: number[]; // array of numbers representing the embedding -}; - -type Resource = { - listId: string; // UUIDString - resourceId: string; // UUIDString - competencies: Competence[]; // array of competencies -}; - type ResourceInput = { resourceId?: string; competencies: CompetenceInput[]; }; -type ResourceList = { - listId: string; // UUIDString - resources: { - resourceId: string; // UUIDString - competencies: Competence[]; // array of competencies - }[]; -}; - type ResourceListInput = ResourceInput[]; type MatchingTask = { @@ -56,21 +37,12 @@ type MatchingTask = { requiredCompetencies?: string[] | CompetenceInput[]; // either array of competenceIds or array of CompetenceInput }; -type MatchingTaskInput = - | { - competenceListId: string; // UUIDString - tasks: MatchingTask[]; // array of tasks to match - } - | { - competenceList: ResourceListInput; // array of resources with competencies - tasks: MatchingTask[]; // array of tasks to match - }; - type Match = { - [resourceId: string]: { - matchingConfidence: number; // 0-1 - reason: string; - }; + competenceId: string; + text: string; + type: string; + distance: number; + reason?: string; }; interface VectorDBOptions { @@ -110,3 +82,17 @@ interface MatchingJob { resourceId?: string; // Optional: If matching against a single resource tasks: MatchingTask[]; // Tasks to match } + +type GroupedMatchResults = { + taskId: string; + competences: { + competenceId: string; + matchings: { + text: string; + type: 'name' | 'description' | 'proficiencyLevel'; + similarity: number; + reason?: string; + }[]; + avgsimilarity: number; + }[]; +}[]; diff --git a/src/competence-matcher/src/utils/worker.ts b/src/competence-matcher/src/utils/worker.ts index 34e98a7fb..5be61883d 100644 --- a/src/competence-matcher/src/utils/worker.ts +++ b/src/competence-matcher/src/utils/worker.ts @@ -1,6 +1,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { Worker } from 'worker_threads'; +import { parentPort } from 'worker_threads'; +import VectorDataBase from '../db/db'; export function createWorker(filename: string): Worker { const tsPath = path.resolve(__dirname, `../worker/${filename}.ts`); @@ -36,3 +38,34 @@ export function createWorker(filename: string): Worker { return worker; } + +export async function workerWrapper(db: VectorDataBase, jobId: string, func: () => Promise) { + try { + // Mark job as running + db.updateJobStatus(jobId, 'running'); + parentPort!.postMessage({ type: 'status', status: 'running', jobId }); + + await func(); + + // Mark job completed + db.updateJobStatus(jobId, 'completed'); + // Notify parent (not really necessary) + parentPort!.postMessage({ type: 'status', status: 'completed', jobId }); + } catch (err) { + // Notify parent about error + !parentPort?.postMessage({ + jobId, + status: 'failed', + error: err instanceof Error ? err.message : 'Unknown error', + }); + // On any error: mark job as failed + try { + db.updateJobStatus(jobId, 'failed'); + } catch {} + } finally { + // Clean up: close DB and exit + db.close(); + parentPort!.close(); + process.exit(0); + } +} diff --git a/src/competence-matcher/src/worker/embedder.ts b/src/competence-matcher/src/worker/embedder.ts index b48a59ff3..4dc06529f 100644 --- a/src/competence-matcher/src/worker/embedder.ts +++ b/src/competence-matcher/src/worker/embedder.ts @@ -2,6 +2,7 @@ import { parentPort } from 'worker_threads'; import Embedding from '../tasks/embedding'; import { getDB } from '../utils/db'; import { splitSemantically } from '../tasks/semantic-split'; +import { workerWrapper } from '../utils/worker'; parentPort!.once('message', async (job: EmbeddingJob) => { const { jobId, dbName, tasks } = job; @@ -11,18 +12,14 @@ parentPort!.once('message', async (job: EmbeddingJob) => { // But it refers to the same database file const db = getDB(dbName); - try { - // Mark job as running - db.updateJobStatus(jobId, 'running'); - parentPort!.postMessage({ type: 'status', status: 'running', jobId }); - + workerWrapper(db, jobId, async () => { let work = tasks; - // try { - // work = await splitSemantically(tasks); - // } catch (error) { - // const errorMessage = error instanceof Error ? error.message : String(error); - // parentPort!.postMessage({ type: 'error', error: errorMessage }); - // } + try { + work = await splitSemantically(tasks); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + parentPort!.postMessage({ type: 'error', error: errorMessage }); + } // For each task: embed & upsert for (const { listId, resourceId, competenceId, text, type } of work) { @@ -30,26 +27,5 @@ parentPort!.once('message', async (job: EmbeddingJob) => { // console.log(`Embedded text for job ${jobId}:`, text, '->', vector); db.upsertEmbedding({ listId, resourceId, competenceId, text, type, embedding: vector }); } - - // Mark job completed - db.updateJobStatus(jobId, 'completed'); - // Notify parent (not really necessary) - parentPort!.postMessage({ type: 'status', status: 'completed', jobId }); - } catch (err) { - // Notify parent about error - !parentPort?.postMessage({ - jobId, - status: 'failed', - error: err instanceof Error ? err.message : 'Unknown error', - }); - // On any error: mark job as failed - try { - db.updateJobStatus(jobId, 'failed'); - } catch {} - } finally { - // Clean up: close DB and exit - db.close(); - parentPort!.close(); - process.exit(0); - } + }); }); diff --git a/src/competence-matcher/src/worker/matcher.ts b/src/competence-matcher/src/worker/matcher.ts index e411c6298..a5b42d015 100644 --- a/src/competence-matcher/src/worker/matcher.ts +++ b/src/competence-matcher/src/worker/matcher.ts @@ -2,6 +2,8 @@ import { parentPort } from 'worker_threads'; import Embedding from '../tasks/embedding'; import DBManager from '../db/db-manager'; import { getDB } from '../utils/db'; +import { workerWrapper } from '../utils/worker'; +import { addReason } from '../tasks/reason'; parentPort!.once('message', async (job: MatchingJob) => { const { jobId, dbName, listId, resourceId, tasks } = job; @@ -10,11 +12,7 @@ parentPort!.once('message', async (job: MatchingJob) => { // Note: This is another DB instance, not the one used by the main thread const db = getDB(dbName); - try { - // Mark job as running - db.updateJobStatus(jobId, 'running'); - parentPort!.postMessage({ type: 'status', status: 'running', jobId }); - + workerWrapper(db, jobId, async () => { // For each task: embed text and search for matches for (const task of tasks) { const { taskId, name, description, executionInstructions, requiredCompetencies } = task; @@ -25,13 +23,16 @@ parentPort!.once('message', async (job: MatchingJob) => { const [vector] = await Embedding.embed(description); // Search for matches in the competence list (and resource if provided) - const matches = db.searchEmbedding(vector, { + let matches: Match[] = db.searchEmbedding(vector, { filter: { listId: listId, resourceId: resourceId, // Optional: If matching against a single resource }, }); + // Add reasoning for matching score + matches = await addReason(matches, description); + for (const match of matches) { db.addMatchResult({ jobId, @@ -40,30 +41,9 @@ parentPort!.once('message', async (job: MatchingJob) => { text: match.text, type: match.type as 'name' | 'description' | 'proficiencyLevel', distance: match.distance, + reason: match.reason, }); } - console.log(matches); } - - // Mark job completed - db.updateJobStatus(jobId, 'completed'); - // Notify parent (not really necessary) - parentPort!.postMessage({ type: 'status', status: 'completed', jobId }); - } catch (err) { - // On any error: mark job as failed - !parentPort?.postMessage({ - jobId, - status: 'failed', - error: err instanceof Error ? err.message : 'Unknown error', - }); - // Update job status in DB - try { - db.updateJobStatus(jobId, 'failed'); - } catch {} - } finally { - // Clean up: close DB and exit - db.close(); - parentPort!.close(); - process.exit(0); - } + }); }); diff --git a/src/competence-matcher/src/worker/worker-manager.ts b/src/competence-matcher/src/worker/worker-manager.ts new file mode 100644 index 000000000..f133fbdf5 --- /dev/null +++ b/src/competence-matcher/src/worker/worker-manager.ts @@ -0,0 +1,5 @@ +import { Worker, workerData, parentPort } from 'worker_threads'; +import { config } from '../config'; +import { createWorker } from '../utils/worker'; + +const { numberOfThreads } = config; From d939e8a0b49abacd8b85df856bc976c2a27ed06b Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:13:35 +0200 Subject: [PATCH 14/17] Refactor competence matcher: Enhance model loading, semantic splitting, and reasoning - Updated server initialization to ensure availability of Hugging Face and Ollama models. - Refactored Embedding class to use a new TransformerPipeline base class for better model management. - Introduced ZeroShot class for zero-shot classification tasks. - Implemented semantic splitting functionality with improved error handling and batch processing. - Enhanced reasoning capabilities by integrating semantic opposites classification. - Added worker management for concurrent processing of embedding and matching tasks. - Created a new script for converting ONNX model weights to external data format. - Updated various utility functions and types for better type safety and clarity. - Removed deprecated semantic-opposites.ts file and replaced it with a new implementation. --- src/competence-matcher/src/config.ts | 15 +- src/competence-matcher/src/db/db.ts | 15 +- .../src/middleware/match.ts | 34 ++-- .../src/middleware/resource.ts | 49 ++++-- src/competence-matcher/src/server.ts | 62 ++++++- src/competence-matcher/src/tasks/embedding.ts | 108 ++++-------- src/competence-matcher/src/tasks/reason.ts | 11 +- .../src/tasks/semantic-opposites.ts | 39 +++++ .../src/tasks/semantic-split.ts | 163 ++++++++++++++---- .../src/tasks/sematic-opposites.ts | 0 .../src/utils/huggingface.ts | 13 ++ src/competence-matcher/src/utils/model.ts | 61 +++++++ src/competence-matcher/src/utils/ollama.ts | 8 +- src/competence-matcher/src/utils/prompts.ts | 9 +- src/competence-matcher/src/utils/types.ts | 48 ++++-- src/competence-matcher/src/utils/worker.ts | 92 +++++----- src/competence-matcher/src/worker/embedder.ts | 24 +-- src/competence-matcher/src/worker/matcher.ts | 111 ++++++++---- src/competence-matcher/src/worker/test.ts | 9 + .../src/worker/worker-manager.ts | 120 ++++++++++++- .../tools/onnx-model-external-data.py | 32 ++++ 21 files changed, 745 insertions(+), 278 deletions(-) create mode 100644 src/competence-matcher/src/tasks/semantic-opposites.ts delete mode 100644 src/competence-matcher/src/tasks/sematic-opposites.ts create mode 100644 src/competence-matcher/src/utils/huggingface.ts create mode 100644 src/competence-matcher/src/utils/model.ts create mode 100644 src/competence-matcher/src/worker/test.ts create mode 100644 src/competence-matcher/tools/onnx-model-external-data.py diff --git a/src/competence-matcher/src/config.ts b/src/competence-matcher/src/config.ts index 5bf330572..02a8a7001 100644 --- a/src/competence-matcher/src/config.ts +++ b/src/competence-matcher/src/config.ts @@ -1,14 +1,19 @@ +import * as os from 'node:os'; + export const config = { dbPath: process.env.DB_PATH || 'src/db/dbs/', embeddingModel: process.env.EMBEDDING_MODEL || 'onnx-community/Qwen3-Embedding-0.6B-ONNX', embeddingDim: parseInt(process.env.EMBEDDING_DIM || '1024', 10), - embeddingModelCache: process.env.MODEL_CACHE || 'src/models/', + nliModel: process.env.NLI_MODEL || './src/models/roberta_mnli_onnx', + modelCache: process.env.MODEL_CACHE || 'src/models/', useGPU: process.env.USE_GPU === 'true' || false, port: parseInt(process.env.PORT || '8501', 10), multipleDBs: process.env.MULTIPLE_DBS === 'true' || false, ollamaPath: process.env.OLLAMA_PATH || 'http://localhost:11434', - ollamaSplittingModel: process.env.OLLAMA_SPLITTING_MODEL || 'llama3.2', - ollamaReasonModel: process.env.OLLAMA_REASON_MODEL || 'llama3.2', - splittingSymbol: process.env.SPLITTING_SYMBOL || '', - numberOfThreads: parseInt(process.env.NUMBER_OF_THREADS || '10', 10), + ollamaBatchSize: parseInt(process.env.OLLAMA_BATCH_SIZE || '5', 10), + splittingModel: process.env.SPLITTING_MODEL || 'llama3.2', + reasonModel: process.env.REASON_MODEL || 'llama3.2', + splittingSymbol: process.env.SPLITTING_SYMBOL || 'SPLITTING_SYMBOL', + maxWorkerThreads: parseInt(process.env.NUMBER_OF_THREADS || String(os.cpus().length - 1), 10), // -1 for main thread + maxJobTime: parseInt(process.env.MAX_JOB_TIME || '600', 10) * 1_000, // converted from seconds to milliseconds }; diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts index ae67645c0..ad1ec27c2 100644 --- a/src/competence-matcher/src/db/db.ts +++ b/src/competence-matcher/src/db/db.ts @@ -3,6 +3,7 @@ import { DatabaseSync } from 'node:sqlite'; import * as path from 'node:path'; import * as sqliteVec from 'sqlite-vec'; import { v4 as uuid } from 'uuid'; +import { CompetenceDBOutput, VectorDBOptions } from '../utils/types'; class VectorDataBase { private db: DatabaseSync; @@ -226,13 +227,14 @@ class VectorDataBase { distance: number; reason?: string; // optional reason for the match }): void { + console.log(opts); const id = uuid(); this.db .prepare( ` INSERT INTO match_results (id, job_id, task_id, competence_id, text, type, distance, reason) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -257,6 +259,7 @@ class VectorDataBase { text: string; type: string; distance: number; + reason?: string; }> { return this.db .prepare( @@ -716,7 +719,6 @@ class VectorDataBase { throw new Error(`Embedding must have length ${this.embeddingDim}`); } const cid = this.getCompetenceCidByCompetenceId(listId, resourceId, competenceId); - // console.log(`Upserting embedding for competence ${competenceId} (${cid}) with text "${text}"`); const cidInt = `${Math.floor(cid)}`; // This + the cast is a workaround, sqlite-vec or sqlite read the cid as a float even though it is an integer. (Could be the lib or the fact that it is a virtual table, not sure) @@ -802,7 +804,7 @@ class VectorDataBase { if (whereClauses.length > 0) { sql += ` WHERE ` + whereClauses.join(' AND '); } - sql += ` GROUP BY c.competence_id, ce.type`; + // sql += ` GROUP BY c.competence_id, ce.type`; sql += ` ORDER BY distance ASC`; if (k) { @@ -837,6 +839,13 @@ class VectorDataBase { })); } + // Since similariy is now normalised to [0, 1], + // we need to adapt it, as it should be somewhat interpretable as a probability + result = result.map((row) => ({ + ...row, + distance: 1 - row.distance, // Convert distance to similarity + })); + return result; } } diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts index 8321fe135..a1df4ac3c 100644 --- a/src/competence-matcher/src/middleware/match.ts +++ b/src/competence-matcher/src/middleware/match.ts @@ -2,6 +2,14 @@ import { Request, Response, NextFunction } from 'express'; import { PATHS } from '../server'; import { getDB } from '../utils/db'; import { createWorker } from '../utils/worker'; +import workerManager from '../worker/worker-manager'; +import { + CompetenceInput, + GroupedMatchResults, + MatchingJob, + MatchingTask, + ResourceListInput, +} from '../utils/types'; export function matchCompetenceList(req: Request, res: Response, next: NextFunction): void { try { @@ -106,9 +114,7 @@ export function matchCompetenceList(req: Request, res: Response, next: NextFunct }), }; - const worker = createWorker('matcher'); - - worker.postMessage(job); + workerManager.enqueue(job, 'matcher'); // Respond with jobId in location header res @@ -173,34 +179,42 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti // where matches are sorted by distance ascending // and competences are sorted by avgDistance ascending const groupedResults: GroupedMatchResults = results.reduce((acc, result) => { - const { taskId, competenceId, text, type, distance } = result as { + const { taskId, competenceId, text, type, distance, reason } = result as { taskId: string; competenceId: string; text: string; type: 'name' | 'description' | 'proficiencyLevel'; distance: number; + reason?: string; }; + // Find or create task in accumulator let task = acc.find((t) => t.taskId === taskId); if (!task) { task = { taskId, competences: [] }; acc.push(task); } + // Find or create competence in task let competence = task.competences.find((c) => c.competenceId === competenceId); if (!competence) { - competence = { competenceId, matchings: [], avgsimilarity: 0 }; + competence = { competenceId, matchings: [], avgMatchProbability: 0 }; task.competences.push(competence); } - competence.matchings.push({ text, type, similarity: distance }); - competence.matchings.sort((a, b) => a.similarity - b.similarity); + // Add match to competence + competence.matchings.push({ text, type, matchProbability: distance, reason }); + + // Sort matches by matchProbability (distance) in descending order + competence.matchings.sort((a, b) => b.matchProbability - a.matchProbability); - competence.avgsimilarity = - competence.matchings.reduce((sum, match) => sum + match.similarity, 0) / + // Calculate average match probability for competence + competence.avgMatchProbability = + competence.matchings.reduce((sum, match) => sum + match.matchProbability, 0) / competence.matchings.length; - task.competences.sort((a, b) => a.avgsimilarity - b.avgsimilarity); + // Sort competences by avgMatchProbability in descending order + task.competences.sort((a, b) => b.avgMatchProbability - a.avgMatchProbability); return acc; }, [] as GroupedMatchResults); diff --git a/src/competence-matcher/src/middleware/resource.ts b/src/competence-matcher/src/middleware/resource.ts index 1e2bfcbe1..f028a46d6 100644 --- a/src/competence-matcher/src/middleware/resource.ts +++ b/src/competence-matcher/src/middleware/resource.ts @@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express'; import { PATHS } from '../server'; import { getDB } from '../utils/db'; import { createWorker } from '../utils/worker'; +import workerManager from '../worker/worker-manager'; +import { splitSemantically } from '../tasks/semantic-split'; +import { CompetenceInput, EmbeddingJob, EmbeddingTask, ResourceInput } from '../utils/types'; export function getResourceLists(req: Request, res: Response, next: NextFunction): void { try { @@ -120,19 +123,37 @@ export function createResourceList(req: Request, res: Response, next: NextFuncti }; }); }) - .flat(); - - const job: EmbeddingJob = { - jobId: jobId!, - dbName: req.dbName!, - // @ts-ignore (Checked above) - tasks: descriptionEmbeddingInput, - }; - - const worker = createWorker('embedder'); - - // Send the job - worker.postMessage(job); + .flat() as EmbeddingTask[]; + + // This is a workaround to avoid the worker crashing silently + // Preferably the splitting should be done in the worker + // For now it is just done asynchronously here + splitSemantically(descriptionEmbeddingInput) + .then((tasks) => { + // console.log(tasks); + const job: EmbeddingJob = { + jobId: jobId!, + dbName: req.dbName!, + tasks, + }; + workerManager.enqueue(job, 'embedder'); + }) + .catch((err) => { + console.error('Error splitting semantically:', err); + // Do embedding without splitting in case of error + const job: EmbeddingJob = { + jobId: jobId!, + dbName: req.dbName!, + tasks: descriptionEmbeddingInput, + }; + workerManager.enqueue(job, 'embedder'); + }); + // const job: EmbeddingJob = { + // jobId: jobId!, + // dbName: req.dbName!, + // tasks: descriptionEmbeddingInput, + // }; + // workerManager.enqueue(job, 'embedder'); // Respond with jobid in location header res @@ -158,7 +179,7 @@ export function getJobStatus(req: Request, res: Response) { res .status(201) .setHeader('Location', `${PATHS.resource}/${job.referenceId}`) - .json({ jobId: job.jobId, status: job.status, id: job.referenceId }); + .json({ jobId: job.jobId, status: job.status, competenceListId: job.referenceId }); return; case 'failed': res.status(500).json({ jobId: job.jobId, status: job.status }); diff --git a/src/competence-matcher/src/server.ts b/src/competence-matcher/src/server.ts index 403991043..c3e234865 100644 --- a/src/competence-matcher/src/server.ts +++ b/src/competence-matcher/src/server.ts @@ -6,7 +6,11 @@ import { config } from './config'; import { dbHeader } from './middleware/db-locator'; import { requestLogger } from './middleware/logging'; import Embedding from './tasks/embedding'; -import { ensureAllModelsAreAvailable } from './utils/ollama'; +import { ensureAllOllamaModelsAreAvailable } from './utils/ollama'; +import { splitSemantically } from './tasks/semantic-split'; +import { createWorker } from './utils/worker'; +import { ensureAllHuggingfaceModelsAreAvailable } from './utils/huggingface'; +import { EmbeddingTask } from './utils/types'; const { port: PORT } = config; @@ -25,10 +29,58 @@ declare module 'express-serve-static-core' { async function main() { const app = express(); - // Ensure embedding model is loaded - Embedding.getInstance(); // Ensure all required models are available - await ensureAllModelsAreAvailable(); + // Hugging Face models + await ensureAllHuggingfaceModelsAreAvailable(); + // Ollama models + await ensureAllOllamaModelsAreAvailable(); + + const tasks = [ + { + listId: 'test-list', + resourceId: 'test-resource', + competenceId: 'test-competence', + text: 'This competence covers the principles and best practices of designing scalable software systems. It includes high-level architecture, component interaction, and trade-off analysis. Practitioners will need to balance performance, reliability, and maintainability when making design decisions.', + type: 'description', + }, + { + listId: 'test-list', + resourceId: 'test-resource', + competenceId: 'test-competence', + text: 'This competence focuses on building and maintaining RESTful and GraphQL APIs. It covers endpoint design, versioning strategies, and error handling. Learners will gain hands-on experience with request validation, authentication, and performance tuning.', + type: 'description', + }, + { + listId: 'test-list', + resourceId: 'test-resource', + competenceId: 'test-competence', + text: 'This competence entails designing effective database schemas to represent business domains. It involves normalization, denormalization, and indexing strategies for optimal query performance. Real-world scenarios will illustrate when to choose relational versus NoSQL approaches.', + type: 'description', + }, + { + listId: 'test-list', + resourceId: 'test-resource', + competenceId: 'test-competence', + text: 'This competence covers fundamental security principles for web applications. Topics include authentication, authorization, encryption, and secure configuration management. Practical exercises demonstrate common vulnerabilities and how to mitigate them effectively.', + type: 'description', + }, + { + listId: 'test-list', + resourceId: 'test-resource', + competenceId: 'test-competence', + text: "This person can not swim at all. Please don't let them close water at all.", + type: 'description', + }, + ] as EmbeddingTask[]; + + // const testworker = createWorker('test'); + // testworker.on('message', (message) => { + // console.log(message); + // }); + // testworker.postMessage(tasks); + + // const result = await splitSemantically(tasks); + // console.log(result); // Parse JSON app.use(express.json()); @@ -55,6 +107,6 @@ async function main() { } main().catch((error) => { - console.error('Error starting the server:', error); + console.error('Server shutdown due to error:', error); process.exit(1); }); diff --git a/src/competence-matcher/src/tasks/embedding.ts b/src/competence-matcher/src/tasks/embedding.ts index 9871e8127..0f320214c 100644 --- a/src/competence-matcher/src/tasks/embedding.ts +++ b/src/competence-matcher/src/tasks/embedding.ts @@ -1,96 +1,48 @@ import { - pipeline, - env as huggingfaceEnv, PipelineType, - ProgressCallback, FeatureExtractionPipeline, FeatureExtractionPipelineOptions, - PretrainedModelOptions, } from '@huggingface/transformers'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; import { config } from '../config'; - -const { embeddingModel, embeddingModelCache, embeddingDim, useGPU } = config; - -class Embedding { - private static task: PipelineType = 'feature-extraction'; - private static model = embeddingModel; - private static instance: FeatureExtractionPipeline | null = null; - - /** - * Return the singleton pipeline instance, loading if needed. - * @param progressCallback Optional progress callback for model loading. - */ - public static async getInstance( - progressCallback: ProgressCallback | null = (progressInfo) => { - // console.log(progressInfo); - if (progressInfo.status === 'ready') { - console.log('Embedding model loaded successfully.'); - } - }, - ) { - if (Embedding.instance === null) { - Embedding.configureEnv(); - - const opts: PretrainedModelOptions = { - use_external_data_format: true, - dtype: 'fp32', - device: useGPU ? 'cuda' : 'cpu', - }; - // { progress_callback, config, cache_dir, local_files_only, revision, device, dtype, subfolder, use_external_data_format, model_file_name, session_options, } - if (progressCallback) opts.progress_callback = progressCallback; - if (progressCallback) { - opts.progress_callback = progressCallback; - } - - const pipelineResult: any = await pipeline(Embedding.task, Embedding.model, opts); - Embedding.instance = pipelineResult as FeatureExtractionPipeline; - } - return Embedding.instance; - } - - private static configureEnv() { - if (embeddingModelCache) { - const absCache = path.resolve(embeddingModelCache); - if (!fs.existsSync(absCache)) { - fs.mkdirSync(absCache, { recursive: true }); - } - huggingfaceEnv.cacheDir = absCache; - } - huggingfaceEnv.allowLocalModels = true; +import { TransformerPipeline } from '../utils/model'; +import { TransformerPipelineOptions } from '../utils/types'; + +export default class Embedding extends TransformerPipeline { + protected static override getPipelineOptions(): TransformerPipelineOptions { + return { + task: 'feature-extraction' as PipelineType, + model: config.embeddingModel, + options: { + // progress_callback: (progress) => { + // console.log(`Embedding progress: ${progress}`); + // }, + }, + }; } /** - * Compute embeddings (mean-pooled, normalised by default) for one or more texts. - * @param texts Single string or array of strings to embed. - * @param options Pipeline options, e.g. { pooling: 'mean', normalize: true }. - * @returns 2D array [numText][embeddingDim] + * Turn text (or array of text) into one or more vectors. + * @param texts string or array + * @param opts override default mean-pooling/normalize */ public static async embed( texts: string | string[], - options: FeatureExtractionPipelineOptions = { pooling: 'mean', normalize: true }, + opts: FeatureExtractionPipelineOptions = { pooling: 'mean', normalize: true }, ): Promise { - const pipe = await Embedding.getInstance(); - const inputs = Array.isArray(texts) ? texts : [texts]; - const output = await pipe(inputs, options); - - // The pipeline returns a Tensor or array of Tensors (depending on input) - const raw = Array.isArray(output) ? output : [output]; - - const embeddings = raw.map((tensor) => { + // Pipeline is loaded & cached by TransformerPipeline + const pipe = await this.getInstance(); + const input = Array.isArray(texts) ? texts : [texts]; + // call the pipeline + const raw = await pipe(input, opts); + const arrs = (Array.isArray(raw) ? raw : [raw]).map((tensor) => { + // each tensor.data is a Float32Array const data = (tensor as any).data as Float32Array; - const arr = Array.from(data); - if (arr.length !== embeddingDim) { - throw new Error( - `Embedding dimension mismatch: expected ${embeddingDim}, got ${arr.length}`, - ); + const vec = Array.from(data); + if (vec.length !== config.embeddingDim) { + throw new Error(`Expected embeddingDim=${config.embeddingDim}, got ${vec.length}`); } - return arr; + return vec; }) as number[][]; - - return embeddings; + return arrs; } } - -export default Embedding; diff --git a/src/competence-matcher/src/tasks/reason.ts b/src/competence-matcher/src/tasks/reason.ts index f586dabd2..bb171e083 100644 --- a/src/competence-matcher/src/tasks/reason.ts +++ b/src/competence-matcher/src/tasks/reason.ts @@ -2,25 +2,26 @@ import { ollama } from '../utils/ollama'; import { config } from '../config'; import { MATCH_REASON as intructPrompt } from '../utils/prompts'; import type { Message } from 'ollama'; +import { Match } from '../utils/types'; -const { ollamaReasonModel } = config; +const { reasonModel } = config; -export async function addReason(matches: Match[], targetText: string): Promise { +export async function addReason(matches: T[], targetText: string): Promise { if (matches.length === 0) { return matches; // No matches to reason about } - const reasonMatches: Match[] = await Promise.all( + const reasonMatches: T[] = await Promise.all( matches.map(async (match) => { const messages: Message[] = [ ...intructPrompt, { role: 'user', - content: `Task: ${targetText}\nCompetence: ${match.text}\nSimilarity Score: ${match.distance.toFixed(2)}`, + content: `Task: ${targetText}\nCompetence: ${match.text}\nSimilarity Score: ${match.distance}`, }, ]; try { const response = await ollama.chat({ - model: ollamaReasonModel, + model: reasonModel, messages: messages, }); diff --git a/src/competence-matcher/src/tasks/semantic-opposites.ts b/src/competence-matcher/src/tasks/semantic-opposites.ts new file mode 100644 index 000000000..17d4eb062 --- /dev/null +++ b/src/competence-matcher/src/tasks/semantic-opposites.ts @@ -0,0 +1,39 @@ +import { + PipelineType, + TextClassificationPipeline, + ZeroShotClassificationPipeline, +} from '@huggingface/transformers'; +import { config } from '../config'; +import { TransformerPipeline } from '../utils/model'; +import { TransformerPipelineOptions } from '../utils/types'; + +export default class ZeroShot extends TransformerPipeline { + protected static override getPipelineOptions(): TransformerPipelineOptions { + return { + task: 'zero-shot-classification' as PipelineType, + model: config.nliModel, + options: { + // progress_callback: (progress) => { + // console.log(`Embedding progress: ${progress}`); + // }, + model_file_name: 'model.onnx', + use_external_data_format: true, + local_files_only: true, + }, + }; + } + + /** + * Classify text against a set of labels using zero-shot classification. + * @param text The text to classify. + * @param labels The labels to classify against. + * @param hypothesisTemplate Optional hypothesis template for classification - should include '{}' as placeholder for label. + */ + public static async classify(text: string, labels?: string[], hypothesisTemplate?: string) { + const _labels = labels || ['contradicting', 'aligning']; + const hypothesis_template = + hypothesisTemplate || 'Task description and Skill/Capability descriptions are {}.'; + const pipe = await this.getInstance(); + return pipe(text, _labels, { hypothesis_template }); + } +} diff --git a/src/competence-matcher/src/tasks/semantic-split.ts b/src/competence-matcher/src/tasks/semantic-split.ts index 8111c248f..c07583a8b 100644 --- a/src/competence-matcher/src/tasks/semantic-split.ts +++ b/src/competence-matcher/src/tasks/semantic-split.ts @@ -2,14 +2,95 @@ import { ollama } from '../utils/ollama'; import { config } from '../config'; import { SEMANTIC_SPLITTER as intructPrompt } from '../utils/prompts'; import type { Message } from 'ollama'; +import { EmbeddingTask } from '../utils/types'; -const { ollamaSplittingModel, splittingSymbol } = config; +const { splittingModel, splittingSymbol, ollamaBatchSize } = config; const MIN_TEXT_LENGTH = 60; // Minimum length of text to consider for splitting (I noticed that text inputs that are too short often lead to errors in the splitting process - and since they are so small, they can be embedded directly without splitting) +// async function ollamaChat(messages: Array<{ role: string; content: string }>) { +// const res = await fetch(`${ollamaPath}/api/chat`, { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ +// model: config.ollamaSplittingModel, +// messages, +// stream: false, +// }), +// }); +// if (!res.ok) { +// const t = await res.text(); +// throw new Error(`Ollama REST chat failed: ${res.status} ${t}`); +// } +// const data = (await res.json()) as { message: { content: string } }; +// return data.message.content as string; +// } + +// export async function splitSemantically(tasks: EmbeddingTask[]): Promise { +// const splittedTasks: EmbeddingTask[] = []; + +// // First, for each task, decide whether it needs splitting (filteredMessages) +// // or can be passed through as‐is. +// const toSplit: { task: EmbeddingTask; messages: Message[] }[] = []; +// for (const task of tasks) { +// const messages: Message[] = [...intructPrompt, { role: 'user', content: task.text }]; + +// // Filter out too‐short or empty +// const filtered = messages.filter(({ content }) => { +// const c = content.replace(/\s+/g, ' ').trim(); +// return c.length > MIN_TEXT_LENGTH; +// }); + +// if (filtered.length === 0) { +// // no splitting needed +// splittedTasks.push({ ...task, text: task.text }); +// } else { +// toSplit.push({ task, messages: filtered }); +// } +// } + +// // Now process in batches of size ollamaBatchSize +// for (let i = 0; i < toSplit.length; i += ollamaBatchSize) { +// const batch = toSplit.slice(i, i + ollamaBatchSize); + +// // Kick off all requests in this batch in parallel +// const promises = batch.map(async ({ task, messages }) => { +// try { +// const response = await ollamaChat(messages); +// const parts = response +// .replace(/\s+/g, ' ') +// .trim() +// .split(splittingSymbol) +// .map((p: string) => p.trim()) +// .filter((p: string) => p.length > 0); + +// if (parts.length === 0) { +// // fallback to original text if splitting yields nothing +// splittedTasks.push({ ...task, text: task.text }); +// } else { +// for (const part of parts) { +// splittedTasks.push({ ...task, text: part }); +// } +// } +// } catch (err) { +// console.error('Error during semantic splitting:', err); +// // in case of error, include the original +// splittedTasks.push({ ...task, text: task.text }); +// } +// }); + +// // Wait for this batch to finish before launching the next +// await Promise.all(promises); +// } + +// return splittedTasks; +// } + +// _______________________________________________ + export async function splitSemantically(tasks: EmbeddingTask[]): Promise { const splittedTasks: EmbeddingTask[] = []; - const promises: Promise[] = []; + const toSplit: { task: EmbeddingTask; messages: Message[] }[] = []; for (const task of tasks) { const messages: Message[] = [ @@ -21,46 +102,54 @@ export async function splitSemantically(tasks: EmbeddingTask[]): Promise { - const content = message.content.replace(/\s+/g, ' ').trim(); - if (content.trim() !== '' && content.length > MIN_TEXT_LENGTH) { - filteredMessages.push(message); - } else { - splittedTasks.push({ - ...task, - text: message.content, - }); - } + const filteredMessages = messages.filter((message) => { + const content = message.content + // Remove excessive whitespace + .replace(/\s+/g, ' ') + // Remove potential occurences of splittingSymbol in upper and lower case + .replace(new RegExp(splittingSymbol, 'gi'), ' ') + // Trim leading and trailing whitespace + .trim(); + return content !== '' && content.length > MIN_TEXT_LENGTH; }); - if (filteredMessages.length > 0) { - const promise = ollama - .chat({ - model: ollamaSplittingModel, - messages: filteredMessages, - }) - .then((response) => { - const parts = response.message.content.split(splittingSymbol).map((part) => part.trim()); - parts.forEach((part) => { - if (part !== '') { - splittedTasks.push({ - ...task, - text: part, - }); - } - }); - }) - .catch((error) => { - console.error('Error during semantic splitting:', error); - splittedTasks.push(task); - }); - - promises.push(promise); + if (filteredMessages.length === 0) { + splittedTasks.push({ ...task, text: task.text }); + } else { + toSplit.push({ task, messages: filteredMessages }); } } - await Promise.all(promises); + // Process in batches + for (let i = 0; i < toSplit.length; i += ollamaBatchSize) { + const batch = toSplit.slice(i, i + ollamaBatchSize); + + const promises = batch.map(async ({ task, messages }) => { + try { + const response = await ollama.chat({ + model: splittingModel, + messages, + }); + const parts = response.message.content + .split(splittingSymbol) + .map((part: string) => part.trim()) + .filter((part: string) => part !== ''); + + if (parts.length === 0) { + splittedTasks.push({ ...task, text: task.text }); + } else { + for (const part of parts) { + splittedTasks.push({ ...task, text: part }); + } + } + } catch (error) { + console.error('Error during semantic splitting:', error); + splittedTasks.push({ ...task, text: task.text }); + } + }); + + await Promise.all(promises); + } return splittedTasks; } diff --git a/src/competence-matcher/src/tasks/sematic-opposites.ts b/src/competence-matcher/src/tasks/sematic-opposites.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/competence-matcher/src/utils/huggingface.ts b/src/competence-matcher/src/utils/huggingface.ts new file mode 100644 index 000000000..04222b4db --- /dev/null +++ b/src/competence-matcher/src/utils/huggingface.ts @@ -0,0 +1,13 @@ +import Embedding from '../tasks/embedding'; +import ZeroShotSemanticOpposites from '../tasks/semantic-opposites'; + +export async function ensureAllHuggingfaceModelsAreAvailable() { + try { + Embedding.getInstance(); + ZeroShotSemanticOpposites.getInstance(); + } catch (error) { + throw error; + } + + console.log('All required Hugging Face models are available.'); +} diff --git a/src/competence-matcher/src/utils/model.ts b/src/competence-matcher/src/utils/model.ts new file mode 100644 index 000000000..907591251 --- /dev/null +++ b/src/competence-matcher/src/utils/model.ts @@ -0,0 +1,61 @@ +import { config } from '../config'; + +import { + pipeline, + env as huggingfaceEnv, + PipelineType, + PretrainedModelOptions, +} from '@huggingface/transformers'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { TransformerPipelineOptions } from './types'; + +/** + * Base class that handles: + * - singleton pipeline instance + * - model cache dir + * - on‑demand loading with optional progressCallback + */ +export abstract class TransformerPipeline { + protected static instance: any = null; + protected static loaded = false; + + protected static getPipelineOptions(): TransformerPipelineOptions { + throw new Error('getPipelineOptions must be implemented in subclasses'); + } + + private static async initEnv(cacheDir?: string) { + if (cacheDir) { + const abs = path.resolve(cacheDir); + if (!fs.existsSync(abs)) fs.mkdirSync(abs, { recursive: true }); + huggingfaceEnv.cacheDir = abs; + } + huggingfaceEnv.allowLocalModels = true; + } + + public static async getInstance(): Promise { + if (this.instance === null) { + const { task, model, options } = this.getPipelineOptions(); + await this.initEnv(options?.cache_dir || config.modelCache); + + const opts: PretrainedModelOptions = { + cache_dir: options?.cache_dir || config.modelCache, + use_external_data_format: options?.use_external_data_format ?? true, + device: options?.device || (config.useGPU ? 'cuda' : 'cpu'), + dtype: options?.dtype || 'fp32', + progress_callback: options?.progress_callback, + }; + + // actually load the pipeline + this.instance = await pipeline(task as PipelineType, model, opts); + + // mark it as loaded and log on first load + if (!this.loaded) { + console.log(`${model} (${task}) is ready`); + this.loaded = true; + } + } + + return this.instance; + } +} diff --git a/src/competence-matcher/src/utils/ollama.ts b/src/competence-matcher/src/utils/ollama.ts index 8b605f2a4..cb53af134 100644 --- a/src/competence-matcher/src/utils/ollama.ts +++ b/src/competence-matcher/src/utils/ollama.ts @@ -1,7 +1,7 @@ import { Ollama } from 'ollama'; import { config } from '../config'; -const { ollamaPath, ollamaSplittingModel, ollamaReasonModel } = config; +const { ollamaPath, splittingModel, reasonModel } = config; export const ollama = new Ollama({ host: ollamaPath, @@ -16,8 +16,8 @@ export const ollama = new Ollama({ * If the model cannot be downloaded or is not available, an error will be thrown. * (Ensures all needed models are actually available) */ -export const ensureAllModelsAreAvailable = async () => { - const models = [ollamaSplittingModel, ollamaReasonModel]; +export async function ensureAllOllamaModelsAreAvailable() { + const models = [splittingModel, reasonModel]; const availableModels = (await ollama.list()).models.map((model) => model.model); @@ -39,4 +39,4 @@ export const ensureAllModelsAreAvailable = async () => { } console.log('All required models are available in ollama.'); -}; +} diff --git a/src/competence-matcher/src/utils/prompts.ts b/src/competence-matcher/src/utils/prompts.ts index a2bffcb54..023fe5697 100644 --- a/src/competence-matcher/src/utils/prompts.ts +++ b/src/competence-matcher/src/utils/prompts.ts @@ -11,7 +11,7 @@ const SEMANTIC_SPLITTER_INTRUCT: Message = { Preserve the original ordering of words within each group — but groups themselves need not follow the original sequence. Separate each group only by the delimiter ${splittingSymbol} - (i.e. exactly as shown, on a line by themselves). + (i.e. exactly as shown, on a line by themselves, no additional whitespaces, just '${splittingSymbol}'). If the entire input is already one coherent semantic unit, return it verbatim without any delimiter. Grouping need do not be adjacent - just semantically related (i.e. two related text parts might be separated by other text parts). If the input is empty, return an empty string. @@ -134,7 +134,7 @@ const MATCH_REASON_EXAMPLES: Message[] = [ { role: 'assistant', content: ` - The task and competence match very well because the task requires operating CNC milling machines, which is exactly what the competence is about. + The statements match very well because the task requires operating CNC milling machines, which is exactly what the competence is about. `, }, { @@ -148,7 +148,7 @@ const MATCH_REASON_EXAMPLES: Message[] = [ { role: 'assistant', content: ` - The task and competence have a moderate match because while assembling circuit boards requires some knowledge of electronics, it does not specifically require advanced soldering skills. + The the statements have a moderate match because while assembling circuit boards requires some knowledge of electronics, it does not specifically require advanced soldering skills. `, }, { @@ -162,7 +162,7 @@ const MATCH_REASON_EXAMPLES: Message[] = [ { role: 'assistant', content: ` - The task and competence have a low match because preparing raw materials is a basic task that does not require advanced inventory management or supply chain logistics skills. + The statements have a low match because preparing raw materials is a basic task that does not require advanced inventory management or supply chain logistics skills. `, }, ]; @@ -172,7 +172,6 @@ export const MATCH_REASON: Message[] = [MATCH_REASON_INTRUCT, ...MATCH_REASON_EX /** * ------------------------------------------------------------- */ - // """""""""" // The warehouse must maintain ambient temperatures between 15°C and 25°C to protect sensitive goods. Humidity levels should not exceed 60% to prevent corrosion and mold growth. Inventory audits are scheduled weekly to ensure accuracy and compliance with safety standards. // """""""""" diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts index 313de6330..491b947ac 100644 --- a/src/competence-matcher/src/utils/types.ts +++ b/src/competence-matcher/src/utils/types.ts @@ -1,4 +1,6 @@ -type Competence = { +import { PretrainedModelOptions } from '@huggingface/transformers'; + +export type Competence = { listId: string; // UUIDString resourceId: string; // UUIDString competenceId: string; // UUIDString @@ -11,7 +13,7 @@ type Competence = { lastUsages?: string[]; // ISO date strings, optional }; -type CompetenceInput = { +export type CompetenceInput = { competenceId?: string; name?: string; description?: string; @@ -22,14 +24,14 @@ type CompetenceInput = { lastUsages?: string[]; }; -type ResourceInput = { +export type ResourceInput = { resourceId?: string; competencies: CompetenceInput[]; }; -type ResourceListInput = ResourceInput[]; +export type ResourceListInput = ResourceInput[]; -type MatchingTask = { +export type MatchingTask = { taskId: string; // UUIDString name?: string; // optional description?: string; // optional but recommended to have content @@ -37,7 +39,7 @@ type MatchingTask = { requiredCompetencies?: string[] | CompetenceInput[]; // either array of competenceIds or array of CompetenceInput }; -type Match = { +export type Match = { competenceId: string; text: string; type: string; @@ -45,12 +47,12 @@ type Match = { reason?: string; }; -interface VectorDBOptions { +export interface VectorDBOptions { filePath?: string; // If undefined or ":memory:", use in-memory; else path to file - Note: memory will not work with workers!! embeddingDim: number; } -type CompetenceDBOutput = { +export type CompetenceDBOutput = { competence_id: string; competence_name: string | null; competence_description: string | null; @@ -61,7 +63,7 @@ type CompetenceDBOutput = { last_usages: string | null; // JSON string }; -type EmbeddingTask = { +export type EmbeddingTask = { listId: string; // UUIDString resourceId: string; // UUIDString competenceId: string; // UUIDString @@ -69,30 +71,44 @@ type EmbeddingTask = { type: 'name' | 'description' | 'proficiencyLevel'; // Type of text }; -interface EmbeddingJob { +export interface Job { jobId: string; dbName: string; +} + +export interface EmbeddingJob extends Job { tasks: EmbeddingTask[]; } -interface MatchingJob { - jobId: string; - dbName: string; +export interface MatchingJob extends Job { listId?: string; // Which List to match against resourceId?: string; // Optional: If matching against a single resource tasks: MatchingTask[]; // Tasks to match } -type GroupedMatchResults = { +export type GroupedMatchResults = { taskId: string; competences: { competenceId: string; matchings: { text: string; type: 'name' | 'description' | 'proficiencyLevel'; - similarity: number; + matchProbability: number; reason?: string; }[]; - avgsimilarity: number; + avgMatchProbability: number; }[]; }[]; + +export type workerTypes = 'embedder' | 'matcher'; + +export interface WorkerQueue { + job: any; + workerScript: workerTypes; +} + +export interface TransformerPipelineOptions { + task: string; + model: string; + options?: PretrainedModelOptions; +} diff --git a/src/competence-matcher/src/utils/worker.ts b/src/competence-matcher/src/utils/worker.ts index 5be61883d..d7b051825 100644 --- a/src/competence-matcher/src/utils/worker.ts +++ b/src/competence-matcher/src/utils/worker.ts @@ -1,8 +1,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { Worker } from 'worker_threads'; -import { parentPort } from 'worker_threads'; +import { Worker, parentPort } from 'worker_threads'; import VectorDataBase from '../db/db'; +import { getDB } from './db'; +import { config } from '../config'; + +const { maxJobTime } = config; export function createWorker(filename: string): Worker { const tsPath = path.resolve(__dirname, `../worker/${filename}.ts`); @@ -17,55 +20,60 @@ export function createWorker(filename: string): Worker { const worker = new Worker(workerFile, { execArgv }); - worker.on('error', (err) => { - console.error('Embedding worker crashed:', err); - }); - worker.on('exit', (code) => { - if (code !== 0) { - console.error(`Worker exited with code ${code}`); - } - }); - worker.on('message', (message) => { - switch (message.type) { - case 'status': - console.log(`Worker for job ${message.jobId} status:`, message.status); - break; - case 'error': - console.error(`Worker for job ${message.jobId} error:`, message.error); - break; - } - }); - return worker; } -export async function workerWrapper(db: VectorDataBase, jobId: string, func: () => Promise) { +export async function withJobUpdates( + job: { jobId: string; dbName: string }, + cb: (db: VectorDataBase, payload: T) => Promise, + options?: { + onStart?: () => void; + onDone?: () => void; + onError?: (error: Error) => void; + }, +) { + const db = getDB(job.dbName); + let exitCode = 0; // success by default + let maxTimeCheck = setTimeout(() => { + // if not completed by then, timeout + process.exit(2); + }, maxJobTime); try { - // Mark job as running - db.updateJobStatus(jobId, 'running'); - parentPort!.postMessage({ type: 'status', status: 'running', jobId }); + if (options && options.onStart) { + options.onStart(); + } else { + db.updateJobStatus(job.jobId, 'running'); + parentPort!.postMessage({ type: 'status', jobId: job.jobId, status: 'running' }); + } - await func(); + await cb(db, job as any as T); - // Mark job completed - db.updateJobStatus(jobId, 'completed'); - // Notify parent (not really necessary) - parentPort!.postMessage({ type: 'status', status: 'completed', jobId }); + if (options && options.onDone) { + options.onDone(); + } else { + db.updateJobStatus(job.jobId, 'completed'); + parentPort!.postMessage({ type: 'status', jobId: job.jobId, status: 'completed' }); + } } catch (err) { - // Notify parent about error - !parentPort?.postMessage({ - jobId, - status: 'failed', - error: err instanceof Error ? err.message : 'Unknown error', - }); - // On any error: mark job as failed - try { - db.updateJobStatus(jobId, 'failed'); - } catch {} + if (options && options.onError) { + options.onError(err as Error); + } else { + exitCode = 1; // indicate failure + parentPort!.postMessage({ + type: 'error', + jobId: job.jobId, + error: err instanceof Error ? err.message : String(err), + }); + db.updateJobStatus(job.jobId, 'failed'); + } } finally { - // Clean up: close DB and exit + clearTimeout(maxTimeCheck); db.close(); parentPort!.close(); - process.exit(0); + process.exit(exitCode); } } + +export function log(...args: any[]) { + parentPort?.postMessage({ type: 'log', message: args.map(String).join(' ') }); +} diff --git a/src/competence-matcher/src/worker/embedder.ts b/src/competence-matcher/src/worker/embedder.ts index 4dc06529f..e51f65949 100644 --- a/src/competence-matcher/src/worker/embedder.ts +++ b/src/competence-matcher/src/worker/embedder.ts @@ -1,30 +1,24 @@ import { parentPort } from 'worker_threads'; import Embedding from '../tasks/embedding'; -import { getDB } from '../utils/db'; import { splitSemantically } from '../tasks/semantic-split'; -import { workerWrapper } from '../utils/worker'; +import { withJobUpdates } from '../utils/worker'; +import { config } from '../config'; +import { EmbeddingJob } from '../utils/types'; parentPort!.once('message', async (job: EmbeddingJob) => { - const { jobId, dbName, tasks } = job; + (global as any).CURRENT_JOB = job.jobId; - // Open the DB in this thread - // Note: This is another DB-Connector instance, not the one used by the main thread - // But it refers to the same database file - const db = getDB(dbName); - - workerWrapper(db, jobId, async () => { + await withJobUpdates(job, async (db, { tasks, jobId }) => { let work = tasks; - try { - work = await splitSemantically(tasks); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - parentPort!.postMessage({ type: 'error', error: errorMessage }); - } + // TODO: This appears to cause the worker to crash silently + // Split tasks semantically + // work = await splitSemantically(tasks); // For each task: embed & upsert for (const { listId, resourceId, competenceId, text, type } of work) { const [vector] = await Embedding.embed(text); // console.log(`Embedded text for job ${jobId}:`, text, '->', vector); + db.upsertEmbedding({ listId, resourceId, competenceId, text, type, embedding: vector }); } }); diff --git a/src/competence-matcher/src/worker/matcher.ts b/src/competence-matcher/src/worker/matcher.ts index a5b42d015..3207be04a 100644 --- a/src/competence-matcher/src/worker/matcher.ts +++ b/src/competence-matcher/src/worker/matcher.ts @@ -1,49 +1,86 @@ import { parentPort } from 'worker_threads'; import Embedding from '../tasks/embedding'; -import DBManager from '../db/db-manager'; -import { getDB } from '../utils/db'; -import { workerWrapper } from '../utils/worker'; +import { withJobUpdates } from '../utils/worker'; import { addReason } from '../tasks/reason'; +import { Match, MatchingJob } from '../utils/types'; +import ZeroShot from '../tasks/semantic-opposites'; parentPort!.once('message', async (job: MatchingJob) => { - const { jobId, dbName, listId, resourceId, tasks } = job; + // For workaround: + const matchResults: { [description: string]: any[] } = {}; + for (const task of job.tasks) { + const { taskId, name, description, executionInstructions, requiredCompetencies } = task; + if (!description) { + continue; // Skip tasks without description + } + // Add task description to match results + matchResults[description] = []; + } - // Open the DB in this thread - // Note: This is another DB instance, not the one used by the main thread - const db = getDB(dbName); + await withJobUpdates( + job, + async (db, { jobId, tasks, listId, resourceId }) => { + for (const task of tasks) { + const { taskId, name, description, executionInstructions, requiredCompetencies } = task; + if (!description) { + continue; // Skip tasks without description + } + // Embed the task description + const [vector] = await Embedding.embed(description); - workerWrapper(db, jobId, async () => { - // For each task: embed text and search for matches - for (const task of tasks) { - const { taskId, name, description, executionInstructions, requiredCompetencies } = task; - if (!description) { - continue; // Skip tasks without description - } - // Embed the task description - const [vector] = await Embedding.embed(description); + // Search for matches in the competence list (and resource if provided) + let matches: Match[] = db.searchEmbedding(vector, { + filter: { + listId: listId, + resourceId: resourceId, // Optional: If matching against a single resource + }, + }); - // Search for matches in the competence list (and resource if provided) - let matches: Match[] = db.searchEmbedding(vector, { - filter: { - listId: listId, - resourceId: resourceId, // Optional: If matching against a single resource - }, - }); + // TODO: This appears to cause the worker to not start at all + // Invert potentially contrastive matches + // Add reasoning for matching score + // matches = await addReason(matches, description); - // Add reasoning for matching score - matches = await addReason(matches, description); + for (const match of matches) { + // Check for semantic opposites + const zeroshotText = `Task description: ${description}\nSkill/Capability description: ${match.text}`; + const classification = await ZeroShot.classify(zeroshotText); + // @ts-ignore + switch (classification.labels[0]) { + case 'contradicting': + // Invert the match distance (since it's normalised to [0,1]: 1 - distance) + match.distance = 1 - match.distance; + break; + // switch instead of if, as we may want to have additional label-based checks in the future + } + // db.addMatchResult({ + // jobId, + // taskId, + // competenceId: match.competenceId, + // text: match.text, + // type: match.type as 'name' | 'description' | 'proficiencyLevel', + // distance: match.distance, + // reason: match.reason, + // }); + // } - for (const match of matches) { - db.addMatchResult({ - jobId, - taskId, - competenceId: match.competenceId, - text: match.text, - type: match.type as 'name' | 'description' | 'proficiencyLevel', - distance: match.distance, - reason: match.reason, - }); + // Workaround to avoid the worker crashing silently + matchResults[description].push({ + jobId, + taskId, + competenceId: match.competenceId, + text: match.text, + type: match.type as 'name' | 'description' | 'proficiencyLevel', + distance: match.distance, + reason: match.reason, + }); + } } - } - }); + }, + { + onDone: () => { + parentPort!.postMessage({ type: 'job', job: 'reason', workload: matchResults }); + }, + }, + ); }); diff --git a/src/competence-matcher/src/worker/test.ts b/src/competence-matcher/src/worker/test.ts new file mode 100644 index 000000000..fde1d468a --- /dev/null +++ b/src/competence-matcher/src/worker/test.ts @@ -0,0 +1,9 @@ +import { parentPort } from 'worker_threads'; +import { splitSemantically } from '../tasks/semantic-split'; +import { EmbeddingJob } from '../utils/types'; + +parentPort!.once('message', async (job: EmbeddingJob) => { + const { tasks, jobId } = job; + parentPort!.postMessage({ type: 'status', jobId, status: 'running' }); + parentPort!.postMessage(await splitSemantically(tasks)); +}); diff --git a/src/competence-matcher/src/worker/worker-manager.ts b/src/competence-matcher/src/worker/worker-manager.ts index f133fbdf5..62460686a 100644 --- a/src/competence-matcher/src/worker/worker-manager.ts +++ b/src/competence-matcher/src/worker/worker-manager.ts @@ -1,5 +1,121 @@ -import { Worker, workerData, parentPort } from 'worker_threads'; +import { Worker } from 'worker_threads'; import { config } from '../config'; import { createWorker } from '../utils/worker'; +import { splitSemantically } from '../tasks/semantic-split'; +import { Match, WorkerQueue, workerTypes } from '../utils/types'; +import { addReason } from '../tasks/reason'; +import { getDB } from '../utils/db'; -const { numberOfThreads } = config; +class WorkerManager { + private concurrency: number; + private queue: WorkerQueue[] = []; + private active: Set = new Set(); + + constructor(concurrency: number) { + this.concurrency = concurrency; + } + + /** + * Enqueue a job for the named worker script + */ + public enqueue(job: any, workerScript: workerTypes) { + this.queue.push({ job, workerScript }); + this.dispatch(); + } + + /** Try to start as many queued jobs as we have free threads */ + private dispatch() { + while (this.active.size < this.concurrency && this.queue.length > 0) { + const { job, workerScript } = this.queue.shift()!; + this.startWorker(job, workerScript); + } + } + + /** Spawn one worker, hook up its lifecycle, and send the job */ + private startWorker(job: any, workerScript: workerTypes) { + const worker = createWorker(workerScript); + + this.active.add(worker); + + worker.once('online', () => { + console.log(`[WorkerManager] Worker for ${workerScript} started`); + worker.postMessage(job); + }); + + // When the worker exits (success or failure), remove from active set & dispatch next + worker.once('exit', (code) => { + this.active.delete(worker); + if (code === 1) { + console.error(`[WorkerManager] ${workerScript} exited (failed) with code`, code); + } else if (code === 0) { + console.log(`[WorkerManager] ${workerScript} exited successfully`); + } else if (code === 2) { + console.error(`[WorkerManager] ${workerScript} timed out`); + } + this.dispatch(); + }); + + worker.once('error', (err) => { + console.error(`[WorkerManager] ${workerScript} error:`, err); + }); + + worker.on('message', async (message) => { + switch (message.type) { + case 'status': + console.log(`[WorkerManager] Worker for job ${message.jobId} status:`, message.status); + break; + case 'error': + console.error(`[WorkerManager] Worker for job ${message.jobId} error:`, message.error); + break; + case 'log': + console.log(`[WorkerManager] Worker for job ${message.jobId} log:`, message.message); + break; + + // Workaround for adding reasoning before saving in DB + case 'job': + switch (message.job) { + case 'reason': + const finalMatches = []; + // Add reasoning before saving in DB + for (const [task, matches] of Object.entries(message.workload)) { + const taskMatches = await addReason< + Match & { taskId: string; type: 'name' | 'description' | 'proficiencyLevel' } + >( + matches as (Match & { + taskId: string; + type: 'name' | 'description' | 'proficiencyLevel'; + })[], + task, + ); + finalMatches.push(...taskMatches); + } + + // Save in DB + const db = getDB(job.dbName); + + for (const match of finalMatches) { + db.addMatchResult({ + jobId: job.jobId, + taskId: match.taskId, + competenceId: match.competenceId, + text: match.text, + type: match.type, + distance: match.distance, + reason: match.reason, + }); + } + + // Update job status + db.updateJobStatus(job.jobId, 'completed'); + + break; + } + break; + } + }); + } +} + +// export a singleton instance +const manager = new WorkerManager(config.maxWorkerThreads); +export default manager; diff --git a/src/competence-matcher/tools/onnx-model-external-data.py b/src/competence-matcher/tools/onnx-model-external-data.py new file mode 100644 index 000000000..f2c74c216 --- /dev/null +++ b/src/competence-matcher/tools/onnx-model-external-data.py @@ -0,0 +1,32 @@ +import onnx +import argparse +import sys + +def main(): + parser = argparse.ArgumentParser(description="Convert ONNX model weights to external data format.") + parser.add_argument('--input', '-i', required=True, help='Path to the input ONNX model') + parser.add_argument('--output', '-o', required=False, help='Path to save the modified ONNX model (defaults to input path)', default=None) + + # If only -h/--help is present, show help and exit + if len(sys.argv) == 2 and sys.argv[1] in ('-h', '--help'): + parser.print_help() + sys.exit(0) + + args = parser.parse_args() + + if args.output is None: + args.output = args.input + + model = onnx.load(args.input) + + onnx.external_data_helper.convert_model_to_external_data( + model, + convert_attribute=True, + all_tensors_to_one_file=True, + location="model.onnx_data" + ) + + onnx.save(model, args.output) + +if __name__ == "__main__": + main() From 8f6c3788ab00f0a83f64a16343275d7fad6ffecb Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:44:50 +0200 Subject: [PATCH 15/17] feat: Implement OpenAPI specification for Matching Server API with resource competence list management and matching tasks fix: Update job status to include 'preprocessing' state in VectorDataBase feat: Enhance matchCompetenceList middleware to handle inline competence lists and job creation refactor: Improve resource list retrieval and creation logic in resource middleware fix: Update WorkerManager to support job options for online, exit, error, and message events chore: Clean up commented-out code in server initialization --- src/competence-matcher/opanAPI.json | 630 ++++++++++++++++++ src/competence-matcher/package.json | 4 +- src/competence-matcher/src/db/db.ts | 15 +- .../src/middleware/match.ts | 66 +- .../src/middleware/resource.ts | 222 +++--- src/competence-matcher/src/server.ts | 74 +- src/competence-matcher/src/utils/types.ts | 6 + .../src/worker/worker-manager.ts | 90 +-- 8 files changed, 909 insertions(+), 198 deletions(-) create mode 100644 src/competence-matcher/opanAPI.json diff --git a/src/competence-matcher/opanAPI.json b/src/competence-matcher/opanAPI.json new file mode 100644 index 000000000..ae6190cee --- /dev/null +++ b/src/competence-matcher/opanAPI.json @@ -0,0 +1,630 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Matching Server API", + "version": "0.1.0", + "description": "API for managing resource competence lists and matching tasks to resources." + }, + "servers": [ + { + "url": "http://localhost:8501" + } + ], + "paths": { + "/": { + "get": { + "tags": [ + "Default" + ], + "summary": "Welcome endpoint", + "responses": { + "200": { + "description": "Hello World!", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/resource-competence-list": { + "get": { + "tags": [ + "Resources" + ], + "summary": "Get all resource lists (only if multipleDBs=true)", + "responses": { + "200": { + "description": "Array of resource list IDs", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/resource-competence-list/jobs": { + "post": { + "tags": [ + "Resources" + ], + "summary": "Create a resource competence list job", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResourceInput" + } + } + } + } + }, + "responses": { + "202": { + "description": "Job accepted", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponse" + } + } + } + } + } + } + }, + "/resource-competence-list/jobs/{jobId}": { + "get": { + "tags": [ + "Resources" + ], + "summary": "Get status of a resource creation job", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Job pending/preprocessing/running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponse" + } + } + } + }, + "201": { + "description": "Completed with competenceListId", + "headers": { + "Location": { + "schema": { + "type": "string" + }, + "description": "Complete Path to created Competence-List" + } + }, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/JobResponse" + }, + { + "type": "object", + "properties": { + "competenceListId": { + "type": "string" + } + }, + "required": [ + "competenceListId" + ] + } + ] + } + } + } + }, + "500": { + "description": "Job failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponse" + } + } + } + } + } + } + }, + "/resource-competence-list/{competenceListId}": { + "get": { + "tags": [ + "Resources" + ], + "summary": "Get a specific resource competence list", + "parameters": [ + { + "name": "competenceListId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Resource list details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceList" + } + } + } + }, + "400": { + "description": "Missing resourceListId" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/matching-task-to-resource/jobs": { + "post": { + "tags": [ + "Matching" + ], + "summary": "Create a matching job", + "description": "Start a matching job by providing either an existing competenceListId or an inline competenceList.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/MatchByListIdRequest" + }, + { + "$ref": "#/components/schemas/MatchByListRequest" + } + ] + }, + "examples": { + "Match existing list": { + "summary": "Use an existing list ID", + "value": { + "competenceListId": "123e4567-e89b-12d3-a456-426614174000", + "tasks": [ + { + "taskId": "task1", + "name": "Task 1", + "description": "This is what we currently use for matching", + "executionInstructions": "Some execution instructions", + "requiredCompetencies": [ + { + "competenceId": "comp1", + "name": "Competence 1", + "description": "Description of competence 1", + "externalQualificationNeeded": false, + "renewTime": 30, + "proficiencyLevel": "advanced", + "qualificationDates": [ + "2025-07-01" + ], + "lastUsages": [ + "2025-07-10T14:30:00Z" + ] + } + ] + } + ] + } + }, + "Match inline list": { + "summary": "Pass a full competenceList inline", + "value": { + "competenceList": [ + { + "resourceId": "string", + "competencies": [ + { + "competenceId": "string", + "name": "string", + "description": "string", + "externalQualificationNeeded": true, + "renewTime": 0, + "proficiencyLevel": "string", + "qualificationDates": [ + "2025-07-15" + ], + "lastUsages": [ + "2025-07-15T10:37:09.695Z" + ] + } + ] + } + ], + "tasks": [ + { + "taskId": "task1", + "name": "Task 1", + "description": "This is what we currently use for matching", + "executionInstructions": "Some execution instructions", + "requiredCompetencies": [ + { + "competenceId": "comp1", + "name": "Competence 1", + "description": "Description of competence 1", + "externalQualificationNeeded": false, + "renewTime": 30, + "proficiencyLevel": "advanced", + "qualificationDates": [ + "2025-07-01" + ], + "lastUsages": [ + "2025-07-10T14:30:00Z" + ] + } + ] + } + ] + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Match job accepted", + "headers": { + "Location": { + "schema": { + "type": "string" + }, + "description": "Complete Path to created Competence-List" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponse" + } + } + } + } + } + } + }, + "/matching-task-to-resource/jobs/{jobId}": { + "get": { + "tags": [ + "Matching" + ], + "summary": "Get match job results", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Job pending/running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResponse" + } + } + } + }, + "200": { + "description": "Match results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupedMatchResults" + } + } + } + }, + "404": { + "description": "Job not found" + }, + "500": { + "description": "Job failed" + } + } + } + } + }, + "components": { + "schemas": { + "ResourceInput": { + "type": "object", + "required": [ + "resourceId", + "competencies" + ], + "properties": { + "resourceId": { + "type": "string" + }, + "competencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompetenceInput" + } + } + } + }, + "CompetenceInput": { + "type": "object", + "required": [ + "competenceId" + ], + "properties": { + "competenceId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalQualificationNeeded": { + "type": "boolean" + }, + "renewTime": { + "type": "number" + }, + "proficiencyLevel": { + "type": "string" + }, + "qualificationDates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "lastUsages": { + "type": "array", + "items": { + "type": "string", + "format": "date-time" + } + } + } + }, + "ResourceList": { + "type": "object", + "required": [ + "competenceListId", + "resources" + ], + "properties": { + "competenceListId": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "required": [ + "resourceId", + "competencies" + ], + "properties": { + "resourceId": { + "type": "string" + }, + "competencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompetenceInput" + } + } + } + } + } + } + }, + "JobResponse": { + "type": "object", + "required": [ + "jobId", + "status" + ], + "properties": { + "jobId": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "MatchByListIdRequest": { + "type": "object", + "required": [ + "competenceListId", + "tasks" + ], + "properties": { + "competenceListId": { + "type": "string" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchingTask" + } + } + } + }, + "MatchByListRequest": { + "type": "object", + "required": [ + "competenceList", + "tasks" + ], + "properties": { + "competenceList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResourceInput" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchingTask" + } + } + } + }, + "MatchingTask": { + "type": "object", + "required": [ + "taskId" + ], + "properties": { + "taskId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "executionInstructions": { + "type": "string" + }, + "requiredCompetencies": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/CompetenceInput" + } + ] + } + } + } + }, + "GroupedMatchResults": { + "type": "array", + "items": { + "type": "object", + "required": [ + "taskId", + "competences" + ], + "properties": { + "taskId": { + "type": "string" + }, + "competences": { + "type": "array", + "items": { + "type": "object", + "required": [ + "competenceId", + "avgMatchProbability", + "matchings" + ], + "properties": { + "competenceId": { + "type": "string" + }, + "avgMatchProbability": { + "type": "number" + }, + "matchings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchDetail" + } + } + } + } + } + } + } + }, + "MatchDetail": { + "type": "object", + "required": [ + "text", + "type", + "matchProbability" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "name", + "description", + "proficiencyLevel" + ] + }, + "matchProbability": { + "type": "number" + }, + "reason": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/competence-matcher/package.json b/src/competence-matcher/package.json index 5ee53612d..032b82a8d 100644 --- a/src/competence-matcher/package.json +++ b/src/competence-matcher/package.json @@ -1,6 +1,6 @@ { "name": "competence-matcher", - "version": "0.0.1", + "version": "0.1.0", "description": "Matching microservice that allows to allows to define and match on data criteria", "main": "dist/server.js", "scripts": { @@ -37,4 +37,4 @@ "engines": { "node": ">=23.5.0" } -} +} \ No newline at end of file diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts index ad1ec27c2..b5bed7812 100644 --- a/src/competence-matcher/src/db/db.ts +++ b/src/competence-matcher/src/db/db.ts @@ -190,7 +190,7 @@ class VectorDataBase { */ public updateJobStatus( jobId: string, - status: 'pending' | 'running' | 'completed' | 'failed', + status: 'pending' | 'preprocessing' | 'running' | 'completed' | 'failed', ): void { const result = this.db.prepare(`UPDATE jobs SET status = ? WHERE id = ?`).run(status, jobId); if (result.changes === 0) throw new Error(`Job with id ${jobId} not found`); @@ -227,15 +227,14 @@ class VectorDataBase { distance: number; reason?: string; // optional reason for the match }): void { - console.log(opts); const id = uuid(); this.db .prepare( ` - INSERT INTO match_results - (id, job_id, task_id, competence_id, text, type, distance, reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, + INSERT INTO match_results + (id, job_id, task_id, competence_id, text, type, distance, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, ) .run( id, @@ -328,7 +327,7 @@ class VectorDataBase { * @throws if listId doesn’t exist. */ public getResourceList(listId: string): { - listId: string; + competenceListId: string; resources: Array<{ resourceId: string; competencies: Array<{ @@ -351,7 +350,7 @@ class VectorDataBase { .all(listId) as Array<{ _rid: number; resource_id: string }>; return { - listId, + competenceListId: listId, resources: resources.map(({ _rid, resource_id }) => { const comps = this.db .prepare( diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts index a1df4ac3c..954d265aa 100644 --- a/src/competence-matcher/src/middleware/match.ts +++ b/src/competence-matcher/src/middleware/match.ts @@ -1,7 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { PATHS } from '../server'; import { getDB } from '../utils/db'; -import { createWorker } from '../utils/worker'; import workerManager from '../worker/worker-manager'; import { CompetenceInput, @@ -10,6 +9,7 @@ import { MatchingTask, ResourceListInput, } from '../utils/types'; +import { handleCreateResourceList } from './resource'; export function matchCompetenceList(req: Request, res: Response, next: NextFunction): void { try { @@ -128,11 +128,63 @@ export function matchCompetenceList(req: Request, res: Response, next: NextFunct /**-------------------------------------------- * Case new Competence-List was passed *---------------------------------------------*/ - res.status(501).json({ - error: - 'Matching with new competence lists is not implemented, yet. For now, please create a competence list first and then match against it.', - }); - return; + // Create a new competence list + const matchingJobId = db.createJob(); + if (list!) { + db.updateJobStatus(matchingJobId, 'preprocessing'); + handleCreateResourceList(req.dbName!, list, (job, code, jobId) => { + try { + // Embedding fails -> no matching possible (i.e. fail the matching job) + if (code !== 0) { + db.updateJobStatus(matchingJobId, 'failed'); + return; + } + db.updateJobStatus(matchingJobId, 'pending'); + + // Retrieve the competence list ID + const { referenceId: listId } = db.getJob(jobId); + // Create the matching job + const matchingJob: MatchingJob = { + jobId: matchingJobId, + dbName: req.dbName!, + listId, + resourceId: undefined, // For now, we don't support matching against a single resource + tasks: taskInput.map((task) => { + return { + taskId: task.taskId, + name: task.name, + description: task.description, + executionInstructions: task.executionInstructions, + requiredCompetencies: (task.requiredCompetencies ?? []).map((competence) => + typeof competence === 'string' + ? (competence as string) + : ({ + competenceId: competence.competenceId, + name: competence.name, + description: competence.description, + externalQualificationNeeded: competence.externalQualificationNeeded, + renewTime: competence.renewTime, + proficiencyLevel: competence.proficiencyLevel, + qualificationDates: competence.qualificationDates, + lastUsages: competence.lastUsages, + } as CompetenceInput), + ) as string[] | CompetenceInput[], + }; + }), + }; + // Enqueue the matching job + workerManager.enqueue(matchingJob, 'matcher'); + } catch (error) { + db.updateJobStatus(matchingJobId, 'failed'); + console.error('Error creating (inline) matching job:', error); + } + }); + + res + .setHeader('Location', `${PATHS.match}/jobs/${matchingJobId}`) + .status(202) + .json({ jobId: matchingJobId, status: 'pending' }); + } } catch (error) { console.error('Error matching:', error); res.status(500).json({ error: 'Internal Server Error' }); @@ -151,7 +203,7 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti } // Job can be pending, running, completed, or failed - if (job.status === 'pending' || job.status === 'running') { + if (job.status === 'pending' || job.status === 'running' || job.status === 'preprocessing') { res.status(202).json({ jobId, status: job.status, diff --git a/src/competence-matcher/src/middleware/resource.ts b/src/competence-matcher/src/middleware/resource.ts index f028a46d6..028d451f0 100644 --- a/src/competence-matcher/src/middleware/resource.ts +++ b/src/competence-matcher/src/middleware/resource.ts @@ -1,7 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { PATHS } from '../server'; import { getDB } from '../utils/db'; -import { createWorker } from '../utils/worker'; import workerManager from '../worker/worker-manager'; import { splitSemantically } from '../tasks/semantic-split'; import { CompetenceInput, EmbeddingJob, EmbeddingTask, ResourceInput } from '../utils/types'; @@ -12,7 +11,7 @@ export function getResourceLists(req: Request, res: Response, next: NextFunction const availableResourceLists = db.getAvailableResourceLists(); - res.status(200).json(availableResourceLists); + res.status(200).json(availableResourceLists); // string[] } catch (error) { console.error('Error retrieving resource lists:', error); res.status(500).json({ error: 'Internal Server Error' }); @@ -29,88 +28,92 @@ export function getResourceList(req: Request, res: Response, next: NextFunction) return; } - const resourceList = db.getResourceList(resourceListId); - - if (!resourceList) { + let resourceList; + try { + resourceList = db.getResourceList(resourceListId); + } catch (error) { res.status(404).json({ error: 'Resource list not found' }); return; } res.status(200).json(resourceList); + // type: + // resourceList: { + // competenceListId: string; + // resources: Array<{ + // resourceId: string; + // competencies: Array<{ + // competenceId: string; + // name?: string; + // description?: string; + // externalQualificationNeeded: boolean; + // renewTime?: number; + // proficiencyLevel?: string; + // qualificationDates: string[]; + // lastUsages: string[]; + // }>; + // }>; + // } } catch (error) { console.error('Error retrieving resource list:', error); res.status(500).json({ error: 'Internal Server Error' }); } } -export function createResourceList(req: Request, res: Response, next: NextFunction): void { - // Check if the request body contains the necessary data +// Helper function to handle the creation logic +export async function handleCreateResourceList( + dbName: string, + resources: ResourceInput[], + onWorkerExit?: (job: any, code: number, jobId: string) => void, +): Promise<{ jobId: string; status: string }> { let resourceIds: string[] = []; - let competences: CompetenceInput /* resourceIndex */[] /* competenceIndex */[] = []; - try { - if (!Array.isArray(req.body) || req.body.length === 0) { - res.status(400).json({ error: 'Invalid request body. Expected an array of resources.' }); + let competences: CompetenceInput[][] = []; + + // Validate and extract data + resources.forEach(({ resourceId, competencies }: ResourceInput) => { + if (!resourceId || typeof resourceId !== 'string') { + throw new Error('Invalid resourceId in request body'); } - req.body.forEach(({ resourceId, competencies }: ResourceInput) => { - if (!resourceId || typeof resourceId !== 'string') { - throw new Error('Invalid resourceId in request body'); - } - if (!Array.isArray(competencies) /* || competencies.length === 0 */) { - throw new Error('Invalid competencies in request body'); + if (!Array.isArray(competencies)) { + throw new Error('Invalid competencies in request body'); + } + resourceIds.push(resourceId); + const checkedCompetences = competencies.map((c: CompetenceInput) => { + if (!c.competenceId || typeof c.competenceId !== 'string') { + throw new Error('Invalid competenceId in request body'); } - resourceIds.push(resourceId); - const checkedCompetences = competencies.map((c: CompetenceInput) => { - if (!c.competenceId || typeof c.competenceId !== 'string') { - throw new Error('Invalid competenceId in request body'); - } - return { - competenceId: c.competenceId, - name: c.name, - description: c.description, - externalQualificationNeeded: c.externalQualificationNeeded, - renewTime: c.renewTime, - proficiencyLevel: c.proficiencyLevel, - qualificationDates: c.qualificationDates, - lastUsages: c.lastUsages, - }; - }); - - competences.push(checkedCompetences); + return { + competenceId: c.competenceId, + name: c.name, + description: c.description, + externalQualificationNeeded: c.externalQualificationNeeded, + renewTime: c.renewTime, + proficiencyLevel: c.proficiencyLevel, + qualificationDates: c.qualificationDates, + lastUsages: c.lastUsages, + }; }); - } catch (error) { - console.error('Error processing request body:', error); - res.status(400).json({ error: 'Invalid request body format' }); - return; - } - /* ------------------------- */ + competences.push(checkedCompetences); + }); + + // Create a new resource list in the database let listId: string; let jobId: string; - try { - const db = getDB(req.dbName!); - // TODO: Should we blindly trust that client only send integrity data? -> Maybe add a check for id duplicates? - db.atomicStep(() => { - // ResourceList - listId = db.createResourceList(); - // Resources - resourceIds.forEach((resourceId) => { - db.addResource(listId, resourceId); - }); - // Competences - competences.forEach((competenceArray, resourceIndex) => { - competenceArray.forEach((competence) => { - db.addCompetence(listId, resourceIds[resourceIndex], competence); - }); + const db = getDB(dbName); + db.atomicStep(() => { + listId = db.createResourceList(); + resourceIds.forEach((resourceId) => { + db.addResource(listId, resourceId); + }); + competences.forEach((competenceArray, resourceIndex) => { + competenceArray.forEach((competence) => { + db.addCompetence(listId, resourceIds[resourceIndex], competence); }); - // Embeddings is offloaded to worker -> Just create a job - jobId = db.createJob(listId); }); - } catch (error) { - console.error('Error adding resource list:', error); - res.status(500).json({ error: 'Internal Server Error' }); - return; - } + jobId = db.createJob(listId); + }); - // Start Embedding Worker + // Prepare embedding tasks const descriptionEmbeddingInput = competences .map((competenceArray, resourceIndex) => { return competenceArray.map((competence) => { @@ -125,42 +128,54 @@ export function createResourceList(req: Request, res: Response, next: NextFuncti }) .flat() as EmbeddingTask[]; - // This is a workaround to avoid the worker crashing silently - // Preferably the splitting should be done in the worker - // For now it is just done asynchronously here - splitSemantically(descriptionEmbeddingInput) - .then((tasks) => { - // console.log(tasks); - const job: EmbeddingJob = { - jobId: jobId!, - dbName: req.dbName!, - tasks, - }; - workerManager.enqueue(job, 'embedder'); - }) - .catch((err) => { - console.error('Error splitting semantically:', err); - // Do embedding without splitting in case of error - const job: EmbeddingJob = { - jobId: jobId!, - dbName: req.dbName!, - tasks: descriptionEmbeddingInput, - }; - workerManager.enqueue(job, 'embedder'); - }); - // const job: EmbeddingJob = { - // jobId: jobId!, - // dbName: req.dbName!, - // tasks: descriptionEmbeddingInput, - // }; - // workerManager.enqueue(job, 'embedder'); - - // Respond with jobid in location header - res - .setHeader('Location', `${PATHS.resource}/jobs/${jobId!}`) - // Rspond with accepted status and jobId - .status(202) - .json({ jobId: jobId!, status: 'pending' }); + // Workaround for now + // Ideally, the worker should handle the splitting as well + db.updateJobStatus(jobId!, 'preprocessing'); + let job: EmbeddingJob | undefined; + try { + const tasks = await splitSemantically(descriptionEmbeddingInput); + job = { + jobId: jobId!, + dbName: dbName, + tasks, + }; + } catch (err) { + console.error('Error splitting semantically:', err); + job = { + jobId: jobId!, + dbName: dbName, + tasks: descriptionEmbeddingInput, + }; + } + db.updateJobStatus(jobId!, 'pending'); + workerManager.enqueue(job, 'embedder', { + onExit: (job, code) => onWorkerExit?.(job, code, jobId!), + }); + + return { jobId: jobId!, status: 'pending' }; +} + +export function createResourceList(req: Request, res: Response, next: NextFunction): void { + if (!Array.isArray(req.body) || req.body.length === 0) { + res.status(400).json({ error: 'Invalid request body. Expected an array of resources.' }); + return; + } + try { + handleCreateResourceList(req.dbName!, req.body) + .then(({ jobId, status }) => { + res + .setHeader('Location', `${PATHS.resource}/jobs/${jobId}`) + .status(202) + .json({ jobId, status }); + }) + .catch((error) => { + console.error('Error adding resource list:', error); + res.status(400).json({ error: error.message || 'Invalid request body format' }); + }); + } catch (error) { + console.error('Error processing request body:', error); + res.status(400).json({ error: 'Invalid request body format' }); + } } export function getJobStatus(req: Request, res: Response) { @@ -170,10 +185,9 @@ export function getJobStatus(req: Request, res: Response) { switch (job.status) { case 'pending': - res.status(202).json({ jobId: job.jobId, status: job.status }); - return; + case 'preprocessing': case 'running': - res.status(202).json({ jobId: job.jobId, status: job.status }); + res.status(202).json({ jobId: job.jobId, status: job.status }); // both strings return; case 'completed': res @@ -182,7 +196,7 @@ export function getJobStatus(req: Request, res: Response) { .json({ jobId: job.jobId, status: job.status, competenceListId: job.referenceId }); return; case 'failed': - res.status(500).json({ jobId: job.jobId, status: job.status }); + res.status(500).json({ jobId: job.jobId, status: job.status }); //both strings return; default: res.status(500).json({ error: 'Internal Server Error' }); diff --git a/src/competence-matcher/src/server.ts b/src/competence-matcher/src/server.ts index c3e234865..158a5faac 100644 --- a/src/competence-matcher/src/server.ts +++ b/src/competence-matcher/src/server.ts @@ -35,43 +35,43 @@ async function main() { // Ollama models await ensureAllOllamaModelsAreAvailable(); - const tasks = [ - { - listId: 'test-list', - resourceId: 'test-resource', - competenceId: 'test-competence', - text: 'This competence covers the principles and best practices of designing scalable software systems. It includes high-level architecture, component interaction, and trade-off analysis. Practitioners will need to balance performance, reliability, and maintainability when making design decisions.', - type: 'description', - }, - { - listId: 'test-list', - resourceId: 'test-resource', - competenceId: 'test-competence', - text: 'This competence focuses on building and maintaining RESTful and GraphQL APIs. It covers endpoint design, versioning strategies, and error handling. Learners will gain hands-on experience with request validation, authentication, and performance tuning.', - type: 'description', - }, - { - listId: 'test-list', - resourceId: 'test-resource', - competenceId: 'test-competence', - text: 'This competence entails designing effective database schemas to represent business domains. It involves normalization, denormalization, and indexing strategies for optimal query performance. Real-world scenarios will illustrate when to choose relational versus NoSQL approaches.', - type: 'description', - }, - { - listId: 'test-list', - resourceId: 'test-resource', - competenceId: 'test-competence', - text: 'This competence covers fundamental security principles for web applications. Topics include authentication, authorization, encryption, and secure configuration management. Practical exercises demonstrate common vulnerabilities and how to mitigate them effectively.', - type: 'description', - }, - { - listId: 'test-list', - resourceId: 'test-resource', - competenceId: 'test-competence', - text: "This person can not swim at all. Please don't let them close water at all.", - type: 'description', - }, - ] as EmbeddingTask[]; + // const tasks = [ + // { + // listId: 'test-list', + // resourceId: 'test-resource', + // competenceId: 'test-competence', + // text: 'This competence covers the principles and best practices of designing scalable software systems. It includes high-level architecture, component interaction, and trade-off analysis. Practitioners will need to balance performance, reliability, and maintainability when making design decisions.', + // type: 'description', + // }, + // { + // listId: 'test-list', + // resourceId: 'test-resource', + // competenceId: 'test-competence', + // text: 'This competence focuses on building and maintaining RESTful and GraphQL APIs. It covers endpoint design, versioning strategies, and error handling. Learners will gain hands-on experience with request validation, authentication, and performance tuning.', + // type: 'description', + // }, + // { + // listId: 'test-list', + // resourceId: 'test-resource', + // competenceId: 'test-competence', + // text: 'This competence entails designing effective database schemas to represent business domains. It involves normalization, denormalization, and indexing strategies for optimal query performance. Real-world scenarios will illustrate when to choose relational versus NoSQL approaches.', + // type: 'description', + // }, + // { + // listId: 'test-list', + // resourceId: 'test-resource', + // competenceId: 'test-competence', + // text: 'This competence covers fundamental security principles for web applications. Topics include authentication, authorization, encryption, and secure configuration management. Practical exercises demonstrate common vulnerabilities and how to mitigate them effectively.', + // type: 'description', + // }, + // { + // listId: 'test-list', + // resourceId: 'test-resource', + // competenceId: 'test-competence', + // text: "This person can not swim at all. Please don't let them close water at all.", + // type: 'description', + // }, + // ] as EmbeddingTask[]; // const testworker = createWorker('test'); // testworker.on('message', (message) => { diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts index 491b947ac..a12ab3dfb 100644 --- a/src/competence-matcher/src/utils/types.ts +++ b/src/competence-matcher/src/utils/types.ts @@ -105,6 +105,12 @@ export type workerTypes = 'embedder' | 'matcher'; export interface WorkerQueue { job: any; workerScript: workerTypes; + options?: { + onOnline?: (job: any) => void; + onExit?: (job: any, code: number) => void; + onError?: (job: any, error: Error) => void; + onMessage?: (job: any, message: any) => void; + }; } export interface TransformerPipelineOptions { diff --git a/src/competence-matcher/src/worker/worker-manager.ts b/src/competence-matcher/src/worker/worker-manager.ts index 62460686a..d529a8514 100644 --- a/src/competence-matcher/src/worker/worker-manager.ts +++ b/src/competence-matcher/src/worker/worker-manager.ts @@ -18,28 +18,30 @@ class WorkerManager { /** * Enqueue a job for the named worker script */ - public enqueue(job: any, workerScript: workerTypes) { - this.queue.push({ job, workerScript }); + public enqueue(job: any, workerScript: workerTypes, options: WorkerQueue['options'] = {}) { + this.queue.push({ job, workerScript, options }); this.dispatch(); } /** Try to start as many queued jobs as we have free threads */ private dispatch() { while (this.active.size < this.concurrency && this.queue.length > 0) { - const { job, workerScript } = this.queue.shift()!; - this.startWorker(job, workerScript); + const { job, workerScript, options } = this.queue.shift()!; + this.startWorker(job, workerScript, options); } } /** Spawn one worker, hook up its lifecycle, and send the job */ - private startWorker(job: any, workerScript: workerTypes) { + private startWorker(job: any, workerScript: workerTypes, options: WorkerQueue['options']) { const worker = createWorker(workerScript); this.active.add(worker); worker.once('online', () => { - console.log(`[WorkerManager] Worker for ${workerScript} started`); + // console.log(`[WorkerManager] Worker for ${workerScript} started`); worker.postMessage(job); + + options?.onOnline?.(job); }); // When the worker exits (success or failure), remove from active set & dispatch next @@ -48,15 +50,19 @@ class WorkerManager { if (code === 1) { console.error(`[WorkerManager] ${workerScript} exited (failed) with code`, code); } else if (code === 0) { - console.log(`[WorkerManager] ${workerScript} exited successfully`); + // console.log(`[WorkerManager] ${workerScript} exited successfully`); } else if (code === 2) { console.error(`[WorkerManager] ${workerScript} timed out`); } this.dispatch(); + + options?.onExit?.(job, code); }); worker.once('error', (err) => { console.error(`[WorkerManager] ${workerScript} error:`, err); + + options?.onError?.(job, err); }); worker.on('message', async (message) => { @@ -75,47 +81,51 @@ class WorkerManager { case 'job': switch (message.job) { case 'reason': - const finalMatches = []; - // Add reasoning before saving in DB - for (const [task, matches] of Object.entries(message.workload)) { - const taskMatches = await addReason< - Match & { taskId: string; type: 'name' | 'description' | 'proficiencyLevel' } - >( - matches as (Match & { - taskId: string; - type: 'name' | 'description' | 'proficiencyLevel'; - })[], - task, - ); - finalMatches.push(...taskMatches); - } - - // Save in DB - const db = getDB(job.dbName); - - for (const match of finalMatches) { - db.addMatchResult({ - jobId: job.jobId, - taskId: match.taskId, - competenceId: match.competenceId, - text: match.text, - type: match.type, - distance: match.distance, - reason: match.reason, - }); - } - - // Update job status - db.updateJobStatus(job.jobId, 'completed'); - + await handleReasoning(job, message); break; } break; } + options?.onMessage?.(job, message); }); } } +async function handleReasoning(job: any, message: any) { + const finalMatches = []; + // Add reasoning before saving in DB + for (const [task, matches] of Object.entries(message.workload)) { + const taskMatches = await addReason< + Match & { taskId: string; type: 'name' | 'description' | 'proficiencyLevel' } + >( + matches as (Match & { + taskId: string; + type: 'name' | 'description' | 'proficiencyLevel'; + })[], + task, + ); + finalMatches.push(...taskMatches); + } + + // Save in DB + const db = getDB(job.dbName); + + for (const match of finalMatches) { + db.addMatchResult({ + jobId: job.jobId, + taskId: match.taskId, + competenceId: match.competenceId, + text: match.text, + type: match.type, + distance: match.distance, + reason: match.reason, + }); + } + + // Update job status + db.updateJobStatus(job.jobId, 'completed'); +} + // export a singleton instance const manager = new WorkerManager(config.maxWorkerThreads); export default manager; From d3e6ee8b423e528c19430a3fe8dc386ca94bd568 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Sun, 20 Jul 2025 16:10:29 +0200 Subject: [PATCH 16/17] Updated OpenAPI specification for Matching Server API and enhance database schema for match results --- .../{opanAPI.json => openAPI.json} | 51 ++++-- src/competence-matcher/src/db/db.ts | 35 ++-- .../src/middleware/match.ts | 171 ++++++++++++------ .../src/middleware/resource.ts | 42 +++-- src/competence-matcher/src/utils/model.ts | 4 +- src/competence-matcher/src/utils/types.ts | 35 ++-- src/competence-matcher/src/worker/matcher.ts | 8 +- .../src/worker/worker-manager.ts | 11 +- 8 files changed, 241 insertions(+), 116 deletions(-) rename src/competence-matcher/{opanAPI.json => openAPI.json} (92%) diff --git a/src/competence-matcher/opanAPI.json b/src/competence-matcher/openAPI.json similarity index 92% rename from src/competence-matcher/opanAPI.json rename to src/competence-matcher/openAPI.json index ae6190cee..f22c309b1 100644 --- a/src/competence-matcher/opanAPI.json +++ b/src/competence-matcher/openAPI.json @@ -563,37 +563,64 @@ "items": { "type": "object", "required": [ - "taskId", - "competences" + "resourceId", + "taskMatchings", + "avgTaskMatchProbability" ], "properties": { - "taskId": { + "resourceId": { "type": "string" }, - "competences": { + "taskMatchings": { "type": "array", "items": { "type": "object", "required": [ - "competenceId", - "avgMatchProbability", - "matchings" + "taskId", + "taskText", + "competenceMatchings", + "maxMatchProbability" ], "properties": { - "competenceId": { + "taskId": { "type": "string" }, - "avgMatchProbability": { - "type": "number" + "taskText": { + "type": "string" }, - "matchings": { + "competenceMatchings": { "type": "array", "items": { - "$ref": "#/components/schemas/MatchDetail" + "type": "object", + "required": [ + "competenceId", + "matchings", + "avgMatchProbability" + ], + "properties": { + "competenceId": { + "type": "string" + }, + "matchings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MatchDetail" + } + }, + "avgMatchProbability": { + "type": "number" + } + } } + }, + "maxMatchProbability": { + "type": "number" } } } + }, + "avgTaskMatchProbability": { + "type": "number" } } } diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts index b5bed7812..313b8c46e 100644 --- a/src/competence-matcher/src/db/db.ts +++ b/src/competence-matcher/src/db/db.ts @@ -65,12 +65,14 @@ class VectorDataBase { CREATE TABLE IF NOT EXISTS match_results ( id TEXT PRIMARY KEY, -- UUID for this match record job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - task_id TEXT NOT NULL, -- task ID this match belongs to + task_id TEXT NOT NULL, -- task ID this match belongs to, + task_text TEXT NOT NULL, -- task text that was used for matching competence_id TEXT NOT NULL, -- matched competence + resource_id TEXT NOT NULL, -- resource ID the competence belongs to distance REAL NOT NULL, -- similarity score text TEXT NOT NULL, -- the matched snippet type TEXT NOT NULL, -- 'name' | 'description' | 'proficiencyLevel' - reason TEXT -- llm based reason for the match + reason TEXT -- llm based reason for the match ); `); this.db.exec(` @@ -221,10 +223,12 @@ class VectorDataBase { public addMatchResult(opts: { jobId: string; taskId: string; + taskText: string; competenceId: string; + resourceId: string; + distance: number; text: string; type: 'name' | 'description' | 'proficiencyLevel'; - distance: number; reason?: string; // optional reason for the match }): void { const id = uuid(); @@ -232,18 +236,20 @@ class VectorDataBase { .prepare( ` INSERT INTO match_results - (id, job_id, task_id, competence_id, text, type, distance, reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (id, job_id, task_id, task_text, competence_id, resource_id, distance, text, type, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( id, opts.jobId, opts.taskId, + opts.taskText, opts.competenceId, + opts.resourceId, + opts.distance, opts.text, opts.type, - opts.distance, opts.reason ?? null, ); } @@ -254,16 +260,18 @@ class VectorDataBase { */ public getMatchResults(jobId: string): Array<{ taskId: string; + taskText: string; competenceId: string; + resourceId: string; + distance: number; text: string; type: string; - distance: number; reason?: string; }> { return this.db .prepare( ` - SELECT task_id, competence_id, text, type, distance, reason + SELECT task_id, task_text, competence_id, resource_id, distance, text, type, reason FROM match_results WHERE job_id = ? ORDER BY task_id, distance @@ -272,10 +280,12 @@ class VectorDataBase { .all(jobId) .map((r: any) => ({ taskId: r.task_id, + taskText: r.task_text, competenceId: r.competence_id, + resourceId: r.resource_id, + distance: r.distance, text: r.text, type: r.type, - distance: r.distance, reason: r.reason ?? undefined, })); } @@ -767,6 +777,7 @@ class VectorDataBase { }, ): Array<{ competenceId: string; + resourceId: string; text: string; type: string; distance: number; @@ -783,7 +794,7 @@ class VectorDataBase { if (k !== undefined && k <= 0) throw new Error('k must be > 0'); let sql = ` - SELECT c.competence_id, ce.text, ce.type, + SELECT c.competence_id, r.resource_id, ce.text, ce.type, ${metric}(ce.embedding, vec_f32(?)) AS distance FROM competence_embedding ce JOIN competence c ON ce.cid = c._cid @@ -815,9 +826,10 @@ class VectorDataBase { let result = rows.map((r) => ({ competenceId: r.competence_id, + resourceId: r.resource_id, + distance: r.distance, text: r.text, type: r.type, - distance: r.distance, })); // Normalise distances to [0, 1], depending on the metric: @@ -844,7 +856,6 @@ class VectorDataBase { ...row, distance: 1 - row.distance, // Convert distance to similarity })); - return result; } } diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts index 954d265aa..c00b5a1d8 100644 --- a/src/competence-matcher/src/middleware/match.ts +++ b/src/competence-matcher/src/middleware/match.ts @@ -202,74 +202,135 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti return; } - // Job can be pending, running, completed, or failed - if (job.status === 'pending' || job.status === 'running' || job.status === 'preprocessing') { - res.status(202).json({ - jobId, - status: job.status, - }); - return; - } - if (job.status === 'failed') { - res.status(500).json({ - error: `Job with ID ${jobId} failed.`, - }); - return; - } - if (job.status !== 'completed') { - // This should not happen, but just in case - console.error(`Unexpected job status: ${job.status} for jobId: ${jobId}`); - res.status(500).json({ - error: `Job with ID ${jobId} failed.`, - }); - return; + // Job can be pending, preprocessing, running, completed, or failed + switch (job.status) { + case 'pending': + case 'running': + case 'preprocessing': + res.status(202).json({ + jobId, + status: job.status, + }); + return; + case 'failed': + res.status(500).json({ + error: `Job with ID ${jobId} failed.`, + }); + return; + case 'completed': + // Proceed to return results below + break; + default: + console.error(`Unexpected job status: ${job.status} for jobId: ${jobId}`); + res.status(500).json({ + error: `Job with ID ${jobId} failed.`, + }); + return; } // Return match results const results = db.getMatchResults(jobId); - // Group by taskId and within each taskId by competenceId - // where matches are sorted by distance ascending - // and competences are sorted by avgDistance ascending - const groupedResults: GroupedMatchResults = results.reduce((acc, result) => { - const { taskId, competenceId, text, type, distance, reason } = result as { - taskId: string; - competenceId: string; - text: string; - type: 'name' | 'description' | 'proficiencyLevel'; - distance: number; - reason?: string; - }; - // Find or create task in accumulator - let task = acc.find((t) => t.taskId === taskId); - if (!task) { - task = { taskId, competences: [] }; - acc.push(task); + // Get the structure of the results + let groupedResults: GroupedMatchResults = results.reduce((acc, result) => { + const { taskId, taskText, competenceId, resourceId, distance, text, type, reason } = result; + + // resourceId + let resourceGroup = acc.find((group) => group.resourceId === resourceId); + if (!resourceGroup) { + resourceGroup = { resourceId, taskMatchings: [], avgTaskMatchProbability: 0 }; + acc.push(resourceGroup); + } + // taskMatchings + let taskMatches = resourceGroup.taskMatchings.find((task) => task.taskId === taskId); + if (!taskMatches) { + taskMatches = { + taskId, + taskText, + competenceMatchings: [], + maxMatchProbability: 0, + }; + resourceGroup.taskMatchings.push(taskMatches); } - // Find or create competence in task - let competence = task.competences.find((c) => c.competenceId === competenceId); - if (!competence) { - competence = { competenceId, matchings: [], avgMatchProbability: 0 }; - task.competences.push(competence); + // competenceMatchings + let competenceMatches = taskMatches.competenceMatchings.find( + (competence) => competence.competenceId === competenceId, + ); + if (!competenceMatches) { + competenceMatches = { + competenceId, + matchings: [], + avgMatchProbability: 0, + }; + taskMatches.competenceMatchings.push(competenceMatches); } - // Add match to competence - competence.matchings.push({ text, type, matchProbability: distance, reason }); + // Add the match to competenceMatches + competenceMatches.matchings.push({ + text, + type: type as 'name' | 'description' | 'proficiencyLevel', + matchProbability: distance, + reason: reason || undefined, + }); - // Sort matches by matchProbability (distance) in descending order - competence.matchings.sort((a, b) => b.matchProbability - a.matchProbability); + return acc; + }, [] as GroupedMatchResults); - // Calculate average match probability for competence - competence.avgMatchProbability = - competence.matchings.reduce((sum, match) => sum + match.matchProbability, 0) / - competence.matchings.length; + // Aggregate and sort + groupedResults = groupedResults + .map((resourceGroup) => { + const { resourceId, taskMatchings, avgTaskMatchProbability } = resourceGroup; - // Sort competences by avgMatchProbability in descending order - task.competences.sort((a, b) => b.avgMatchProbability - a.avgMatchProbability); + const newTaskMatchings = taskMatchings.map((taskGroup) => { + const { taskId, taskText, competenceMatchings, maxMatchProbability } = taskGroup; - return acc; - }, [] as GroupedMatchResults); + const newCompetenceMatchings = competenceMatchings.map((competenceGroup) => { + const { competenceId, matchings, avgMatchProbability } = competenceGroup; + + // Calculate average match probability for this competence (i.e. avg over all parts of this competence) + const totalMatchProbability = matchings.reduce( + (sum, match) => sum + match.matchProbability, + 0, + ); + + // Return sorted + return { + competenceId, + matchings: matchings.sort((a, b) => b.matchProbability - a.matchProbability), + avgMatchProbability: totalMatchProbability / matchings.length, + }; + }); + + // Return sorted + return { + taskId, + taskText, + competenceMatchings: newCompetenceMatchings.sort( + (a, b) => b.avgMatchProbability - a.avgMatchProbability, + ), + maxMatchProbability: Math.max( + ...newCompetenceMatchings.map((c) => c.avgMatchProbability), + ), + }; + }); + + // Calculate average task match probability for this resource + const totalTaskMatchProbability = newTaskMatchings.reduce( + (sum, task) => sum + task.maxMatchProbability, + 0, + ); + + // Return sorted + return { + resourceId, + taskMatchings: newTaskMatchings.sort( + (a, b) => b.maxMatchProbability - a.maxMatchProbability, + ), + avgTaskMatchProbability: totalTaskMatchProbability / newTaskMatchings.length, + }; + }) + .sort((a, b) => b.avgTaskMatchProbability - a.avgTaskMatchProbability); res.status(200).json(groupedResults); } diff --git a/src/competence-matcher/src/middleware/resource.ts b/src/competence-matcher/src/middleware/resource.ts index 028d451f0..cf5d9848e 100644 --- a/src/competence-matcher/src/middleware/resource.ts +++ b/src/competence-matcher/src/middleware/resource.ts @@ -132,25 +132,29 @@ export async function handleCreateResourceList( // Ideally, the worker should handle the splitting as well db.updateJobStatus(jobId!, 'preprocessing'); let job: EmbeddingJob | undefined; - try { - const tasks = await splitSemantically(descriptionEmbeddingInput); - job = { - jobId: jobId!, - dbName: dbName, - tasks, - }; - } catch (err) { - console.error('Error splitting semantically:', err); - job = { - jobId: jobId!, - dbName: dbName, - tasks: descriptionEmbeddingInput, - }; - } - db.updateJobStatus(jobId!, 'pending'); - workerManager.enqueue(job, 'embedder', { - onExit: (job, code) => onWorkerExit?.(job, code, jobId!), - }); + + splitSemantically(descriptionEmbeddingInput) + .then((tasks) => { + job = { + jobId: jobId!, + dbName: dbName, + tasks, + }; + }) + .catch((err) => { + console.error('Error splitting semantically:', err); + job = { + jobId: jobId!, + dbName: dbName, + tasks: descriptionEmbeddingInput, + }; + }) + .finally(() => { + db.updateJobStatus(jobId!, 'pending'); + workerManager.enqueue(job!, 'embedder', { + onExit: (job, code) => onWorkerExit?.(job, code, jobId!), + }); + }); return { jobId: jobId!, status: 'pending' }; } diff --git a/src/competence-matcher/src/utils/model.ts b/src/competence-matcher/src/utils/model.ts index 907591251..885c6fce4 100644 --- a/src/competence-matcher/src/utils/model.ts +++ b/src/competence-matcher/src/utils/model.ts @@ -10,6 +10,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { TransformerPipelineOptions } from './types'; +import { isMainThread } from 'node:worker_threads'; + /** * Base class that handles: * - singleton pipeline instance @@ -50,7 +52,7 @@ export abstract class TransformerPipeline { this.instance = await pipeline(task as PipelineType, model, opts); // mark it as loaded and log on first load - if (!this.loaded) { + if (!this.loaded && isMainThread) { console.log(`${model} (${task}) is ready`); this.loaded = true; } diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts index a12ab3dfb..40003fd6e 100644 --- a/src/competence-matcher/src/utils/types.ts +++ b/src/competence-matcher/src/utils/types.ts @@ -41,6 +41,7 @@ export type MatchingTask = { export type Match = { competenceId: string; + resourceId: string; text: string; type: string; distance: number; @@ -87,18 +88,28 @@ export interface MatchingJob extends Job { } export type GroupedMatchResults = { - taskId: string; - competences: { - competenceId: string; - matchings: { - text: string; - type: 'name' | 'description' | 'proficiencyLevel'; - matchProbability: number; - reason?: string; - }[]; - avgMatchProbability: number; - }[]; -}[]; + resourceId: string; + taskMatchings: { + taskId: string; + taskText: string; + competenceMatchings: { + competenceId: string; + matchings: { + text: string; + type: 'name' | 'description' | 'proficiencyLevel'; + // Sorted by DESC + matchProbability: number; // Normalised inverted distance (, where distance refers to the cosine similarity) + reason?: string; // Reason for the match + }[]; // Array: Competence-Parts matched to task + // Sorted by DESC + avgMatchProbability: number; // Average matchProbability of all parts of this competence + }[]; // Array: Competences matched to task + // Sorted by DESC + maxMatchProbability: number; // Best avgMatchingProbability of all competences for this task + }[]; // Array: Matching of the resource to each task, respectively + // Sorted by DESC + avgTaskMatchProbability: number; // Average maxMatchProbability of all tasks for this resource +}[]; // Array: All available resources with their matchings export type workerTypes = 'embedder' | 'matcher'; diff --git a/src/competence-matcher/src/worker/matcher.ts b/src/competence-matcher/src/worker/matcher.ts index 3207be04a..04e4fc7f0 100644 --- a/src/competence-matcher/src/worker/matcher.ts +++ b/src/competence-matcher/src/worker/matcher.ts @@ -19,7 +19,7 @@ parentPort!.once('message', async (job: MatchingJob) => { await withJobUpdates( job, - async (db, { jobId, tasks, listId, resourceId }) => { + async (db, { jobId, tasks, listId: listIdFilter, resourceId: resourceIdFilter }) => { for (const task of tasks) { const { taskId, name, description, executionInstructions, requiredCompetencies } = task; if (!description) { @@ -31,8 +31,8 @@ parentPort!.once('message', async (job: MatchingJob) => { // Search for matches in the competence list (and resource if provided) let matches: Match[] = db.searchEmbedding(vector, { filter: { - listId: listId, - resourceId: resourceId, // Optional: If matching against a single resource + listId: listIdFilter, + resourceId: resourceIdFilter, // Optional: If matching against a single resource }, }); @@ -68,7 +68,9 @@ parentPort!.once('message', async (job: MatchingJob) => { matchResults[description].push({ jobId, taskId, + taskText: description, competenceId: match.competenceId, + resourceId: match.resourceId, text: match.text, type: match.type as 'name' | 'description' | 'proficiencyLevel', distance: match.distance, diff --git a/src/competence-matcher/src/worker/worker-manager.ts b/src/competence-matcher/src/worker/worker-manager.ts index d529a8514..df67be3fc 100644 --- a/src/competence-matcher/src/worker/worker-manager.ts +++ b/src/competence-matcher/src/worker/worker-manager.ts @@ -96,10 +96,15 @@ async function handleReasoning(job: any, message: any) { // Add reasoning before saving in DB for (const [task, matches] of Object.entries(message.workload)) { const taskMatches = await addReason< - Match & { taskId: string; type: 'name' | 'description' | 'proficiencyLevel' } + Match & { + taskId: string; + taskText: string; + type: 'name' | 'description' | 'proficiencyLevel'; + } >( matches as (Match & { taskId: string; + taskText: string; type: 'name' | 'description' | 'proficiencyLevel'; })[], task, @@ -114,10 +119,12 @@ async function handleReasoning(job: any, message: any) { db.addMatchResult({ jobId: job.jobId, taskId: match.taskId, + taskText: match.taskText, competenceId: match.competenceId, + resourceId: match.resourceId, + distance: match.distance, text: match.text, type: match.type, - distance: match.distance, reason: match.reason, }); } From 6cf8e30e561d801193178eee43f568ffb24c5d35 Mon Sep 17 00:00:00 2001 From: Maxi <76120220+MaxiLein@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:29:27 +0200 Subject: [PATCH 17/17] feat: Enhance matching functionality with new alignment classification and ranking options --- src/competence-matcher/openAPI.json | 69 +++++++++++- src/competence-matcher/src/db/db.ts | 13 ++- .../src/middleware/match.ts | 104 +++++++++++++++--- ...ntic-opposites.ts => semantic-zeroshot.ts} | 6 +- .../src/utils/huggingface.ts | 2 +- src/competence-matcher/src/utils/types.ts | 31 ++++-- src/competence-matcher/src/worker/matcher.ts | 58 ++++++++-- .../src/worker/worker-manager.ts | 3 + 8 files changed, 244 insertions(+), 42 deletions(-) rename src/competence-matcher/src/tasks/{semantic-opposites.ts => semantic-zeroshot.ts} (91%) diff --git a/src/competence-matcher/openAPI.json b/src/competence-matcher/openAPI.json index f22c309b1..dc04d3fad 100644 --- a/src/competence-matcher/openAPI.json +++ b/src/competence-matcher/openAPI.json @@ -345,6 +345,19 @@ "schema": { "type": "string" } + }, + { + "name": "rankBy", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "avgFit", + "bestFit" + ] + }, + "description": "Optional ranking method: 'avgFit' or 'bestFit'" } ], "responses": { @@ -559,6 +572,39 @@ } }, "GroupedMatchResults": { + "type": "object", + "required": [ + "taskOverview", + "resourceRanking" + ], + "properties": { + "tasks": { + "$ref": "#/components/schemas/TaskOverview" + }, + "resourceRanking": { + "$ref": "#/components/schemas/ResourceRanking" + } + } + }, + "TaskOverview": { + "type": "array", + "items": { + "type": "object", + "required": [ + "taskId", + "taskText" + ], + "properties": { + "taskId": { + "type": "string" + }, + "taskText": { + "type": "string" + } + } + } + }, + "ResourceRanking": { "type": "array", "items": { "type": "object", @@ -585,9 +631,6 @@ "taskId": { "type": "string" }, - "taskText": { - "type": "string" - }, "competenceMatchings": { "type": "array", "items": { @@ -609,18 +652,30 @@ }, "avgMatchProbability": { "type": "number" + }, + "avgBestFitTaskMatchProbability": { + "type": "number" } } } }, "maxMatchProbability": { "type": "number" + }, + "maxBestFitMatchProbability": { + "type": "number" } } } }, "avgTaskMatchProbability": { "type": "number" + }, + "avgBestFitTaskMatchProbability": { + "type": "number" + }, + "contradicting": { + "type": "boolean" } } } @@ -647,6 +702,14 @@ "matchProbability": { "type": "number" }, + "alignment": { + "type": "string", + "enum": [ + "aligning", + "neutral", + "contradicting" + ] + }, "reason": { "type": "string" } diff --git a/src/competence-matcher/src/db/db.ts b/src/competence-matcher/src/db/db.ts index 313b8c46e..c92974a24 100644 --- a/src/competence-matcher/src/db/db.ts +++ b/src/competence-matcher/src/db/db.ts @@ -72,6 +72,7 @@ class VectorDataBase { distance REAL NOT NULL, -- similarity score text TEXT NOT NULL, -- the matched snippet type TEXT NOT NULL, -- 'name' | 'description' | 'proficiencyLevel' + alignment TEXT NOT NULL, -- 'contradicting' | 'neutral' | 'aligning' reason TEXT -- llm based reason for the match ); `); @@ -228,7 +229,8 @@ class VectorDataBase { resourceId: string; distance: number; text: string; - type: 'name' | 'description' | 'proficiencyLevel'; + type: string; // 'name' | 'description' | 'proficiencyLevel' + alignment: string; // 'contradicting' | 'neutral' | 'aligning' reason?: string; // optional reason for the match }): void { const id = uuid(); @@ -236,8 +238,8 @@ class VectorDataBase { .prepare( ` INSERT INTO match_results - (id, job_id, task_id, task_text, competence_id, resource_id, distance, text, type, reason) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, job_id, task_id, task_text, competence_id, resource_id, distance, text, type, alignment, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -250,6 +252,7 @@ class VectorDataBase { opts.distance, opts.text, opts.type, + opts.alignment, opts.reason ?? null, ); } @@ -266,12 +269,13 @@ class VectorDataBase { distance: number; text: string; type: string; + alignment: string; // 'contradicting' | 'neutral' | 'aligning' reason?: string; }> { return this.db .prepare( ` - SELECT task_id, task_text, competence_id, resource_id, distance, text, type, reason + SELECT task_id, task_text, competence_id, resource_id, distance, text, type, alignment, reason FROM match_results WHERE job_id = ? ORDER BY task_id, distance @@ -286,6 +290,7 @@ class VectorDataBase { distance: r.distance, text: r.text, type: r.type, + alignment: r.alignment, reason: r.reason ?? undefined, })); } diff --git a/src/competence-matcher/src/middleware/match.ts b/src/competence-matcher/src/middleware/match.ts index c00b5a1d8..86c44bb56 100644 --- a/src/competence-matcher/src/middleware/match.ts +++ b/src/competence-matcher/src/middleware/match.ts @@ -8,6 +8,8 @@ import { MatchingJob, MatchingTask, ResourceListInput, + ResourceRanking, + TaskOverview, } from '../utils/types'; import { handleCreateResourceList } from './resource'; @@ -192,7 +194,11 @@ export function matchCompetenceList(req: Request, res: Response, next: NextFunct } export function getMatchJobResults(req: Request, res: Response, next: NextFunction): void { + // Get jobId from path const { jobId } = req.params; + // Get sorter from query params + const requestedSorter = req.query.rankBy as string | undefined; + const sorter = requestedSorter == 'bestFit' ? 'bestFit' : 'avgFit'; // Default to avgFit const db = getDB(req.dbName!); // Check if job exists @@ -231,14 +237,29 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti // Return match results const results = db.getMatchResults(jobId); + const tasks: TaskOverview = results.reduce((acc, result) => { + const { taskId, taskText } = result; + // Check if task already exists in the overview + if (!acc.some((task) => task.taskId === taskId)) { + acc.push({ taskId, taskText }); + } + return acc; + }, [] as TaskOverview); + // Get the structure of the results - let groupedResults: GroupedMatchResults = results.reduce((acc, result) => { - const { taskId, taskText, competenceId, resourceId, distance, text, type, reason } = result; + let groupedResults: ResourceRanking = results.reduce((acc, result) => { + const { taskId, competenceId, resourceId, distance, text, type, alignment, reason } = result; // resourceId let resourceGroup = acc.find((group) => group.resourceId === resourceId); if (!resourceGroup) { - resourceGroup = { resourceId, taskMatchings: [], avgTaskMatchProbability: 0 }; + resourceGroup = { + resourceId, + taskMatchings: [], + avgTaskMatchProbability: 0, + avgBestFitTaskMatchProbability: 0, + contradicting: false, + }; acc.push(resourceGroup); } // taskMatchings @@ -246,9 +267,9 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti if (!taskMatches) { taskMatches = { taskId, - taskText, competenceMatchings: [], maxMatchProbability: 0, + maxBestFitMatchProbability: 0, }; resourceGroup.taskMatchings.push(taskMatches); } @@ -262,6 +283,7 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti competenceId, matchings: [], avgMatchProbability: 0, + avgBestFitMatchProbability: 0, }; taskMatches.competenceMatchings.push(competenceMatches); } @@ -271,22 +293,26 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti text, type: type as 'name' | 'description' | 'proficiencyLevel', matchProbability: distance, + alignment: alignment as 'contradicting' | 'neutral' | 'aligning', reason: reason || undefined, }); return acc; - }, [] as GroupedMatchResults); + }, [] as ResourceRanking); // Aggregate and sort groupedResults = groupedResults .map((resourceGroup) => { - const { resourceId, taskMatchings, avgTaskMatchProbability } = resourceGroup; + const { resourceId, taskMatchings, avgTaskMatchProbability, avgBestFitTaskMatchProbability } = + resourceGroup; const newTaskMatchings = taskMatchings.map((taskGroup) => { - const { taskId, taskText, competenceMatchings, maxMatchProbability } = taskGroup; + const { taskId, competenceMatchings, maxMatchProbability, maxBestFitMatchProbability } = + taskGroup; const newCompetenceMatchings = competenceMatchings.map((competenceGroup) => { - const { competenceId, matchings, avgMatchProbability } = competenceGroup; + const { competenceId, matchings, avgMatchProbability, avgBestFitMatchProbability } = + competenceGroup; // Calculate average match probability for this competence (i.e. avg over all parts of this competence) const totalMatchProbability = matchings.reduce( @@ -294,24 +320,38 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti 0, ); + let numberOfBestFits = 0; + const totalBestFitMatchProbability = matchings.reduce((sum, match) => { + if (match.alignment === 'aligning') { + numberOfBestFits++; + return sum + match.matchProbability; + } + return sum; + }, 0); + // Return sorted return { competenceId, matchings: matchings.sort((a, b) => b.matchProbability - a.matchProbability), avgMatchProbability: totalMatchProbability / matchings.length, + avgBestFitMatchProbability: + numberOfBestFits > 0 ? totalBestFitMatchProbability / numberOfBestFits : 0, // If no best fit, set to 0 }; }); // Return sorted return { taskId, - taskText, - competenceMatchings: newCompetenceMatchings.sort( - (a, b) => b.avgMatchProbability - a.avgMatchProbability, - ), + competenceMatchings: newCompetenceMatchings.sort((a, b) => { + const key = sorter === 'bestFit' ? 'avgBestFitMatchProbability' : 'avgMatchProbability'; + return b[key] - a[key]; + }), maxMatchProbability: Math.max( ...newCompetenceMatchings.map((c) => c.avgMatchProbability), ), + maxBestFitMatchProbability: Math.max( + ...newCompetenceMatchings.map((c) => c.avgBestFitMatchProbability), + ), }; }); @@ -320,17 +360,47 @@ export function getMatchJobResults(req: Request, res: Response, next: NextFuncti (sum, task) => sum + task.maxMatchProbability, 0, ); + const totalBestFitTaskMatchProbability = newTaskMatchings.reduce( + (sum, task) => sum + task.maxBestFitMatchProbability, + 0, + ); // Return sorted return { resourceId, - taskMatchings: newTaskMatchings.sort( - (a, b) => b.maxMatchProbability - a.maxMatchProbability, - ), + taskMatchings: newTaskMatchings.sort((a, b) => { + const key = sorter === 'bestFit' ? 'maxBestFitMatchProbability' : 'maxMatchProbability'; + return b[key] - a[key]; + }), avgTaskMatchProbability: totalTaskMatchProbability / newTaskMatchings.length, + avgBestFitTaskMatchProbability: + totalBestFitTaskMatchProbability / newTaskMatchings.length || 0, // If no best fit, set to 0 + contradicting: newTaskMatchings.some((task) => + task.competenceMatchings.some((competence) => + competence.matchings.some((match) => match.alignment === 'contradicting'), + ), + ), }; }) - .sort((a, b) => b.avgTaskMatchProbability - a.avgTaskMatchProbability); + .sort((a, b) => { + const key = + sorter === 'bestFit' ? 'avgBestFitTaskMatchProbability' : 'avgTaskMatchProbability'; + + // Sort in two levels: Contradicting, key + // First not contradicting resources, then contradicting ones + // Case one is contradicting, the other is not + if (a.contradicting !== b.contradicting) { + return a.contradicting ? 1 : -1; // Non-contradicting first + } + // Both are contradicting or both are not + // Sort by the key + return b[key] - a[key]; + }); + + const load: GroupedMatchResults = { + tasks, + resourceRanking: groupedResults, + }; - res.status(200).json(groupedResults); + res.status(200).json(load); } diff --git a/src/competence-matcher/src/tasks/semantic-opposites.ts b/src/competence-matcher/src/tasks/semantic-zeroshot.ts similarity index 91% rename from src/competence-matcher/src/tasks/semantic-opposites.ts rename to src/competence-matcher/src/tasks/semantic-zeroshot.ts index 17d4eb062..23556ee84 100644 --- a/src/competence-matcher/src/tasks/semantic-opposites.ts +++ b/src/competence-matcher/src/tasks/semantic-zeroshot.ts @@ -30,7 +30,11 @@ export default class ZeroShot extends TransformerPipeline(); diff --git a/src/competence-matcher/src/utils/huggingface.ts b/src/competence-matcher/src/utils/huggingface.ts index 04222b4db..3d6d5128f 100644 --- a/src/competence-matcher/src/utils/huggingface.ts +++ b/src/competence-matcher/src/utils/huggingface.ts @@ -1,5 +1,5 @@ import Embedding from '../tasks/embedding'; -import ZeroShotSemanticOpposites from '../tasks/semantic-opposites'; +import ZeroShotSemanticOpposites from '../tasks/semantic-zeroshot'; export async function ensureAllHuggingfaceModelsAreAvailable() { try { diff --git a/src/competence-matcher/src/utils/types.ts b/src/competence-matcher/src/utils/types.ts index 40003fd6e..fb51ca786 100644 --- a/src/competence-matcher/src/utils/types.ts +++ b/src/competence-matcher/src/utils/types.ts @@ -87,29 +87,44 @@ export interface MatchingJob extends Job { tasks: MatchingTask[]; // Tasks to match } -export type GroupedMatchResults = { +export type ResourceRanking = { resourceId: string; taskMatchings: { - taskId: string; - taskText: string; + taskId: string; // Which of the tasks this matching is referring to competenceMatchings: { competenceId: string; matchings: { text: string; type: 'name' | 'description' | 'proficiencyLevel'; - // Sorted by DESC + // Sorted DESC by matchProbability: number; // Normalised inverted distance (, where distance refers to the cosine similarity) + alignment: 'contradicting' | 'neutral' | 'aligning'; // Semantic opposite classification reason?: string; // Reason for the match }[]; // Array: Competence-Parts matched to task - // Sorted by DESC + // Sorted DESC by either: avgMatchProbability: number; // Average matchProbability of all parts of this competence + avgBestFitMatchProbability: number; // Average of the parts that align well with the task, 0 means there is none }[]; // Array: Competences matched to task - // Sorted by DESC + // Sorted DESC by either: maxMatchProbability: number; // Best avgMatchingProbability of all competences for this task + maxBestFitMatchProbability: number; // Best avgBestFitMatchProbability of all competences for this task, 0 means there is none }[]; // Array: Matching of the resource to each task, respectively - // Sorted by DESC + // Sorted DESC first by [not contradicting , contradicting] then by either: avgTaskMatchProbability: number; // Average maxMatchProbability of all tasks for this resource -}[]; // Array: All available resources with their matchings + avgBestFitTaskMatchProbability: number; // Average maxBestFitMatchProbability of all tasks for this resource, 0 means there is none + + contradicting: boolean; // Whether there is a part in a competence of this resource that contradicts the task +}[]; + +export type TaskOverview = { + taskId: string; // UUIDString + taskText: string; // Text of the task +}[]; + +export type GroupedMatchResults = { + tasks: TaskOverview; + resourceRanking: ResourceRanking; +}; export type workerTypes = 'embedder' | 'matcher'; diff --git a/src/competence-matcher/src/worker/matcher.ts b/src/competence-matcher/src/worker/matcher.ts index 04e4fc7f0..7e2fe7a93 100644 --- a/src/competence-matcher/src/worker/matcher.ts +++ b/src/competence-matcher/src/worker/matcher.ts @@ -3,7 +3,7 @@ import Embedding from '../tasks/embedding'; import { withJobUpdates } from '../utils/worker'; import { addReason } from '../tasks/reason'; import { Match, MatchingJob } from '../utils/types'; -import ZeroShot from '../tasks/semantic-opposites'; +import ZeroShot from '../tasks/semantic-zeroshot'; parentPort!.once('message', async (job: MatchingJob) => { // For workaround: @@ -44,15 +44,56 @@ parentPort!.once('message', async (job: MatchingJob) => { for (const match of matches) { // Check for semantic opposites const zeroshotText = `Task description: ${description}\nSkill/Capability description: ${match.text}`; - const classification = await ZeroShot.classify(zeroshotText); + // From unsuitable to suitable + const contraLabels = ['contradicting', 'aligning']; + const contraHypothesis = 'Task description and Skill/Capability descriptions are {}.'; + const scalingLabls = ['perfect', 'mediocre']; + const scalingHypothesis = + 'Task description and Skill/Capability descriptions are a {} match.'; + const labelScalar = [ + 0.25, // Contradicting matches should be penalised + 0.5, // Scale it down a bit to avoid too high scores for irrelevant matches + 1, // keep the best matches as is + ]; + const contraClassification = await ZeroShot.classify( + zeroshotText, + contraLabels, + contraHypothesis, + ); + let flag: 'contradicting' | 'neutral' | 'aligning' = 'neutral'; + // console.log(contraClassification); + // @ts-ignore - switch (classification.labels[0]) { - case 'contradicting': - // Invert the match distance (since it's normalised to [0,1]: 1 - distance) - match.distance = 1 - match.distance; - break; - // switch instead of if, as we may want to have additional label-based checks in the future + if (contraClassification.labels[0] === contraLabels[0]) { + // Invert the match distance (since it's normalised to [0,1]: 1 - distance) + match.distance = (1 - match.distance) * labelScalar[0]; + flag = 'contradicting'; + } else { + const scalingClassification = await ZeroShot.classify( + zeroshotText, + scalingLabls, + scalingHypothesis, + ); + + // console.log(scalingClassification); + if ( + // @ts-ignore + scalingClassification.labels[0] === scalingLabls[0] && + // @ts-ignore + scalingClassification.scores[0] > 0.65 + ) { + // Keep the match as is + match.distance *= labelScalar[2]; + flag = 'aligning'; + } + // @ts-ignore + else if (scalingClassification.labels[0] === scalingLabls[1]) { + // Scale it down a bit + match.distance *= labelScalar[1]; + flag = 'neutral'; + } } + // db.addMatchResult({ // jobId, // taskId, @@ -73,6 +114,7 @@ parentPort!.once('message', async (job: MatchingJob) => { resourceId: match.resourceId, text: match.text, type: match.type as 'name' | 'description' | 'proficiencyLevel', + alignment: flag, distance: match.distance, reason: match.reason, }); diff --git a/src/competence-matcher/src/worker/worker-manager.ts b/src/competence-matcher/src/worker/worker-manager.ts index df67be3fc..0bb7c1a00 100644 --- a/src/competence-matcher/src/worker/worker-manager.ts +++ b/src/competence-matcher/src/worker/worker-manager.ts @@ -100,12 +100,14 @@ async function handleReasoning(job: any, message: any) { taskId: string; taskText: string; type: 'name' | 'description' | 'proficiencyLevel'; + alignment: 'contradicting' | 'neutral' | 'aligning'; } >( matches as (Match & { taskId: string; taskText: string; type: 'name' | 'description' | 'proficiencyLevel'; + alignment: 'contradicting' | 'neutral' | 'aligning'; })[], task, ); @@ -126,6 +128,7 @@ async function handleReasoning(job: any, message: any) { text: match.text, type: match.type, reason: match.reason, + alignment: match.alignment, }); }