diff --git a/.github/workflows/diveni-deploy.yaml b/.github/workflows/diveni-deploy.yaml index 96481d1a5..b1018d6c1 100644 --- a/.github/workflows/diveni-deploy.yaml +++ b/.github/workflows/diveni-deploy.yaml @@ -8,6 +8,7 @@ on: - 'backend/**' - 'frontend/**' - 'proxy/**' + - 'ai/**' - '.github/workflows/diveni-deploy.yaml' workflow_dispatch: @@ -18,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - service: [backend, frontend, proxy] + service: [ai, backend, frontend, proxy] permissions: contents: read packages: write diff --git a/ai/Dockerfile b/ai/Dockerfile new file mode 100644 index 000000000..734cc2f59 --- /dev/null +++ b/ai/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11.2 + +WORKDIR /ai + +RUN pip install poetry + +COPY . . + +RUN poetry install +RUN poetry self add poetry-dotenv-plugin + +EXPOSE 8000 + +CMD ["poetry","run", "uvicorn", "controller.gpt_controller:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 000000000..eee797b30 --- /dev/null +++ b/ai/README.md @@ -0,0 +1,2 @@ +This is our python container, used for the communication between our Java Spring Boot Backend +and the openAI API. diff --git a/ai/controller/gpt_controller.py b/ai/controller/gpt_controller.py new file mode 100644 index 000000000..79e586ddf --- /dev/null +++ b/ai/controller/gpt_controller.py @@ -0,0 +1,63 @@ +from fastapi import FastAPI +import service.gpt_service as service +from dto.user_story import UserStory +from dto.title import Title +from dto.estimation_data import Estimation_data + +app = FastAPI() + + +@app.post("/improve-title") +async def improve_title(title: Title): + print("gpt_controller: --> improve_title(), title={", title.name, "}") + response = await service.improve_title(title.name, title.confidential_data) + print("gpt_controller: <-- improve_title()") + return {"improvedTitle": response} + + +@app.post("/improve-description") +async def improve_description(data: UserStory): + print("gpt_controller: --> improve_description(), description={", data, "}") + response = await service.improve_description(data) + print("gpt_controller: <-- improve_description()") + return {"improved_description": response.description, "improved_acceptance_criteria": response.acceptance_criteria} + + +@app.post("/grammar-check") +async def grammar_check(data: UserStory): + print("gpt_controller: --> grammar_check(), description={", data, "}") + response = await service.grammar_check(data) + print("gpt_controller: <-- grammar_check()") + return {"improved_description": response} + + +@app.post("/estimate-user-story") +async def estimate_user_story(data: Estimation_data): + print("gpt_controller: --> estimate_user_story(), data={", data, "}") + response = await service.estimate_user_story(data) + print("gpt_controller: <-- estimate_user_story()") + return {"estimation": response} + + +@app.post("/split-user-story") +async def split_user_story(data: UserStory): + print("gpt_controller: --> split_user_story(), data={", data, "}") + response = await service.split_user_story(data) + print("gpt_controller: <-- split_user_story()") + return {"new_user_stories": response} + + +@app.post("/mark-description") +async def mark_description(data: UserStory): + print("gpt_controller: --> mark_description(), data={", data, "}") + response = await service.mark_description(data) + print("gpt_controller: <-- mark_description()") + return {"description": response} + + +@app.get("/check-api-key") +def check_api_key(): + print("gpt_controller: --> check_api_key()") + response = service.check_api_key() + print("gpt_controller: <-- check_api_key()") + return {"has_api_key": response} diff --git a/ai/dto/description_response.py b/ai/dto/description_response.py new file mode 100644 index 000000000..0c2a8c245 --- /dev/null +++ b/ai/dto/description_response.py @@ -0,0 +1,7 @@ +class Description_Response: + description: str + acceptance_criteria: list + + def __init__(self, description, acceptance_criteria): + self.description = description + self.acceptance_criteria = acceptance_criteria diff --git a/ai/dto/estimation_data.py b/ai/dto/estimation_data.py new file mode 100644 index 000000000..6a7d44dc0 --- /dev/null +++ b/ai/dto/estimation_data.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class Estimation_data(BaseModel): + title: str + description: str + voteSet: list + confidential_data: dict diff --git a/ai/dto/title.py b/ai/dto/title.py new file mode 100644 index 000000000..2237f7441 --- /dev/null +++ b/ai/dto/title.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class Title(BaseModel): + name: str + confidential_data: dict diff --git a/ai/dto/user_story.py b/ai/dto/user_story.py new file mode 100644 index 000000000..e6bce7d4b --- /dev/null +++ b/ai/dto/user_story.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class UserStory(BaseModel): + title: str + description: str + language: str + confidential_data: dict diff --git a/ai/pyproject.toml b/ai/pyproject.toml new file mode 100644 index 000000000..64ff70699 --- /dev/null +++ b/ai/pyproject.toml @@ -0,0 +1,24 @@ +[tool.poetry] +name = "ai" +version = "0.1.0" +description = "" +authors = ["SponsoredByPuma"] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.110.0" +uvicorn = "^0.28.0" +openai = "^1.13.3" +pytest = "^8.1.1" +mock = "^5.1.0" + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.1.1" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + diff --git a/ai/resource/names.txt b/ai/resource/names.txt new file mode 100644 index 000000000..4c14aa1cd --- /dev/null +++ b/ai/resource/names.txt @@ -0,0 +1,100 @@ +Alice Johnson +Benjamin Lee +Catherine Adams +David Martinez +Ella Thompson +Franklin White +Grace Robinson +Henry Harris +Isabella Turner +James Edwards +Katherine Scott +Liam Walker +Mia Lewis +Noah Hall +Olivia Green +Patrick Carter +Quinn Foster +Rachel King +Samuel Miller +Taylor Allen +Victoria Young +William Brown +Zoe Adams +Alexander Clark +Brooklyn Hill +Christopher Turner +Daisy Martinez +Ethan Johnson +Faith Robinson +Gabriel Lee +Hannah White +Isaac Harris +Jasmine Thompson +Kevin Edwards +Lily Scott +Mason Walker +Nora Lewis +Owen Hall +Penelope Green +Quentin Foster +Ruby King +Sebastian Miller +Tessa Allen +Ulysses Young +Violet Brown +Wyatt Adams +Xander Clark +Yara Hill +Zachary Turner +Ava Carter +Bryce Foster +Chloe Robinson +Daniel Lee +Emily Harris +Finn Thompson +Grace Turner +Henry Walker +Isabella Green +Jacob Hall +Kylie Scott +Liam Young +Mila Adams +Nathan Clark +Olivia Hill +Parker Foster +Quinn Lewis +Ryan Martinez +Sophia Turner +Thomas Edwards +Uma Robinson +Vincent Lee +Willow Harris +Xavier Thompson +Yasmine Turner +Zara Walker +Adam Green +Bella Hall +Caleb Scott +Diana Young +Eli Adams +Fiona Clark +George Hill +Hazel Foster +Ian Lewis +Jade Martinez +Kai Turner +Luna Edwards +Max Robinson +Nina Lee +Oscar Harris +Paige Thompson +Quincy Walker +Riley Green +Sofia Hall +Theo Scott +Una Young +Vera Adams +Wyatt Clark +Xena Hill +Yuki Foster diff --git a/ai/resource/prompts/prompt_estimation_fibo.txt b/ai/resource/prompts/prompt_estimation_fibo.txt new file mode 100644 index 000000000..7a9cf2504 --- /dev/null +++ b/ai/resource/prompts/prompt_estimation_fibo.txt @@ -0,0 +1,13 @@ +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: filter numbers out of the input field +Description: The existing Backend Service should filter numbers out of the input, since the system crashes with numbers. +Valid Options are: ['1','2','3','5','8','13','21'] +Answer: {"estimation": "2"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: Create a webapp like amazon +Description: Our Company should have a web application like amazon, but better +## Acceptance Criteria: +* robust and fast backend with java +* good looking design +* nosql database should be implemented +Valid Options are: ['1','2','3','5','8','13','21'] +Answer: {"estimation": "21"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: diff --git a/ai/resource/prompts/prompt_estimation_hour.txt b/ai/resource/prompts/prompt_estimation_hour.txt new file mode 100644 index 000000000..324bfa5b8 --- /dev/null +++ b/ai/resource/prompts/prompt_estimation_hour.txt @@ -0,0 +1,13 @@ +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: filter numbers out of the input field +Description: The existing Backend Service should filter numbers out of the input, since the system crashes with numbers. +Valid Options are: ['1','2','3','4','5','6','8','10','12','16'] +Answer: {"estimation": "2"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: Create a webapp like amazon +Description: Our Company should have a web application like amazon, but better +## Acceptance Criteria: +* robust and fast backend with java +* good looking design +* nosql database should be implemented +Valid Options are: ['1','2','3','4','5','6','8','10','12','16'] +Answer: {"estimation": "16"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: diff --git a/ai/resource/prompts/prompt_estimation_number.txt b/ai/resource/prompts/prompt_estimation_number.txt new file mode 100644 index 000000000..0c13600ca --- /dev/null +++ b/ai/resource/prompts/prompt_estimation_number.txt @@ -0,0 +1,13 @@ +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: filter numbers out of the input field +Description: The existing Backend Service should filter numbers out of the input, since the system crashes with numbers. +Valid Options are: ['1','2','3','4','5','6','7','8','9','10'] +Answer: {"estimation": "2"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: Create a webapp like amazon +Description: Our Company should have a web application like amazon, but better +## Acceptance Criteria: +* robust and fast backend with java +* good looking design +* nosql database should be implemented +Valid Options are: ['1','2','3','4','5','6','7','8','9','10'] +Answer: {"estimation": "10"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: diff --git a/ai/resource/prompts/prompt_estimation_shirt.txt b/ai/resource/prompts/prompt_estimation_shirt.txt new file mode 100644 index 000000000..3e843cd46 --- /dev/null +++ b/ai/resource/prompts/prompt_estimation_shirt.txt @@ -0,0 +1,13 @@ +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: filter numbers out of the input field +Description: The existing Backend Service should filter numbers out of the input, since the system crashes with numbers. +Valid Options are: ['XS','S','M','L','XL'] +Answer: {"estimation": "S"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: Create a webapp like amazon +Description: Our Company should have a web application like amazon, but better +## Acceptance Criteria: +* robust and fast backend with java +* good looking design +* nosql database should be implemented +Valid Options are: ['XS','S','M','L','XL'] +Answer: {"estimation": "XL"} +Task: Send a JSON with "estimation" and estimate the effort for this user story: Title: diff --git a/ai/resource/prompts/prompt_grammar_check.txt b/ai/resource/prompts/prompt_grammar_check.txt new file mode 100644 index 000000000..3ceeb7dab --- /dev/null +++ b/ai/resource/prompts/prompt_grammar_check.txt @@ -0,0 +1,11 @@ +Task: Send a JSON with "description" & "acceptance_criteria" and fix grammar & syntax mistakes, but do not add new elements, for this markdown-text: As a user i want 2 be able to accessed a homepage so that I can easily navigate to different sections of the website. +##### Acceptance Criteria: +* The homepage should have a visualy appealing design. +* The homepage should have an clear and concise navigation menü +Solution: {"description" : "As a user, I want to be able to access a homepage so that I can easily navigate to different sections of the website. +##### Acceptance Criteria:\n ", +"acceptance_criteria" : ["* The homepage should have a visually appealing design.","* The homepage should have a clear and concise navigation menu."]} +Task: Send a JSON with "description" & "acceptance_criteria" and fix grammar & syntax mistakes, but do not add new elements, for this markdown-text: As a devloper, i want to crete a backend servize with a REST API so dhat i can easily manage and manipulate dayta from the database +Solution: {"description" : "As a developer, I want to create a backend service with a REST API so that I can easily manage and manipulate data from the database.", +"acceptance_criteria" : []} +Task: Send a JSON with "description" & "acceptance_criteria" and fix grammar & syntax mistakes, but do not add new elements, for this markdown-text: diff --git a/ai/resource/prompts/prompt_improve_description.txt b/ai/resource/prompts/prompt_improve_description.txt new file mode 100644 index 000000000..4f52dbd29 --- /dev/null +++ b/ai/resource/prompts/prompt_improve_description.txt @@ -0,0 +1,18 @@ +Task: Send JSON with "description" & "acceptance_criteria" for this User Story: create rest service for backend +Solution: {"description" : "As a developer, I want to create a REST service for the backend so that I can easily access and manipulate data from the database.", +"acceptance_criteria" : [" +"The REST service should have endpoints for GET, POST, PUT & DELETE requests", +"Unit Tests should be written"]} +Task: Send JSON with "description" & "acceptance_criteria" for this User Story: create Homepage +Solution: {"description" : "As a user, I want to be able to access a homepage so that I can easily navigate to different sections of the website.", +"acceptance_criteria" : ["The homepage should have a visually appealing design.", "The homepage should have a clear and concise navigation menu.", "It should have a search bar for easy navigation.","The homepage should be accessible on different devices."]} +Task: Send JSON with "description" & "acceptance_criteria" for this User Story: create a homepage for placerholder-company-1 with a java backend. It should not cost more than 100 thousand euros +### Acceptance Criteria: +* good looking design +* logo of placerholder-company-1 should be on the navbar +Solution: {"description" : "As a developer, I want to create a visually appealing homepage for placerholder-company-1. The website should have a modern design and incorporate a Java backend.", +"acceptance_criteria" : [ +"The homepage should have an aesthetically pleasing layout.", +"Use responsive design principles to ensure it looks great on various devices", +"The placerholder-company-1 logo must be prominently displayed on the navigation bar"]} +Task: Send JSON with "description" & "acceptance_criteria" for this User Story (at least 6 acceptance criteria): diff --git a/ai/resource/prompts/prompt_improve_description_german.txt b/ai/resource/prompts/prompt_improve_description_german.txt new file mode 100644 index 000000000..802379ffe --- /dev/null +++ b/ai/resource/prompts/prompt_improve_description_german.txt @@ -0,0 +1,10 @@ +Aufgabe: Sende ein JSON mit "description" & "acceptance_criteria" für diese User Story: Erstelle einen REST Service für das Backend +Antwort: {"description": "Als Entwickler im Backend-Team möchte ich einen RESTful Service erstellen, um die Kommunikation zwischen dem Frontend und dem Backend zu ermöglichen.", +"acceptance_criteria": ["Die Anfragen können GET, POST, PUT oder DELETE sein.","Der Service muss sicherstellen, dass nur autorisierte Benutzer auf die Endpunkte zugreifen können.","Der Service sollte die eingehenden Anfragen validieren, um sicherzustellen, dass sie den erwarteten Parametern und Formaten entsprechen."]} +Aufgabe: Sende ein JSON mit "description" & "acceptance_criteria" für diese User Story: Erstelle eine Homepage für placeholder-company-1 mit einem java Backend. Es sollte nicht mehr als 100 tausend Euro kosten +## Akzeptanz Kriterien +* gut aussehende Webseite +* Das Logo von placeholder-company-1 sollte auf der Navigationsleiste zu sehen sein +Antwort: {"description" : "Als Entwickler möchte ich eine professionelle Homepage, mit einem Java-Backend für placeholder-company-1 erstellen, um eine benutzerfreundliche Plattform zu schaffen, die das Unternehmen online repräsentiert.", +"acceptance_criteria" : ["Die Kosten für die Erstellung der Homepage dürfen 100.000 Euro nicht überschreiten","Das Java-Backend muss robust und sicher sein, um die Funktionalität der Website zu unterstützen.", "Die Website sollte eine klare Navigation haben, um Benutzern das Auffinden von Informationen zu erleichtern"]} +Aufgabe: Sende ein JSON mit "description" & "acceptance_criteria" für diese User Story (mindestens 6 Akzeptanz Kriterien): diff --git a/ai/resource/prompts/prompt_mark_description.txt b/ai/resource/prompts/prompt_mark_description.txt new file mode 100644 index 000000000..814c75ae5 --- /dev/null +++ b/ai/resource/prompts/prompt_mark_description.txt @@ -0,0 +1,23 @@ +Task: Send JSON with "description" for this User Story and mark all important content: As a backend developer, I want to create a reliable and scalable RESTful API service, so that our application can efficiently handle client requests and data processing. +##### Acceptance Criteria: +* The API service should support common HTTP methods (GET, POST, PUT, DELETE) for CRUD operations. +* The service should implement proper error handling and return appropriate HTTP status codes. +* Authentication and authorization mechanisms should be implemented to secure the API endpoints +Solution: {"description" : "As a *backend developer*, I want to create a **reliable and scalable RESTful API service**, so that our application can efficiently handle client requests and data processing. +##### Acceptance Criteria: +* The API service should **support** common HTTP methods (GET, POST, PUT, DELETE) for **CRUD operations**. +* The service should **implement proper error handling** and return **appropriate HTTP status codes**. +* **Authentication and authorization mechanisms** should be implemented to secure the API endpoints" } +Task: Send JSON with "description" for this User Story and mark all important content: As a web developer, I want to create an engaging and informative homepage for our website, so that visitors can easily understand our product or service offerings. +Solution: {"description" : "As a *web developer*, I want to create an **engaging and informative homepage** for our website, so that visitors can easily understand our product or service offerings."} +Task: Send JSON with "description" for this User Story and mark all important content: As a web developer, I want to create a homepage for Placeholder Company 1 with a Java backend, while ensuring that the total cost does not exceed 100,000 euros. +##### Acceptance Criteria: +* The homepage should be built using Java for the backend and a modern frontend framework (e.g., React, Angular, Vue.js). +* The homepage should accurately represent Placeholder Company 1's brand, products, and services. +* The total cost of development, including labor, hosting, and any third-party tools or services, must not exceed 100,000 euros +Solution: {"description" : "As a *web developer*, I want to create a **homepage for Placeholder Company 1 with a Java backend**, while ensuring that the total cost does not exceed **100,000 euros**. +##### Acceptance Criteria: +* The homepage should be **built using Java** for the backend and a modern frontend framework (e.g., React, Angular, Vue.js). +* The homepage should **accurately represent** Placeholder Company 1's brand, products, and services. +* The total cost of development, including **labor, hosting, and any third-party tools or services**, must not exceed **100,000 euros**"} +Task: Send JSON with "description" for this User Story and mark all important content: diff --git a/ai/resource/prompts/prompt_mark_description_german.txt b/ai/resource/prompts/prompt_mark_description_german.txt new file mode 100644 index 000000000..907cd853f --- /dev/null +++ b/ai/resource/prompts/prompt_mark_description_german.txt @@ -0,0 +1,23 @@ +Aufgabe: Send JSON with "description" for this User Story and mark all important content: Als Backend-Entwickler möchte ich einen zuverlässigen und skalierbaren RESTful API-Service erstellen, damit unsere Anwendung Clientanfragen und Datenverarbeitung effizient handhaben kann. +##### Acceptance Criteria: +* Der API-Service sollte gängige HTTP-Methoden (GET, POST, PUT, DELETE) für CRUD-Operationen unterstützen. +* Der Service sollte eine angemessene Fehlerbehandlung implementieren und entsprechende HTTP-Statuscodes zurückgeben. +* Authentifizierungs- und Autorisierungsmechanismen sollten implementiert werden, um die API-Endpunkte abzusichern. +Antwort: {"description" : "Als *Backend-Entwickler* möchte ich einen **zuverlässigen und skalierbaren RESTful API-Service erstellen**, damit unsere Anwendung Clientanfragen und Datenverarbeitung effizient handhaben kann. +##### Acceptance Criteria: +* Der API-Service sollte gängige HTTP-Methoden (GET, POST, PUT, DELETE) für **CRUD-Operationen unterstützen**. +* Der Service sollte eine **angemessene Fehlerbehandlung implementieren** und **entsprechende HTTP-Statuscodes zurückgeben**. +* **Authentifizierungs- und Autorisierungsmechanismen** sollten implementiert werden, um die API-Endpunkte abzusichern. } +Aufgabe: Send JSON with "description" for this User Story and mark all important content: Als Webentwickler möchte ich eine ansprechende und informative Homepage für unsere Website erstellen, damit Besucher unsere Produkte oder Dienstleistungen leicht verstehen können. +Antwort: {"description" : "Als *Webentwickler* möchte ich eine **ansprechende und informative Homepage** für unsere Website erstellen, damit Besucher unsere Produkte oder Dienstleistungen leicht verstehen können."} +Aufgabe: Send JSON with "description" for this User Story and mark all important content: Als Webentwickler möchte ich eine Homepage für Placeholder Company 1 mit Java-Backend erstellen, wobei die Gesamtkosten 100.000 Euro nicht überschreiten dürfen. +##### Acceptance Criteria: +* Die Homepage sollte mit Java für das Backend und einem modernen Frontend-Framework (z.B. React, Angular, Vue.js) aufgebaut sein. +* Die Homepage sollte die Marke, Produkte und Dienstleistungen von Placeholder Company 1 korrekt widerspiegeln. +* Die Gesamtkosten für Entwicklung, einschließlich Arbeitsaufwand, Hosting und eventueller Drittanbieter-Tools oder -Services, dürfen 100.000 Euro nicht überschreiten. +Antwort: {"description" : "Als *Webentwickler* möchte ich eine **Homepage für Placeholder Company 1 mit Java-Backend erstellen**, wobei die Gesamtkosten **100.000 Euro nicht überschreiten** dürfen. +##### Acceptance Criteria: +* Die Homepage sollte mit **Java für das Backend** und einem modernen Frontend-Framework (z.B. *React*, *Angular*, *Vue.js*) aufgebaut sein. +* Die Homepage sollte die Marke, Produkte und Dienstleistungen von Placeholder Company 1 **korrekt widerspiegeln**. +* Die Gesamtkosten für Entwicklung, einschließlich **Arbeitsaufwand, Hosting und eventueller Drittanbieter-Tools oder -Services**, dürfen **100.000 Euro nicht überschreiten**."} +Aufgabe: Send JSON with "description" for this User Story and mark all important content: diff --git a/ai/resource/prompts/prompt_split_story.txt b/ai/resource/prompts/prompt_split_story.txt new file mode 100644 index 000000000..bc1cfa29a --- /dev/null +++ b/ai/resource/prompts/prompt_split_story.txt @@ -0,0 +1,33 @@ +Task: Split this User Story in multiple smaller User Stories. +Title: Create a Web application +Description: I want a Web application, that does have a vue frontend and a java backend. It should be an online shop for smartphones. +Solution: { +"user_stories": [ +{ +"title": "Create Frontend for Application", +"description": "As a user, I want to have a user-friendly frontend for the application so that I can easily interact with the features and functionalities.", +"acceptance_criteria": [ +"The frontend should have a clean and intuitive design.", +"It should be responsive and work well on different devices." +] +}, +{ +"title": "Create Backend for Application", +"description": "As a developer, I want to create a backend for the application using Java so that I can efficiently manage and manipulate data.", +"acceptance_criteria": [ +"The backend should be built using Java programming language.", +"It should have endpoints for GET, POST, PUT, and DELETE requests." +] +}, +{ +"title": "Create Database for Application", +"description": "As a developer, I want to create a database for the application so that I can store and retrieve data efficiently.", +"acceptance_criteria": [ +"The database should be designed to handle large amounts of data.", +"It should have tables and relationships that accurately represent the data model of the application." +] +} +] +} +Task: Split this User Story in multiple smaller User Stories. +Title: diff --git a/ai/resource/prompts/prompt_split_story_german.txt b/ai/resource/prompts/prompt_split_story_german.txt new file mode 100644 index 000000000..4d97be027 --- /dev/null +++ b/ai/resource/prompts/prompt_split_story_german.txt @@ -0,0 +1,33 @@ +Aufgabe: Teile diese User Story in mehrere kleinere User Stories auf. +Titel: Erstellung einer Web Applikation +Beschreibung: Ich möchte eine Web Applikation, die ein Vue Frontend und ein Java Backend besitzt. Es soll als Online-Shop für Smartphones fungieren. +Antwort: { +"user_stories": [ +{ +"title": "Erstelle das Frontend für die Applikation", +"description" : "Als ein Nutzer, möchte ich ein benutzerfreundliches Frontend für die Applikation nutzen, damit ich ohne Probleme mit den Features und Funktionalitäten interagieren kann.", +"acceptance_criteria" : [ +"Das Frontend soll ein sauberes und intuitives Design haben.", +"Es soll responsive sein und auf unterschiedlichen Endgeräten gut funktionieren" +] +}, +{ +"title": "Erstellung des Backends für die Applikation", +"description": "Als Entwickler, möchte ich das Backend für die Applikation in Java schreiben, damit ich effizient Daten managen und manipulieren kann.", +"acceptance_criteria": [ +"Das Backend soll Java als Programmiersprache nutzen", +"Es sollte Endpunkte für die möglichen Requests besitzen: GET, POST, PUT und DELETE" +] +}, +{ +"title": "Erstellung einer Datenbank für die Applikation", +"description": "Als Entwickler, möchte ich eine Datenbank für die Applikation erstellen, damit ich Daten effizient speichern und aufrufen kann.", +"acceptance_criteria": [ +"Die Datenbank sollte große Mengen an Daten gut managen können,", +"Sie sollte Tables und Relationships besitzen, die das Datenmodel der Applikation wiederspiegeln." +] +} +] +} +Aufgabe: Teile diese User Story in mehrere kleinere User Stories auf. +Titel: diff --git a/ai/resource/prompts/prompt_title.txt b/ai/resource/prompts/prompt_title.txt new file mode 100644 index 000000000..28bb1c129 --- /dev/null +++ b/ai/resource/prompts/prompt_title.txt @@ -0,0 +1,7 @@ +Task: Improve the title of this issue in its language: Create a homepage for information research +Answer: Designing an Information Research Homepage: Enhancing User Experience and Accessibility +Task: Improve the title of this issue in its language: Create a homepage for placeholder-company-0 +Answer: Designing a Homepage for a placeholder-company-0 Website +Task: Improve the title of this issue in its language: Erstellung einer Homepage +Answer: Erstellung einer benutzerfreundlichen Homepage: Verbessernde User Experience +Task: Improve the title of this issue in its language: diff --git a/ai/service/gpt_service.py b/ai/service/gpt_service.py new file mode 100644 index 000000000..6ab0b1d02 --- /dev/null +++ b/ai/service/gpt_service.py @@ -0,0 +1,264 @@ +import os +import random +import re +from openai import OpenAI +from dto.user_story import UserStory +from dto.description_response import Description_Response +from dto.estimation_data import Estimation_data +import json + + +def setUp(): + API_KEY = os.environ.get('api_key') + model_id = 'gpt-3.5-turbo-instruct' + client = OpenAI(api_key=API_KEY) + return client, model_id + + +def readFile(filename): + filehandle = open(filename) + names = filehandle.read().splitlines() + filehandle.close() + return names + + +def replace_confidential_data(data: str, confidential_map: dict[str, str]): + fileDir = os.path.dirname(os.path.realpath('__file__')) + filename = os.path.join(fileDir, 'resource/names.txt') + filename = os.path.abspath(os.path.realpath(filename)) + names = readFile(filename) + replaced_words = {} + count = 0 + for private_data in confidential_map.keys(): + replaced_data = re.compile(private_data.strip(), re.IGNORECASE) + if str(confidential_map.get(private_data)) == "company": + data = replaced_data.sub("placeholder-" + str(confidential_map.get(private_data)) + "-" + str(count), data) + replaced_words[private_data] = "placeholder-" + str(confidential_map.get(private_data)) + "-" + str(count) + if str(confidential_map.get(private_data)) == "person": + data = replaced_data.sub(names[count], data) + replaced_words[private_data] = names[count] + if str(confidential_map.get(private_data)) == "number": + placerholder_number = random.randint(0, 1000000) + data = replaced_data.sub(str(placerholder_number), data) + replaced_words[private_data] = str(placerholder_number) + count = count + 1 + return replaced_words, data + + +def replace_confidential_data_to_original(data, confidential_map: dict): + if type(data) == str: + for key, value in confidential_map.items(): + replaced_data = re.compile(value, re.IGNORECASE) + data = replaced_data.sub(key, data) + return data + else: # needed for list of strings + for key, value in confidential_map.items(): + for x in range(len(data)): + replaced_data = re.compile(value, re.IGNORECASE) + data[x] = replaced_data.sub(key, data[x]) + return data + + +def get_prompt(information): + fileDir = os.path.dirname(os.path.realpath('__file__')) + filename = os.path.join(fileDir, "resource/prompts/prompt_" + information + ".txt") + prompt = open(filename, 'r', encoding='utf-8').read() + return prompt + + +async def improve_title(original_title: str, confidential_data: dict): + print("gpt_service: --> improve_title()") + swapped_data, new_title = replace_confidential_data(original_title, confidential_data) + client, model_id = setUp() + # "Improve the title of this issue: " + prompt_input = get_prompt("title") + new_title + "\nAnswer:" + completion = client.completions.create( + model=model_id, + prompt=prompt_input, + max_tokens=100, + temperature=0.8 + ) + response = completion.choices[0].text.lstrip().rstrip() + title = replace_confidential_data_to_original(response, swapped_data) + print("gpt_service: <-- improve_title()") + return title + + +async def improve_description(original_user_story: UserStory): + print("gpt_service: --> improve_description()") + swappedData, new_description = replace_confidential_data(original_user_story.description, original_user_story.confidential_data) + client, model_id = setUp() + # prompt_input = ("Send JSON with 'description' & 'acceptance_criteria' (acceptance_criteria should be a list & description needs " + # "improvement) for this user story description: ") + new_description + + if original_user_story.language == "english": + final_prompt = get_prompt("improve_description") + new_description + "\n Solution: " + else: + final_prompt = get_prompt("improve_description_german") + new_description + "\n Antwort: " + + completion = client.completions.create( + model=model_id, + prompt=final_prompt, + max_tokens=1500, + temperature=0.1 + ) + output = completion.choices[0].text + start_brace = output.find('{') + end_brace = output.rfind('}') + json_ready_string = output[start_brace: end_brace + 1] + data = json.loads(json_ready_string) + # data = json.loads(completion.choices[0].text.strip("'<>() ").replace('\'', '\"'), strict = False) + description = data.get("description", "") + acceptance_criteria = data.get("acceptance_criteria", []) + if original_user_story.language == "german": + description = description + "\n ##### Akzeptanz Kriterien: \n" + else: + description = description + "\n ##### Acceptance Criteria: \n" + acceptance_criteria = ["* " + criteria + "\n" for criteria in acceptance_criteria] + description = replace_confidential_data_to_original(description, swappedData) + acceptance_criteria = replace_confidential_data_to_original(acceptance_criteria, swappedData) + print("gpt_service: <-- improve_description()") + return Description_Response(description, acceptance_criteria) + + +async def grammar_check(original_user_story: UserStory): + print("gpt_service: --> grammar_check()") + swappedData, new_description = replace_confidential_data(original_user_story.description, original_user_story.confidential_data) + client, model_id = setUp() + prompt_input = ("Fix grammar & syntax mistakes, but do not add new elements. Send it back as a JSON with 'description' and " + "'acceptance_criteria' (list) field." + "If the text does not mention acceptance criteria, leave the field blank. This is the text: ") + new_description + final_prompt = get_prompt("grammar_check") + new_description + "\n Solution: " + completion = client.completions.create( + model=model_id, + prompt=final_prompt, + max_tokens=1500, + temperature=0 + ) + output = completion.choices[0].text + start_brace = output.find('{') + end_brace = output.rfind('}') + json_ready_string = output[start_brace: end_brace + 1] + data = json.loads(json_ready_string, strict=False) + description = data.get("description", "") + if "acceptance_criteria" in data: + acceptance_criteria = data.get("acceptance_criteria") + criterias = "" + for criteria in acceptance_criteria: + criterias = criterias + " \n" + criteria + if criterias != "": + if original_user_story.language == "english": + text = "Acceptance Criteria" + else: + text = "Akzeptanz Kriterien" + description = description + "\n ##### " + text + ": \n" + criterias + description = replace_confidential_data_to_original(description, swappedData) + print("gpt_service: <-- grammar_check()") + return description + + +async def estimate_user_story(original_data: Estimation_data): + print("gpt_service: --> estimate_user_story()") + client, model_id = setUp() + swappedDataTitle, new_title = replace_confidential_data(original_data.title, original_data.confidential_data) + swappedDataDescription, new_description = replace_confidential_data(original_data.description, original_data.confidential_data) + if original_data.voteSet == ['1', '2', '3', '5', '8', '13', '21']: + prompt = get_prompt("estimation_fibo") + new_title + "\n Description: " + new_description + "\n Valid Options are: " + str( + original_data.voteSet) + elif original_data.voteSet == ['XS', 'S', 'M', 'L', 'XL']: + prompt = get_prompt("estimation_shirt") + new_title + "\n Description: " + new_description + "\n Valid Options are: " + str( + original_data.voteSet) + elif original_data.voteSet == ['1', '2', '3', '4', '5', '6', '8', '10', '12', '16']: + prompt = get_prompt("estimation_hour") + new_title + "\n Description: " + new_description + "\n Valid Options are: " + str( + original_data.voteSet) + elif original_data.voteSet == ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']: + prompt = get_prompt("estimation_number") + new_title + "\n Description: " + new_description + "\n Valid Options are: " + str( + original_data.voteSet) + else: + prompt = ("Task: Send a JSON with \"estimation\" and estimate the effort for this user story: Title: " + new_title + "\n " + "Description: " + new_description + "\n Valid Options are: " + str( + original_data.voteSet)) + completion = client.completions.create( + model=model_id, + prompt=prompt, + max_tokens=1000, + temperature=0 + ) + output = completion.choices[0].text + start_brace = output.find('{') + end_brace = output.rfind('}') + json_ready_string = output[start_brace: end_brace + 1] + data = json.loads(json_ready_string, strict=False) + estimation = data.get("estimation", "") + print("gpt_service: <-- estimate_user_story()") + return estimation + + +async def split_user_story(data: UserStory): + print("gpt_service: --> split_user_story()") + client, model_id = setUp() + swapped_data_title, new_title = replace_confidential_data(data.title, data.confidential_data) + swapped_data_description, new_description = replace_confidential_data(data.description, data.confidential_data) + if data.language == "english": + prompt = get_prompt("split_story") + new_title + "\nDescription: " + new_description + "\nSolution:" + else: + prompt = get_prompt("split_story_german") + new_title + "\nBeschreibung: " + new_description + "\nAntwort:" + completion = client.completions.create( + model=model_id, + prompt=prompt, + max_tokens=3000, + temperature=0 + ) + output = completion.choices[0].text + start_brace = output.find('{') + end_brace = output.rfind('}') + json_ready_string = output[start_brace: end_brace + 1] + replaced_title_data = replace_confidential_data_to_original(json_ready_string, swapped_data_title) + replace_description_data = replace_confidential_data_to_original(replaced_title_data, swapped_data_description) + gpt_json = json.loads(replace_description_data, strict=False) + user_story_list = [] + for user_story in gpt_json["user_stories"]: + acceptance_criteria = user_story["acceptance_criteria"] + criterias = "" + for criteria in acceptance_criteria: + criterias = criterias + " \n* " + criteria + if data.language == "english": + text = "Acceptance Criteria" + else: + text = "Akzeptanz Kriterien" + description = user_story["description"] + "\n##### " + text + ": \n" + criterias + user_story_list.append({"title": user_story["title"], "description": description}) + print("gpt_service: <-- split_user_story()") + return user_story_list + +async def mark_description(data: UserStory): + print("gpt_service: --> mark_description()") + client, model_id = setUp() + swappedData, new_description = replace_confidential_data(data.description, data.confidential_data) + if data.language == "english": + prompt = get_prompt("mark_description") + new_description + "\nSolution:" + else: + prompt = get_prompt("mark_description_german") + new_description + "\nAntwort:" + completion = client.completions.create( + model=model_id, + prompt=prompt, + max_tokens=3000, + temperature=0 + ) + output = completion.choices[0].text + start_brace = output.find('{') + end_brace = output.rfind('}') + json_ready_string = output[start_brace: end_brace + 1] + result_data = json.loads(json_ready_string, strict=False) + description = result_data.get("description", "") + description = replace_confidential_data_to_original(description, swappedData) + print("gpt_service: <-- mark_description()") + return description + + +def check_api_key(): + print("gpt_service: --> check_api_key()") + api_key = os.environ.get('api_key') + print("gpt_service: <-- check_api_key()") + return False if api_key is None else True + diff --git a/ai/test_main.http b/ai/test_main.http new file mode 100644 index 000000000..a2d81a92c --- /dev/null +++ b/ai/test_main.http @@ -0,0 +1,11 @@ +# Test your FastAPI endpoints + +GET http://127.0.0.1:8000/ +Accept: application/json + +### + +GET http://127.0.0.1:8000/hello/User +Accept: application/json + +### diff --git a/ai/tests/__init__.py b/ai/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/main/java/io/diveni/backend/controller/AiController.java b/backend/src/main/java/io/diveni/backend/controller/AiController.java new file mode 100644 index 000000000..2a47347c9 --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/controller/AiController.java @@ -0,0 +1,73 @@ +package io.diveni.backend.controller; + +import io.diveni.backend.dto.GptConfidentialData; +import io.diveni.backend.service.ai.AiService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/ai") +public class AiController { + private static final Logger LOGGER = LoggerFactory.getLogger(AiController.class); + + @Autowired AiService aiService; + + @PostMapping("/improve-title") + public ResponseEntity improveTitle(@RequestBody GptConfidentialData data) { + LOGGER.debug("--> improveTitle(), userstory={}", data); + ResponseEntity response = aiService.improveTitle(data); + LOGGER.debug("<-- improveTitle()"); + return response; + } + + @PostMapping("/improve-description") + public ResponseEntity improveDescription(@RequestBody GptConfidentialData data) { + LOGGER.debug("--> improveDescription(), userStory={}", data); + ResponseEntity response = aiService.improveDescription(data); + LOGGER.debug("<-- improveDescription()"); + return response; + } + + @PostMapping("/grammar-check") + public ResponseEntity grammarCheck(@RequestBody GptConfidentialData data) { + LOGGER.debug("--> grammarCheck(), userStory={}", data); + ResponseEntity response = aiService.grammarCheck(data); + LOGGER.debug("<-- grammarCheck()"); + return response; + } + + @PostMapping("estimate-user-story") + public ResponseEntity estimateUserStory(@RequestBody GptConfidentialData data) { + LOGGER.debug("--> estimateUserStory(), GptConfidentialData={}", data); + ResponseEntity response = aiService.estimateUserStory(data); + LOGGER.debug("<-- estimateUserStory()"); + return response; + } + + @PostMapping("split-user-story") + public ResponseEntity splitUserStory(@RequestBody GptConfidentialData data) { + LOGGER.debug("--> splitUserStory(), GptConfidentialData={}", data); + ResponseEntity response = aiService.splitUserStory(data); + LOGGER.debug("<-- splitUserStory()"); + return response; + } + + @PostMapping("mark-description") + public ResponseEntity markDescription(@RequestBody GptConfidentialData data) { + LOGGER.debug("--> markDescription(), GptConfidentialData={}", data); + ResponseEntity response = aiService.markDescription(data); + LOGGER.debug("<-- markDescription()"); + return response; + } + + @GetMapping("check-api-key") + public ResponseEntity checkApiKey() { + LOGGER.debug("--> checkApiKey()"); + ResponseEntity response = aiService.checkApiKey(); + LOGGER.debug("<-- checkApiKey()"); + return response; + } +} diff --git a/backend/src/main/java/io/diveni/backend/dto/GptConfidentialData.java b/backend/src/main/java/io/diveni/backend/dto/GptConfidentialData.java new file mode 100644 index 000000000..e2840b9ed --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/dto/GptConfidentialData.java @@ -0,0 +1,29 @@ +package io.diveni.backend.dto; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.json.JSONObject; + +import java.util.List; + +@EqualsAndHashCode +@RequiredArgsConstructor +@Getter +public class GptConfidentialData { + final String id; + + final String title; + + final String description; + + final String estimation; + + final Boolean isActive; + + final JSONObject confidentialData; + + final String language; + + final List voteSet; +} diff --git a/backend/src/main/java/io/diveni/backend/service/ai/AiService.java b/backend/src/main/java/io/diveni/backend/service/ai/AiService.java new file mode 100644 index 000000000..1097a3db7 --- /dev/null +++ b/backend/src/main/java/io/diveni/backend/service/ai/AiService.java @@ -0,0 +1,124 @@ +package io.diveni.backend.service.ai; + +import com.google.gson.Gson; +import io.diveni.backend.dto.GptConfidentialData; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.beans.factory.annotation.Value; + +import java.util.*; + +@Service +public class AiService { + + private static final Logger LOGGER = LoggerFactory.getLogger(AiService.class); + + @Value("${python_ai_url}") + private String aiUrl; + + @PostConstruct + public void logConfig() { + LOGGER.info("Url to Server is: " + aiUrl); + } + + public ResponseEntity executeRequest(String url, HttpMethod method, Object body) { + LOGGER.debug("--> executeRequest()"); + // Create a RestTemplate object + RestTemplate restTemplate = new RestTemplate(); + // Set the headers for the request + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(body, headers); + LOGGER.debug("<-- executeRequest()"); + return restTemplate.exchange(url, method, request, String.class); + } + + public ResponseEntity improveTitle(GptConfidentialData data) { + LOGGER.debug("--> improveTitle()"); + Map content = new HashMap<>(); + content.put("name", data.getTitle()); + content.put("confidential_data", data.getConfidentialData().toMap()); + ResponseEntity response = + executeRequest(aiUrl + "/improve-title", HttpMethod.POST, new Gson().toJson(content)); + LOGGER.debug("<-- improveTitle()"); + return response; + } + + public ResponseEntity improveDescription(GptConfidentialData data) { + LOGGER.debug("--> improveDescription()"); + Map content = new HashMap<>(); + content.put("title", data.getTitle()); + content.put("description", data.getDescription()); + content.put("confidential_data", data.getConfidentialData().toMap()); + content.put("language", data.getLanguage()); + ResponseEntity response = + executeRequest(aiUrl + "/improve-description", HttpMethod.POST, new Gson().toJson(content)); + LOGGER.debug("<-- improveDescription()"); + return response; + } + + public ResponseEntity grammarCheck(GptConfidentialData data) { + LOGGER.debug("--> grammarCheck()"); + Map content = new HashMap<>(); + content.put("title", data.getTitle()); + content.put("description", data.getDescription()); + content.put("confidential_data", data.getConfidentialData().toMap()); + content.put("language", data.getLanguage()); + ResponseEntity response = + executeRequest(aiUrl + "/grammar-check", HttpMethod.POST, new Gson().toJson(content)); + LOGGER.debug("<-- grammarCheck()"); + return response; + } + + public ResponseEntity estimateUserStory(GptConfidentialData data) { + LOGGER.debug("--> estimateUserStory()"); + Map content = new HashMap<>(); + content.put("title", data.getTitle()); + content.put("description", data.getDescription()); + content.put("confidential_data", data.getConfidentialData().toMap()); + content.put("voteSet", data.getVoteSet()); + ResponseEntity response = + executeRequest(aiUrl + "/estimate-user-story", HttpMethod.POST, new Gson().toJson(content)); + LOGGER.debug("<-- estimateUserStory()"); + return response; + } + + public ResponseEntity splitUserStory(GptConfidentialData data) { + LOGGER.debug("--> splitUserStory()"); + Map content = new HashMap<>(); + content.put("title", data.getTitle()); + content.put("description", data.getDescription()); + content.put("confidential_data", data.getConfidentialData().toMap()); + content.put("language", data.getLanguage()); + ResponseEntity response = + executeRequest(aiUrl + "/split-user-story", HttpMethod.POST, new Gson().toJson(content)); + LOGGER.debug("<-- splitUserStory()"); + return response; + } + + public ResponseEntity markDescription(GptConfidentialData data) { + LOGGER.debug("--> markDescription"); + Map content = new HashMap<>(); + content.put("title", data.getTitle()); + content.put("description", data.getDescription()); + content.put("confidential_data", data.getConfidentialData().toMap()); + content.put("language", data.getLanguage()); + ResponseEntity response = + executeRequest(aiUrl + "/mark-description", HttpMethod.POST, new Gson().toJson(content)); + LOGGER.debug("<-- markDescription"); + return response; + } + + public ResponseEntity checkApiKey() { + LOGGER.debug("--> checkApiKey()"); + ResponseEntity response = + executeRequest(aiUrl + "/check-api-key", HttpMethod.GET, null); + LOGGER.debug("<-- checkApiKey()"); + return response; + } +} diff --git a/backend/src/main/resources/application-dev-diveni.properties b/backend/src/main/resources/application-dev-diveni.properties new file mode 100644 index 000000000..0981046a0 --- /dev/null +++ b/backend/src/main/resources/application-dev-diveni.properties @@ -0,0 +1,6 @@ +spring.data.mongodb.uri=mongodb://diveni_database:27017 +spring.data.mongodb.database=diveni +server.port=9090 +server.forward-headers-strategy=framework +springdoc.swagger-ui.disable-swagger-default-url=true +python_ai_url=http://dev_diveni_ai:8000 diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index 19a052b43..7baf5940c 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -1,3 +1,5 @@ spring.data.mongodb.uri=mongodb://localhost:27017 spring.data.mongodb.database=diveni server.port=8081 + +python_ai_url=http://127.0.0.1:8000 diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index 1d8718302..2cc47d038 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -3,3 +3,4 @@ spring.data.mongodb.database=diveni server.port=9090 server.forward-headers-strategy=framework springdoc.swagger-ui.disable-swagger-default-url=true +python_ai_url=http://diveni_ai:8000 diff --git a/backend/src/test/java/io/diveni/backend/controller/AiControllerTest.java b/backend/src/test/java/io/diveni/backend/controller/AiControllerTest.java new file mode 100644 index 000000000..b68b4f09d --- /dev/null +++ b/backend/src/test/java/io/diveni/backend/controller/AiControllerTest.java @@ -0,0 +1,116 @@ +package io.diveni.backend.controller; + +import com.google.gson.Gson; +import io.diveni.backend.dto.GptConfidentialData; +import io.diveni.backend.service.ai.AiService; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public class AiControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private AiService service; + + @BeforeEach + public void setUp() { + // improve Title + Mockito.when(service.improveTitle(Mockito.any(GptConfidentialData.class))) + .thenReturn(new ResponseEntity<>("'improvedTitle': 'TEST'", HttpStatus.OK)); + // improve Description + String improvedAcceptanceCriteria = "* pictures \n, * navigation bar\n"; + String improvedDescription = "As a User i want a homepage. \n ##### Acceptance Criteria: \n"; + Mockito.when(service.improveDescription(Mockito.any(GptConfidentialData.class))) + .thenReturn( + new ResponseEntity<>( + "{ 'improved_description': '" + + improvedDescription + + "', 'improved_acceptance_criteria': '" + + improvedAcceptanceCriteria + + "'}", + HttpStatus.OK)); + // grammar check + String correctedDescription = + "As a backend developer, I want to establish websocket communication between the server and" + + " client, so that real-time data can be transmitted and received."; + Mockito.when(service.grammarCheck(Mockito.any(GptConfidentialData.class))) + .thenReturn( + new ResponseEntity<>( + "{ 'improved_description': '" + correctedDescription + "'}", HttpStatus.OK)); + } + + @Test + public void getTitle_valid_statusOKAndContainsTitle() throws Exception { + Map content = new HashMap<>(); + content.put("test-company", "company"); + JSONObject testObject = new JSONObject(content); + GptConfidentialData data = + new GptConfidentialData( + "1", "TEST", "TEST", "3", true, testObject, "english", List.of("1", "2", "3", "4")); + this.mockMvc + .perform( + post("/ai/improve-title") + .contentType(MediaType.APPLICATION_JSON) + .content(new Gson().toJson(data))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("improvedTitle"))); + } + + @Test + public void getDescription_valid_statusOKAndContainsDescriptionAndAcceptanceCriteria() + throws Exception { + Map content = new HashMap<>(); + content.put("test-company", "company"); + JSONObject testObject = new JSONObject(content); + GptConfidentialData data = + new GptConfidentialData( + "1", "TEST", "TEST", "3", true, testObject, "english", List.of("1", "2", "3", "4")); + + this.mockMvc + .perform( + post("/ai/improve-description") + .contentType(MediaType.APPLICATION_JSON) + .content(new Gson().toJson(data))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("'improved_description'"))) + .andExpect(content().string(containsString("'improved_acceptance_criteria'"))); + } + + @Test + public void grammarCheck_valid_statusOKAndContainsDescription() throws Exception { + Map content = new HashMap<>(); + content.put("test-company", "company"); + JSONObject testObject = new JSONObject(content); + GptConfidentialData data = + new GptConfidentialData( + "1", "TEST", "TEST", "3", true, testObject, "english", List.of("1", "2", "3", "4")); + + this.mockMvc + .perform( + post("/ai/grammar-check") + .contentType(MediaType.APPLICATION_JSON) + .content(new Gson().toJson(data))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("'improved_description'"))); + } +} diff --git a/backend/src/test/java/io/diveni/backend/service/ai/AiServiceTest.java b/backend/src/test/java/io/diveni/backend/service/ai/AiServiceTest.java new file mode 100644 index 000000000..fbdec87f2 --- /dev/null +++ b/backend/src/test/java/io/diveni/backend/service/ai/AiServiceTest.java @@ -0,0 +1,76 @@ +package io.diveni.backend.service.ai; + +import io.diveni.backend.dto.GptConfidentialData; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@SpringBootTest +public class AiServiceTest { + + @InjectMocks private AiService aiService = new AiService(); + + @Test + public void improveTitle() { + aiService = Mockito.mock(AiService.class); + String jsonReturnValue = "{'improvedTitle' : 'test'}"; + HttpHeaders mockedHeaders = new HttpHeaders(); + mockedHeaders.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity mockedResponse = + new ResponseEntity<>(jsonReturnValue, mockedHeaders, HttpStatusCode.valueOf(200)); + Map content = new HashMap<>(); + content.put("test-company", "company"); + JSONObject testObject = new JSONObject(content); + GptConfidentialData data = + new GptConfidentialData( + "1", "TEST", "TEST", "3", true, testObject, "english", List.of("1", "2", "3", "4")); + + when(aiService.improveDescription(data)).thenReturn(mockedResponse); + + ResponseEntity returnResponse = aiService.improveDescription(data); + + assertEquals("{'improvedTitle' : 'test'}", returnResponse.getBody()); + } + + @Test + public void improveDescription() { + aiService = Mockito.mock(AiService.class); + String jsonReturnValue = + "{'improved_description' : 'test', 'improved_acceptance_criteria' : '* TEST \n" + + ", * TEST 2 \n" + + "'}"; + HttpHeaders mockedHeaders = new HttpHeaders(); + mockedHeaders.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity mockedResponse = + new ResponseEntity<>(jsonReturnValue, mockedHeaders, HttpStatusCode.valueOf(200)); + + Map content = new HashMap<>(); + content.put("test-company", "company"); + JSONObject testObject = new JSONObject(content); + GptConfidentialData data = + new GptConfidentialData( + "1", "TEST", "TEST", "3", true, testObject, "english", List.of("1", "2", "3", "4")); + + when(aiService.improveDescription(data)).thenReturn(mockedResponse); + + ResponseEntity returnResponse = aiService.improveDescription(data); + + assertEquals( + "{'improved_description' : 'test', 'improved_acceptance_criteria' : '* TEST \n" + + ", * TEST 2 \n" + + "'}", + returnResponse.getBody()); + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index f8cfc331b..1568c8571 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -3,6 +3,9 @@ de.flapdoodle.mongodb.embedded.version=5.0.5 LOCALE=de +#Python Container URL +python_ai_url=http://127.0.0.1:8000 + # Jira Cloud JIRA_CLOUD_CLIENTID=xxx JIRA_CLOUD_CLIENTSECRET=xxx diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 54f405e65..1299a9d46 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,9 +14,18 @@ services: retries: 3 start_period: 30s + ai: + build: + ./ai + container_name: diveni_ai + restart: unless-stopped + env_file: + ./ai/.env + backend: depends_on: - database + - ai build: ./backend container_name: diveni_backend diff --git a/docker-compose.yml b/docker-compose.yml index 9087dbf99..a3d734ccb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,18 @@ services: retries: 3 start_period: 30s + ai: + image: ghcr.io/sybit-education/diveni-ai:latest + container_name: diveni_ai + restart: unless-stopped + env_file: + - ./ai/.env + backend: image: ghcr.io/sybit-education/diveni-backend:latest depends_on: - database + - ai container_name: diveni_backend restart: unless-stopped environment: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a282c8c9a..723ba0234 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "core-js": "^3.37.0", "gsap": "^3.12.5", "hammerjs": "^2.0.8", + "jira2md": "^3.0.1", "papaparse": "^5.4.1", "pinia": "^2.1.7", "popper.js": "^1.16.1", @@ -28,6 +29,7 @@ "uuid": "^9.0.1", "vue": "^3.4.33", "vue-axios": "^3.5.2", + "vue-debounce": "^5.0.0", "vue-i18n": "^9.12.1", "vue-router": "^4.3.3", "vue-toastification": "^2.0.0-rc.5", @@ -9721,6 +9723,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jira2md": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jira2md/-/jira2md-3.0.1.tgz", + "integrity": "sha512-BQlgr64fveNCT8lmTUmUcwdJwLJwGnZpNO0aeRyyFbRONZ0WiIar0iaS6V8loqbNM6pgmRilCKRhFO8tH8977Q==", + "dependencies": { + "marked": "^4.0.12" + } + }, "node_modules/joi": { "version": "17.13.3", "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", @@ -10625,6 +10635,17 @@ "semver": "bin/semver.js" } }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", @@ -15234,6 +15255,14 @@ "vue": "^3.0.0 || ^2.0.0" } }, + "node_modules/vue-debounce": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vue-debounce/-/vue-debounce-5.0.0.tgz", + "integrity": "sha512-heIPTbmFC6Hghhnwbfjw0nYRBYH+YFp/d9xGQ/rDRPUfU6NBIndbJz+i0pHVii4BXXRi1CaljT6H3fDSbbPMvg==", + "peerDependencies": { + "vue": ">= 3.0.0" + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 96553daca..195056475 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "core-js": "^3.37.0", "gsap": "^3.12.5", "hammerjs": "^2.0.8", + "jira2md": "^3.0.1", "papaparse": "^5.4.1", "pinia": "^2.1.7", "popper.js": "^1.16.1", @@ -32,6 +33,7 @@ "uuid": "^9.0.1", "vue": "^3.4.33", "vue-axios": "^3.5.2", + "vue-debounce": "^5.0.0", "vue-i18n": "^9.12.1", "vue-router": "^4.3.3", "vue-toastification": "^2.0.0-rc.5", diff --git a/frontend/src/assets/style/_variables.scss b/frontend/src/assets/style/_variables.scss index d9324acc8..05cc98a66 100644 --- a/frontend/src/assets/style/_variables.scss +++ b/frontend/src/assets/style/_variables.scss @@ -62,6 +62,13 @@ /*Editor*/ --editor-toolbar-switch-bg: #7a777773; --editor-defaultui-container-bg: #3e4b6c; + + /*Ai-Feature*/ + --text-field: #b4b2b2; + --ai-stars: #266aea; + --b-modal-background: white; + --markConfidentialInformation: #2131aa; + --newUserStoryShadow: #575757; } /* Define styles for the root window with dark - mode preference */ @@ -127,6 +134,13 @@ /*Editor*/ --editor-toolbar-switch-bg: #7a777773; --editor-defaultui-container-bg: #3e4b6c; + + /*Ai-Feature*/ + --text-field: #56585d; + --ai-stars: #266aea; + --b-modal-background: #282C35; + --markConfidentialInformation: #2131aa; + --newUserStoryShadow: #b0b0b0; } $border-radius: 0.5rem !default; diff --git a/frontend/src/assets/style/main.scss b/frontend/src/assets/style/main.scss index c725a4e4d..b68e6b095 100644 --- a/frontend/src/assets/style/main.scss +++ b/frontend/src/assets/style/main.scss @@ -117,12 +117,12 @@ a { } .btn-secondary { - background-color: var(--secondary-button) !important; + background-color: var(--secondary-button); border-width: 2px; border-color: var(--btn-border-color); &:hover { - background-color: var(--secondary-button-hovered) !important; + background-color: var(--secondary-button-hovered); border-color: var(--btn-border-color); } diff --git a/frontend/src/components/GptModal.vue b/frontend/src/components/GptModal.vue new file mode 100644 index 000000000..a1eb797d0 --- /dev/null +++ b/frontend/src/components/GptModal.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/src/components/JiraComponent.vue b/frontend/src/components/JiraComponent.vue index bc79a75f0..042fb32b3 100644 --- a/frontend/src/components/JiraComponent.vue +++ b/frontend/src/components/JiraComponent.vue @@ -8,7 +8,7 @@
  • {{ t("session.prepare.step.selection.mode.description.withIssueTracker.descriptionLine1") }} - + diff --git a/frontend/src/components/MarkdownEditor.vue b/frontend/src/components/MarkdownEditor.vue index d147cace7..8c4b74d28 100644 --- a/frontend/src/components/MarkdownEditor.vue +++ b/frontend/src/components/MarkdownEditor.vue @@ -1,5 +1,5 @@ @@ -22,13 +69,17 @@ import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin import "@toast-ui/editor/dist/i18n/de-de"; -import { defineComponent } from "vue"; +import {customRef, defineComponent} from "vue"; import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight-all"; import UiToastEditorWrapper from "@/components/UiToastEditorWrapper.vue"; +import { PropType } from "vue"; +import PrivacyModal from "@/components/PrivacyModal.vue"; +import {useI18n} from "vue-i18n"; export default defineComponent({ name: "MarkdownEditor", components: { + PrivacyModal, editor: UiToastEditorWrapper, }, model: { @@ -53,10 +104,28 @@ export default defineComponent({ type: String, default: "", }, + currentStoryID: { + type: [String, null] as PropType, + required: false, + default: null + }, + acceptedStories: { + type: Array<{ storyID: string | null, issueType: string }>, + required: false, + default: [] + }, + hasApiKey: { + type: Boolean, + required: false, + default: false + }, + }, + setup() { + const { t } = useI18n(); + return { t }; }, data() { return { - text: "", editorOptions: { autofocus: false, height: "auto", @@ -78,6 +147,13 @@ export default defineComponent({ plugins: [codeSyntaxHighlight], }, theme: localStorage.getItem("user-theme"), + showPopOver: false, + aiButtonVisible: false, + currentText: "", + foundDescription: false, + foundGrammar: false, + showModal: false, + currentIssue: "", }; }, mounted() { @@ -86,6 +162,27 @@ export default defineComponent({ this.theme = customEvent.detail.storage; }); }, + methods: { + customRef, + showAiButton({description}) { + if (description.trim().length > 0) { // hier vielleicht eher überprüfen ob String Text oder Zahlen beinhaltet + this.currentText = description; + this.foundDescription = !this.acceptedStories.find(us => us.storyID === this.currentStoryID && us.issueType === 'improveDescription'); + this.foundGrammar = !this.acceptedStories.find(us => us.storyID === this.currentStoryID && us.issueType === 'grammar'); + this.aiButtonVisible = this.foundDescription || this.foundGrammar; + } + }, + aiButtonClicked(issue) { + this.showPopOver = false; + this.aiButtonVisible = false; + this.showModal = true; + this.currentIssue = issue; + }, + redirectSubmit({description, confidentialData, language}) { + this.$emit("sendGPTDescriptionRequest", {description: description, issue: this.currentIssue, confidentialData: confidentialData, language: language }); + this.showModal = false; + }, + }, }); @@ -103,14 +200,31 @@ export default defineComponent({ } .toastui-editor-defaultUI .ProseMirror { - background-color: var(--editor-defaultui-container-bg); + background-color: var(--text-field); + font-size: 18px; + padding: 18px 60px 18px 25px; +} + +.toastui-editor-contents ul > li::before { + height: 9px; + width: 9px; + margin-left: -16px; +} + +.toastui-editor-contents ul > li::before, .toastui-editor-contents ol > li::before { + top: 4px; +} + +.toastui-editor-contents h5 { + color: var(--text-primary-color); } .toastui-editor-md-container .toastui-editor-md-preview { overflow: auto; - padding: 0 25px; + overflow-wrap: break-word; + padding: 0 60px 0 25px; height: 100%; - background-color: var(--editor-defaultui-container-bg); + background-color: var(--text-field); /* var(--editor-defaultui-container-bg) */ } .toastui-editor-mode-switch { @@ -118,18 +232,15 @@ export default defineComponent({ } .toastui-editor-contents p { - color: var(--text-primary-color) !important; + color: var(--text-primary-color); + font-size: large; } -.lightMode .toastui-editor-defaultUI .ProseMirror { - background-color: var(--textAreaColour); -} .lightMode .toastui-editor-md-container .toastui-editor-md-preview { overflow: auto; padding: 0 25px; height: 100%; - background-color: var(--textAreaColour); } .lightMode .toastui-editor-mode-switch { @@ -143,5 +254,70 @@ export default defineComponent({ .ProseMirror { height: 100%; color: var(--text-primary-color) !important; + z-index: 0; +} +/*AI FEATURE*/ + +.aiDescriptionButtons { + border: none !important; + border-radius: 0 !important; + background-color: transparent !important; + color: black !important; + transition: color 0.3s linear !important; + + &:hover { + background-color: transparent !important; + color: var(--ai-stars) !important; + border-radius: 1em !important; + } +} + +#aiPopOver{ + background-color: white; + position: absolute; + border: 3px solid black; + right: 2.5em; + bottom: 6.5em; +} + +.triangle-down { + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid black; + position: absolute; + right: 2.45em; + bottom: 5.9em; +} + +#popoverBody { + display: inline-grid; +} + +#aiStars { + height: 2em; + width: 2em; } + +#submitAIDescription { + position: absolute; + right: 1vw; + top: 81%; + color: var(--ai-stars) !important; + background-color: transparent !important; + border-style: none; + animation: showUp 1s; + z-index: 2000; +} + +@keyframes showUp { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + diff --git a/frontend/src/components/PrivacyModal.vue b/frontend/src/components/PrivacyModal.vue new file mode 100644 index 000000000..bc284f55b --- /dev/null +++ b/frontend/src/components/PrivacyModal.vue @@ -0,0 +1,250 @@ + + + + diff --git a/frontend/src/components/SignInWithJiraServerButtonComponent.vue b/frontend/src/components/SignInWithJiraServerButtonComponent.vue index 47a8f357c..6698724cc 100644 --- a/frontend/src/components/SignInWithJiraServerButtonComponent.vue +++ b/frontend/src/components/SignInWithJiraServerButtonComponent.vue @@ -129,6 +129,8 @@ export default defineComponent({ this.$nextTick(() => { this.showVerificationModal = false; }); + this.store.isJiraSelected = true; + this.$emit("jira") }, showToast(error) { if (error.message == "failed to retrieve access token") { diff --git a/frontend/src/components/SplitUserStoriesModal.vue b/frontend/src/components/SplitUserStoriesModal.vue new file mode 100644 index 000000000..5d241a7b6 --- /dev/null +++ b/frontend/src/components/SplitUserStoriesModal.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/frontend/src/components/UiToastEditorWrapper.vue b/frontend/src/components/UiToastEditorWrapper.vue index dc1db5473..39fb4e71f 100644 --- a/frontend/src/components/UiToastEditorWrapper.vue +++ b/frontend/src/components/UiToastEditorWrapper.vue @@ -1,8 +1,19 @@ + + - - diff --git a/frontend/src/components/UserStories.vue b/frontend/src/components/UserStories.vue index 9376d7033..807026573 100644 --- a/frontend/src/components/UserStories.vue +++ b/frontend/src/components/UserStories.vue @@ -14,7 +14,7 @@ /> - + - + + + @@ -106,6 +112,22 @@ {{ t("page.session.before.userStories.button.addUserStory") }} + + @@ -113,9 +135,12 @@ import { defineComponent } from "vue"; import UserStory from "../model/UserStory"; import { useI18n } from "vue-i18n"; +import PrivacyModal from "@/components/PrivacyModal.vue"; +import SplitUserStoriesModal from "@/components/SplitUserStoriesModal.vue"; export default defineComponent({ name: "UserStories", + components: {SplitUserStoriesModal, PrivacyModal}, props: { cardSet: { type: Array, required: true }, initialStories: { type: Array, required: true }, @@ -123,6 +148,9 @@ export default defineComponent({ showEditButtons: { type: Boolean, required: false, default: true }, hostSelectedStoryIndex: { type: Number, required: false, default: null }, storyMode: { type: String, required: false, default: null }, + splittedUserStories: { type: Array, required: false, default: [] }, + storyToSplitIdx: { type: Number, required: false, default: 0 }, + hasApiKey: { type: Boolean, required: false, default: false }, }, setup() { const { t } = useI18n(); @@ -138,6 +166,9 @@ export default defineComponent({ filterActive: false, savedStories: [] as Array, generatedUUIDs: new Set(), + showPrivacyModal: false, + showUserStorySplitModal: false, + splittedUserStoriesData: [] as Array, }; }, watch: { @@ -151,15 +182,21 @@ export default defineComponent({ }, mounted() { this.userStories = this.initialStories as Array; + this.splittedUserStoriesData = this.splittedUserStories; }, methods: { setUserStoryAsActive(index) { - this.selectedStoryIndex = index; - this.$emit("selectedStory", index); + if (index >= this.userStories.length) { + console.log("Pressed after deletion"); + } else { + this.selectedStoryIndex = index; + this.$emit("selectedStory", index); + this.publishChanges(index, false); + } }, addUserStory() { const story: UserStory = { - id: this.storyMode === "US_JIRA" ? null : this.generateNumericUUID(), + id: this.storyMode === "US_JIRA" ? null : this.generateNumericUUID().toString(), title: "", description: "", estimation: null, @@ -183,23 +220,24 @@ export default defineComponent({ if (filteredUserStories.length > 0) { this.filterActive = true; this.userStories = filteredUserStories; - this.publishChanges(null, false); } else { this.filterActive = true; this.userStories = []; - this.publishChanges(null, false); } } else { this.filterActive = false; this.userStories = this.savedStories; - this.publishChanges(null, false); } }, deleteStory(index) { this.publishChanges(index, true); }, publishChanges(index, remove) { - this.$emit("userStoriesChanged", { us: this.userStories, idx: index, doRemove: remove }); + if (this.userStories[index] !== undefined) { + if (this.userStories[index].title !== "" || remove) { + this.$emit("userStoriesChanged", { us: this.userStories, idx: index, doRemove: remove }); + } + } }, markUserStory(index) { const stories = this.userStories.map((s) => ({ @@ -221,12 +259,36 @@ export default defineComponent({ this.generatedUUIDs.add(uuid); return uuid; }, + submitRequest({description, confidentialData, language}) { + this.$emit("sendGPTRequest",{confidentialData: confidentialData, language: language, retry: false}) + }, + acceptSplitting({newUserStories}) { + newUserStories.map(us => this.userStories.push(us)); + this.publishChanges(this.storyToSplitIdx, true); + if (this.storyMode === "US_JIRA") { + let count = newUserStories.length; + newUserStories.forEach(() => { + this.publishChanges(this.userStories.length - count, false); + count = count - 1; + }); + } + }, + retry() { + this.$emit("sendGPTRequest", {retry: true}); + } }, }); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f98d02dab..309d461d8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -11,7 +11,51 @@ "backend": "Licenses (Backend)", "translations": "Improve translation" }, - "feedback": "Feedback" + "feedback": "Feedback", + "aiFeature": { + "optionButtons": { + "keep": "Keep", + "adjust": "Adjust", + "tryAgain": "Try Again", + "delete": "Delete", + "cancel": "Cancel" + }, + "descriptionButtons": { + "description": "Improve Description", + "grammar": "Grammar Check", + "mark": "Mark important content" + }, + "privacyModal": { + "title": "Mark confidential information", + "warning": "Warning", + "warningInfo" : "Your User Story will be sent to OpenAI's GPT. Mark confidential Information now.", + "buttons": { + "company": "Companies", + "person": "Personal Information", + "number": "Number", + "add": "Add", + "ok": "Ok", + "cancel": "Cancel" + }, + "labels": { + "english": "English", + "german": "German" + } + }, + "descriptionModal": { + "title": "Alternative Description" + }, + "estimationToast": { + "startingText": "Proposed Estimation for ", + "endingText": " is: " + }, + "splitUserStoriesModal" : { + "title" : "Splitted User Stories", + "originalStory" : "Original User Story", + "newStories" : "New User Stories", + "selectedStory" : "Selected User Story" + } + } }, "page": { "session": { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 59e8d7eda..30598f151 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -16,6 +16,7 @@ import "./assets/style/main.scss"; import Toast from "vue-toastification"; import "vue-toastification/dist/index.css"; import i18n from "@/i18n"; +import vueDebounce from "vue-debounce"; Vue.use(IconsPlugin); Vue.use(ModalPlugin); @@ -29,6 +30,8 @@ const app = createApp(App); const pinia = createPinia(); +app.directive("debounce", vueDebounce({ lock: true })); + app .use(VueAxios, axios) .use(router) diff --git a/frontend/src/model/UserStory.ts b/frontend/src/model/UserStory.ts index 6d56edd65..63fca6a1a 100644 --- a/frontend/src/model/UserStory.ts +++ b/frontend/src/model/UserStory.ts @@ -1,5 +1,5 @@ interface UserStory { - id: number | null; + id: string | null; title: string; description: string; estimation: string | null; diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index 8b8385651..bf1ce5814 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -1,6 +1,7 @@ import constants from "@/constants"; -import { JiraRequestTokenDto, JiraResponseCodeDto, PullRequestDto } from "@/types"; -import axios, { AxiosResponse } from "axios"; +import {JiraRequestTokenDto, JiraResponseCodeDto, PullRequestDto} from "@/types"; +import axios, {AxiosResponse} from "axios"; +import UserStory from "@/model/UserStory"; class ApiService { public async getIssueTrackerConfig(): Promise> { @@ -153,6 +154,135 @@ class ApiService { }); return response.data; } + + public async improveTitle(userStory: UserStory, confidentialData: Map) { + return await axios.post(`${constants.backendURL}/ai/improve-title`, { + id: userStory.id, + title: userStory.title, + description: userStory.description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + }); + } + + public async retryImproveTitle(userStory: UserStory, oldTitle: string, confidentialData: Map) { + return await axios.post(`${constants.backendURL}/ai/improve-title`, { + id: userStory.id, + title: oldTitle, + description: userStory.description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + }); + } + public async improveDescription(userStory: UserStory, description: string, confidentialData: Map, language: string) { + const response = await axios.post(`${constants.backendURL}/ai/improve-description`, { + id: userStory.id, + title: userStory.title, + description: description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + language: language, + }); + return { + description: response.data.improved_description, + acceptance_criteria: response.data.improved_acceptance_criteria, + }; + + } + public async grammarCheck(userStory: UserStory, description: string, confidentialData: Map, language: string) { + const response = await axios.post(`${constants.backendURL}/ai/grammar-check`, { + id: userStory.id, + title: userStory.title, + description: description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + language: language, + } + ); + return { + description: response.data.improved_description, + acceptance_criteria: response.data.improved_acceptance_criteria, + }; + } + + public async estimateUserStory(userStory: UserStory, confidentialData: Map, voteSet: string[]) { + const response = await axios.post(`${constants.backendURL}/ai/estimate-user-story`, { + id: userStory.id, + title: userStory.title, + description: userStory.description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + voteSet: voteSet, + }) + return response.data.estimation; + } + + public async splitUserStory(userStory: UserStory, confidentialData: Map, language: string): Promise> { + const response = await axios.post(`${constants.backendURL}/ai/split-user-story`, { + id: userStory.id, + title: userStory.title, + description: userStory.description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + language: language, + }) + const splitted_stories: Array = response.data.new_user_stories; + splitted_stories.map(us => us.id = null); + return splitted_stories; + } + + public async markDescription(userStory: UserStory, description: string, confidentialData: Map, language: string) { + const response = await axios.post(`${constants.backendURL}/ai/mark-description`, { + id: userStory.id, + title: userStory.title, + description: description, + estimation: userStory.estimation, + isActive: userStory.isActive, + confidentialData: JSON.stringify( + Array.from(confidentialData.entries()).reduce((o, [key, value]) => { + o[key] = value; + return o; + }, {})), + language: language, + }); + return response.data.description; + } + + public async checkApiKey() { + const response = await axios.get(`${constants.backendURL}/ai/check-api-key`); + return response.data.has_api_key; + } + } export default new ApiService(); diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 739ced1f8..28bcfac7b 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -1,4 +1,5 @@ import SockJS from "sockjs-client"; +import j2m from "jira2md"; import webstomp, { Client } from "webstomp-client"; import Constants from "../constants"; import { defineStore } from "pinia"; @@ -25,6 +26,7 @@ export const useDiveniStore = defineStore("diveni-store", { hostEstimation: undefined as AdminVote | undefined, hostVoting: false, autoReveal: false, + isJiraSelected: false, }), actions: { setMembers(members) { @@ -132,6 +134,9 @@ export const useDiveniStore = defineStore("diveni-store", { this.projects = projects; }, setUserStories({ stories }) { + if (this.isJiraSelected) { + stories.filter(us => us.description).map(us => us.description = j2m.to_markdown(us.description)); + } this.userStories = stories; }, setTokenId(tokenId) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5e12dfd54..514805c08 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -17,6 +17,7 @@ export interface StoreState { hostEstimation: string | undefined; selectedUserStoryIndex: number | undefined; autoReveal: boolean; + isJiraSelected: boolean; } export interface JiraRequestTokenDto { diff --git a/frontend/src/views/LandingPage.vue b/frontend/src/views/LandingPage.vue index c929906d6..1ff08c4c4 100644 --- a/frontend/src/views/LandingPage.vue +++ b/frontend/src/views/LandingPage.vue @@ -184,7 +184,7 @@ export default defineComponent({ set: Array; timerSeconds: number; userStories: Array<{ - id: number | null; + id: string | null; title: string; description: string; estimation: string | null; diff --git a/frontend/src/views/MemberVotePage.vue b/frontend/src/views/MemberVotePage.vue index f7d9ba15d..5430410b3 100644 --- a/frontend/src/views/MemberVotePage.vue +++ b/frontend/src/views/MemberVotePage.vue @@ -153,8 +153,8 @@ - -
    + +
    - - + + @@ -210,10 +217,12 @@ import { defineComponent } from "vue"; import { useDiveniStore } from "@/store"; import { useToast } from "vue-toastification"; import { useI18n } from "vue-i18n"; +import UserStoryTitle from "@/components/UserStoryTitle.vue"; export default defineComponent({ name: "MemberVotePage", components: { + UserStoryTitle, SessionLeaveButton, RoundedAvatar, MemberVoteCard, @@ -233,7 +242,7 @@ export default defineComponent({ }, data() { return { - index: null as number | null, + index: 0, hostSelectedStoryIndex: undefined, draggedVote: null, voteSet: [] as string[], diff --git a/frontend/src/views/PrepareSessionPage.vue b/frontend/src/views/PrepareSessionPage.vue index 3524f6831..2fad9d280 100644 --- a/frontend/src/views/PrepareSessionPage.vue +++ b/frontend/src/views/PrepareSessionPage.vue @@ -96,7 +96,7 @@ {{ t("session.prepare.step.selection.mode.description.withUS.importButton") }}
    - + @@ -288,6 +288,8 @@ export default defineComponent({ hostVoting: false, isIssueTrackerEnabled: false, theme: localStorage.getItem("user-theme"), + isJiraSelected: false, + generatedUUIDs: new Set(), tabs: [ { title: this.t("session.prepare.step.wizard.modeSelection"), @@ -409,6 +411,7 @@ export default defineComponent({ userStoryMode: session.sessionConfig.userStoryMode, hostVoting: this.hostVoting, rejoined: "false", + isJiraSelected: this.isJiraSelected, }, }); }, @@ -441,6 +444,14 @@ export default defineComponent({ fileUpload.click(); } }, + generateNumericUUID() { + let uuid: number; + do { + uuid = Math.floor(Math.random() * 1e15) + Date.now(); + } while (this.generatedUUIDs.has(uuid)); + this.generatedUUIDs.add(uuid); + return uuid; + }, importStory(event: Event) { const target = event.target as HTMLInputElement; const files = target.files; @@ -459,7 +470,7 @@ export default defineComponent({ const { title, description, estimation } = story; stories.push({ - id: null, + id: this.generateNumericUUID().toString(), title: title, description: description, estimation: estimation, diff --git a/frontend/src/views/SessionPage.vue b/frontend/src/views/SessionPage.vue index aafec1c61..573335907 100644 --- a/frontend/src/views/SessionPage.vue +++ b/frontend/src/views/SessionPage.vue @@ -45,9 +45,6 @@
    -
    - -
    + + - - + + - - + + @@ -342,10 +404,16 @@ import { useDiveniStore } from "@/store"; import { useToast } from "vue-toastification"; import { useI18n } from "vue-i18n"; import SessionAdminCard from "@/components/SessionAdminCard.vue"; +import GptModal from "@/components/GptModal.vue"; +import UserStoryTitle from "@/components/UserStoryTitle.vue"; +import j2m from "jira2md"; +import UserStory from "@/model/UserStory"; export default defineComponent({ name: "SessionPage", components: { + UserStoryTitle, + GptModal, SessionStartButton, SessionCloseButton, KickUserWrapper, @@ -387,6 +455,32 @@ export default defineComponent({ session: {}, hostEstimation: "", autoReveal: false, + //needed for jira converter + isJiraSelected: history.state.isJiraSelected as boolean, + //generell needed for GPT usage + showGPTModal: false, + gptMode: "", + hasApiKey: false, + // needed for title + anti spam + gptTitleResponse: false, + alternateTitle: "", + //needed for description + anti spam + gptDescriptionResponse: false, + alternateDescription: "", + descriptionMode: "", + updateComponent: false, + acceptedStoriesDescription: [] as Array<{ + storyID: string | null, + issueType: string, + }>, + showSpinner: false, + // needed for privacy feature + confidentialData: {} as Map, + // needed for multi-language GPT + userStoryLanguage: "", + // needed for splitting user stories + splitted_user_stories: [] as Array, + language: "", }; }, computed: { @@ -458,8 +552,8 @@ export default defineComponent({ }, }, async created() { - this.copyPropsToData(); this.store.clearStoreWithoutUserStories(); + this.hasApiKey = await apiService.checkApiKey(); if (!this.sessionID || !this.adminID) { //check for cookie await this.checkAdminCookie(); @@ -529,17 +623,6 @@ export default defineComponent({ } } }, - copyPropsToData() { - // if (this.adminID) { - // this.session_adminID = this.adminID; - // this.session_sessionID = this.sessionID; - // this.session_sessionState = this.sessionState; - // this.session_timerSecondsString = this.timerSecondsString; - // this.session_voteSetJson = this.voteSetJson; - // this.session_userStoryMode = this.userStoryMode; - // this.session_hostVoting = String(this.hostVoting).toLowerCase() === "true"; - // } - }, assignSessionToData(session) { if (Object.keys(session).length !== 0) { this.adminID = session.adminID; @@ -600,6 +683,9 @@ export default defineComponent({ console.log(`assigned id: ${us[idx].id}`); } } else { + if (this.isJiraSelected && us[idx].description) { + us[idx].description = j2m.to_jira(us[idx].description); + } response = await apiService.updateUserStory(JSON.stringify(us[idx])); } } @@ -619,6 +705,9 @@ export default defineComponent({ } this.store.setUserStories({ stories: us }); if (this.webSocketIsConnected) { + if (this.isJiraSelected && us[idx].description) { + us[idx].description = j2m.to_jira(us[idx].description); + } const endPoint = `${Constants.webSocketAdminUpdatedUserStoriesRoute}`; this.store.sendViaBackendWS(endPoint, JSON.stringify(us)); } @@ -631,37 +720,6 @@ export default defineComponent({ this.store.sendViaBackendWS(endPoint, JSON.stringify(response)); } }, - async onSynchronizeJira({ story, doRemove }) { - if (this.userStoryMode === "US_JIRA") { - let response; - if (doRemove) { - response = await apiService.deleteUserStory(story.id); - } else { - console.log(`ID: ${story.id}`); - if (story.id === null) { - response = await apiService.createUserStory( - JSON.stringify(story), - this.selectedProject?.id - ); - if (response.status === 200) { - const updatedStories = this.userStories.map( - (s) => s.title === story.title && s.description === story.description - ); - this.store.setUserStories({ stories: updatedStories }); - } - } else { - response = await apiService.updateUserStory(JSON.stringify(story)); - } - } - if (response.status === 200) { - this.toast.success( - this.t("session.notification.messages.issueTrackerSynchronizeSuccess") - ); - } else { - this.toast.error(this.t("session.notification.messages.issueTrackerSynchronizeFailed")); - } - } - }, onSelectedStory($event) { if (this.planningStart && $event != null) { const endPoint = Constants.webSocketAdminSelectedUserStoryRoute; @@ -732,12 +790,119 @@ export default defineComponent({ }) ); }, + async improveTitle({ userStory, confidentialInformation }) { + const trimmedStoryTitle = userStory.title.trim(); + if (trimmedStoryTitle.length > 0) { + const response = await apiService.improveTitle(userStory, confidentialInformation); + this.alternateTitle = response.data.improvedTitle; + this.gptTitleResponse = true; + } + }, + acceptSuggestionTitle({ id }) { + this.userStories + .filter((us) => { + us.id !== id; + }) + .map((us) => { + us.title = this.alternateTitle; + }); + this.gptTitleResponse = false; + this.alternateTitle = ""; + this.onUserStoriesChanged({ us: this.userStories, idx: this.index, doRemove: false }); + }, + adjustOriginalTitle() { + this.alternateTitle = ""; + this.gptTitleResponse = false; + }, + async retryImproveTitle({ id, originalTitle, confidentialData }) { + this.gptTitleResponse = false; + const userstory = this.userStories.find((us) => us.id === id); + if (userstory) { + const response = await apiService.retryImproveTitle(userstory, originalTitle, confidentialData); + this.alternateTitle = response.data.improvedTitle; + this.gptTitleResponse = true; + } + }, + deleteTitle() { + this.alternateTitle = ""; + this.gptTitleResponse = false; + }, + async improveDescription({ userStory, description, issue, confidentialData, language }) { + this.userStoryLanguage = language; + this.confidentialData = confidentialData; + this.showSpinner = true; + if (issue === 'improveDescription') { + const response = await apiService.improveDescription(userStory, description, this.confidentialData, language); + this.alternateDescription = + response.description + response.acceptance_criteria.toString().replaceAll(",", ""); + } if (issue === 'grammar') { + const response = await apiService.grammarCheck(userStory, description, this.confidentialData, language); + this.alternateDescription = response.description; + } if (issue === 'markDescription') { + this.alternateDescription= await apiService.markDescription(userStory, description, this.confidentialData, language); + } + this.descriptionMode = issue; + this.gptDescriptionResponse = true; + this.showSpinner = false; + this.showGPTModal = true; + }, + acceptSuggestionDescription({ description, originalText }) { + if (originalText) { + this.userStories[this.index!].description = this.alternateDescription; + } else { + this.userStories[this.index!].description = description; + } + this.onUserStoriesChanged({ us: this.userStories, idx: this.index, doRemove: false }); + this.updateComponent = !this.updateComponent; + this.acceptedStoriesDescription.push({ storyID: this.userStories[this.index!].id, issueType: this.descriptionMode}) + }, + async retrySuggestionDescription() { + await this.improveDescription({userStory: this.userStories[this.index!],description: this.userStories[this.index!].description, issue: this. descriptionMode, confidentialData: this.confidentialData, language: this.userStoryLanguage}); + this.updateComponent = !this.updateComponent; + }, + closeModal() { + this.showGPTModal = false; + this.gptDescriptionResponse = false; + }, + async aiEstimation({confidentialData}){ + this.confidentialData = confidentialData; + this.showSpinner = true; + const response = await apiService.estimateUserStory(this.userStories[this.index!], confidentialData, this.voteSet); + this.showSpinner = false; + this.toast.info(this.t("general.aiFeature.estimationToast.startingText") + "\"" + this.userStories[this.index!].title + "\"" + this.t("general.aiFeature.estimationToast.endingText") + response, {timeout: false}); + }, + async splitUserStory({confidentialData: confidentialData, language: language, retry: retry}) { + if(!retry) { + this.confidentialData = confidentialData; + this.language = language; + this.showSpinner = true; + const response = await apiService.splitUserStory(this.userStories[this.index!], confidentialData, language); + this.showSpinner = false; + this.splitted_user_stories = response; + } else { + this.showSpinner = true; + const response = await apiService.splitUserStory(this.userStories[this.index!], this.confidentialData, this.language); + this.showSpinner = false; + this.splitted_user_stories = response; + } + + } }, });