Compare commits

...

63 Commits
deploy ... main

Author SHA1 Message Date
Zainab MohamedBasheer c45d1b20ed Merge pull request '#bugs' (#110) from #bugs into main
Reviewed-on: #110
2025-07-01 20:03:54 +02:00
s8613 ffd30aefee Fixed text and resetting form. 2025-07-01 19:32:42 +02:00
s8613 be165144ad Fixed min. required example for edit. 2025-07-01 19:09:48 +02:00
Jaronim Pracht 810827e0bb fix host url and prepare for deployment 2025-07-01 18:58:24 +02:00
s8613 3d6458ffb0 Removed example for edit mode. 2025-07-01 11:06:20 +02:00
s8613 7eeca5c3ba Fixed extra frame font and small styling issues. 2025-07-01 10:52:56 +02:00
Anastasia Hanna Ougolnikova 5d0a5ab3c3 Merge pull request 'neue-Kennzahl-spacy' (#94) from neue-Kennzahl-spacy into main
Reviewed-on: #94
2025-06-29 18:23:42 +02:00
Abdulrahman Dabbagh 78e5ce16e2 Tooltip für 'Alle Kennzahlen sind bereits trainiert' hinzugefügt, statischer Text entfernt 2025-06-29 16:59:38 +02:00
Abdulrahman Dabbagh 5af1d40c08 Neu trainieren-Button deaktiviert, wenn keine neuen Kennzahlen vorhanden sind; Trainingsdaten werden beim Start bereinigt 2025-06-29 16:34:25 +02:00
Abdulrahman Dabbagh 521ac1dcbd Merge branch 'neue-Kennzahl-spacy' of https://gitty.informatik.hs-mannheim.de/PSE2_FF/pse2_ff into neue-Kennzahl-spacy 2025-06-29 11:52:06 +02:00
Abdulrahman Dabbagh 843691c571 Weitere Änderungen gespeichert 2025-06-29 11:51:14 +02:00
Abdulrahman Dabbagh f20d86db41 project/frontend/src/routes/config.tsx aktualisiert
Unicode-Zeichen anpassung
2025-06-29 11:26:02 +02:00
Abdulrahman Dabbagh 8e86ecc91b project/frontend/src/components/KPIForm.tsx aktualisiert
Unicode-Zeichen! anpassung
2025-06-29 11:22:01 +02:00
Abdulrahman Dabbagh 386a2c4458 project/backend/coordinator/model/seed_data.py aktualisiert
Unicode-Zeichen anpassen
2025-06-29 11:17:08 +02:00
Abdulrahman Dabbagh 6c55150e9c Deaktiviere Pre-Commit temporär 2025-06-29 11:09:11 +02:00
Abdulrahman Dabbagh 360da3acb0 KPI_data angepasst 2025-06-29 04:57:24 +02:00
Abdulrahman Dabbagh 12783539b3 annotation_data.json in Root entfernt – nicht mehr benötigt 2025-06-29 00:43:34 +02:00
Abdulrahman Dabbagh 09a3099584 Kommentare aus dem Review berücksichtigt und umgesetzt 2025-06-29 00:43:34 +02:00
Zainab MohamedBasheer 15e7752e54 Merge branch 'main' into neue-Kennzahl-spacy 2025-06-28 11:46:36 +02:00
Jaronim Pracht d872d53672 Merge pull request 'Update pdfViewer.tsx' (#109) from #89-bold-headline into main
Reviewed-on: #109
2025-06-27 21:24:58 +02:00
Jaronim Pracht d2c22a0770 Update pdfViewer.tsx 2025-06-27 21:23:54 +02:00
Jaronim Pracht 56f83905a5 Merge pull request 'Add bold headline and zoom in pdf viewer' (#97) from #89-bold-headline into main
Reviewed-on: #97
2025-06-27 21:19:19 +02:00
Jaronim Pracht 740637d6a2 Fix formatting in text search logic and highlight rendering
Adjust PDF container dimensions for better display
2025-06-27 21:14:47 +02:00
Abdulrahman Dabbagh fd8bfa3952 Formatierung durch black, extract-Funktion bereinigt 2025-06-27 11:41:57 +02:00
Abdulrahman Dabbagh 77d169633e Merge remote-tracking branch 'origin/main' into neue-Kennzahl-spacy 2025-06-27 10:16:43 +02:00
Zainab MohamedBasheer 56fc24eb9f Merge pull request 'add validiation in service' (#93) from #91-validate into main
Reviewed-on: #93
2025-06-27 10:06:00 +02:00
Zainab MohamedBasheer 3ae8295c26 Merge branch 'main' into #91-validate 2025-06-27 09:48:37 +02:00
Anastasia Hanna Ougolnikova 68907be99f Merge pull request 'Adjust time zone offset for date formatting' (#108) from #99-fix-timezone into main
Reviewed-on: #108
2025-06-27 07:09:45 +02:00
Jaronim Pracht 28534d4774 Merge pull request 'bug-fixes-106-105-104-103-102' (#107) from bug-fixes into main
Reviewed-on: #107
2025-06-26 22:47:03 +02:00
Jaronim Pracht ac08c67332 Adjust time zone offset for date formatting
Add 2 hours to the hour component to account for the time zone
difference in the application context
2025-06-26 22:26:45 +02:00
s8613 019e10d5b8 Fondsname added with dynamic changes. 2025-06-26 20:54:49 +02:00
s8613 6fef07ac86 Tooltip for added 2025-06-26 20:42:43 +02:00
s8613 0a64411a5f Start for mandatory kpi in config 2025-06-26 20:22:19 +02:00
Anastasia Hanna Ougolnikova 23f047df4e Merge pull request 'exxeta_neu_kennzahl #25' (#96) from exxeta_neue_Kennzahl_clean into main
Reviewed-on: #96
2025-06-26 07:06:19 +02:00
Abdulrahman Dabbagh dc9d693768 project/backend/exxetaGPT-service/extractExxeta.py aktualisiert
Prints entfernt
2025-06-26 03:48:44 +02:00
Zainab MohamedBasheer a52c4c808e Merge branch 'main' into #91-validate 2025-06-25 22:39:44 +02:00
Zainab2604 3b1b7603c4 Add bold headline and zoom in pdf viewer 2025-06-25 21:04:45 +02:00
Abdulrahman Dabbagh a54aff734f Merge-Konflikt in docker-compose.yml gelöst (vor Stash-Wiederherstellung) 2025-06-25 20:43:46 +02:00
Abdulrahman Dabbagh 360d7f4906 Dynamischer Prompt mit API-KPIs, Fortschritt integriert 2025-06-25 19:28:19 +02:00
Abdulrahman Dabbagh 4922dbee95 Formatierung durch Black übernommen 2025-06-25 16:59:32 +02:00
Abdulrahman Dabbagh c9de2cb027 Frontend wie besprochen erweitert, Flask-Backend um Training ergänzt, neue spaCy-Trainingsdaten (.json) und Trainings-Skript hinzugefügt 2025-06-25 16:36:49 +02:00
Abdulrahman Dabbagh 2f1d591202 Merge remote-tracking branch 'origin/main' into neue-Kennzahl-spacy 2025-06-25 16:20:43 +02:00
Jaronim Pracht 0d09a825e9 add validiation in service 2025-06-25 15:36:01 +02:00
Abdulrahman Dabbagh 4ca8314ed2 Merge pull request '#86-87-90' (#92) from #86-87-90 into main
Reviewed-on: #92
2025-06-24 23:04:32 +02:00
s8613 ed34687dc4 Fix highlighting for hyphenated values. Improve highlighting text matching logic. Handle text split across items. 2025-06-24 12:49:47 +02:00
s8613 82f02c0772 Fixed page highlighting in multiple kpis details page. 2025-06-24 11:40:04 +02:00
s8613 685edc9ad2 Added favicon, webtitle and Deka logo. 2025-06-24 11:24:02 +02:00
Abdulrahman Dabbagh 15d4eb1207 Merge pull request '#82-Seiten-Edit-icon-weg,-nur-inline-edit' (#84) from #82-Seiten-Edit-icon-weg,-nur-inline-edit into main
Reviewed-on: #84
2025-06-22 19:33:52 +02:00
Zainab MohamedBasheer 38d24555ff Merge pull request 'Add date to PitchBooksTable and reorder table' (#83) from #81-reorder-table into main
Reviewed-on: #83
2025-06-22 16:06:56 +02:00
Zainab MohamedBasheer af2161eea7 Merge branch 'main' into #81-reorder-table 2025-06-22 16:04:41 +02:00
Anastasia Hanna Ougolnikova 609ec5284a Merge pull request 'Fixed Bug Ticket #63 and #71' (#80) from #71-enable-empty-values into main
Reviewed-on: #80
2025-06-22 14:09:10 +02:00
Zainab2604 73deb3912e Fix hopefully last merge conflict 2025-06-22 13:56:11 +02:00
Zainab2604 8b430f693d Fix merge commit 3 2025-06-22 13:45:20 +02:00
Zainab2604 64426a5f83 Fix merge commit 2 2025-06-22 13:43:56 +02:00
Zainab2604 f6747e45ac Fix merge conflict 2025-06-22 13:41:45 +02:00
Zainab2604 61dcc76203 Eine Seite kommt nur einmal in der Liste vor 2025-06-22 12:44:30 +02:00
s8613 6ddda5036d Changed icon color. 2025-06-21 12:47:42 +02:00
s8613 c023959a97 Removed icon and hover effect. Added inline editing. Changed icon color. 2025-06-21 12:46:39 +02:00
Jaronim Pracht c593fc0e47 Add date to PitchBooksTable and reorder table
Add new date formatting utility and update PitchBooksTable to display
the upload date for each pitch book. The sorting order
for pitch books has been reversed to show the most recent uploads first.
2025-06-20 18:50:08 +02:00
Abdulrahman Dabbagh abccb43741 WIP: Fehler F401 behoben, cleanup vor Branchwechsel 2025-06-20 10:16:36 +02:00
Zainab2604 60b303d92e Fixed Bug Ticket #63 and #71 2025-06-19 22:27:52 +02:00
Abdulrahman Dabbagh 09c314eea3 Merge remote-tracking branch 'origin/main' into neue-Kennzahl-spacy 2025-06-19 15:33:32 +02:00
Abdulrahman Dabbagh d22572cc44 Formatierungsänderungen durch black, jetzt endgültig committen 2025-06-16 14:27:35 +02:00
70 changed files with 13578 additions and 1734 deletions

View File

@ -26,6 +26,6 @@ def health_check():
return "OK"
# für Docker wichtig: host='0.0.0.0'
# Für Docker wichtig: host='0.0.0.0'
if __name__ == "__main__":
socketio.run(app, debug=True, host="0.0.0.0", port=5050)

View File

@ -1,11 +1,12 @@
from controller.spacy_contoller import spacy_controller
from controller.kpi_setting_controller import kpi_setting_controller
from controller.spacy_controller import spacy_controller
from controller.kpi_setting_controller import kpi_setting_controller, kpi_routes
from controller.pitch_book_controller import pitch_book_controller
from controller.progress_controller import progress_controller
def register_routes(app):
app.register_blueprint(kpi_setting_controller)
app.register_blueprint(kpi_routes)
app.register_blueprint(pitch_book_controller)
app.register_blueprint(spacy_controller)
app.register_blueprint(progress_controller)

View File

@ -2,7 +2,10 @@ from flask import Blueprint, request, jsonify
from model.database import db
from model.kpi_setting_model import KPISettingModel, KPISettingType
# Routen für /api/kpi/settings (Auslesen im Frontend)
kpi_routes = Blueprint("kpi_routes", __name__, url_prefix="/api/kpi")
# Routen für /api/kpi_setting/ (Hinzufügen, Ändern, Löschen)
kpi_setting_controller = Blueprint(
"kpi_settings", __name__, url_prefix="/api/kpi_setting"
)
@ -14,12 +17,6 @@ def get_all_kpi_settings():
return jsonify([kpi_setting.to_dict() for kpi_setting in kpi_settings]), 200
@kpi_setting_controller.route("/<int:id>", methods=["GET"])
def get_kpi_setting(id):
kpi_setting = KPISettingModel.query.get_or_404(id)
return jsonify(kpi_setting.to_dict()), 200
@kpi_setting_controller.route("/", methods=["POST"])
def create_kpi_setting():
data = request.json
@ -29,13 +26,12 @@ def create_kpi_setting():
required_fields = [
"name",
"description",
"mandatory",
"type",
"translation",
"example",
"position",
"active",
"examples",
"is_trained",
]
for field in required_fields:
if field not in data:
@ -55,13 +51,12 @@ def create_kpi_setting():
new_kpi_setting = KPISettingModel(
name=data["name"],
description=data["description"],
mandatory=data["mandatory"],
type=kpi_type,
translation=data["translation"],
example=data["example"],
position=data["position"],
active=data["active"],
examples=data.get("examples", []),
is_trained=False,
)
db.session.add(new_kpi_setting)
@ -84,9 +79,6 @@ def update_kpi_setting(id):
return jsonify({"error": "KPI Setting with this name already exists"}), 409
kpi_setting.name = data["name"]
if "description" in data:
kpi_setting.description = data["description"]
if "mandatory" in data:
kpi_setting.mandatory = data["mandatory"]
@ -100,18 +92,18 @@ def update_kpi_setting(id):
400,
)
if "translation" in data:
kpi_setting.translation = data["translation"]
if "example" in data:
kpi_setting.example = data["example"]
if "position" in data:
kpi_setting.position = data["position"]
if "active" in data:
kpi_setting.active = data["active"]
if "examples" in data:
kpi_setting.examples = data["examples"]
if "is_trained" in data:
kpi_setting.is_trained = data["is_trained"]
db.session.commit()
return jsonify(kpi_setting.to_dict()), 200
@ -154,3 +146,21 @@ def update_kpi_positions():
except Exception as e:
db.session.rollback()
return jsonify({"error": f"Failed to update positions: {str(e)}"}), 500
@kpi_routes.route("/settings", methods=["GET"])
def get_kpi_settings():
try:
kpis = KPISettingModel.query.all()
return jsonify([k.to_dict() for k in kpis]), 200
except Exception as e:
return (
jsonify({"error": "Fehler beim Abrufen der KPIs", "details": str(e)}),
500,
)
@kpi_setting_controller.route("/<int:id>", methods=["GET"])
def get_kpi_setting(id):
kpi_setting = KPISettingModel.query.get_or_404(id)
return jsonify(kpi_setting.to_dict()), 200

View File

@ -1,93 +0,0 @@
from flask import Blueprint, request, jsonify, send_file
from io import BytesIO
from model.spacy_model import SpacyModel
import puremagic
from werkzeug.utils import secure_filename
from model.database import db
spacy_controller = Blueprint("spacy", __name__, url_prefix="/api/spacy")
@spacy_controller.route("/", methods=["GET"])
def get_all_files():
files = SpacyModel.query.all()
return jsonify([file.to_dict() for file in files]), 200
@spacy_controller.route("/<int:id>", methods=["GET"])
def get_file(id):
file = SpacyModel.query.get_or_404(id)
return jsonify(file.to_dict()), 200
@spacy_controller.route("/<int:id>/download", methods=["GET"])
def download_file(id):
file = SpacyModel.query.get_or_404(id)
return send_file(
BytesIO(file.file), download_name=file.filename, as_attachment=True
)
@spacy_controller.route("/", methods=["POST"])
def upload_file():
print(request)
if "file" not in request.files:
return jsonify({"error": "No file part in the request"}), 400
uploaded_file = request.files["file"]
if uploaded_file.filename == "":
return jsonify({"error": "No selected file"}), 400
# Read file data once
file_data = uploaded_file.read()
try:
if uploaded_file:
fileName = uploaded_file.filename or ""
new_file = SpacyModel(filename=secure_filename(fileName), file=file_data)
db.session.add(new_file)
db.session.commit()
return jsonify(new_file.to_dict()), 201
except Exception as e:
print(e)
return jsonify({"error": "Invalid file format. Only PDF files are accepted"}), 400
@spacy_controller.route("/<int:id>", methods=["PUT"])
def update_file(id):
file = SpacyModel.query.get_or_404(id)
if "file" in request.files:
uploaded_file = request.files["file"]
if uploaded_file.filename != "":
file.filename = uploaded_file.filename
# Read file data once
file_data = uploaded_file.read()
try:
if (
uploaded_file
and puremagic.from_string(file_data, mime=True) == "application/pdf"
):
file.file = file_data
except Exception as e:
print(e)
if "kpi" in request.form:
file.kpi = request.form.get("kpi")
db.session.commit()
return jsonify(file.to_dict()), 200
@spacy_controller.route("/<int:id>", methods=["DELETE"])
def delete_file(id):
file = SpacyModel.query.get_or_404(id)
db.session.delete(file)
db.session.commit()
return jsonify({"message": f"File {id} deleted successfully"}), 200

View File

@ -0,0 +1,149 @@
from flask import Blueprint, request, jsonify, send_file
from io import BytesIO
from model.spacy_model import SpacyModel
import puremagic
from werkzeug.utils import secure_filename
from model.database import db
import os
import requests
from model.kpi_setting_model import KPISettingModel
spacy_controller = Blueprint("spacy", __name__, url_prefix="/api/spacy")
SPACY_TRAINING_URL = os.getenv("SPACY_TRAINING_URL", "http://spacy:5052/train")
SPACY_URL = os.getenv("SPACY_URL", "http://spacy:5052")
@spacy_controller.route("/train", methods=["POST"])
def trigger_training():
try:
response = requests.post(SPACY_TRAINING_URL, timeout=600)
if response.ok:
return jsonify({"message": "Training erfolgreich angestoßen."}), 200
else:
return (
jsonify({"error": "Training fehlgeschlagen", "details": response.text}),
500,
)
except Exception as e:
return (
jsonify(
{"error": "Fehler beim Senden an Trainingsservice", "details": str(e)}
),
500,
)
@spacy_controller.route("/", methods=["GET"])
def get_all_files():
files = SpacyModel.query.all()
return jsonify([file.to_dict() for file in files]), 200
@spacy_controller.route("/<int:id>", methods=["GET"])
def get_file(id):
file = SpacyModel.query.get_or_404(id)
return jsonify(file.to_dict()), 200
@spacy_controller.route("/<int:id>/download", methods=["GET"])
def download_file(id):
file = SpacyModel.query.get_or_404(id)
return send_file(
BytesIO(file.file), download_name=file.filename, as_attachment=True
)
@spacy_controller.route("/", methods=["POST"])
def upload_file():
if "file" not in request.files:
return jsonify({"error": "No file part in the request"}), 400
uploaded_file = request.files["file"]
if uploaded_file.filename == "":
return jsonify({"error": "No selected file"}), 400
file_data = uploaded_file.read()
try:
fileName = uploaded_file.filename or ""
new_file = SpacyModel(filename=secure_filename(fileName), file=file_data)
db.session.add(new_file)
db.session.commit()
return jsonify(new_file.to_dict()), 201
except Exception as e:
print(e)
return jsonify({"error": "Invalid file format. Only PDF files are accepted"}), 400
@spacy_controller.route("/<int:id>", methods=["PUT"])
def update_file(id):
file = SpacyModel.query.get_or_404(id)
if "file" in request.files:
uploaded_file = request.files["file"]
if uploaded_file.filename != "":
file.filename = uploaded_file.filename
file_data = uploaded_file.read()
try:
if puremagic.from_string(file_data, mime=True) == "application/pdf":
file.file = file_data
except Exception as e:
print(e)
if "kpi" in request.form:
file.kpi = request.form.get("kpi")
db.session.commit()
return jsonify(file.to_dict()), 200
@spacy_controller.route("/<int:id>", methods=["DELETE"])
def delete_file(id):
file = SpacyModel.query.get_or_404(id)
db.session.delete(file)
db.session.commit()
return jsonify({"message": f"File {id} deleted successfully"}), 200
@spacy_controller.route("/append-training-entry", methods=["POST"])
def forward_training_entry():
entry = request.get_json()
try:
response = requests.post(f"{SPACY_URL}/append-training-entry", json=entry)
return jsonify(response.json()), response.status_code
except Exception as e:
return jsonify({"error": str(e)}), 500
# globale Variable oben einfügen
current_training_status = {"running": False}
@spacy_controller.route("/training/status", methods=["POST"])
def update_training_status():
data = request.get_json()
current_training_status["running"] = data.get("running", False)
if current_training_status["running"] is False:
try:
KPISettingModel.query.update({KPISettingModel.is_trained: True})
db.session.commit()
except Exception as e:
db.session.rollback()
return (
jsonify(
{
"error": "is_trained konnte nicht aktualisiert werden",
"details": str(e),
}
),
500,
)
return jsonify({"status": "success", "running": current_training_status["running"]})
@spacy_controller.route("/train-status", methods=["GET"])
def training_status():
return jsonify(current_training_status), 200

View File

@ -2,6 +2,8 @@ from model.database import db
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Enum as SQLAlchemyEnum
from enum import Enum
from sqlalchemy.dialects.postgresql import JSONB
from collections import OrderedDict
class KPISettingType(Enum):
@ -18,37 +20,36 @@ class KPISettingModel(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
description: Mapped[str]
mandatory: Mapped[bool]
type: Mapped[KPISettingType] = mapped_column(
SQLAlchemyEnum(KPISettingType, native_enum=True)
)
translation: Mapped[str]
example: Mapped[str]
position: Mapped[int]
active: Mapped[bool]
examples: Mapped[list] = mapped_column(JSONB, default=[])
is_trained: Mapped[bool] = mapped_column(default=False)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"mandatory": self.mandatory,
"type": self.type.value,
"translation": self.translation,
"example": self.example,
"position": self.position,
"active": self.active,
}
return OrderedDict(
[
("id", self.id),
("name", self.name),
("mandatory", self.mandatory),
("type", self.type.value),
("position", self.position),
("examples", self.examples),
("active", self.active),
("is_trained", self.is_trained),
]
)
def __init__(
self, name, description, mandatory, type, translation, example, position, active
self, name, mandatory, type, position, active, examples=None, is_trained=False
):
self.name = name
self.description = description
self.mandatory = mandatory
self.type = type
self.translation = translation
self.example = example
self.position = position
self.active = active
self.examples = examples or []
self.is_trained = is_trained

View File

@ -10,153 +10,258 @@ def seed_default_kpi_settings():
default_kpi_settings = [
{
"name": "Fondsname",
"description": "Der vollständige Name des Investmentfonds",
"mandatory": True,
"type": KPISettingType.STRING,
"translation": "Fund Name",
"example": "Alpha Real Estate Fund I",
"position": 1,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Der Fonds trägt den Namen Alpha Real Estate Fund I.",
"value": "Alpha Real Estate Fund I",
},
{
"sentence": "Im Pitchbook wird der Fondsname als Alpha Real Estate Fund I angegeben.",
"value": "Alpha Real Estate Fund I",
},
],
},
{
"name": "Fondsmanager",
"description": "Verantwortlicher Manager für die Fondsverwaltung",
"mandatory": True,
"type": KPISettingType.STRING,
"translation": "Fund Manager",
"example": "Max Mustermann",
"position": 2,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Fondsmanager des Projekts ist Max Mustermann.",
"value": "Max Mustermann",
},
{
"sentence": "Die Verwaltung liegt bei Max Mustermann.",
"value": "Max Mustermann",
},
],
},
{
"name": "AIFM",
"description": "Alternative Investment Fund Manager",
"mandatory": True,
"type": KPISettingType.STRING,
"translation": "AIFM",
"example": "Alpha Investment Management GmbH",
"position": 3,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "AIFM ist die Alpha Investment Management GmbH.",
"value": "Alpha Investment Management GmbH",
},
{
"sentence": "Die Alpha Investment Management GmbH fungiert als AIFM.",
"value": "Alpha Investment Management GmbH",
},
],
},
{
"name": "Datum",
"description": "Stichtag der Datenerfassung",
"mandatory": True,
"type": KPISettingType.DATE,
"translation": "Date",
"example": "05.05.2025",
"position": 4,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die Daten basieren auf dem Stand vom 05.05.2025.",
"value": "05.05.2025",
},
{
"sentence": "Stichtag der Angaben ist der 05.05.2025.",
"value": "05.05.2025",
},
],
},
{
"name": "Risikoprofil",
"description": "Klassifizierung des Risikos des Fonds",
"mandatory": True,
"type": KPISettingType.STRING,
"translation": "Risk Profile",
"example": "Core/Core++",
"position": 5,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Der Fonds hat das Risikoprofil Core/Core++.",
"value": "Core/Core++",
},
{
"sentence": "Einstufung des Fondsrisikos: Core/Core++.",
"value": "Core/Core++",
},
],
},
{
"name": "Artikel",
"description": "Artikel 8 SFDR-Klassifizierung",
"mandatory": False,
"type": KPISettingType.BOOLEAN,
"translation": "Article",
"example": "Artikel 8",
"position": 6,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Der Fonds erfüllt die Anforderungen von Artikel 8.",
"value": "Artikel 8",
},
{
"sentence": "Gemäß SFDR fällt dieser Fonds unter Artikel 8.",
"value": "Artikel 8",
},
],
},
{
"name": "Zielrendite",
"description": "Angestrebte jährliche Rendite in Prozent",
"mandatory": True,
"type": KPISettingType.NUMBER,
"translation": "Target Return",
"example": "6.5",
"position": 7,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die angestrebte Zielrendite liegt bei 6.5%.",
"value": "6.5%",
},
{"sentence": "Zielrendite des Fonds beträgt 6.5%.", "value": "6.5%"},
],
},
{
"name": "Rendite",
"description": "Tatsächlich erzielte Rendite in Prozent",
"mandatory": False,
"type": KPISettingType.NUMBER,
"translation": "Return",
"example": "5.8",
"position": 8,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die Rendite für das Jahr beträgt 5.8%.",
"value": "5.8%",
},
{
"sentence": "Im letzten Jahr wurde eine Rendite von 5.8% erzielt.",
"value": "5.8%",
},
],
},
{
"name": "Zielausschüttung",
"description": "Geplante Ausschüttung in Prozent",
"mandatory": False,
"type": KPISettingType.NUMBER,
"translation": "Target Distribution",
"example": "4.0",
"position": 9,
"active": True,
"is_trained": True,
"examples": [
{"sentence": "Die Zielausschüttung beträgt 4.0%.", "value": "4.0%"},
{
"sentence": "Geplante Ausschüttung: 4.0% pro Jahr.",
"value": "4.0%",
},
],
},
{
"name": "Ausschüttung",
"description": "Tatsächliche Ausschüttung in Prozent",
"mandatory": False,
"type": KPISettingType.NUMBER,
"translation": "Distribution",
"example": "3.8",
"position": 10,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die Ausschüttung im Jahr 2024 lag bei 3.8%.",
"value": "3.8%",
},
{
"sentence": "Es wurde eine Ausschüttung von 3.8% vorgenommen.",
"value": "3.8%",
},
],
},
{
"name": "Laufzeit",
"description": "Geplante Laufzeit des Fonds",
"mandatory": True,
"type": KPISettingType.STRING,
"translation": "Duration",
"example": "7 Jahre, 10, Evergreen",
"position": 11,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die Laufzeit des Fonds beträgt 7 Jahre.",
"value": "7 Jahre",
},
{"sentence": "Geplante Dauer: Evergreen-Modell.", "value": "Evergreen"},
],
},
{
"name": "LTV",
"description": "Loan-to-Value Verhältnis in Prozent",
"mandatory": False,
"type": KPISettingType.NUMBER,
"translation": "LTV",
"example": "65.0",
"position": 12,
"active": True,
"is_trained": True,
"examples": [
{"sentence": "Der LTV beträgt 65.0%.", "value": "65.0%"},
{"sentence": "Loan-to-Value-Ratio: 65.0%.", "value": "65.0%"},
],
},
{
"name": "Managementgebühren",
"description": "Jährliche Verwaltungsgebühren in Prozent",
"mandatory": True,
"type": KPISettingType.NUMBER,
"translation": "Management Fees",
"example": "1.5",
"position": 13,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die Managementgebühren betragen jährlich 1.5%.",
"value": "1.5%",
},
{
"sentence": "Für die Verwaltung wird eine Gebühr von 1.5% erhoben.",
"value": "1.5%",
},
],
},
{
"name": "Sektorenallokation",
"description": "Verteilung der Investments nach Sektoren",
"mandatory": False,
"type": KPISettingType.ARRAY,
"translation": "Sector Allocation",
"example": "Büro, Wohnen, Logistik, Studentenwohnen",
"position": 14,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Die Sektorenallokation umfasst Büro, Wohnen und Logistik.",
"value": "Büro, Wohnen, Logistik",
},
{
"sentence": "Investiert wird in Büro, Logistik und Studentenwohnen.",
"value": "Büro, Logistik, Studentenwohnen",
},
],
},
{
"name": "Länderallokation",
"description": "Geografische Verteilung der Investments",
"mandatory": False,
"type": KPISettingType.ARRAY,
"translation": "Country Allocation",
"example": "Deutschland,Frankreich, Österreich, Schweiz",
"position": 15,
"active": True,
"is_trained": True,
"examples": [
{
"sentence": "Investitionen erfolgen in Deutschland, Frankreich und Österreich.",
"value": "Deutschland, Frankreich, Österreich",
},
{
"sentence": "Die Länderallokation umfasst Deutschland, Schweiz und Frankreich.",
"value": "Deutschland, Schweiz, Frankreich",
},
],
},
]
@ -165,13 +270,12 @@ def seed_default_kpi_settings():
for kpi_data in default_kpi_settings:
kpi_setting = KPISettingModel(
name=kpi_data["name"],
description=kpi_data["description"],
mandatory=kpi_data["mandatory"],
type=kpi_data["type"],
translation=kpi_data["translation"],
example=kpi_data["example"],
position=kpi_data["position"],
active=kpi_data["active"],
examples=kpi_data.get("examples", []),
is_trained=kpi_data["is_trained"],
)
db.session.add(kpi_setting)

View File

@ -6,9 +6,12 @@ import json
app = Flask(__name__)
VALIDATE_SERVICE_URL = os.getenv("VALIDATE_SERVICE_URL", "http://localhost:5054/validate")
VALIDATE_SERVICE_URL = os.getenv(
"VALIDATE_SERVICE_URL", "http://localhost:5054/validate"
)
@app.route('/extract', methods=['POST'])
@app.route("/extract", methods=["POST"])
def extract_text_from_ocr_json():
json_data = request.get_json()
@ -16,19 +19,19 @@ def extract_text_from_ocr_json():
pages_data = json_data["extracted_text_per_page"]
entities_json = extract_with_exxeta(pages_data, pitchbook_id)
entities = json.loads(entities_json) if isinstance(entities_json, str) else entities_json
entities = (
json.loads(entities_json) if isinstance(entities_json, str) else entities_json
)
validate_payload = {
"id": pitchbook_id,
"service": "exxeta",
"entities": entities
}
validate_payload = {"id": pitchbook_id, "service": "exxeta", "entities": entities}
print(f"[EXXETA] Sending to validate service: {VALIDATE_SERVICE_URL}")
print(f"[EXXETA] Payload: {validate_payload} entities for pitchbook {pitchbook_id}")
try:
response = requests.post(VALIDATE_SERVICE_URL, json=validate_payload, timeout=600)
response = requests.post(
VALIDATE_SERVICE_URL, json=validate_payload, timeout=600
)
print(f"[EXXETA] Validate service response: {response.status_code}")
if response.status_code != 200:
print(f"[EXXETA] Validate service error: {response.text}")

View File

@ -1,7 +1,6 @@
import requests
import json
import os
import time
import logging
from dotenv import load_dotenv
@ -17,6 +16,20 @@ TIMEOUT = 180
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_dynamic_labels():
url = f"{COORDINATOR_URL}/api/kpi_setting/"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
kpi_list = response.json()
labels = [kpi["name"].upper() for kpi in kpi_list if kpi.get("active", False)]
return labels
except Exception as e:
logger.warning(f"Konnte dynamische Labels nicht laden: {e}")
return []
def extract_with_exxeta(pages_json, pitchbook_id):
results = []
@ -28,11 +41,12 @@ def extract_with_exxeta(pages_json, pitchbook_id):
for page_data in pages_json:
i += 1
if i % 8 == 0:
requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 35 + 60/len(pages_json)*i})
requests.post(
COORDINATOR_URL + "/api/progress",
json={"id": pitchbook_id, "progress": 35 + 60 / len(pages_json) * i},
)
page_num = page_data.get("page")
page_data.get("page")
text = page_data.get("text", "")
if not text:
@ -51,9 +65,9 @@ def extract_with_exxeta(pages_json, pitchbook_id):
"- Gib die Antwort als **JSON-Array** im folgenden Format zurück:\n\n"
"[\n"
" {\n"
" \"label\": \"FONDSNAME\",\n"
" \"entity\": \"...\",\n"
f" \"page\": {page_num},\n"
' "label": "FONDSNAME",\n'
' "entity": "...",\n'
f' "page": {page_num},\n'
" },\n"
" ...\n"
"]\n\n"
@ -61,45 +75,29 @@ def extract_with_exxeta(pages_json, pitchbook_id):
f"TEXT:\n{text}"
)
else:
labels = get_dynamic_labels()
prompt_kennzahlen = "".join([f"- {label}\n" for label in labels])
prompt = (
"Bitte extrahiere relevante Fondskennzahlen aus dem folgenden Pitchbook-Text. "
"Analysiere den Text sorgfältig, um **nur exakt benannte und relevante Werte** zu extrahieren.\n\n"
"ZU EXTRAHIERENDE KENNZAHLEN (immer exakt wie unten angegeben):\n"
"- FONDSNAME\n"
"- FONDSMANAGER\n"
"- AIFM (z. B. Name Kapitalverwaltungsgesellschaft)\n"
"- DATUM\n"
"- RISIKOPROFIL (z. B. CORE, CORE+, VALUE-ADDED, OPPORTUNISTISCH)\n"
"- ARTIKEL (z. B. ARTIKEL 6, 8, 9)\n"
"- ZIELRENDITE\n"
"- RENDITE\n"
"- ZIELAUSSCHÜTTUNG\n"
"- AUSSCHÜTTUNG\n"
"- LAUFZEIT\n"
"- LTV\n"
"- MANAGEMENTGEBÜHREN (ggf. mit Staffelung und Bezug auf NAV/GAV)\n"
"- SEKTORENALLOKATION (z. B. BÜRO, LOGISTIK, WOHNEN... inkl. %-Angaben)\n"
"- LÄNDERALLOKATION (z. B. DEUTSCHLAND, FRANKREICH, etc. inkl. %-Angaben)\n\n"
f"{prompt_kennzahlen}\n"
"WICHTIG:\n"
"- Gib **nur eine Entität pro Kennzahl** an - keine Listen oder Interpretationen.\n"
"- Wenn mehrere Varianten genannt werden (z. B. \"Core und Core+\"), gib sie im Originalformat als **eine entity** an.\n"
'- Wenn mehrere Varianten genannt werden (z. B. "Core und Core+"), gib sie im Originalformat als **eine entity** an.\n'
"- **Keine Vermutungen oder Ergänzungen**. Wenn keine Information enthalten ist, gib die Kennzahl **nicht aus**.\n"
"- Extrahiere **nur wörtlich vorkommende Inhalte** (keine Berechnungen, keine Zusammenfassungen).\n"
"- Jeder gefundene Wert muss einem der obigen Label **eindeutig zuordenbar** sein.\n\n"
"FORMAT:\n"
"Antworte als **reines JSON-Array** mit folgendem Format:\n"
"[\n"
" {\n"
" \"label\": \"Kennzahlname (exakt wie oben)\",\n"
" \"entity\": \"Wert aus dem Text (exakt im Original)\",\n"
f" \"page\": {page_num},\n"
' "label": "Kennzahlname (exakt wie oben)",\n'
' "entity": "Wert aus dem Text (exakt im Original)",\n'
f' "page": {page_num},\n'
" },\n"
" ...\n"
"]\n\n"
f"Falls keine Kennzahl enthalten ist, gib ein leeres Array [] zurück.\n\n"
f"Nur JSON-Antwort - keine Kommentare, keine Erklärungen, kein Text außerhalb des JSON.\n\n"
f"TEXT:\n{text}"
@ -107,28 +105,30 @@ def extract_with_exxeta(pages_json, pitchbook_id):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {EXXETA_API_KEY}"
"Authorization": f"Bearer {EXXETA_API_KEY}",
}
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": "Du bist ein Finanzanalyst. Antworte ausschließlich mit einem validen JSON-Array."},
{"role": "user", "content": prompt}
{
"role": "system",
"content": "Du bist ein Finanzanalyst. Antworte ausschließlich mit einem validen JSON-Array.",
},
{"role": "user", "content": prompt},
],
"temperature": 0.0
"temperature": 0.0,
}
url = f"{EXXETA_BASE_URL}/deployments/{MODEL}/chat/completions"
for attempt in range(1, MAX_RETRIES + 1):
try:
response = requests.post(url, headers=headers, json=payload, timeout=TIMEOUT)
response = requests.post(
url, headers=headers, json=payload, timeout=TIMEOUT
)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
content = content.strip()
content = response.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```json"):
content = content.split("```json")[1]
if content.endswith("```"):
@ -143,14 +143,19 @@ def extract_with_exxeta(pages_json, pitchbook_id):
if isinstance(page_results, list):
results.extend(page_results)
break
except requests.exceptions.RequestException as e:
except requests.exceptions.RequestException:
if attempt == MAX_RETRIES:
results.extend([])
except Exception as e:
except Exception:
if attempt == MAX_RETRIES:
results.extend([])
requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 95})
requests.post(
COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 95}
)
return json.dumps(results, indent=2, ensure_ascii=False)
if __name__ == "__main__":
print("📡 Test-Aufruf get_dynamic_labels:")
print(get_dynamic_labels())

View File

@ -29,19 +29,17 @@ def convert_pdf_async(temp_path, pitchbook_id):
temp_path.unlink() # cleanup
return {"error": "OCR processing failed - all PDFs must be OCR'd"}, 500
with open(ocr_path, 'rb') as ocr_file:
with open(ocr_path, "rb") as ocr_file:
ocr_file.seek(0)
result = pdf_to_json(ocr_file)
payload = {
"id": int(pitchbook_id),
"extracted_text_per_page": result["pages"]
}
payload = {"id": int(pitchbook_id), "extracted_text_per_page": result["pages"]}
logger.info("Sending payload to EXXETA and SPACY services")
requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 35})
requests.post(
COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 35}
)
try:
exxeta_response = requests.post(EXXETA_URL, json=payload, timeout=600)
logger.info(f"EXXETA response: {exxeta_response.status_code}")
@ -54,14 +52,16 @@ def convert_pdf_async(temp_path, pitchbook_id):
except Exception as e:
logger.error(f"Error calling SPACY: {e}")
files=[
('file',('',open(ocr_path,'rb'),'application/pdf'))
]
files = [("file", ("", open(ocr_path, "rb"), "application/pdf"))]
headers = {}
try:
requests.put(f"{COORDINATOR_URL}/api/pitch_book/{pitchbook_id}", files=files, timeout=600, headers=headers)
requests.put(
f"{COORDINATOR_URL}/api/pitch_book/{pitchbook_id}",
files=files,
timeout=600,
headers=headers,
)
logger.info("COORDINATOR response: Progress + File updated")
except Exception as e:
logger.error(f"Error calling COORDINATOR: {e}")
@ -72,7 +72,7 @@ def convert_pdf_async(temp_path, pitchbook_id):
logger.error(f"Exception in OCR processing: {str(e)}", exc_info=True)
@app.route('/ocr', methods=['POST'])
@app.route("/ocr", methods=["POST"])
def convert_extract_text_from_pdf():
if "file" not in request.files:
return {"error": "No file"}, 400
@ -85,7 +85,7 @@ def convert_extract_text_from_pdf():
if not pitchbook_id:
return {"error": "No ID"}, 400
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_file:
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
file.seek(0)
temp_file.write(file.read())
temp_path = Path(temp_file.name)
@ -93,10 +93,7 @@ def convert_extract_text_from_pdf():
thread = threading.Thread(target=convert_pdf_async, args=(temp_path, pitchbook_id))
thread.start()
return {
"status": "sent",
"message": "PDF successfully OCR'd and processed"
}, 200
return {"status": "sent", "message": "PDF successfully OCR'd and processed"}, 200
if __name__ == "__main__":

View File

@ -17,9 +17,10 @@ log_folder = TEMP_DIR / "logs"
output_folder.mkdir(exist_ok=True)
log_folder.mkdir(exist_ok=True)
def pdf_to_json(pdf_input):
try:
if hasattr(pdf_input, 'read'):
if hasattr(pdf_input, "read"):
pdf_input.seek(0)
with pdfplumber.open(pdf_input) as pdf:
@ -83,7 +84,9 @@ def ocr_pdf(input_file_path: Path):
if result.returncode == 0:
if output_file.exists():
logger.info(f"OCR successful, output file size: {output_file.stat().st_size} bytes")
logger.info(
f"OCR successful, output file size: {output_file.stat().st_size} bytes"
)
return output_file
else:
logger.error(f"OCR completed but output file not found: {output_file}")

View File

@ -11,6 +11,8 @@ COPY requirements.txt /app
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install flask-cors
RUN python -m spacy download en_core_web_sm

View File

@ -1,14 +1,26 @@
from flask import Flask, request, jsonify
from extractSpacy import extract
from extractSpacy import extract, load_model
import requests
import os
import json
from flask_cors import CORS
import shutil
import subprocess
training_status = {"running": False}
app = Flask(__name__)
CORS(app)
VALIDATE_SERVICE_URL = os.getenv("VALIDATE_SERVICE_URL", "http://localhost:5054/validate")
COORDINATOR_URL = os.getenv("COORDINATOR_URL", "http://coordinator:5000")
VALIDATE_SERVICE_URL = os.getenv(
"VALIDATE_SERVICE_URL", "http://localhost:5054/validate"
)
@app.route('/extract', methods=['POST'])
@app.route("/extract", methods=["POST"])
def extract_pdf():
json_data = request.get_json()
@ -16,19 +28,19 @@ def extract_pdf():
pages_data = json_data["extracted_text_per_page"]
entities_json = extract(pages_data)
entities = json.loads(entities_json) if isinstance(entities_json, str) else entities_json
entities = (
json.loads(entities_json) if isinstance(entities_json, str) else entities_json
)
validate_payload = {
"id": pitchbook_id,
"service": "spacy",
"entities": entities
}
validate_payload = {"id": pitchbook_id, "service": "spacy", "entities": entities}
print(f"[SPACY] Sending to validate service: {VALIDATE_SERVICE_URL}")
print(f"[SPACY] Payload: {validate_payload} entities for pitchbook {pitchbook_id}")
try:
response = requests.post(VALIDATE_SERVICE_URL, json=validate_payload, timeout=600)
response = requests.post(
VALIDATE_SERVICE_URL, json=validate_payload, timeout=600
)
print(f"[SPACY] Validate service response: {response.status_code}")
if response.status_code != 200:
print(f"[SPACY] Validate service error: {response.text}")
@ -38,5 +50,90 @@ def extract_pdf():
return jsonify("Sent to validate-service"), 200
@app.route("/append-training-entry", methods=["POST"])
def append_training_entry():
entry = request.get_json()
if not entry or "text" not in entry or "entities" not in entry:
return (
jsonify(
{"error": "Ungültiges Format 'text' und 'entities' erforderlich."}
),
400,
)
path = os.path.join("spacy_training", "annotation_data.json")
try:
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = []
# Duplikate prüfen
if entry in data:
return jsonify({"message": "Eintrag existiert bereits."}), 200
data.append(entry)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return jsonify({"message": "Eintrag erfolgreich gespeichert."}), 200
except Exception as e:
print(f"[ERROR] Fehler beim Schreiben der Datei: {e}")
return jsonify({"error": "Interner Fehler beim Schreiben."}), 500
@app.route("/train", methods=["POST"])
def trigger_training():
from threading import Thread
Thread(target=run_training).start()
return jsonify({"message": "Training gestartet"}), 200
@app.route("/reload-model", methods=["POST"])
def reload_model():
try:
load_model()
return jsonify({"message": "Modell wurde erfolgreich neu geladen."}), 200
except Exception as e:
return (
jsonify({"error": "Fehler beim Neuladen des Modells", "details": str(e)}),
500,
)
def run_training():
training_status["running"] = True
notify_coordinator(True)
try:
if os.path.exists("output/model-last"):
shutil.copytree(
"output/model-last", "output/model-backup", dirs_exist_ok=True
)
subprocess.run(["python", "spacy_training/ner_trainer.py"], check=True)
load_model()
except Exception as e:
print("Training failed:", e)
training_status["running"] = False
notify_coordinator(False)
def notify_coordinator(running: bool):
try:
response = requests.post(
f"{COORDINATOR_URL}/api/spacy/training/status", json={"running": running}
)
print(
f"[SPACY] Coordinator: running = {running}, Status = {response.status_code}"
)
except Exception as e:
print(f"[SPACY] Fehler beim Senden des Trainingsstatus: {e}")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5052, debug=True)

View File

@ -2,9 +2,25 @@ import spacy
import os
import json
current_dir = os.path.dirname(os.path.abspath(__file__))
model_path = os.path.join(current_dir, "spacy_training/output/model-last")
nlp = spacy.load(model_path)
# Globales NLP-Modell
nlp = None
def load_model():
global nlp
print("[INFO] Lade SpaCy-Modell aus spacy_training/output/model-last ...")
nlp = spacy.load("spacy_training/output/model-last")
print("[INFO] Modell erfolgreich geladen.")
# Initial einmal laden
load_model()
def extract(pages_json):
results = []
@ -19,10 +35,6 @@ def extract(pages_json):
spacy_result = nlp(text)
for ent in spacy_result.ents:
results.append({
"label": ent.label_,
"entity": ent.text,
"page": page_num
})
results.append({"label": ent.label_, "entity": ent.text, "page": page_num})
return json.dumps(results, indent=2, ensure_ascii=False)

View File

@ -4,3 +4,4 @@ transformers==4.35.2
torch
flask
requests
flask-cors

View File

@ -0,0 +1,33 @@
from flask import Flask, request, jsonify
import os
import json
app = Flask(__name__)
ANNOTATION_FILE = "spacy_training/annotation_data.json"
@app.route("/api/spacy-training-entry", methods=["POST"])
def append_training_entry():
new_entry = request.get_json()
if not new_entry or "text" not in new_entry or "entities" not in new_entry:
return jsonify({"error": "Ungültiges Format"}), 400
# Bestehende Datei laden
if os.path.exists(ANNOTATION_FILE):
with open(ANNOTATION_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
else:
data = []
# Duplikat vermeiden
if new_entry in data:
return jsonify({"message": "Eintrag bereits vorhanden."}), 200
# Anfügen
data.append(new_entry)
with open(ANNOTATION_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return jsonify({"message": "Eintrag erfolgreich gespeichert."}), 200

File diff suppressed because it is too large Load Diff

View File

@ -1,85 +0,0 @@
# This is an auto-generated partial config. To use it with 'spacy train'
# you can run spacy init fill-config to auto-fill all default settings:
# python -m spacy init fill-config ./base_config.cfg ./config.cfg
[paths]
train = ./data/train.spacy
dev = ./data/train.spacy
vectors = null
[system]
gpu_allocator = null
[nlp]
lang = "de"
pipeline = ["tok2vec","ner"]
batch_size = 1000
[components]
[components.tok2vec]
factory = "tok2vec"
[components.tok2vec.model]
@architectures = "spacy.Tok2Vec.v2"
[components.tok2vec.model.embed]
@architectures = "spacy.MultiHashEmbed.v2"
width = ${components.tok2vec.model.encode.width}
attrs = ["NORM", "PREFIX", "SUFFIX", "SHAPE"]
rows = [5000, 1000, 2500, 2500]
include_static_vectors = false
[components.tok2vec.model.encode]
@architectures = "spacy.MaxoutWindowEncoder.v2"
width = 96
depth = 4
window_size = 1
maxout_pieces = 3
[components.ner]
factory = "ner"
[components.ner.model]
@architectures = "spacy.TransitionBasedParser.v2"
state_type = "ner"
extra_state_tokens = false
hidden_width = 64
maxout_pieces = 2
use_upper = true
nO = null
[components.ner.model.tok2vec]
@architectures = "spacy.Tok2VecListener.v1"
width = ${components.tok2vec.model.encode.width}
[corpora]
[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
max_length = 0
[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
max_length = 0
[training]
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
[training.optimizer]
@optimizers = "Adam.v1"
[training.batcher]
@batchers = "spacy.batch_by_words.v1"
discard_oversize = false
tolerance = 0.2
[training.batcher.size]
@schedules = "compounding.v1"
start = 100
stop = 1000
compound = 1.001
[initialize]
vectors = ${paths.vectors}

View File

@ -0,0 +1,18 @@
import json
# Alte Daten laden
with open("annotation_data.json", "r", encoding="utf-8") as f:
data = json.load(f)
# Neue Kennzahl (als Dict/Objekt)
neuer_eintrag = {
"text": "Hier steht der Beispielsatz mit der neuen Kennzahl.",
"entities": [[1, 5, "NEUEKENNZAHL"]],
}
# Anhängen
data.append(neuer_eintrag)
# Wieder speichern
with open("annotation_data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)

View File

@ -0,0 +1,81 @@
import spacy
from spacy.training.example import Example
import json
import os
import shutil
import sys
def load_data(file_path):
with open(file_path, "r", encoding="utf8") as f:
raw = json.load(f)
return [
(
entry["text"],
{
"entities": [
(start, end, label) for start, end, label in entry["entities"]
]
},
)
for entry in raw
]
def main():
# Stelle sicher, dass der "output"-Ordner existiert
os.makedirs("output", exist_ok=True)
TRAIN_DATA = load_data(os.path.join("spacy_training", "annotation_data.json"))
nlp = spacy.blank("de")
ner = nlp.add_pipe("ner")
ner.add_label("KENNZAHL")
optimizer = nlp.begin_training()
for i in range(20):
for text, annotations in TRAIN_DATA:
example = Example.from_dict(nlp.make_doc(text), annotations)
nlp.update([example], drop=0.2, sgd=optimizer)
temp_model_dir = "output/temp-model"
final_model_dir = "output/model-last"
backup_dir = "output/model-backup"
try:
# Vorheriges temporäres Verzeichnis entfernen
if os.path.exists(temp_model_dir):
shutil.rmtree(temp_model_dir)
# Modell zunächst in temp speichern
nlp.to_disk(temp_model_dir)
# Backup der letzten Version (falls vorhanden)
if os.path.exists(final_model_dir):
if os.path.exists(backup_dir):
shutil.rmtree(backup_dir)
shutil.copytree(final_model_dir, backup_dir)
shutil.rmtree(final_model_dir)
# Modell verschieben
shutil.move(temp_model_dir, final_model_dir)
print("[INFO] Training abgeschlossen und Modell gespeichert.")
nlp.to_disk("spacy_training/output/model-last")
# Training beendet Status auf False setzen
with open("spacy_training/training_running.json", "w") as f:
json.dump({"running": False}, f)
sys.exit(0)
except Exception as e:
print(f"[FEHLER] Während des Trainings ist ein Fehler aufgetreten: {e}")
if os.path.exists(temp_model_dir):
shutil.rmtree(temp_model_dir)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -1,21 +1,21 @@
[paths]
train = "./data/train.spacy"
dev = "./data/train.spacy"
train = null
dev = null
vectors = null
init_tok2vec = null
[system]
gpu_allocator = null
seed = 0
gpu_allocator = null
[nlp]
lang = "de"
pipeline = ["tok2vec","ner"]
batch_size = 1000
pipeline = ["ner"]
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
batch_size = 1000
tokenizer = {"@tokenizers":"spacy.Tokenizer.v1"}
vectors = {"@vectors":"spacy.Vectors.v1"}
@ -38,51 +38,34 @@ use_upper = true
nO = null
[components.ner.model.tok2vec]
@architectures = "spacy.Tok2VecListener.v1"
width = ${components.tok2vec.model.encode.width}
upstream = "*"
[components.tok2vec]
factory = "tok2vec"
[components.tok2vec.model]
@architectures = "spacy.Tok2Vec.v2"
[components.tok2vec.model.embed]
@architectures = "spacy.MultiHashEmbed.v2"
width = ${components.tok2vec.model.encode.width}
attrs = ["NORM","PREFIX","SUFFIX","SHAPE"]
rows = [5000,1000,2500,2500]
include_static_vectors = false
[components.tok2vec.model.encode]
@architectures = "spacy.MaxoutWindowEncoder.v2"
@architectures = "spacy.HashEmbedCNN.v2"
pretrained_vectors = null
width = 96
depth = 4
embed_size = 2000
window_size = 1
maxout_pieces = 3
subword_features = true
[corpora]
[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
max_length = 0
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
max_length = 0
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[training]
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
seed = ${system.seed}
gpu_allocator = ${system.gpu_allocator}
dropout = 0.1
@ -93,6 +76,8 @@ max_steps = 20000
eval_frequency = 200
frozen_components = []
annotating_components = []
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
before_to_disk = null
before_update = null

View File

@ -0,0 +1,63 @@
{
"lang":"de",
"name":"pipeline",
"version":"0.0.0",
"spacy_version":">=3.8.7,<3.9.0",
"description":"",
"author":"",
"email":"",
"url":"",
"license":"",
"spacy_git_version":"4b65aa7",
"vectors":{
"width":0,
"vectors":0,
"keys":0,
"name":null,
"mode":"default"
},
"labels":{
"ner":[
"AUSSCH\u00dcTTUNGSRENDITE",
"ESFCG",
"HHGK",
"KENNZAHL",
"LAUFZEIT",
"LAUFZEIT2",
"L\u00c4NDERALLOKATION",
"MANAGMENTGEB\u00dcHREN",
"RENDITE",
"RISIKOPROFIL",
"SEKTORENALLOKATION",
"TEST",
"TEST3",
"TEST323",
"TEST33",
"TEST34",
"TEST345",
"TEST35",
"TEST36",
"TEST37",
"TEST4",
"TEST44",
"TEST45",
"TEST5",
"TEST65",
"TEST67",
"TEST88",
"TEST99",
"ZIELAUSSCH\u00dcTTUNG",
"ZIELRENDITE",
"ZZGER 33"
]
},
"pipeline":[
"ner"
],
"components":[
"ner"
],
"disabled":[
]
}

View File

@ -0,0 +1,13 @@
{
"moves":null,
"update_with_oracle_cut_size":100,
"multitasks":[
],
"min_action_freq":1,
"learn_tokens":false,
"beam_width":1,
"beam_density":0.0,
"beam_update_prob":0.0,
"incorrect_spans_key":null
}

View File

@ -0,0 +1 @@
¥movesÚÈ{"0":{},"1":{"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10,"LAUFZEIT2":-11,"TEST":-12,"TEST3":-13,"TEST4":-14,"TEST5":-15,"TEST34":-16,"TEST35":-17,"TEST36":-18,"TEST37":-19,"HHGK":-20,"TEST67":-21,"ESFCG":-22,"ZZGER 33":-23,"TEST65":-24,"TEST99":-25,"TEST88":-26,"TEST44":-27,"TEST33":-28,"TEST345":-29,"TEST45":-30,"TEST323":-31},"2":{"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10,"LAUFZEIT2":-11,"TEST":-12,"TEST3":-13,"TEST4":-14,"TEST5":-15,"TEST34":-16,"TEST35":-17,"TEST36":-18,"TEST37":-19,"HHGK":-20,"TEST67":-21,"ESFCG":-22,"ZZGER 33":-23,"TEST65":-24,"TEST99":-25,"TEST88":-26,"TEST44":-27,"TEST33":-28,"TEST345":-29,"TEST45":-30,"TEST323":-31},"3":{"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10,"LAUFZEIT2":-11,"TEST":-12,"TEST3":-13,"TEST4":-14,"TEST5":-15,"TEST34":-16,"TEST35":-17,"TEST36":-18,"TEST37":-19,"HHGK":-20,"TEST67":-21,"ESFCG":-22,"ZZGER 33":-23,"TEST65":-24,"TEST99":-25,"TEST88":-26,"TEST44":-27,"TEST33":-28,"TEST345":-29,"TEST45":-30,"TEST323":-31},"4":{"":1,"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10,"LAUFZEIT2":-11,"TEST":-12,"TEST3":-13,"TEST4":-14,"TEST5":-15,"TEST34":-16,"TEST35":-17,"TEST36":-18,"TEST37":-19,"HHGK":-20,"TEST67":-21,"ESFCG":-22,"ZZGER 33":-23,"TEST65":-24,"TEST99":-25,"TEST88":-26,"TEST44":-27,"TEST33":-28,"TEST345":-29,"TEST45":-30,"TEST323":-31},"5":{"":1}}£cfg<66>§neg_keyÀ

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
{
"mode":"default"
}

View File

@ -1,21 +1,21 @@
[paths]
train = "./data/train.spacy"
dev = "./data/train.spacy"
train = null
dev = null
vectors = null
init_tok2vec = null
[system]
gpu_allocator = null
seed = 0
gpu_allocator = null
[nlp]
lang = "de"
pipeline = ["tok2vec","ner"]
batch_size = 1000
pipeline = ["ner"]
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
batch_size = 1000
tokenizer = {"@tokenizers":"spacy.Tokenizer.v1"}
vectors = {"@vectors":"spacy.Vectors.v1"}
@ -38,51 +38,34 @@ use_upper = true
nO = null
[components.ner.model.tok2vec]
@architectures = "spacy.Tok2VecListener.v1"
width = ${components.tok2vec.model.encode.width}
upstream = "*"
[components.tok2vec]
factory = "tok2vec"
[components.tok2vec.model]
@architectures = "spacy.Tok2Vec.v2"
[components.tok2vec.model.embed]
@architectures = "spacy.MultiHashEmbed.v2"
width = ${components.tok2vec.model.encode.width}
attrs = ["NORM","PREFIX","SUFFIX","SHAPE"]
rows = [5000,1000,2500,2500]
include_static_vectors = false
[components.tok2vec.model.encode]
@architectures = "spacy.MaxoutWindowEncoder.v2"
@architectures = "spacy.HashEmbedCNN.v2"
pretrained_vectors = null
width = 96
depth = 4
embed_size = 2000
window_size = 1
maxout_pieces = 3
subword_features = true
[corpora]
[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
max_length = 0
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
max_length = 0
gold_preproc = false
max_length = 0
limit = 0
augmenter = null
[training]
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
seed = ${system.seed}
gpu_allocator = ${system.gpu_allocator}
dropout = 0.1
@ -93,6 +76,8 @@ max_steps = 20000
eval_frequency = 200
frozen_components = []
annotating_components = []
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
before_to_disk = null
before_update = null

View File

@ -17,11 +17,9 @@
"mode":"default"
},
"labels":{
"tok2vec":[
],
"ner":[
"AUSSCH\u00dcTTUNGSRENDITE",
"KENNZAHL",
"LAUFZEIT",
"L\u00c4NDERALLOKATION",
"MANAGMENTGEB\u00dcHREN",
@ -33,68 +31,12 @@
]
},
"pipeline":[
"tok2vec",
"ner"
],
"components":[
"tok2vec",
"ner"
],
"disabled":[
],
"performance":{
"ents_f":0.9608938547,
"ents_p":1.0,
"ents_r":0.9247311828,
"ents_per_type":{
"RISIKOPROFIL":{
"p":1.0,
"r":1.0,
"f":1.0
},
"AUSSCH\u00dcTTUNGSRENDITE":{
"p":1.0,
"r":0.5925925926,
"f":0.7441860465
},
"LAUFZEIT":{
"p":1.0,
"r":1.0,
"f":1.0
},
"RENDITE":{
"p":1.0,
"r":1.0,
"f":1.0
},
"L\u00c4NDERALLOKATION":{
"p":1.0,
"r":0.8965517241,
"f":0.9454545455
},
"ZIELRENDITE":{
"p":1.0,
"r":1.0,
"f":1.0
},
"ZIELAUSSCH\u00dcTTUNG":{
"p":1.0,
"r":1.0,
"f":1.0
},
"MANAGMENTGEB\u00dcHREN":{
"p":1.0,
"r":1.0,
"f":1.0
},
"SEKTORENALLOKATION":{
"p":1.0,
"r":1.0,
"f":1.0
}
},
"tok2vec_loss":33.6051129291,
"ner_loss":740.5764770508
}
]
}

View File

@ -1 +1 @@
¥movesÚL{"0":{},"1":{"RISIKOPROFIL":161,"L\u00c4NDERALLOKATION":161,"RENDITE":91,"AUSSCH\u00dcTTUNGSRENDITE":68,"LAUFZEIT":38,"ZIELRENDITE":12,"SEKTORENALLOKATION":12,"MANAGMENTGEB\u00dcHREN":8,"ZIELAUSSCH\u00dcTTUNG":2},"2":{"RISIKOPROFIL":161,"L\u00c4NDERALLOKATION":161,"RENDITE":91,"AUSSCH\u00dcTTUNGSRENDITE":68,"LAUFZEIT":38,"ZIELRENDITE":12,"SEKTORENALLOKATION":12,"MANAGMENTGEB\u00dcHREN":8,"ZIELAUSSCH\u00dcTTUNG":2},"3":{"RISIKOPROFIL":161,"L\u00c4NDERALLOKATION":161,"RENDITE":91,"AUSSCH\u00dcTTUNGSRENDITE":68,"LAUFZEIT":38,"ZIELRENDITE":12,"SEKTORENALLOKATION":12,"MANAGMENTGEB\u00dcHREN":8,"ZIELAUSSCH\u00dcTTUNG":2},"4":{"RISIKOPROFIL":161,"L\u00c4NDERALLOKATION":161,"RENDITE":91,"AUSSCH\u00dcTTUNGSRENDITE":68,"LAUFZEIT":38,"ZIELRENDITE":12,"SEKTORENALLOKATION":12,"MANAGMENTGEB\u00dcHREN":8,"ZIELAUSSCH\u00dcTTUNG":2,"":1},"5":{"":1}}£cfg<66>§neg_keyÀ
¥movesÚˆ{"0":{},"1":{"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10},"2":{"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10},"3":{"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10},"4":{"":1,"KENNZAHL":-1,"RISIKOPROFIL":-2,"AUSSCH\u00dcTTUNGSRENDITE":-3,"LAUFZEIT":-4,"RENDITE":-5,"L\u00c4NDERALLOKATION":-6,"ZIELRENDITE":-7,"ZIELAUSSCH\u00dcTTUNG":-8,"MANAGMENTGEB\u00dcHREN":-9,"SEKTORENALLOKATION":-10},"5":{"":1}}£cfg<66>§neg_keyÀ

View File

@ -52,9 +52,7 @@
"*",
"+",
"+/D",
"+/d",
"+AU",
"+au",
",",
",00",
",03",
@ -129,21 +127,15 @@
"/080%/212%/491",
"/2,12",
"/3",
"/AuM",
"/Core+",
"/FK",
"/XX",
"/XxX",
"/Xxxx+",
"/aum",
"/core+",
"/d",
"/d,dd",
"/ddd%/ddd%/ddd",
"/fk",
"/xx",
"/xxx",
"/xxxx+",
"0",
"0%+",
"0,0",
@ -281,7 +273,6 @@
"45",
"491",
"5",
"5%+",
"5,0",
"5,00",
"5,1",
@ -311,7 +302,6 @@
"7",
"7,1",
"7,5",
"7,5%+",
"7,50",
"7,50%+",
"7.5",
@ -328,8 +318,6 @@
"8-D",
"8-d",
"80",
"800",
"84,0",
"85",
"8D",
"8d",
@ -469,7 +457,6 @@
"Abteilung",
"Access",
"Add",
"Agreements",
"Aktive",
"Aktueller",
"AlF",
@ -477,7 +464,6 @@
"Allocation",
"Allokation",
"Allokationsprofil",
"Alternative",
"Amsterdam",
"Andere",
"Anfrage",
@ -513,7 +499,6 @@
"Artikel",
"Asset",
"Assets",
"AuM",
"Aufbau",
"Auflage",
"Aufl\u00f6sung",
@ -583,10 +568,7 @@
"COR",
"CORE",
"CSU",
"CSp",
"Cash",
"Cash-Flow-Stabilit\u00e4t",
"Cash-flow",
"Chr",
"Chr.",
"Cie",
@ -648,7 +630,6 @@
"E.",
"EAN",
"ECLF",
"EIT",
"EM",
"ERD",
"ESG-",
@ -683,7 +664,6 @@
"F",
"F.",
"FDR",
"FIL",
"FR",
"FRANCE",
"FUND",
@ -794,11 +774,9 @@
"III.",
"INK",
"INREV",
"ION",
"IRR",
"IRR6.5",
"IT",
"ITE",
"IUM",
"IV",
"IV.",
@ -824,7 +802,6 @@
"Interner",
"Invastitionsfokus",
"Investftionsvolumen",
"Investing",
"Investitionen",
"Investitions-annahmen",
"Investitionsphase",
@ -834,7 +811,6 @@
"Investmentzeitraum",
"Investoren",
"Investtionszeltraum",
"Investtionszeltraum,10",
"Investtonszeltraum",
"Ireland",
"Irland",
@ -866,6 +842,7 @@
"K.",
"K.O.",
"KAGB",
"KENNZAHL",
"KINGDOM",
"KVG",
"Kapitalstruktur",
@ -905,7 +882,6 @@
"Light",
"Limited",
"Lisbon",
"Loan-to-Value",
"Local",
"Logistics",
"Logistik",
@ -1056,12 +1032,10 @@
"Prof",
"Prof.",
"Professor",
"Prognose",
"Prognostiderte",
"Prognostizierte",
"Projektentwicklungen",
"Projektentwicklungsrisiken",
"PropCo",
"Pt",
"Punkt",
"Q",
@ -1072,7 +1046,6 @@
"R.",
"R.I.P.",
"RE",
"REN",
"RENDITE",
"REV",
"REWE",
@ -1104,7 +1077,6 @@
"S",
"S'",
"SCS",
"SCSp",
"SEKTORENALLOKATION",
"SFDR",
"SG-",
@ -1197,7 +1169,6 @@
"U.S.S.",
"UK",
"UND",
"UNG",
"UNITED",
"USt",
"Univ",
@ -1264,7 +1235,6 @@
"XXXX",
"XXXX-XXXX",
"XXXd.d",
"XXXx",
"XXXxx",
"XXx",
"XXxxxx",
@ -1289,18 +1259,15 @@
"Xxxx-XXX",
"Xxxx-Xxxx-Xxxxx",
"Xxxx-Xxxxx-XXX",
"Xxxx-xx-Xxxxx",
"Xxxx-xxx",
"Xxxx-xxxx",
"Xxxx.",
"Xxxx.-Xxx",
"Xxxx.-Xxx.",
"XxxxXx",
"Xxxxx",
"Xxxxx)-",
"Xxxxx)/Xxxx",
"Xxxxx+",
"Xxxxx,dd",
"Xxxxx-",
"Xxxxx-XXX",
"Xxxxx-XxX",
@ -1381,6 +1348,7 @@
"a.g.",
"a.m.",
"a.z.",
"a34",
"ab",
"abb",
"abb.",
@ -1410,14 +1378,11 @@
"advantage",
"ae",
"aft",
"agb",
"age",
"agreements",
"aha",
"ahe",
"ahl",
"ahr",
"aif",
"ail",
"aiming",
"ain",
@ -1429,7 +1394,6 @@
"al.",
"ald",
"ale",
"alf",
"all",
"allg",
"allg.",
@ -1440,8 +1404,6 @@
"allokationsprofil",
"als",
"also",
"alt",
"alternative",
"aly",
"am.",
"ambulant",
@ -1497,7 +1459,6 @@
"ary",
"as",
"ase",
"ash",
"ass",
"asset",
"assetor",
@ -1535,7 +1496,6 @@
"aussch\u00fcttungsrandite",
"aussch\u00fcttungsrendite",
"aussch\u00fcttungsrendites",
"aut",
"ave",
"ax.",
"b",
@ -1544,7 +1504,6 @@
"b.sc",
"b.sc.",
"bahnhof",
"bal",
"balanced",
"basis",
"bau",
@ -1586,7 +1545,6 @@
"both",
"bps",
"br.",
"brands",
"broad",
"brussels",
"bruttofondsverm\u00f6gens",
@ -1619,8 +1577,6 @@
"capital",
"capped",
"carbon",
"cash",
"cash-flow",
"cash-flow-stabilit\u00e4t",
"cbd",
"cdu",
@ -1643,7 +1599,6 @@
"cl.",
"class",
"cle",
"clf",
"closed",
"closing",
"closings",
@ -1663,7 +1618,6 @@
"construction",
"contract",
"contracts",
"cor",
"core",
"core+",
"core+/d",
@ -1672,7 +1626,6 @@
"could",
"country",
"creation",
"csp",
"csu",
"cts",
"currency",
@ -1684,7 +1637,6 @@
"d+au",
"d+aut",
"d,d",
"d,d%+",
"d,dd",
"d,dd%+",
"d,ddd",
@ -1710,6 +1662,7 @@
"darge",
"darlehen",
"das",
"dasda34",
"dat",
"dd",
"dd,d",
@ -1735,7 +1688,6 @@
"der",
"dergleichen",
"des",
"destinations",
"deutsche",
"deutsches",
"deutschland",
@ -1756,7 +1708,6 @@
"dipl.",
"dipl.-ing",
"dipl.-ing.",
"dis",
"discretionary",
"distributions",
"diversification",
@ -1767,8 +1718,6 @@
"dle",
"do",
"do.",
"dom",
"domicile",
"domiciled",
"don",
"down",
@ -1783,7 +1732,6 @@
"durchschnittlich",
"du\u2019s",
"dv.",
"dxxx.\u20ac",
"dy",
"d\u00e4nemark",
"d\u2019",
@ -1847,7 +1795,6 @@
"eln",
"els",
"elt",
"ely",
"em",
"em.",
"emerging",
@ -1877,7 +1824,6 @@
"er.",
"erb",
"erbbaurechte",
"erd",
"ere",
"erfolgten",
"erg",
@ -1902,7 +1848,6 @@
"ete",
"etr",
"ets",
"eturn",
"eu-offenlegungsverordnung",
"eur",
"euro",
@ -1940,7 +1885,6 @@
"fam",
"fam.",
"favour",
"fdr",
"feb",
"feb.",
"fee",
@ -1952,7 +1896,6 @@
"ff",
"fierce",
"fil",
"financially",
"finanzierung",
"finanzierungskonditionen",
"finland",
@ -1960,7 +1903,6 @@
"first",
"flagship",
"flow",
"flow-oriented",
"fl\u00e4che",
"focus",
"focused",
@ -2051,7 +1993,6 @@
"gic",
"gie",
"gl.",
"global",
"globale",
"gmbh",
"goal",
@ -2231,7 +2172,6 @@
"investoren",
"investors",
"investtionszeltraum",
"investtionszeltraum,10",
"investtonszeltraum",
"inw",
"io.",
@ -2374,8 +2314,6 @@
"lls",
"llt",
"llv",
"lly",
"loan-to-value",
"local",
"locations",
"lock-in",
@ -2383,7 +2321,6 @@
"logistik",
"logistikimmobilien",
"london",
"long-term",
"low",
"lps",
"lso",
@ -2393,7 +2330,6 @@
"lto",
"ltv",
"ltv-ziel",
"lty",
"lu",
"lub",
"lue",
@ -2427,7 +2363,6 @@
"management",
"manager",
"manager-defined",
"managmentgeb\u00fchren",
"mandate",
"mandates",
"market",
@ -2439,7 +2374,6 @@
"maximal",
"maximaler",
"mbH",
"mbh",
"means",
"medizin",
"medizinnahe",
@ -2659,11 +2593,7 @@
"parformanceabh\u00e4ngige",
"paris",
"parks",
"partners",
"partnership",
"pattern",
"pci",
"pco",
"ped",
"pen",
"per",
@ -2694,18 +2624,15 @@
"pricey",
"pricing",
"prime",
"pro",
"prof",
"prof.",
"profile",
"prognose",
"prognostiderte",
"prognostizierte",
"program",
"projects",
"projektentwicklungen",
"projektentwicklungsrisiken",
"propco",
"properties",
"proprietary",
"provide",
@ -2719,7 +2646,6 @@
"q.",
"q.e.d",
"q.e.d.",
"qin",
"quality",
"quarterly",
"quota",
@ -2756,7 +2682,6 @@
"relationships",
"remains",
"ren",
"rendite",
"rendite-",
"rendite-risiko-profil",
"renegotiation",
@ -2773,7 +2698,6 @@
"retailinvestitionsvolumen",
"return",
"returns",
"rev",
"reversion",
"rewe",
"rge",
@ -2800,12 +2724,10 @@
"rop",
"rotterdam",
"rr.",
"rre",
"rs.",
"rsg",
"rst",
"rte",
"rtt",
"rz.",
"r\u00f6m",
"r\u00f6m.",
@ -2826,7 +2748,6 @@
"schweden",
"scope",
"scs",
"scsp",
"sd.",
"sector",
"sectors",
@ -2835,7 +2756,6 @@
"segment",
"sektor",
"sektoraler",
"sektorenallokation",
"selection",
"sen",
"sen.",
@ -2849,7 +2769,6 @@
"set",
"sf.",
"sfdr",
"sg-",
"sg.",
"short-term",
"sicav-raif",
@ -2870,7 +2789,6 @@
"sofern",
"sog",
"sog.",
"solely",
"solvency",
"some",
"son",
@ -3128,9 +3046,6 @@
"worldwide",
"x",
"x'",
"x+xx",
"x+xxx",
"x-xxxx",
"x.",
"x.X",
"x.X.",
@ -3157,38 +3072,23 @@
"xemoours",
"xit",
"xx",
"xx-xxxx",
"xx.",
"xx.x",
"xxXxx",
"xxx",
"xxx-",
"xxx-Xxxxx",
"xxx-xxxx",
"xxx.",
"xxxd.d",
"xxxx",
"xxxx)-",
"xxxx)/xxxx",
"xxxx+",
"xxxx+/x",
"xxxx+/xxxx",
"xxxx,dd",
"xxxx-",
"xxxx-xx",
"xxxx-xx-xxxx",
"xxxx-xxx",
"xxxx-xxxx",
"xxxx-xxxx-xxx",
"xxxx-xxxx-xxxx",
"xxxx.",
"xxxx\u0308xx",
"xxxx\u0308xxx-xxxx",
"xxxx\u0308xxxx",
"xxxxdd",
"xxxx\u2019x",
"xxx\u2019x",
"xx\u0308x",
"xx\u0308xxxx",
"xx\u2019x",
"x\u0308xxx",
"x\u0308xxxx",
@ -3224,7 +3124,6 @@
"zielallokation",
"zielanlagestrategie",
"zielausschu\u0308ttung",
"zielaussch\u00fcttung",
"zielmarkts",
"zielm\u00e4rkte",
"zielobjekte",

View File

@ -0,0 +1,122 @@
[
{
"text": "Core",
"entities": [
[
0,
4,
"RISIKOPROFIL"
]
]
},
{
"text": "Core+",
"entities": [
[
0,
5,
"RISIKOPROFIL"
]
]
},
{
"text": "Core/Core+",
"entities": [
[
0,
10,
"RISIKOPROFIL"
]
]
},
{
"text": "Value Add",
"entities": [
[
0,
9,
"RISIKOPROFIL"
]
]
},
{
"text": "Core/Value Add",
"entities": [
[
0,
14,
"RISIKOPROFIL"
]
]
},
{
"text": "Core+/Value Add",
"entities": [
[
0,
15,
"RISIKOPROFIL"
]
]
},
{
"text": "Core/Core+/Value Add",
"entities": [
[
0,
20,
"RISIKOPROFIL"
]
]
},
{
"text": "The RE portfolio of the fund is a good illustration of Fond expertise in European core/core+ investments .",
"entities": [
[
82,
92,
"RISIKOPROFIL"
]
]
},
{
"text": "Risk level: Core/Core+",
"entities": [
[
12,
22,
"RISIKOPROFIL"
]
]
},
{
"text": "Different risk profile (core, core+, value-added)",
"entities": [
[
24,
48,
"RISIKOPROFIL"
]
]
},
{
"text": "Core/Core+ with OpCo premium",
"entities": [
[
0,
10,
"RISIKOPROFIL"
]
]
},
{
"text": "Core /Core+ Assets, well-established = Key Gateway Cities in Europe le.g. hotels in the market with minor asset London, Paris, Amsterdam, Berlin] management initiatives",
"entities": [
[
0,
11,
"RISIKOPROFIL"
]
]
}
]

View File

@ -1,563 +0,0 @@
TRAINING_DATA = [
(
"Core",
{"entities": [[0, 4, "RISIKOPROFIL"]]},
),
(
"Core+",
{"entities": [[0, 5, "RISIKOPROFIL"]]},
),
(
"Core/Core+",
{"entities": [[0, 10, "RISIKOPROFIL"]]},
),
(
"Value Add",
{"entities": [[0, 9, "RISIKOPROFIL"]]},
),
(
"Core/Value Add",
{"entities": [[0, 14, "RISIKOPROFIL"]]},
),
(
"Core+/Value Add",
{"entities": [[0, 15, "RISIKOPROFIL"]]},
),
(
"Core/Core+/Value Add",
{"entities": [[0, 20, "RISIKOPROFIL"]]},
),
(
"The RE portfolio of the fund is a good illustration of Fond expertise in European core/core+ investments .",
{"entities": [[82, 92, "RISIKOPROFIL"]]},
),
(
"Risk level: Core/Core+",
{"entities": [[12, 22, "RISIKOPROFIL"]]},
),
(
"Different risk profile (core, core+, value-added)",
{"entities": [[24, 48, "RISIKOPROFIL"]]},
),
(
"Core/Core+ with OpCo premium",
{"entities": [[0, 10, "RISIKOPROFIL"]]},
),
(
"Core /Core+ Assets, well-established = Key Gateway Cities in Europe le.g. hotels in the market with minor asset London, Paris, Amsterdam, Berlin] management initiatives",
{"entities": [[0, 11, "RISIKOPROFIL"]]},
),
(
"Risikoprofil: Core, Core +",
{"entities": [[14, 26, "RISIKOPROFIL"]]},
),
(
"Name des Fonds Name des Investmentmanagers Allgemeine Informationen Name des Ansprechpartners Telefonnummer des Ansprechpartners E-Mail des Ansprechpartners Art des Anlagevehikels Struktur des Anlagevehikels Sitz des Anlagevehikels Struktur des Antagevehikels vom Manager festgelegter Stil Rechtsform Jahr des ersten Closings Laufzeit Geplantes Jahr der Auflösung Ziel-Netto-IRR / Gesamtrendite* Zielvolumen des Anlagevehikels Ziel-LTY Aktueller LTV Ziirraiaein Maximaler LTV Zielregionfen)/Jand Zielsektoren Zielanlagestrategie INREV Fonds Offen Deutschland Core, Core + Offener Immobilien-Spezialfonds 2022 10 - 12 Jahre 2032 - 2034 7,50%+ 250 Mio. € 20% 0% 20% Führende Metropolregionen Deutschlands und ausgewählte Standorte >50T Einw. Wohnimmobilien Wertstabile Wohnimmobilien (mit Bestandsentwicklungen)",
{"entities": [[560, 572, "RISIKOPROFIL"]]},
),
(
"Core/Core+ strategy, with tactical exposure to development projects aiming at enhancing the quality of the portfolio over time",
{"entities": [[0, 10, "RISIKOPROFIL"]]},
),
(
"Strategie - Übersicht Risikoprofil Core+ Halten-Strategie Kaufen — Halten (langfristig) — Exit 1. Nachvermietungsstrategie Anlagestrategien 2. Standortaufwertungsstrategie 3. Strategie der Aufwertung der Immobilien Niederlande (max. 35 %) Länderallokation Frankreich (max. 35 %) (in % vom Zielvolumen) Skandinavien (Schweden, Dänemark) (max. 35 %) Deutschland (<= 10 %)",
{"entities": [[35, 40, "RISIKOPROFIL"]]},
),
(
"Core and Core+",
{"entities": [[0, 14, "RISIKOPROFIL"]]},
),
(
"core, core+, value-added",
{"entities": [[0, 24, "RISIKOPROFIL"]]},
),
(
"Manage to Core: max 20%",
{"entities": [[10, 14, "RISIKOPROFIL"]]},
),
(
"Benefits of the core/ core+ segment",
{"entities": [[16, 27, "RISIKOPROFIL"]]},
),
(
"Drawbacks of the core/ core+ segment",
{"entities": [[17, 28, "RISIKOPROFIL"]]},
),
(
"Why a Core / Core + investment program?",
{"entities": [[6, 19, "RISIKOPROFIL"]]},
),
(
"Different risk profile (core, core+, value-added)",
{"entities": [[24, 48, "RISIKOPROFIL"]]},
),
(
"INK MGallery Hotel Area: Amsterdam Core Tenant: Closed in 2018",
{"entities": [[35, 39, "RISIKOPROFIL"]]},
),
(
"A strategy targeting high quality Core and Core+ buildings, with defined SRI objectives, in order to extract value through an active asset management.",
{"entities": [[34, 48, "RISIKOPROFIL"]]},
),
(
"Navigate the diversity of the Core/Core+ investment opportunities in European Prime Cities",
{"entities": [[30, 40, "RISIKOPROFIL"]]},
),
(
"GEDis an open-ended Lux-based fund providing an attractive core/core+ real estate exposure, leveraging GRRE expertise in European RE markets. It offers diversification in terms of pan-European geographies and sectors: Offices, Retail and Hotels.",
{"entities": [[59, 69, "RISIKOPROFIL"]]},
),
(
"Core assets leave less room for active asset management value creation",
{"entities": [[0, 4, "RISIKOPROFIL"]]},
),
(
"capital preservation is defined here as a characteristic of core/core+ investments. There is no guarantee of capital.",
{"entities": [[60, 70, "RISIKOPROFIL"]]},
),
(
"Country / city BELGIUM Brussels BELGIUM Brussels SPAIN Madrid FRANCE Levallois FRANCE Paris 14 BELGIUM Brussels NETHERLANDS Rotterdam NETHERLANDS Rotterdam Sector Offices Offices Offices Offices Offices Offices Offices Logistics Risk Core",
{"entities": [[234, 238, "RISIKOPROFIL"]]},
),
(
"GERD(a balanced pan-European open ended retail fund — under the form of a French collective undertaking for Real Estate investments “OPCI”) is the flagship ofQin France and combines RE and listed assets (respective targets of 60% and 40%) with max. 40% leverage. The RE portfolio of the fund is a good illustration Of expertise in European core/core+ investments.",
{"entities": [[340, 350, "RISIKOPROFIL"]]},
),
(
"Prime office assets in Prime markets are very pricey unless rent reversion is real. Risk premium remains attractive on a leveraged basis. Manage to core or build to core can make sense as a LT investor in main cities. Residential is also attractive",
{"entities": [[148, 152, "RISIKOPROFIL"]]},
),
(
"Paris region is a deep and liquid market. Rents have some potential to improve. Considering current low yield and fierce competition, office right outside CBD for Core + assets can be considered. Manage to core strategies could make sense.",
{"entities": [[163, 169, "RISIKOPROFIL"]]},
),
(
"Lisbon is a small market but it experienced a rapid economic recovery in recent years and is interesting for Core Offices, quality Retail assetor Hotel walls with top operators. Limited liquidity of this market means investment must be small",
{"entities": [[109, 113, "RISIKOPROFIL"]]},
),
(
"4,0 %",
{"entities": [[0, 5, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Prognostizierte jährliche Ausschüttung von 4,0%",
{"entities": [[44, 48, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"20% über einer @ Ausschüttungsrendite von 4,0%",
{"entities": [[44, 48, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Prognostizierte Ausschüttungsrandite* Mindestanlage Mitgliedschaft Im Anlagesusschuss Ankaufs- / Verkaufs- / Verkaufs(Teflimmobilfe)- / Baumanagementgebahr (inkl. USt.) Parformanceabhängige Vergütung Einmalige Strukturierungsgebühr Laufzeit / Investtionszeltraum Ausschüttungsintervalle Deutsche Metropolregianen und umliegende Regionen mit Städten >50T Einwohner Artikel 8 Wohnimmobilien Deutschland Aktive Bestandsentwicklung Offener Spezial-AlF mit festen Anlagebedingungen rd. 200 Mio. € / max. 20% rd. 250 Mio. € 7,5 % (nach Kosten & Gebühren, vor Steuern) 8 4,0 % {nach Kosten & Gebühren, var Steuern}",
{"entities": [[570, 575, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"5,00-5,25 % Ausschüttungsrendite",
{"entities": [[0, 11, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Zielrendite 5,00-5,25 % Ausschüttungsrendite",
{"entities": [[12, 23, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite 4,9% 5,3%",
{"entities": [[21, 25, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite 4,9% 5,3%",
{"entities": [[26, 30, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschittungsrendite 3,8% 5,7%",
{"entities": [[20, 24, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschittungsrendite 3,8% 5,7%",
{"entities": [[25, 29, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite 4,5% 4,6%",
{"entities": [[21, 25, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite 4,5% 4,6%",
{"entities": [[26, 30, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite 5,0% 4,7%",
{"entities": [[26, 30, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite 5,0% 4,7%",
{"entities": [[21, 25, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite “eons a Nuremberg aha 5,0 % 4,8 %",
{"entities": [[43, 48, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Auschüttungsrendite “eons a Nuremberg aha 5,0 % 4,8 %",
{"entities": [[49, 54, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"3-4% dividend yield",
{"entities": [[0, 4, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Zielmärkte Klassifizierung SFDR Invastitionsfokus Rendite- / Risikoprofil Rechtsform Eigenkapital /FK Quote Investftionsvolumen Prognostizierte Gesamtrendite {IRR)* Prognostizierte Ausschüttungsrandite* Mindestanlage Mitgliedschaft Im Anlagesusschuss Ankaufs- / Verkaufs- / Verkaufs(Teflimmobilfe)- / Baumanagementgebahr (inkl. USt.) Parformanceabhängige Vergütung Einmalige Strukturierungsgebühr Deutsche Metropolregianen und umliegende Regionen mit Städten >50T Einwohner Artikel 8 Wohnimmobilien Deutschland Aktive Bestandsentwicklung Offener Spezial-AlF mit festen Anlagebedingungen rd. 200 Mio. € / max. 20% rd. 250 Mio. € 7,5 % (nach Kosten & Gebühren, vor Steuern) 8 4,0 % {nach Kosten & Gebühren, var Steuern} 5Mio.€ Ab 10 Mio. € 1,40 % / 0,80 % /2,12% / 4,91 % Laufzeit / Investtionszeltraum Ausschüttungsintervalle 20 % über einer @ Ausschüttungsrendite von 4,0 % 0,1% der bis zum 31.12.2023 erfolgten Kapitalzusagen (max. 200.000 &) 10 bis 12 Jahre / bis zu 24 Monate angestrebt Mindestens jährlich",
{"entities": [[945, 960, "LAUFZEIT"]]},
),
(
"Laufzeit / Investtionszeltraum,10 bis 12 Jahre / bis zu 24 Monate angestrebt Ausschüttungsintervalle,Mindestens jährlich",
{"entities": [[31, 46, "LAUFZEIT"]]},
),
(
"10-12 Jahre Laufzeit bei einem LTV von bis zu 20%",
{"entities": [[0, 11, "LAUFZEIT"]]},
),
(
"vom Manager festgelegter Stil Rechtsform Jahr des ersten Closings Laufzeit Geplantes Jahr der Auflösung Ziel-Netto-IRR / Gesamtrendite* Zielvolumen des Anlagevehikels Ziel-LTYAktueller LTV Zielsektoren Zielanlagestrategie Fonds Offen Deutschland Core, Core + Offener Immobilien-Spezialfonds 2022 10 - 12 Jahre",
{"entities": [[297, 310, "LAUFZEIT"], [247, 259, "RISIKOPROFIL"]]},
),
(
"Allgemeine Annahmen Ankaufsphase Haltedauer Zielobjektgröße Finanzierung Investitions-annahmen Zielrendite 24 Monate Investmentzeitraum 10 Jahre (+) EUR 20-75 Mio. Keine externe Finanzierung zum Auftakt (ausschließlich Darlehen der Anteilseigner). Die Finanzierung wird nach der Ankaufsphase und Stabilisierung der Zinssätze neu geprüft. Angestrebter LTV zwischen 25-40 % Investitionen für Renovierungen und ESG- Verbesserungen werden für jedes Objekt einzeln festgelegt. 5,00-5,25 % Ausschüttungsrendites",
{"entities": [[136, 148, "LAUFZEIT"], [472, 483, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Zielrendite 5,00-5,25 % Ausschüttungsrendite 1) Ankauf von Objekten an Tag eins mit 100% Eigenkapital. Die Strategie unterstellt die Aufnahme von Fremdkapital, sobald sich die Zins- und Finanzierungskonditionen nachhaltig stabilisieren. Strategie - Übersicht Risikoprofil Core+",
{"entities": [[12, 23, "AUSSCHÜTTUNGSRENDITE"], [272, 277, "RISIKOPROFIL"]]},
),
(
"Vehicle lifetime / investment period Open-ended fund",
{"entities": [[37, 52, "LAUFZEIT"]]},
),
(
"Vehicle / domicile Alternative Investment Fund / Luxembourg (e.g. SCSp SICAV-RAIF) Investment strategy eturn pro Real Estate (PropCo + OpCo) Investing in upscale hotels with long-term management contracts in major European destinations Core/Core+ with OpCo premium Management Agreements solely with financially strong and experienced partners/ global brands Cash flow-oriented Cash-flow pattern Target equity /AuM € 400m equity / € 800m AuM (50% Loan-to-Value) Vehicle lifetime / investment period Open-ended fund",
{"entities": [[498, 513, "LAUFZEIT"], [236, 245, "RISIKOPROFIL"]]},
),
(
"Vehicle type (Lux-RAIF) (net of fees) IRR6.5% ACCOR Vehicle structure Open-ended Targetvehiclesize € 400m (equity) Manager-defined Core/Core+ with | style OpCo Premium darge CLV. 50% Pt H | LTO N WORLDWIDE Year of first closing 2020 Target no. ofinvestors 1-5 Fund life (yrs} Open-ended Min-commitmentper —¢ 400m",
{"entities": [[131, 141, "RISIKOPROFIL"], [70, 80, "LAUFZEIT"]]},
),
(
"Fund term: Open-ended",
{"entities": [[11, 21, "LAUFZEIT"]]},
),
(
"Abdeckung der Risiko-Rendite-Bandbreite (Core, Core+, Value-Add)",
{"entities": [[41, 63, "RISIKOPROFIL"]]},
),
(
"5,1% - 8,5% IRR!",
{"entities": [[0, 11, "RENDITE"]]},
),
(
"Retailinvestitionsvolumen nach Ländern (2024) Vereinigtes Königreich, 26,4% Deutschland, 19,0% Andere, 19,7% Italien, 8,2% Irland, 3,3% N | Frankreich, Spanien, 8,1%",
{"entities": [[46, 75, "LÄNDERALLOKATION"], [76, 94, "LÄNDERALLOKATION"], [95, 108, "LÄNDERALLOKATION"], [109, 122, "LÄNDERALLOKATION"], [123, 135, "LÄNDERALLOKATION"]]},
),
(
"Erwartete IRR 5 (je nach Objekt- A(E) 6.00% - 8,00%",
{"entities": [[39, 52, "RENDITE"]]},
),
(
"Zielmarkts Deutsche Metropolregianen und umliegende Regionen mit Städten >50T Einwohner Klassifizierung SFDR Artikel 8 Invastitionsfokus Wohnimmobilien Deutschland Rendite- / Risikoprofil Aktive Bestandsentwicklung Rechtsform Offener Spezial-AlF mit festen Anlagebedingungen Eigenkapital /FK Quote rd. 200 Mio. € / max. 20% Investftionsvolumen rd. 250 Mio. € Prognostiderte Gesamtrendite {IRR)* 7,5 % (nach Kosten & Gebühren, vor Steuern) Prognostizierte Ausschüttungsrandite* @ 4,0 % {nach Kosten & Gebühren, var Steuern} Mindestanlage 5Mio.€ Mitgliedschaft Im Anlagesusschuss Ab 10 Mio. € Ankaufs- / Verkaufs- / Verkaufs(Teflimmobilfe)- / Baumanagementgebahr (inkl. USt) 1,40 %/080%/212%/491% Parformanceabhängige Vergütung 20 % über einer ® Ausschüttungsrendite von 4,0% Einmalige Strukturierungsgebühr 0,1% der bis zum 31.12.2023 erfolgten Kapitalzusagen (max. 200.000 €) Laufzelt / Investtonszeltraum 10 bis 12 Jahre / bis zu 24 Monate angestrebt Ausschüttungsintervalle Mindestens jährlich",
{"entities": [[396, 401, "RENDITE"], [482, 487, "AUSSCHÜTTUNGSRENDITE"], [914, 929, "LAUFZEIT"]]},
),
(
"= Prognostizierte jährliche Ausschüttung von @ 4,0%* = Prognostizierte Gesamtrendite (IRR) von 7,5%*",
{"entities": [[48, 52, "AUSSCHÜTTUNGSRENDITE"], [96, 100, "RENDITE"]]},
),
(
"Prognose: 7,5%+ IRR auf Fondsebene",
{"entities": [[10, 14, "RENDITE"]]},
),
(
"= Prognostizierte jährliche Ausschüttung* von 84,0% = Prognostizierte Gesamtrendite (IRR}* von 7,5%",
{"entities": [[96, 100, "RENDITE"], [49, 53, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"= Lagefokussierung: Metropolregionen Deutschlands = Finanzierung: max. 20% LTV = Risikoprofil: Core, Core +",
{"entities": [[95, 107, "RISIKOPROFIL"]]},
),
(
"Performance-Fee: 20% über einer @ Ausschüttungsrendite von 4,0%",
{"entities": [[61, 65, "AUSSCHÜTTUNGSRENDITE"]]},
),
(
"Fondstyp Offener Spezial-AIF nach KAGB mit festen Anlagebedingungen ESG-Klassifizierung Fonds gemäß Artikel 8 EU-Offenlegungsverordnung KVG IntReal GmbH, Hamburg Anlagestrategie Aufbau eines Objektportfolios aus Ärztehäusern, die langfristig vermietet sind Ärztehäuser, Laborimmobilien, im Verbund mit Ärztehäusern auch ambulant Zielobjekte betreute Wohngemeinschaften; Mietanteil Medizin und medizinnahe Dienstleistungen/Handel > 65 % (Objektebene) WALT >5 Jahre bei Ankauf Objektbaujahre Ab 2000 Anlagegrenzen Einzelinvestment 8-30 Mio. EUR Anzahl Objekte 10-20 Deutschland bundesweit; jeweiliges Einzugsgebiet > 25.000 Einwohner mit Regionen stabiler Bevölkerungsprognose Risikoprofil Core / Core +",
{"entities": [[689, 702, "RISIKOPROFIL"]]},
),
(
"Fondsvolumen 300 Mio. EUR Zielrendite (IRR) > 6,0 % p. a. Ausschuttung >5,0 % p. a. Ankaufszeitraum 2024-2026 Laufzeit 31.12.2036 Mindestanlage 10 Mio. EUR Anlageausschuss Ja, entscheidet u. a. über Objekterwerb (Mitglied kann ab 20 Mio. EUR gestellt werden) Gebührenstruktur Marktüblich (auf Anfrage) Projektentwicklungen keine Forward-Deals Möglich, maximal 18 Monate Vorlauf; keine Projektentwicklungsrisiken beim Fonds Erbbaurechte Möglich, sofern Laufzeit > 60 Jahre und angemessene Entschädigung bei Ablauf und Heimfall Status Objektpipeline vorhanden: siehe Folie 16 ff.",
{"entities": [[44, 57, "RENDITE"], [71, 83, "AUSSCHÜTTUNGSRENDITE"], [120, 130, "LAUFZEIT"]]},
),
(
"Niederlande (max. 35 %) Länderallokation Frankreich (max. 35 %) (in % vom Zielvolumen) Skandinavien (Schweden, Dänemark) (max. 35 %) Deutschland (<= 10 %)",
{"entities": [[0, 23, "LÄNDERALLOKATION"], [41, 63, "LÄNDERALLOKATION"], [87, 132, "LÄNDERALLOKATION"], [133, 154, "LÄNDERALLOKATION"]]},
),
(
"Führender Immobilien-Investmentmanager in den Nordics für globale ll institutionelle Investoren in Value Add und Core Strategien",
{"entities": [[101, 119, "RISIKOPROFIL"]]},
),
(
"Core und Core+ Fonds",
{"entities": [[0, 14, "RISIKOPROFIL"]]},
),
(
"Risikoprofil Core / Core+",
{"entities": [[13, 25, "RISIKOPROFIL"]]},
),
(
"Durchschnittlich geplante jährliche Ausschüttung von 4,5-5,5% auf das investierte Eigenkapital an die Anleger Geplante Gesamtrendite von 5-6% (IRR) auf das eingezahlte Eigenkapital",
{"entities": [[54, 62, "AUSSCHÜTTUNGSRENDITE"], [138, 142, "RENDITE"]]},
),
(
"Geografische Zielallokation nach Investitionsphase des Fonds: 1) Schweden 20-60% Allokation Länder 2) Finnland 20-60% 3) Norwegen 10-40% 4) Dänemark 10-40%",
{"entities": [[65, 80, "LÄNDERALLOKATION"], [102, 117, "LÄNDERALLOKATION"], [121, 136, "LÄNDERALLOKATION"], [140, 155, "LÄNDERALLOKATION"]]},
),
(
"Deutsches Spezial-Sondervermögen mit festen Anlagebedingungen ($284 KAGB) Immobilien- oder Infrastrukturquote (nach Solvency II) Core / Core+ Euro Hauptstadtregionen und andere Großstädte in den Nordics €500 Mio. 4,5-5,5% 15 Jahre; Fonds hat unbegrenzte Laufzeit; Investmentphase 4 Jahre Maximaler Fremdkapitalanteil 50% (LTV-Ziel bei Ankauf), Langfristiges LTV-Ziel auf Fondsebene ist 45% 0,625% p. a. des Bruttofondsvermögens Zeichnungen ab € 30 Mio. - 0,03 % Rabatt Zeichnungen ab € 50 Mio. - zusatzl. 0,03 % Rabatt 1,1% des Verkehrswertes 0,6% der Bruttoverkaufswert 10% wenn Hurdle Rate 5,0 % p. a. (IRR netto) überschritten wird (nach 15 Jahren berechnet) Ja",
{"entities": [[129, 141, "RISIKOPROFIL"], [213, 221, "ZIELRENDITE"], [242, 262, "LAUFZEIT"]]},
),
(
"Standort Helsinki, Finnland Sektor Bildungswesen, Schule& Kindertagesstätte Vermietbare Fläche 3.321 m? Leerstand bei Ankauf 0% / 0% Ankaufspreis+ Investitionen €21,4 Mio. + €0,2 Mio Eigenkapital €21,6 Mio. Ankaufs- / Stabilisierungs- / Exitrendite 5,0%/ 5,5%/ 5,0% NOI zum Ankaufszeitpunkt / Exit-NOI €1.1m/ €1.2m Zielrenditen (netto für LPs) 5,4% IRR/ 1.5x EM / DY 4,3% Ankauf / Exit Dezember 2023/ Dezember 2033",
{"entities": [[345, 349, "ZIELRENDITE"]]},
),
(
"Evergreen/offene Fondsstrukturenv Core / Core+ Strategien",
{"entities": [[34, 46, "RISIKOPROFIL"]]},
),
(
"BEE Henderson German 2012 Logistik Core/D/Art. 8 € 336 Mio. 12 (voll investiert) 13,0 % p.a.",
{"entities": [[35, 39, "RISIKOPROFIL"], [81, 87, "RENDITE"]]},
),
(
"ICF German Logistics 2014 Logistik Core/D/Art. 8 € 400 Mio. 16 (voll investiert) 12,0 % p.a.",
{"entities": [[35, 39, "RISIKOPROFIL"], [81, 87, "RENDITE"]]},
),
(
"Individualmandat 2015 Logistik Core / D+AU/ ArTt. 6 € 200 Mio. 8 (realisiert) 8,0 % p.a.",
{"entities": [[31, 35, "RISIKOPROFIL"], [78, 83, "RENDITE"]]},
),
(
"European Logistics Partnership” 2017 Logistik Value-Add / Europ/a - € 1.000 Mio. 28 (realisiert) 20,0 % p.a.",
{"entities": [[46, 55, "RISIKOPROFIL"], [97, 103, "RENDITE"]]},
),
(
"European Core Logistics Fund (ECLF 1) 2021 Logistik Core / Euro/p Arat. 8 € 314 Mio. 12 (voll investiert) 7,50 % p.a.",
{"entities": [[9, 13, "RISIKOPROFIL"], [106, 112, "RENDITE"]]},
),
(
"P-Logistik Europa Fonds (ECLF 2) 2022 Logistik Core / Euro/p Arat. 8 € 150 Mio.? A (voll investiert) 6,5 % p.a.?",
{"entities": [[47, 51, "RISIKOPROFIL"], [101, 106, "RENDITE"]]},
),
(
"First Business Parks 2015 Light Industrial Value Add / D+AUT € 100 Mio. 6 (realisiert) 16,0 % p.a.",
{"entities": [[43, 52, "RISIKOPROFIL"], [87, 93, "RENDITE"]]},
),
(
"Unternehmensimmobilien Club 1 2016 Light Industrial Core+/D € 186 Mio. 9 (voll investiert) 13,0 % p.a.",
{"entities": [[91, 97, "RENDITE"]]},
),
(
"Unternehmensimmobilien Club 1 2016 Light Industrial Core+/D € 186 Mio. 9 (voll investiert) 13,0 % p.a.",
{"entities": [[52, 57, "RISIKOPROFIL"], [91, 97, "RENDITE"]]},
),
(
"Unternehmensimmobilien Club 2 2021 Light Industrial Core+/D € 262 Mio. 12 (voll investiert) 9,00 % p.a.",
{"entities": [[52, 57, "RISIKOPROFIL"], [92, 98, "RENDITE"]]},
),
(
"Individualmandat 2022 Light Industrial Value-Add / Nordics € 100 Mio. 5 (voll investiert) 18,0 % p.a.",
{"entities": [[39, 48, "RISIKOPROFIL"], [90, 96, "RENDITE"]]},
),
(
"EUROPEAN CORE LOGISTICS FUND 3",
{"entities": [[9, 13, "RISIKOPROFIL"]]},
),
(
"Core Investitionen",
{"entities": [[0, 4, "RISIKOPROFIL"]]},
),
(
"8 % IRR",
{"entities": [[0, 3, "RENDITE"]]},
),
(
"Rendite-Risiko-Profil Core ° Geographischer Fokus Kontinentaleuropaische Kernvolkswirtschaften nach Allokationsprofil * Sektoraler Fokus Logistikimmobilien nach Allokationsprofil Kapitalstruktur ° Eigenkapital € 250 Mio. ° Fremdkapital 50 % angestrebt, max. 60 % der Immobilienwerte (Objektebene) °e Mindestzeichnung € 10 Mio. Vehikelstruktur ° Rechtsform Immobilien-Spezial-AlF mit festen Anlagebedingungen nach 3 284 KAGB ° Klassifikation Artikel 8 Offenlegungsverordnung ¢ Anlagehorizont 10 Jahre mit Verlängerungsoption um 2 Jahre! ° Geplante Auflage 01 2025 Performanceziel? ° Ausschüttung 6,0 % p.a. (Durchschnitt 10 Jahre Haltedauer) ° Interner Zinsfuß (IRR) 8,0 % p.a. (10 Jahre Haltedauer, Target-IRR)",
{"entities": [[22, 26, "RISIKOPROFIL"], [596, 601, "AUSSCHÜTTUNGSRENDITE"], [667, 672, "RENDITE"]]},
),
(
"Core/Core+, mit Cash-Flow-Stabilität",
{"entities": [[0, 10, "RISIKOPROFIL"]]},
),
(
"Zielausschüttung: min. 5,10%",
{"entities": [[24, 29, "ZIELAUSSCHÜTTUNG"]]},
),
(
"Zielrendite (IRR): min. 5,50%",
{"entities": [[24, 29, "ZIELRENDITE"]]},
),
(
"Rewe & Lidl Maxhütte-Haidhof é ae: 6 s Bahnhof Ankermieter REWE & Lidl er WALT 20 und 17 Jahre Miete p.a. 1.127.916 € Kaufpreis 21,43 Mio. € Faktor 19,00 x LTV / Zins 80% / 4,0% Ausschüttung 5,7 % IRR 7,1%",
{"entities": [[193, 198, "AUSSCHÜTTUNGSRENDITE"], [203, 207, "ZIELRENDITE"]]},
),
(
"Real Estate Prime Europe Access the Core of European Prime Cities with a green SRI fund including a genuine low carbon commitment",
{"entities": [[36, 40, "RISIKOPROFIL"]]},
),
(
"(FR, UK, DE, BE, NL, LU, Nordics, Allocation SP, IT, CH)",
{"entities": [[1, 32, "LÄNDERALLOKATION"], [45, 55, "LÄNDERALLOKATION"]]},
),
(
"IRR: 6% - 7%",
{"entities": [[5, 12, "RENDITE"]]},
),
(
"Europe | Germany 67 Value Add",
{"entities": [[9, 16, "LÄNDERALLOKATION"], [20, 29, "RISIKOPROFIL"]]},
),
(
"Germany, Norway 336 Core Plus",
{"entities": [[0, 7, "LÄNDERALLOKATION"], [20, 29, "RISIKOPROFIL"]]},
),
(
"UK",
{"entities": [[0, 2, "LÄNDERALLOKATION"]]},
),
(
"NORWAY",
{"entities": [[0, 6, "LÄNDERALLOKATION"]]},
),
(
"9.8% IRR",
{"entities": [[0, 4, "RENDITE"]]},
),
(
"Investment volume down 52% to €2.3 billion, with 4,000 100 14% value-add and core-plus increasing YoY",
{"entities": [[63, 86, "RISIKOPROFIL"]]},
),
(
"Geared Gross IRR seeking a range of 16-18% per annum",
{"entities": [[37, 43, "RENDITE"]]},
),
(
"Open-ended fund 24 months, incl. rolling reinvestment Sale of individual assets with respective management contracts or geared leases IRR: >6.5% | CoC: >5.0%",
{"entities": [[0, 10, "LAUFZEIT"], [139, 144, "RENDITE"]]},
),
(
"Our investment strategy focuses on investing in upscale hotels in European prime locations, including DACH, Italy, Spain, Portugal, France, UK, Denmark, Benelux,and Poland.",
{"entities": [[102, 171, "LÄNDERALLOKATION"]]},
),
(
"Core+ assets with value-add potential, Emerging Gateway Cities Helsinki] Core+ with Value well-mitigated risk and great upside Potential potential through asset improvement or = Max. 20% UK & Ireland {no contract renegotiation currency risk hedging], 80% tinental E > IRR target of 6-9%",
{"entities": [[0, 5, "RISIKOPROFIL"], [282, 286, "RENDITE"]]},
),
(
"10% net IRR since inception in 2018?",
{"entities": [[0, 3, "RENDITE"]]},
),
(
"Eurozone: Benelux, France and Germany",
{"entities": [[10, 37, "LÄNDERALLOKATION"]]},
),
(
"Open-ended, with quarterly liquidity (redemption rights, dual pricing)",
{"entities": [[0, 10, "LAUFZEIT"]]},
),
(
"Class A & B (Institutional): 0.93% on NAV; Class D (Wholesale): 1.80% on NAV; Class P (Wholesale): 1.25% on NAV",
{"entities": [[29, 34, "MANAGMENTGEBÜHREN"], [64, 69, "MANAGMENTGEBÜHREN"], [99, 104, "MANAGMENTGEBÜHREN"]]},
),
(
"Risk profile: favour core > © at least and core+ assets with a targeted N 2 n allocation to value add assets to enhance returns",
{"entities": [[21, 25, "RISIKOPROFIL"], [43, 48, "RISIKOPROFIL"]]},
),
(
"The Netherlands (38 assets) = Germany (9 assets) 10 largest Country assets split France (8 assets)",
{"entities": [[0, 15, "LÄNDERALLOKATION"], [30, 37, "LÄNDERALLOKATION"], [81, 87, "LÄNDERALLOKATION"]]},
),
(
"Expected IRR 10.9%",
{"entities": [[13, 18, "ZIELRENDITE"]]},
),
(
"Structure Open-end, perpetual life, Luxembourg domiciled Initial Target Size* €2 billion 6-8% total return,",
{"entities": [[10, 18, "LAUFZEIT"], [89, 93, "RENDITE"]]},
),
(
"Geographic Focus: UK, Ireland, Iberia, Nordics, Netherlands, Germany, France, Italy",
{"entities": [[18, 83, "LÄNDERALLOKATION"]]},
),
(
"IRR of 13-14%",
{"entities": [[7, 13, "RENDITE"]]},
),
(
"Value-add",
{"entities": [[0, 9, "RISIKOPROFIL"]]},
),
(
"Geographic allocation NORDICS UNITED KINGDOM GERMANY FRANCE PORTUGAL BENELUX",
{"entities": [[22, 76, "LÄNDERALLOKATION"]]},
),
(
"Strong track record delivering a 17% net IRR, 1.7x net multiple across all divested assets (both discretionary and non-discretionary mandates)",
{"entities": [[33, 36, "RENDITE"]]},
),
(
"Targeting a 7-8% net annual return and a 3-4% dividend yield, reflecting a target LTV of 35% (capped at 37.5%)",
{"entities": [[12, 16, "RENDITE"]]},
),
(
"Sweden Norway Denmark Finland",
{"entities": [[0, 29, "LÄNDERALLOKATION"]]},
),
(
"Logistics Residential Office Other",
{"entities": [[0, 34, "SEKTORENALLOKATION"]]},
),
(
"Fund Term Open-ended with an initial 24-month lock-in for new investors",
{"entities": [[10, 20, "LAUFZEIT"]]},
),
(
"Management fee of 85 bps on NAV.",
{"entities": [[18, 24, "MANAGMENTGEBÜHREN"]]},
),
(
"Core/Core+ strategy, with tactical exposure to development projects aiming at enhancing the quality of the portfolio over time",
{"entities": [[0, 10, "RISIKOPROFIL"]]},
),
(
"Fund term: Open-ended",
{"entities": [[11, 21, "LAUFZEIT"]]},
),
(
"Return targets: The fund targets a net internal rate of return (IRR) of 8% and a net annual income yield of 5% with planned quarterly distributions.",
{"entities": [[72, 74, "RENDITE"]]},
),
(
"Geographic scope: The fund has a broad mandate to invest in commercial and residential real estate across Sweden, Denmark, Finland, and Norway. 50% LTV Asset selection: Heirs to acquire high-quality, income-generating properties in major Nordic cities and enhance their value through active asset management. Portfolio construction: The goal is to build diversified portfolios that are appealing to core buyers upon exit.",
{"entities": [[106, 142, "LÄNDERALLOKATION"]]},
),
(
"Experience: Since 2012, | | has demonstrated its capability to build diversified and resilient portfolios for its core-plus funds. German Real Estate Quota advantage . Local expertise: extensive local relationships and proprietary deal flow in key Nordic markets provide a strategic advantage.",
{"entities": [[114, 123, "RISIKOPROFIL"]]},
),
(
"Target returns: 8% net IRR with 5% net annual income yield! * Geographic focus: Sweden, Denmark, Norway and Finland « Target leverage: 50% LTV (excluding short-term borrowing) « Sector exposure: office, logistics, public properties, retail (focused on grocery anchored and necessity driven retail) and residentials « Investment focus: high quality properties,",
{"entities": [[16, 18, "RENDITE"], [80, 115, "LÄNDERALLOKATION"], [195, 239, "SEKTORENALLOKATION"]]},
),
(
"The Fund 2 xemoours common limited partnership (SCS) (SICAV-RAIF) Investment Objective To pursue investments in commercial and residential properties throughout the Nordic Region Fund Target Size €300 million (equity) Return Targets Target net IRR of 8%, target net annual income yield of 5%",
{"entities": [[251, 253, "RENDITE"]]},
)
]

View File

@ -1,40 +0,0 @@
import os
from pathlib import Path
import spacy
from spacy.cli.train import train
from spacy.tokens import DocBin
from tqdm import tqdm
from training_data import TRAINING_DATA
nlp = spacy.blank("de")
# create a DocBin object
db = DocBin()
for text, annot in tqdm(TRAINING_DATA):
doc = nlp.make_doc(text)
ents = []
# add character indexes
for start, end, label in annot["entities"]:
span = doc.char_span(start, end, label=label, alignment_mode="contract")
if span is None:
print(f"Skipping entity: |{text[start:end]}| Start: {start}, End: {end}, Label: {label}")
else:
ents.append(span)
# label the text with the ents
doc.ents = ents
db.add(doc)
# save the DocBin object
os.makedirs("./data", exist_ok=True)
db.to_disk("./data/train.spacy")
config_path = Path("config.cfg")
output_path = Path("output")
print("Starte Training...")
train(config_path, output_path)

View File

@ -0,0 +1 @@
{"running": false}

View File

@ -40,7 +40,9 @@ def send_to_coordinator_service(processed_data, request_id):
def process_data_async(request_id, spacy_data, exxeta_data):
try:
requests.post(COORDINATOR_URL + "/api/progress", json={"id": request_id, "progress": 95})
requests.post(
COORDINATOR_URL + "/api/progress", json={"id": request_id, "progress": 95}
)
print(f"Start asynchronous processing for PitchBook: {request_id}")
# Perform merge
@ -96,7 +98,6 @@ def validate():
# If both datasets are present, start asynchronous processing
if spacy_data is not None and exxeta_data is not None:
# Start asynchronous processing in a separate thread
processing_thread = threading.Thread(
target=process_data_async,

View File

@ -27,7 +27,6 @@ def merge_entities(spacy_data, exxeta_data):
and s_entity_norm == e_entity_norm
and s_page == e_page
):
merged.append(
{
"label": s["label"],

View File

@ -1,15 +1,38 @@
from typing import Dict, List
import re
import requests
import os
# SETTINGS = [{"id": "Rendite", "type": "number"}]
COORDINATOR_URL = os.getenv("COORDINATOR_URL", "http://localhost:5000")
def validate_entities(entities):
try:
response = requests.get(COORDINATOR_URL + "/api/kpi_setting/")
if response.status_code == 200:
settings = response.json()
else:
settings = []
except requests.exceptions.RequestException as e:
print(f"Error fetching settings: {e}")
settings = []
# settings = SETTINGS
result = []
reduced_kpi: Dict[str, List[Dict[str, str | int]]] = {}
reduced_kpi: Dict[str, List[Dict[str, str]]] = {}
# reduce entities by label. Example: {"PERSON": [{"label": "PERSON", "entity": "John Doe", "status": "validated"}]}
for item in entities:
label = item["label"]
if label not in reduced_kpi:
reduced_kpi[label] = []
reduced_kpi[label].append(item)
reduced_kpi = delete_exxeta_unknown(reduced_kpi)
reduced_kpi = validate_number(reduced_kpi, settings)
reduced_kpi = delete_duplicate_entities(reduced_kpi)
for item in reduced_kpi.items():
if item[0] == "FONDSNAME":
result.extend(item[1])
@ -21,6 +44,7 @@ def validate_entities(entities):
result.extend(item[1])
continue
# Filter not validated, if there are valid values
validated = False
for entity in item[1]:
if entity["status"] == "validated":
@ -34,13 +58,92 @@ def validate_entities(entities):
return result
def validate_number(entity_list, settings):
filtered_kpi = {}
for label, entity_list in entity_list.items():
setting = next((s for s in settings if s["name"].upper() == label), None)
if setting and setting["type"] == "number":
filtered_entities = [
entity
for entity in entity_list
if is_valid_number(str(entity["entity"]))
]
for entity in entity_list:
if not is_valid_number(str(entity["entity"])):
print(f"Invalid number: {entity}")
if filtered_entities: # Only add the label if there are entities left
filtered_kpi[label] = filtered_entities
else:
filtered_kpi[label] = entity_list
return filtered_kpi
def is_valid_number(number):
pattern = r"^[0-9\-\s%,.€]+$"
return (
any(char.isdigit() for char in number)
and not re.search(r"\d+\s\d+", number)
and re.fullmatch(pattern, number)
)
def delete_exxeta_unknown(entity_list):
filtered_kpi = {}
for label, entity_list in entity_list.items():
# Filter out entities with "nichtangegeben" or "n/a" (case-insensitive and stripped)
filtered_entities = [
entity
for entity in entity_list
if str(entity["entity"]).lower().replace(" ", "")
not in {"nichtangegeben", "n/a"}
]
for entity in entity_list:
if str(entity["entity"]).lower().replace(" ", "") in {
"nichtangegeben",
"n/a",
}:
print(f"filtered out: {entity}")
if filtered_entities: # Only add the label if there are entities left
filtered_kpi[label] = filtered_entities
return filtered_kpi
def delete_duplicate_entities(entity_list):
unique_entities = {}
for label, entity_list in entity_list.items():
values = set()
filtered_entities = []
for entity in entity_list:
if str(entity["entity"]).lower().replace(" ", "") not in values:
filtered_entities.append(entity)
else:
print(f"Duplicate entity: {entity}")
values.add(str(entity["entity"]).lower().replace(" ", ""))
if filtered_entities:
unique_entities[label] = filtered_entities
return unique_entities
if __name__ == "__main__":
entities = [
{"label": "PERSON", "entity": "John Doe", "status": "validated"},
{"label": "PERSON", "entity": "Exxeta", "status": "invalid"},
{"label": "ORG", "entity": "Google", "status": "invalid"},
{"label": "FONDSNAME", "entity": "Microsoft", "status": "validated"},
{"label": "FONDSNAME", "entity": "Amazon", "status": "invalid"},
{"label": "FONDSNAME", "entity": "Apple", "status": "invalid"}
# {"label": "PERSON", "entity": "John Doe", "status": "validated"},
# {"label": "PERSON", "entity": "Exxeta", "status": "invalid"},
# {"label": "ORG", "entity": "Google", "status": "invalid"},
# {"label": "FONDSNAME", "entity": "Microsoft", "status": "validated"},
# {"label": "FONDSNAME", "entity": "Amazon", "status": "invalid"},
# {"label": "FONDSNAME", "entity": "Apple", "status": "invalid"},
{"label": "RENDITE", "entity": "8 8 8 8 8", "status": "validated"},
{"label": "RENDITE", "entity": "N/A", "status": "validated"},
{"label": "RENDITE", "entity": "nicht angegeben", "status": "validated"},
{"label": "RENDITE", "entity": "uaieluae--t>", "status": "validated"},
{"label": "RENDITE", "entity": "3,5", "status": "validated"},
{"label": "RENDITE", "entity": "3,5", "status": "validated"},
{"label": "RENDITE", "entity": "3 , 5", "status": "validated"},
{"label": "RENDITE", "entity": "3%", "status": "validated"},
{"label": "RENDITE", "entity": "", "status": "invalid"},
{"label": "RENDITE", "entity": "2 mehr als 6", "status": "invalid"},
{"label": "RENDITE", "entity": 2, "status": "invalid"},
]
print(validate_entities(entities))

View File

@ -58,6 +58,8 @@ services:
- VALIDATE_SERVICE_URL=http://validate:5000/validate
ports:
- 5052:5052
volumes:
- ./backend/spacy-service/spacy_training:/app/spacy_training
exxeta:
build:

View File

@ -4,7 +4,9 @@ WORKDIR /usr/src/app
# install dependencies into temp directory
# this will cache them and speed up future builds
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
#RUN bun install --frozen-lockfile
RUN bun install
COPY . .

View File

@ -3,15 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico?v=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-tsrouter-app"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Create TanStack App - frontend</title>
<title>Pitchbook Extractor</title>
</head>
<body>
<div id="app"></div>

6335
project/frontend/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@
"@biomejs/biome": "1.9.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/file-saver": "^2.0.7",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -6,41 +6,56 @@ import type { Kennzahl } from "../types/kpi";
import { getDisplayType } from "../types/kpi";
import { fetchKennzahlen as fetchK } from "../util/api";
import { API_HOST } from "../util/api";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
type ConfigTableProps = {
export type ConfigTableProps = {
from?: string;
trainingRunning?: boolean;
};
export function ConfigTable({ from }: ConfigTableProps) {
export function ConfigTable({ from, trainingRunning }: ConfigTableProps) {
const navigate = useNavigate();
const [kennzahlen, setKennzahlen] = useState<Kennzahl[]>([]);
const [draggedItem, setDraggedItem] = useState<Kennzahl | null>(null);
const [isUpdatingPositions, setIsUpdatingPositions] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchKennzahlen = async () => {
while (true) {
try {
console.log("Fetching kennzahlen from API...");
const data = await fetchK();
console.log("Fetched kennzahlen:", data);
const sortedData = data.sort(
(a: Kennzahl, b: Kennzahl) => a.position - b.position,
);
setKennzahlen(sortedData);
setLoading(false);
break;
} catch (err) {
console.error("Error fetching kennzahlen:", err);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
const fetchKennzahlen = async () => {
while (true) {
try {
console.log("Fetching kennzahlen from API...");
const data = await fetchK();
console.log("Fetched kennzahlen:", data);
const sortedData = data.sort((a, b) => a.position - b.position);
setKennzahlen(sortedData);
setLoading(false);
break;
} catch (err) {
console.error("Error fetching kennzahlen:", err);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
};
}
};
useEffect(() => {
fetchKennzahlen();
}, []);
useEffect(() => {
if (trainingRunning === false) {
console.log("[ConfigTable] Training beendet → Kennzahlen neu laden...");
fetchKennzahlen();
}
}, [trainingRunning]);
const handleToggleActive = async (id: number) => {
const kennzahl = kennzahlen.find((k) => k.id === id);
if (!kennzahl) return;
@ -326,12 +341,32 @@ export function ConfigTable({ from }: ConfigTableProps) {
padding: "12px",
fontSize: "14px",
color: "#333",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span title={`Click to view details (ID: ${kennzahl.id})`}>
{kennzahl.name}
{kennzahl.mandatory && <span> *</span>}
</span>
{kennzahl.is_trained === false && (
<Tooltip
title={
<>
<b>Diese Kennzahl ist nicht trainiert.</b><br />
Klicken Sie oben auf <i>"Neu trainieren"</i>, um das Training zu starten.
</>
}
arrow
placement="right"
>
<WarningAmberIcon sx={{ color: "#f57c00", fontSize: 20 }} />
</Tooltip>
)}
</td>
<td style={{ padding: "12px" }}>
<span
style={{

View File

@ -1,247 +1,542 @@
import { Box, Typography, Button, Paper, TextField, FormControlLabel,
Checkbox, Select, MenuItem, FormControl, InputLabel, Divider, CircularProgress } from "@mui/material";
import { useState, useEffect } from "react";
import {
Box,
Button,
Checkbox,
CircularProgress,
Divider,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
} from "@mui/material";
import MuiAlert from "@mui/material/Alert";
import Snackbar from "@mui/material/Snackbar";
import { useEffect, useState } from "react";
import type { Kennzahl } from "../types/kpi";
import { typeDisplayMapping } from "../types/kpi";
import { API_HOST } from "../util/api";
interface KPIFormProps {
mode: 'add' | 'edit';
initialData?: Kennzahl | null;
onSave: (data: Partial<Kennzahl>) => Promise<void>;
onCancel: () => void;
loading?: boolean;
mode: "add" | "edit";
initialData?: Kennzahl | null;
onSave: (data: Partial<Kennzahl>) => Promise<void>;
onCancel: () => void;
loading?: boolean;
resetTrigger?: number;
}
const emptyKPI: Partial<Kennzahl> = {
name: '',
description: '',
mandatory: false,
type: 'string',
translation: '',
example: '',
active: true
};
const createEmptyKPI = (): Partial<Kennzahl> => ({
name: "",
mandatory: false,
type: "string",
active: true,
examples: [{ sentence: "", value: "" }],
});
export function KPIForm({ mode, initialData, onSave, onCancel, loading = false }: KPIFormProps) {
const [formData, setFormData] = useState<Partial<Kennzahl>>(emptyKPI);
const [isSaving, setIsSaving] = useState(false);
export function KPIForm({
mode,
initialData,
onSave,
onCancel,
loading = false,
}: KPIFormProps) {
const [formData, setFormData] = useState<Partial<Kennzahl>>(createEmptyKPI());
const [originalExamples, setOriginalExamples] = useState<
Array<{ sentence: string; value: string }>
>([]);
const [isSaving, setIsSaving] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarSeverity, setSnackbarSeverity] = useState<
"success" | "error" | "info"
>("success");
useEffect(() => {
if (mode === 'edit' && initialData) {
setFormData(initialData);
} else {
setFormData(emptyKPI);
}
}, [mode, initialData]);
useEffect(() => {
if (mode === "edit" && initialData) {
setOriginalExamples(initialData.examples || []);
setFormData({
...initialData,
examples: [{ sentence: "", value: "" }],
});
} else if (mode === "add") {
setOriginalExamples([]);
setFormData(createEmptyKPI());
}
}, [mode, initialData]);
const handleSave = async () => {
if (!formData.name?.trim()) {
alert('Name ist erforderlich');
return;
}
const handleSave = async () => {
if (!formData.name?.trim()) {
setSnackbarMessage("Name ist erforderlich");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
setIsSaving(true);
try {
await onSave(formData);
} catch (error) {
console.error('Error saving KPI:', error);
} finally {
setIsSaving(false);
}
};
if (mode === "add") {
if (!formData.examples || formData.examples.length === 0) {
setSnackbarMessage("Mindestens ein Beispielsatz ist erforderlich");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
const handleCancel = () => {
onCancel();
};
const newExamples = formData.examples.filter(
(ex) => ex.sentence?.trim() && ex.value?.trim(),
);
const updateField = (field: keyof Kennzahl, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
if (newExamples.length === 0) {
setSnackbarMessage(
"Mindestens ein vollständiger Beispielsatz ist erforderlich.",
);
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
}
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="400px"
flexDirection="column"
>
<CircularProgress sx={{ color: '#383838', mb: 2 }} />
<Typography>
{mode === 'edit' ? 'Lade KPI Details...' : 'Laden...'}
</Typography>
</Box>
);
}
const newExamples = (formData.examples || []).filter(
(ex) => ex.sentence?.trim() && ex.value?.trim(),
);
return (
<Paper
elevation={2}
sx={{
width: "90%",
maxWidth: 800,
p: 4,
borderRadius: 2,
backgroundColor: "white"
}}
>
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Kennzahl
</Typography>
<TextField
fullWidth
label="Name *"
value={formData.name || ''}
onChange={(e) => updateField('name', e.target.value)}
sx={{ mb: 2 }}
required
error={!formData.name?.trim()}
helperText={!formData.name?.trim() ? 'Name ist erforderlich' : ''}
/>
</Box>
if (formData.examples && formData.examples.length > 0) {
for (const ex of formData.examples) {
if (!ex.sentence?.trim() && !ex.value?.trim()) continue;
if (!ex.sentence?.trim() || !ex.value?.trim()) {
setSnackbarMessage("Alle Beispielsätze müssen vollständig sein oder leer gelassen werden.");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
}
}
<Divider sx={{ my: 3 }} />
setIsSaving(true);
try {
if (newExamples.length > 0) {
const spacyEntries = generateSpacyEntries({
...formData,
examples: newExamples,
});
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beschreibung
</Typography>
<TextField
fullWidth
multiline
rows={3}
label="Beschreibung"
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
helperText="Beschreibung der Kennzahl"
/>
// Für jeden einzelnen Beispielsatz:
for (const entry of spacyEntries) {
// im localStorage speichern (zum Debuggen oder Vorschau)
const stored = localStorage.getItem("spacyData");
const existingData = stored ? JSON.parse(stored) : [];
const updated = [...existingData, entry];
localStorage.setItem("spacyData", JSON.stringify(updated));
<Box mt={3}>
<FormControlLabel
control={
<Checkbox
checked={formData.mandatory || false}
onChange={(e) => updateField('mandatory', e.target.checked)}
sx={{ color: '#383838' }}
/>
}
label="Erforderlich"
/>
<Typography variant="body2" color="text.secondary" ml={4}>
Die Kennzahl erlaubt keine leeren Werte
</Typography>
</Box>
</Box>
// POST Request an das Flask-Backend
const response = await fetch(
`${API_HOST}/api/spacy/append-training-entry`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(entry),
},
);
<Divider sx={{ my: 3 }} />
const data = await response.json();
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Format: {typeDisplayMapping[formData.type as keyof typeof typeDisplayMapping] || formData.type}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Typ</InputLabel>
<Select
value={formData.type || 'string'}
label="Typ"
onChange={(e) => updateField('type', e.target.value)}
>
<MenuItem value="string">Text</MenuItem>
<MenuItem value="number">Zahl</MenuItem>
<MenuItem value="date">Datum</MenuItem>
<MenuItem value="boolean">Ja/Nein</MenuItem>
<MenuItem value="array">Liste (mehrfach)</MenuItem>
</Select>
</FormControl>
</Box>
if (!response.ok) {
throw new Error(
data.error || "Fehler beim Aufruf von append-training-entry",
);
}
<Divider sx={{ my: 3 }} />
console.log("SpaCy-Eintrag gespeichert:", data);
}
}
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Synonyme & Übersetzungen
</Typography>
<TextField
fullWidth
label="Übersetzung"
value={formData.translation || ''}
onChange={(e) => updateField('translation', e.target.value)}
helperText="z.B. Englische Übersetzung der Kennzahl"
/>
</Box>
const allExamples =
mode === "edit" ? [...originalExamples, ...newExamples] : newExamples;
<Divider sx={{ my: 3 }} />
// Dann in die DB speichern
await onSave({
name: formData.name ?? "",
mandatory: formData.mandatory ?? false,
type: formData.type || "string",
position: formData.position ?? 0,
active: formData.active ?? true,
examples: allExamples,
is_trained: false,
});
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beispiele von Kennzahl
</Typography>
<TextField
fullWidth
multiline
rows={2}
label="Beispiel"
value={formData.example || ''}
onChange={(e) => updateField('example', e.target.value)}
helperText="Beispielwerte für diese Kennzahl"
/>
</Box>
// Formular zurücksetzen:
if (mode === "add") {
setFormData(createEmptyKPI());
} else {
setFormData((prev) => ({
...prev,
examples: [{ sentence: "", value: "" }],
}));
}
{mode === 'add' && (
<>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<FormControlLabel
control={
<Checkbox
checked={formData.active !== false}
onChange={(e) => updateField('active', e.target.checked)}
sx={{ color: '#383838' }}
/>
}
label="Aktiv"
/>
<Typography variant="body2" color="text.secondary" ml={4}>
Die Kennzahl ist aktiv und wird angezeigt
</Typography>
</Box>
</>
)}
const successMessage = newExamples.length > 0
? "Beispielsätze gespeichert. Jetzt auf -Neu trainieren- klicken oder weitere Kennzahlen hinzufügen."
: mode === "edit"
? "Kennzahl erfolgreich aktualisiert."
: "Kennzahl erfolgreich erstellt.";
<Box display="flex" justifyContent="flex-end" gap={2} mt={4}>
<Button
variant="outlined"
onClick={handleCancel}
disabled={isSaving}
sx={{
borderColor: "#383838",
color: "#383838",
"&:hover": { borderColor: "#2e2e2e", backgroundColor: "#f5f5f5" }
}}
>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formData.name?.trim()}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
{isSaving ? (
<>
<CircularProgress size={16} sx={{ mr: 1, color: 'white' }} />
{mode === 'add' ? 'Hinzufügen...' : 'Speichern...'}
</>
) : (
mode === 'add' ? 'Hinzufügen' : 'Speichern'
)}
</Button>
</Box>
</Paper>
);
setSnackbarMessage(successMessage);
setSnackbarSeverity("success");
setSnackbarOpen(true);
} catch (e: any) {
// Prüfe auf 409-Fehler
if (e?.message?.includes("409") || e?.response?.status === 409) {
setSnackbarMessage(
"Diese Kennzahl existiert bereits. Sie können sie unter -Konfiguration- bearbeiten.",
);
setSnackbarSeverity("info");
setSnackbarOpen(true);
} else {
setSnackbarMessage(e.message || "Fehler beim Speichern.");
setSnackbarSeverity("error");
setSnackbarOpen(true);
}
console.error(e);
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setFormData(createEmptyKPI());
onCancel();
};
const updateField = (field: keyof Kennzahl, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const updateExample = (
index: number,
field: "sentence" | "value",
value: string,
) => {
const newExamples = [...(formData.examples || [])];
newExamples[index][field] = value;
updateField("examples", newExamples);
};
const addExample = () => {
const newExamples = [
...(formData.examples || []),
{ sentence: "", value: "" },
];
updateField("examples", newExamples);
};
const removeExample = (index: number) => {
const newExamples = [...(formData.examples || [])];
newExamples.splice(index, 1);
updateField("examples", newExamples);
};
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="400px"
flexDirection="column"
>
<CircularProgress sx={{ color: "#383838", mb: 2 }} />
<Typography>
{mode === "edit" ? "Lade KPI Details..." : "Laden..."}
</Typography>
</Box>
);
}
return (
<>
<Paper
elevation={2}
sx={{
width: "90%",
maxWidth: 800,
p: 4,
borderRadius: 2,
backgroundColor: "white",
}}
>
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Kennzahl
</Typography>
<TextField
fullWidth
label="Name *"
placeholder="z.B. IRR"
value={formData.name || ""}
onChange={(e) => updateField("name", e.target.value)}
sx={{ mb: 2 }}
required
error={!formData.name?.trim()}
helperText={!formData.name?.trim() ? "Name ist erforderlich" : ""}
/>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Format:{" "}
{typeDisplayMapping[
formData.type as keyof typeof typeDisplayMapping
] || formData.type}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Typ</InputLabel>
<Select
value={formData.type || "string"}
label="Typ"
onChange={(e) => updateField("type", e.target.value)}
>
<MenuItem value="string">Text</MenuItem>
<MenuItem value="number">Zahl</MenuItem>
<MenuItem value="date">Datum</MenuItem>
<MenuItem value="boolean">Ja/Nein</MenuItem>
<MenuItem value="array">Liste (mehrfach)</MenuItem>
</Select>
</FormControl>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<FormControlLabel
control={
<Checkbox
checked={formData.active !== false}
onChange={(e) => updateField("active", e.target.checked)}
sx={{
color: "#666666",
"&.Mui-checked": {
color: "#333333",
},
"&:hover": {
backgroundColor: "rgba(102, 102, 102, 0.04)",
},
}}
/>
}
label="Aktiv"
/>
<Typography variant="body2" color="text.secondary" ml={4}>
Die Kennzahl ist aktiv und wird angezeigt
</Typography>
</Box>
<Box mt={3}>
<FormControlLabel
control={
<Checkbox
checked={formData.mandatory || false}
onChange={(e) => updateField("mandatory", e.target.checked)}
sx={{
color: "#666666",
"&.Mui-checked": {
color: "#333333",
},
"&:hover": {
backgroundColor: "rgba(102, 102, 102, 0.04)",
},
}}
/>
}
label="Erforderlich"
/>
<Typography variant="body2" color="text.secondary" ml={4}>
Die Kennzahl erlaubt keine leeren Werte
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
{/* Hinweistext wie viele Beispielsätzen vorhanden sind*/}
{mode === "edit" && originalExamples.length > 0 && (
<Box
mb={2}
p={2}
sx={{
backgroundColor: "#e3f2fd",
border: "1px solid #90caf9",
borderRadius: 2,
}}
>
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
Vorhandene Beispielsätze: {originalExamples.length}
</Typography>
<Typography variant="body2">
Diese Kennzahl hat bereits {originalExamples.length}{" "}
Beispielsätze. Neue Beispielsätze werden zu den vorhandenen
hinzugefügt.
</Typography>
</Box>
)}
{/* Hinweistext vor Beispielsätzen */}
<Box
mb={2}
p={2}
sx={{
backgroundColor: "#fff8e1",
border: "1px solid #ffe082",
borderRadius: 2,
}}
>
<Typography variant="body1" sx={{ fontWeight: "bold", mb: 1 }}>
Hinweis zur Trainingsqualität
</Typography>
<Typography variant="body2">
Damit das System neue Kennzahlen zuverlässig erkennen kann,
empfehlen wir <strong>mindestens 5 Beispielsätze</strong> zu
erstellen je mehr, desto besser.
</Typography>
<Typography variant="body2" mt={1}>
<strong>Wichtig:</strong> Neue Kennzahlen werden erst in
PDF-Dokumenten erkannt, wenn Sie den Button{" "}
<em>"Neu trainieren"</em> auf der Konfigurationsseite ausführen.
</Typography>
<Typography variant="body2" mt={1}>
<strong>Tipp:</strong> Sie können jederzeit weitere Beispielsätze
hinzufügen.
</Typography>
</Box>
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beispielsätze
</Typography>
{(formData.examples || []).map((ex, idx) => (
<Box
key={idx}
sx={{ mb: 2, border: "1px solid #ccc", p: 2, borderRadius: 1 }}
>
<TextField
fullWidth
multiline
label={`Beispielsatz ${idx + 1}`}
placeholder="z.B. Die IRR beträgt 7,8 %"
value={ex.sentence}
onChange={(e) => updateExample(idx, "sentence", e.target.value)}
required
sx={{ mb: 1 }}
/>
<TextField
fullWidth
label="Bezeichneter Wert im Satz"
placeholder="z.B. 7,8 %"
value={ex.value}
onChange={(e) => updateExample(idx, "value", e.target.value)}
required
/>
{(formData.examples?.length || 0) > 1 && (
<Button
onClick={() => removeExample(idx)}
sx={{ mt: 1 }}
color="error"
>
Entfernen
</Button>
)}
</Box>
))}
<Button variant="outlined" onClick={addExample}>
+ Beispielsatz hinzufügen
</Button>
</Box>
<Box display="flex" justifyContent="flex-end" gap={2} mt={4}>
<Button
variant="outlined"
onClick={handleCancel}
disabled={isSaving}
sx={{
borderColor: "#383838",
color: "#383838",
"&:hover": { borderColor: "#2e2e2e", backgroundColor: "#f5f5f5" },
}}
>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formData.name?.trim()}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
{isSaving ? (
<>
<CircularProgress size={16} sx={{ mr: 1, color: "white" }} />
{mode === "add" ? "Hinzufügen..." : "Speichern..."}
</>
) : mode === "add" ? (
"Hinzufügen"
) : (
"Speichern"
)}
</Button>
</Box>
</Paper>
<Snackbar
open={snackbarOpen}
autoHideDuration={5000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
<MuiAlert
elevation={6}
variant="filled"
onClose={() => setSnackbarOpen(false)}
severity={snackbarSeverity}
sx={{
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>{snackbarMessage}</span>
<Button
color="inherit"
size="small"
onClick={() => setSnackbarOpen(false)}
>
OK
</Button>
</MuiAlert>
</Snackbar>
</>
);
}
function generateSpacyEntries(formData: Partial<Kennzahl>) {
const label = formData.name?.trim().toUpperCase() || "";
return (formData.examples || []).map(({ sentence, value }) => {
const trimmedValue = value.trim();
const start = sentence.indexOf(trimmedValue);
if (start === -1) {
throw new Error(
`"${trimmedValue}" nicht gefunden in Satz: "${sentence}"`,
);
}
return {
text: sentence,
entities: [[start, start + trimmedValue.length, label]],
};
});
}

View File

@ -143,7 +143,9 @@ export default function KennzahlenTable({
const handlePageSave = async (index: string) => {
const pageNumber = parseInt(editPageValue);
if (!isNaN(pageNumber) && pageNumber > 0) {
if (editPageValue === "" || pageNumber === 0) {
mutate({ id: index, newPage: 0 });
} else if (!isNaN(pageNumber) && pageNumber > 0) {
mutate({ id: index, newPage: pageNumber });
}
setEditingPageIndex("");
@ -182,10 +184,10 @@ export default function KennzahlenTable({
<Table>
<TableHead>
<TableRow>
<TableCell width="25%">
<TableCell width="30%">
<strong>Kennzahl</strong>
</TableCell>
<TableCell width="60%">
<TableCell width="55%">
<strong>Wert</strong>
</TableCell>
<TableCell align="center" width="15%">
@ -224,7 +226,11 @@ export default function KennzahlenTable({
return (
<TableRow key={row.setting.name}>
<TableCell>{row.setting.name}</TableCell>
<TableCell>{row.setting.name}
{row.setting.mandatory && (
<span> *</span>
)}
</TableCell>
<TableCell
onClick={() => {
// Only allow inline editing for non-multiple value cells
@ -370,7 +376,7 @@ export default function KennzahlenTable({
value={editPageValue}
onChange={(e) => {
const value = e.target.value;
if (value === '' || /^\d+$/.test(value) && parseInt(value) > 0) {
if (value === '' || /^\d+$/.test(value)) {
setEditPageValue(value);
}
}}
@ -399,14 +405,11 @@ export default function KennzahlenTable({
alignItems: "center",
justifyContent: "center",
position: "relative",
cursor: canEditPage ? "pointer" : "default",
cursor: "pointer",
borderRadius: "4px",
minHeight: "32px",
minWidth: "100px",
transition: "all 0.2s ease",
}}
onMouseEnter={() => canEditPage && setHoveredPageIndex(row.setting.name)}
onMouseLeave={() => setHoveredPageIndex("")}
onClick={() => {
if (canEditPage) {
startPageEditing(currentPage, row.setting.name);
@ -426,19 +429,6 @@ export default function KennzahlenTable({
>
{currentPage}
</Link>
{isPageHovered && canEditPage && (
<EditIcon
fontSize="small"
sx={{
position: "absolute",
left: "70px",
color: "#666",
opacity: 0.7,
transition: "opacity 0.2s ease",
}}
/>
)}
</Box>
) : canEditPage ? (
<Box
@ -463,7 +453,7 @@ export default function KennzahlenTable({
sx={{
position: "absolute",
left: "70px",
color: "#555",
color: "black",
cursor: "pointer",
opacity: 0.7,
transition: "opacity 0.2s ease",

View File

@ -21,6 +21,7 @@ import { useCallback, useEffect, useState } from "react";
import { socket } from "../socket";
import { fetchPitchBooksById } from "../util/api";
import { pitchBooksQueryOptions } from "../util/query";
import { formatDate } from "../util/date"
interface PitchBook {
id: number;
@ -46,6 +47,7 @@ export function PitchBooksTable() {
id: number;
progress: number;
filename?: string;
created_at?: string;
buffer: number;
intervalId?: number;
}[]
@ -76,7 +78,6 @@ export function PitchBooksTable() {
const intervalId = prev.find(
(item) => item.id === progress.id,
)?.intervalId;
console.log(intervalId, prev);
intervalId && clearInterval(intervalId);
return [...prev.filter((item) => item.id !== progress.id)];
@ -102,6 +103,7 @@ export function PitchBooksTable() {
filename: oldItem?.filename,
buffer: oldItem ? oldItem.buffer + 0.5 : 0,
intervalId: oldItem.intervalId,
created_at: oldItem?.created_at,
},
];
});
@ -117,6 +119,7 @@ export function PitchBooksTable() {
filename: res.filename,
buffer: 0,
intervalId,
created_at: res.created_at,
},
]);
})
@ -130,6 +133,7 @@ export function PitchBooksTable() {
id: progress.id,
progress: progress.progress,
filename: oldItem?.filename,
created_at: oldItem?.created_at,
buffer: 0,
intervalId,
},
@ -211,6 +215,7 @@ export function PitchBooksTable() {
<TableCell sx={{ width: "60px" }} />
<TableCell sx={{ fontWeight: "bold" }}>Fondsname</TableCell>
<TableCell sx={{ fontWeight: "bold" }}>Fondsmanager</TableCell>
<TableCell sx={{ fontWeight: "bold" }}>Hochgeladen am</TableCell>
<TableCell sx={{ fontWeight: "bold" }}>Dateiname</TableCell>
<TableCell sx={{ fontWeight: "bold", width: "120px" }}>
Status
@ -218,6 +223,71 @@ export function PitchBooksTable() {
</TableRow>
</TableHead>
<TableBody>
{loadingPitchBooks
.sort((a, b) => a.id - b.id)
.map((pitchBook) => (
<TableRow key={pitchBook.id}>
<TableCell>
<Box
sx={{
width: 40,
height: 50,
backgroundColor: "#f0f0f0",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #e0e0e0",
}}
>
<PictureAsPdfIcon fontSize="small" sx={{ color: "#666" }} />
</Box>
</TableCell>
<TableCell colSpan={2}>
<LinearProgress
variant="buffer"
value={pitchBook.progress}
valueBuffer={
pitchBook.buffer
? pitchBook.progress + pitchBook.buffer
: pitchBook.progress
}
/>
</TableCell>
<TableCell>
<Typography
variant="body2"
color="text.secondary"
fontSize="0.875rem"
>
{pitchBook.created_at && formatDate(pitchBook.created_at)}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
color="text.secondary"
fontSize="0.875rem"
>
{pitchBook.filename}
</Typography>
</TableCell>
<TableCell>
<Chip
icon={<HourglassEmptyIcon />}
label="In Bearbeitung"
size="small"
sx={{
backgroundColor: "#fff3e0",
color: "#e65100",
"& .MuiChip-icon": {
color: "#e65100",
},
}}
/>
</TableCell>
</TableRow>
))}
{pitchBooks
.filter(
(pitchbook: PitchBook) =>
@ -225,8 +295,8 @@ export function PitchBooksTable() {
)
.sort(
(a: PitchBook, b: PitchBook) =>
new Date(a.created_at).getTime() -
new Date(b.created_at).getTime(),
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime(),
)
.map((pitchBook: PitchBook) => {
const status = getStatus(pitchBook);
@ -276,6 +346,7 @@ export function PitchBooksTable() {
</Typography>
</TableCell>
<TableCell>{manager}</TableCell>
<TableCell>{formatDate(pitchBook.created_at)}</TableCell>
<TableCell>
<Typography
variant="body2"
@ -289,7 +360,7 @@ export function PitchBooksTable() {
{status === "completed" ? (
<Chip
icon={<CheckCircleIcon />}
label="Abgeschlossen"
label="Extraktion Abgeschlossen"
size="small"
sx={{
backgroundColor: "#e8f5e9",
@ -317,63 +388,6 @@ export function PitchBooksTable() {
</TableRow>
);
})}
{loadingPitchBooks
.sort((a, b) => a.id - b.id)
.map((pitchBook) => (
<TableRow key={pitchBook.id}>
<TableCell>
<Box
sx={{
width: 40,
height: 50,
backgroundColor: "#f0f0f0",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #e0e0e0",
}}
>
<PictureAsPdfIcon fontSize="small" sx={{ color: "#666" }} />
</Box>
</TableCell>
<TableCell colSpan={2}>
<LinearProgress
variant="buffer"
value={pitchBook.progress}
valueBuffer={
pitchBook.buffer
? pitchBook.progress + pitchBook.buffer
: pitchBook.progress
}
/>
</TableCell>
<TableCell>
{" "}
<Typography
variant="body2"
color="text.secondary"
fontSize="0.875rem"
>
{pitchBook.filename}
</Typography>
</TableCell>
<TableCell>
<Chip
icon={<HourglassEmptyIcon />}
label="In Bearbeitung"
size="small"
sx={{
backgroundColor: "#fff3e0",
color: "#e65100",
"& .MuiChip-icon": {
color: "#e65100",
},
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pitchBooks.length === 0 && (

View File

@ -1,11 +1,12 @@
import SettingsIcon from "@mui/icons-material/Settings";
import { Backdrop, Box, Button, IconButton, Paper } from "@mui/material";
import { Backdrop, Box, Button, IconButton, Paper, Typography } from "@mui/material";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react";
import FileUpload from "react-material-file-upload";
import { socket } from "../socket";
import { API_HOST } from "../util/api";
import { CircularProgressWithLabel } from "./CircularProgressWithLabel";
import DekaLogo from "../assets/Deka_logo.png";
export default function UploadPage() {
const [files, setFiles] = useState<File[]>([]);
@ -87,26 +88,50 @@ export default function UploadPage() {
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
justifyContent="flex-start"
height="100vh"
bgcolor="white"
pt={3}
>
<Box
width="100%"
maxWidth="1300px"
display="flex"
justifyContent="flex-end"
px={2}
justifyContent="space-between"
alignItems="center"
px={8}
py={5}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<img
src={DekaLogo}
alt="Company Logo"
style={{ height: "40px", width: "auto" }}
/>
</Box>
<IconButton onClick={() => navigate({ to: "/config" })}>
<SettingsIcon fontSize="large" />
</IconButton>
</Box>
<Typography
variant="h4"
component="h1"
sx={{
fontWeight: "bold",
color: "#383838",
marginBottom: 12,
marginTop: 3,
}}
>
Pitchbook Extractor
</Typography>
<Paper
elevation={3}
sx={{
width: 900,
height: 500,
width: 800,
height: 400,
backgroundColor: "#eeeeee",
borderRadius: 4,
display: "flex",
@ -182,7 +207,7 @@ export default function UploadPage() {
onMouseEnter={() => router.preloadRoute({ to: "/pitchbooks" })}
onClick={() => navigate({ to: "/pitchbooks" })}
>
Alle Pitch Books anzeigen
Alle Pitchbooks anzeigen
</Button>
</Box>
</>

View File

@ -4,6 +4,8 @@ import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";
import ArrowCircleLeftIcon from "@mui/icons-material/ArrowCircleLeft";
import ArrowCircleRightIcon from "@mui/icons-material/ArrowCircleRight";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import { Box, IconButton } from "@mui/material";
import type {
CustomTextRenderer,
@ -30,7 +32,7 @@ export default function PDFViewer({
}: PDFViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(currentPage || 1);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
const [baseWidth, setBaseWidth] = useState<number | null>(null);
const [pdfKey, setPdfKey] = useState(Date.now());
const containerRef = useRef<HTMLDivElement>(null);
const [posHighlight, setPosHighlight] = useState<string[]>([]);
@ -38,6 +40,7 @@ export default function PDFViewer({
const [textContent, setTextContent] = useState<
{ posKey: string; text: string; i: number }[]
>([]);
const [zoomLevel, setZoomLevel] = useState(1.0);
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
@ -46,7 +49,9 @@ export default function PDFViewer({
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
if (!baseWidth) {
setBaseWidth(containerRef.current.offsetWidth);
}
}
};
@ -95,53 +100,82 @@ export default function PDFViewer({
useEffect(() => {
const tmpPos: string[] = [];
const tmpPosHighlight: string[] = [];
const textItems = textContent.filter(
(e) => e.text !== "" && e.text !== " ",
);
textItems.forEach((e, i) => {
for (const s of highlight
.filter((h) => h.page === pageNumber)
.map((h) => h.text)) {
if (s.split(" ")[0] === e.text) {
if (
s.split(" ").reduce((prev, curr, j) => {
return prev && curr === textItems[i + j].text;
}, true)
) {
for (
let k = textItems[i].i;
k < textItems[i + s.split(" ").length]?.i ||
k < textItems[i + s.split(" ").length - 1]?.i;
k++
if (textContent.length === 0) {
setPosHighlight([]);
setPosHighlightFocus([]);
return;
}
const findTextPositions = (searchText: string): number[] => {
const positions: number[] = [];
const normalizedSearch = searchText.toLowerCase().trim();
textContent.forEach((item, index) => {
if (item.text.toLowerCase().trim() === normalizedSearch) {
positions.push(index);
}
});
if (positions.length === 0) {
let cumulativeText = "";
const textBoundaries: { start: number; end: number; index: number }[] =
[];
textContent.forEach((item, index) => {
const start = cumulativeText.length;
cumulativeText += item.text;
const end = cumulativeText.length;
textBoundaries.push({ start, end, index });
});
const lowerCumulative = cumulativeText.toLowerCase();
let searchIndex = lowerCumulative.indexOf(normalizedSearch);
while (searchIndex !== -1) {
const endIndex = searchIndex + normalizedSearch.length;
textBoundaries.forEach((boundary) => {
if (
(boundary.start <= searchIndex && searchIndex < boundary.end) || // Search starts in this item
(boundary.start < endIndex && endIndex <= boundary.end) || // Search ends in this item
(searchIndex <= boundary.start && boundary.end <= endIndex) // This item is completely within search
) {
tmpPos.push(textContent[k].posKey);
if (!positions.includes(boundary.index)) {
positions.push(boundary.index);
}
}
}
});
searchIndex = lowerCumulative.indexOf(
normalizedSearch,
searchIndex + 1,
);
}
}
if (focusHighlight?.page === pageNumber) {
if (focusHighlight.text.split(" ")[0] === e.text) {
if (
focusHighlight.text.split(" ").reduce((prev, curr, j) => {
return prev && curr === textItems[i + j].text;
}, true)
) {
for (
let k = textItems[i].i;
k < textItems[i + focusHighlight.text.split(" ").length]?.i ||
k < textItems[i + focusHighlight.text.split(" ").length - 1]?.i;
k++
) {
tmpPosHighlight.push(textContent[k].posKey);
}
return positions.sort((a, b) => a - b);
};
highlight
.filter((h) => h.page === pageNumber)
.forEach((highlightItem) => {
const positions = findTextPositions(highlightItem.text);
positions.forEach((pos) => {
if (pos >= 0 && pos < textContent.length) {
tmpPos.push(textContent[pos].posKey);
}
});
});
if (focusHighlight?.page === pageNumber && focusHighlight.text) {
const positions = findTextPositions(focusHighlight.text);
positions.forEach((pos) => {
if (pos >= 0 && pos < textContent.length) {
tmpPosHighlight.push(textContent[pos].posKey);
}
}
});
setPosHighlight(tmpPos);
setPosHighlightFocus(tmpPosHighlight);
});
}
setPosHighlight([...new Set(tmpPos)]);
setPosHighlightFocus([...new Set(tmpPosHighlight)]);
}, [highlight, focusHighlight, pageNumber, textContent]);
const onGetTextSuccess: OnGetTextSuccess = useCallback((fullText) => {
@ -154,6 +188,10 @@ export default function PDFViewer({
);
}, []);
const contentWidth = baseWidth ? baseWidth * 0.98 * zoomLevel : 0;
const containerWidth = baseWidth ? baseWidth : 0;
const willOverflow = contentWidth > containerWidth;
return (
<Box
display="flex"
@ -161,16 +199,23 @@ export default function PDFViewer({
justifyContent="center"
alignItems="center"
width="100%"
height="auto"
maxWidth="850px"
margin="0 auto"
>
<Box
ref={containerRef}
width="100%"
height="500px"
sx={{
width: "100%",
height: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
boxShadow: "none",
overflow: "auto",
display: willOverflow ? "block" : "flex",
justifyContent: willOverflow ? "flex-start" : "center",
alignItems: willOverflow ? "flex-start" : "center",
padding: willOverflow ? `${Math.max(0, (500 - (contentWidth * (500 / containerWidth))) / 2)}px ${Math.max(0, (containerWidth - contentWidth) / 2)}px` : 0,
}}
>
<Document
@ -182,10 +227,10 @@ export default function PDFViewer({
}
onSourceError={(error) => console.error("Ungültige PDF:", error)}
>
{containerWidth && (
{baseWidth && (
<Page
pageNumber={pageNumber}
width={containerWidth * 0.98}
width={baseWidth * 0.98 * zoomLevel}
customTextRenderer={textRenderer}
onGetTextSuccess={onGetTextSuccess}
/>
@ -200,6 +245,13 @@ export default function PDFViewer({
gap={1}
p={1}
>
<IconButton
disabled={zoomLevel <= 0.3}
onClick={() => setZoomLevel((z) => Math.max(0.3, z - 0.1))}
title="Verkleinern"
>
<ZoomOutIcon fontSize="large" />
</IconButton>
<IconButton
disabled={pageNumber <= 1}
onClick={() => handlePageChange(pageNumber - 1)}
@ -215,6 +267,12 @@ export default function PDFViewer({
>
<ArrowCircleRightIcon fontSize="large" />
</IconButton>
<IconButton
onClick={() => setZoomLevel((z) => Math.min(3, z + 0.1))}
title="Vergrößern"
>
<ZoomInIcon fontSize="large" />
</IconButton>
</Box>
</Box>
);

View File

@ -1,6 +1,6 @@
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import "react-pdf/dist/Page/TextLayer.css";
@ -10,9 +10,8 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx";
import { pdfjs } from "react-pdf";
import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
@ -27,6 +26,7 @@ const router = createRouter({
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
basepath: "/ff",
});
// Register the router instance for type safety

View File

@ -5,6 +5,7 @@ import { KPIForm } from "../components/KPIForm";
import type { Kennzahl } from "../types/kpi";
import { API_HOST } from "../util/api";
export const Route = createFileRoute("/config-add")({
component: ConfigAddPage,
validateSearch: (search: Record<string, unknown>): { from?: string } => {
@ -47,19 +48,28 @@ function ConfigAddPage() {
body: JSON.stringify(kpiData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
navigate({ to: "/config" });
navigate({
to: "/config",
search: { success: "true", ...(from ? { from } : {}) },
});
} catch (error) {
console.error('Error creating KPI:', error);
throw error;
}
};
const handleCancel = () => {
navigate({ to: "/config" });
navigate({
to: "/config",
search: from ? { from } : undefined,
});
};
return (
@ -83,7 +93,7 @@ function ConfigAddPage() {
>
<Box display="flex" alignItems="center">
<IconButton onClick={handleBack}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }} />
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Neue Kennzahl hinzufügen
@ -93,6 +103,7 @@ function ConfigAddPage() {
<KPIForm
mode="add"
key={Date.now()}
onSave={handleSave}
onCancel={handleCancel}
/>

View File

@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Box, Typography, IconButton, Button, CircularProgress, Paper, Divider
import {
Box, Typography, IconButton, Button, CircularProgress, Paper, Divider
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useEffect, useState } from "react";
@ -38,6 +39,7 @@ function KPIDetailPage() {
try {
setLoading(true);
const response = await fetch(`${API_HOST}/api/kpi_setting/${kpiId}`);
if (!response.ok) {
if (response.status === 404) {
setError('KPI not found');
@ -72,7 +74,6 @@ function KPIDetailPage() {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const updatedKennzahl = await response.json();
setKennzahl(updatedKennzahl);
setIsEditing(false);
@ -82,6 +83,7 @@ function KPIDetailPage() {
}
};
const handleCancel = () => {
setIsEditing(false);
};
@ -153,7 +155,7 @@ function KPIDetailPage() {
>
<Box display="flex" alignItems="center">
<IconButton onClick={handleBack}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }} />
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Detailansicht
@ -192,18 +194,13 @@ function KPIDetailPage() {
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beschreibung
<Typography variant="h6" fontWeight="bold" mb={1}>
Erforderlich:
</Typography>
<Typography variant="body1" color="text.secondary">
{kennzahl.description || "Zurzeit ist die Beschreibung der Kennzahl leer. Klicken Sie auf den Bearbeiten-Button, um die Beschreibung zu ergänzen."}
<Typography variant="body1" sx={{ mb: 2, fontSize: 16 }}>
{kennzahl.mandatory ? 'Ja' : 'Nein'}
</Typography>
<Box mt={2}>
<Typography variant="body2" color="text.secondary">
<strong>Erforderlich:</strong> {kennzahl.mandatory ? 'Ja' : 'Nein'}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
@ -216,28 +213,6 @@ function KPIDetailPage() {
{typeDisplayMapping[kennzahl.type] || kennzahl.type}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Synonyme & Übersetzungen
</Typography>
<Typography variant="body1" color="text.secondary">
{kennzahl.translation || "Zurzeit gibt es keine Einträge für Synonyme und Übersetzungen der Kennzahl. Klicken Sie auf den Bearbeiten-Button, um die Liste zu ergänzen."}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
<Box mb={4}>
<Typography variant="h6" fontWeight="bold" mb={2}>
Beispiele von Kennzahl
</Typography>
<Typography variant="body1" color="text.secondary">
{kennzahl.example || "Zurzeit gibt es keine Beispiele der Kennzahl. Klicken Sie auf den Bearbeiten-Button, um die Liste zu ergänzen."}
</Typography>
</Box>
</Paper>
</Box>
);
@ -264,7 +239,7 @@ function KPIDetailPage() {
>
<Box display="flex" alignItems="center">
<IconButton onClick={handleBack}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }} />
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Kennzahl bearbeiten

View File

@ -3,18 +3,87 @@ import { Box, Button, IconButton, Typography } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useNavigate } from "@tanstack/react-router";
import { ConfigTable } from "../components/ConfigTable";
import { API_HOST } from "../util/api";
import Snackbar from "@mui/material/Snackbar";
import MuiAlert from "@mui/material/Alert";
import { useState, useEffect } from "react";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
export const Route = createFileRoute("/config")({
component: ConfigPage,
validateSearch: (search: Record<string, unknown>): { from?: string } => {
const from = typeof search.from === "string" ? search.from : undefined;
return { from };
validateSearch: (search: Record<string, unknown>): { from?: string; success?: string } => {
return {
from: typeof search.from === "string" ? search.from : undefined,
success: typeof search.success === "string" ? search.success : undefined
};
}
});
function ConfigPage() {
const navigate = useNavigate();
const { from } = Route.useSearch();
const { from, success } = Route.useSearch();
const [snackbarOpen, setSnackbarOpen] = useState(success === "true");
const [snackbarMessage, setSnackbarMessage] = useState<string>("Beispielsätze gespeichert. Jetzt auf -Neu trainieren- klicken oder zuerst weitere Kennzahlen hinzufügen.");
const [trainingRunning, setTrainingRunning] = useState(false);
const [hasUntrainedKPIs, setHasUntrainedKPIs] = useState(false);
const fetchKPISettings = async () => {
try {
const res = await fetch(`${API_HOST}/api/kpi/settings`);
const data = await res.json();
const untrainedExists = data.some((kpi: any) => {
return kpi.is_trained === false;
});
setHasUntrainedKPIs(untrainedExists);
} catch (err) {
console.error("Fehler beim Laden der KPIs:", err);
}
};
useEffect(() => {
fetchKPISettings();
}, []);
useEffect(() => {
if (success === "true") {
setTimeout(() => {
navigate({
to: "/config",
search: from ? { from } : undefined,
replace: true
});
}, 100);
}
}, [success]);
useEffect(() => {
const checkInitialTrainingStatus = async () => {
try {
const res = await fetch(`${API_HOST}/api/spacy/train-status`);
const data = await res.json();
if (data.running) {
setTrainingRunning(true);
pollTrainingStatus();
}
} catch (err) {
console.error("Initiale Trainingsstatus-Abfrage fehlgeschlagen", err);
}
};
checkInitialTrainingStatus();
}, []);
const handleAddNewKPI = () => {
navigate({
@ -31,46 +100,160 @@ function ConfigPage() {
}
};
const handleTriggerTraining = async () => {
setTrainingRunning(true);
try {
const response = await fetch(`${API_HOST}/api/spacy/train`, {
method: "POST",
});
if (!response.ok) throw new Error("Training konnte nicht gestartet werden");
// Erfolgsmeldung erst hier anzeigen
setSnackbarMessage("Training wurde gestartet.");
setSnackbarOpen(true);
pollTrainingStatus(); // jetzt starten
} catch (err) {
console.error(err);
setSnackbarMessage("Fehler beim Starten des Trainings.");
setSnackbarOpen(true);
setTrainingRunning(false);
}
};
const pollTrainingStatus = () => {
const interval = setInterval(async () => {
try {
const res = await fetch(`${API_HOST}/api/spacy/train-status`);
const data = await res.json();
console.log("Trainingsstatus:", data); //Debug-Ausgabe
if (!data.running) {
clearInterval(interval);
console.log("Training abgeschlossen Snackbar wird ausgelöst");
setSnackbarMessage("Training abgeschlossen!");
setSnackbarOpen(true);
setTrainingRunning(false);
fetchKPISettings();
}
} catch (err) {
console.error("Polling-Fehler:", err);
clearInterval(interval);
}
}, 3000);
};
return (
<Box
minHeight="100vh"
width="100vw"
bgcolor="white"
display="flex"
flexDirection="column"
alignItems="center"
pt={3}
pb={4}
>
<>
<Box
width="100%"
minHeight="100vh"
width="100vw"
bgcolor="white"
display="flex"
justifyContent="space-between"
flexDirection="column"
alignItems="center"
px={4}
pt={3}
pb={4}
>
<Box display="flex" alignItems="center">
<IconButton onClick={handleBack}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }}/>
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Konfiguration der Kennzahlen
</Typography>
</Box>
<Button
variant="contained"
onClick={handleAddNewKPI}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
<Box
width="100%"
display="flex"
justifyContent="space-between"
alignItems="center"
px={4}
>
Neue Kennzahl hinzufügen
</Button>
{/* Linke Seite: Zurück & Titel */}
<Box display="flex" alignItems="center">
<IconButton onClick={handleBack}>
<ArrowBackIcon fontSize="large" sx={{ color: '#383838' }} />
</IconButton>
<Typography variant="h5" fontWeight="bold" ml={3}>
Konfiguration der Kennzahlen
</Typography>
</Box>
<Box display="flex" flexDirection="column" alignItems="flex-end" gap={1}>
<Box display="flex" gap={2}>
{trainingRunning || !hasUntrainedKPIs ? (
<Tooltip title="Alle Kennzahlen sind bereits trainiert.">
<span>
<Button
variant="contained"
onClick={handleTriggerTraining}
disabled
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
{trainingRunning ? (
<>
<CircularProgress size={20} sx={{ color: "white", mr: 1 }} />
Wird trainiert...
</>
) : (
"Neu trainieren"
)}
</Button>
</span>
</Tooltip>
) : (
<Button
variant="contained"
onClick={handleTriggerTraining}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
Neu trainieren
</Button>
)}
<Button
variant="contained"
onClick={handleAddNewKPI}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },
}}
>
Neue Kennzahl hinzufügen
</Button>
</Box>
</Box>
</Box>
{/* Tabelle */}
<Box sx={{ width: "100%", mt: 4, display: "flex", justifyContent: "center" }}>
<ConfigTable from={from} trainingRunning={trainingRunning} />
</Box>
</Box>
<Box sx={{ width: "100%", mt: 4, display: "flex", justifyContent: "center" }}>
<ConfigTable from={from} />
</Box>
</Box>
{/* Snackbar */}
<Snackbar
open={snackbarOpen}
autoHideDuration={4000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
<MuiAlert
elevation={6}
variant="filled"
onClose={() => setSnackbarOpen(false)}
severity="success"
sx={{ width: "100%" }}
>
{snackbarMessage}
</MuiAlert>
</Snackbar>
</>
);
}

View File

@ -1,5 +1,5 @@
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
import { Box, Button, Paper, Typography, Snackbar, Alert, IconButton } from "@mui/material";
import { Box, Button, Paper, Typography, Snackbar, Alert, IconButton, Tooltip } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
@ -50,6 +50,8 @@ function ExtractedResultsPage() {
const { data: kpi } = useSuspenseQuery(kpiQueryOptions(pitchBook));
const { data: settings } = useSuspenseQuery(settingsQueryOptions());
const fundName = kpi["FONDSNAME"]?.[0]?.entity;
const status = useMemo(() => {
let hasRedBorders = false;
let hasYellowBorders = false;
@ -158,7 +160,9 @@ function ExtractedResultsPage() {
}}
/>
<Typography variant="h5" gutterBottom>
<strong>Extrahierte Kennzahlen</strong>
<strong>
{fundName ? `Kennzahlen extrahiert aus: ${fundName}` : "Extrahierte Kennzahlen"}
</strong>
</Typography>
</Box>
<Box
@ -235,17 +239,29 @@ function ExtractedResultsPage() {
gap={2}
sx={{ flexShrink: 0 }}
>
<Button variant="contained" sx={{ backgroundColor: "#383838" }}
onClick={handleCopyToClipboard}>
<ContentPasteIcon sx={{ fontSize: 18, mr: 1 }} />
{copied ? "Kopiert!" : "Kennzahlenzeile kopieren"}
</Button>
<Tooltip
title={
<>
<b>Kennzahlen kopieren</b>
<br />
Kopiert alle aktiven Kennzahlen als Excel-Zeile in die Zwischenablage. Kann direkt in Excel eingefügt werden.
</>
}
placement="top"
arrow
>
<Button variant="contained" sx={{ backgroundColor: "#383838" }}
onClick={handleCopyToClipboard}>
<ContentPasteIcon sx={{ fontSize: 18, mr: 1 }} />
{copied ? "Kopiert!" : "Kennzahlenzeile kopieren"}
</Button>
</Tooltip>
<Button
variant="contained"
sx={{ backgroundColor: "#383838" }}
onClick={() => navigate({ to: "/" })}
>
Neu hochladen
Neues Pitchbook hochladen
</Button>
</Box>
</Box>

View File

@ -27,8 +27,7 @@ import {
useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import type { KeyboardEvent } from "react";
import { useEffect, useState, type KeyboardEvent } from "react";
import PDFViewer from "../components/pdfViewer";
import { fetchPutKPI } from "../util/api";
import { kpiQueryOptions } from "../util/query";
@ -69,15 +68,45 @@ function ExtractedResultsPage() {
const [customValue, setCustomValue] = useState("");
const [customPage, setCustomPage] = useState("");
const [editingCustomPage, setEditingCustomPage] = useState(false);
const [focusHighlightOverride, setFocusHighlightOverride] = useState<{ page: number; text: string } | null>(null);
const originalValue = kpiValues[0]?.entity || "";
const originalPage = kpiValues[0]?.page || 0;
const selectedValue =
selectedIndex === -1 ? customValue : kpiValues[selectedIndex]?.entity || "";
// Funktion, um gleiche Werte zusammenzufassen und die Seiten zu sammeln
function groupKpiValues(values: Array<{ entity: string; page: number; [key: string]: any }>): Array<{ entity: string; pages: number[]; [key: string]: any }> {
const map = new Map<string, { entity: string; pages: number[]; [key: string]: any }>();
values.forEach((item: { entity: string; page: number; [key: string]: any }) => {
const key = item.entity.toLowerCase();
if (!map.has(key)) {
map.set(key, { ...item, pages: [item.page] });
} else {
const existingEntry = map.get(key)!;
if (!existingEntry.pages.includes(item.page)) {
existingEntry.pages.push(item.page);
}
}
});
return Array.from(map.values());
}
const groupedKpiValues: Array<{ entity: string; pages: number[]; [key: string]: any }> = groupKpiValues(kpiValues);
const selectedValue: string =
selectedIndex === -1 ? customValue : groupedKpiValues[selectedIndex]?.entity || "";
const selectedPage =
selectedIndex === -1
? (parseInt(customPage) > 0 ? parseInt(customPage) : 1)
: kpiValues[selectedIndex]?.page || 1;
: groupedKpiValues[selectedIndex]?.pages[0] || 1;
// Um zu prüfen, ob der Wert nur aus Leerzeichen besteht
const isSelectedValueEmpty = selectedIndex === -1 ? customValue.trim() === "" : !selectedValue;
const focusHighlight = focusHighlightOverride || {
page: groupedKpiValues.at(selectedIndex)?.pages[0] || -1,
text: groupedKpiValues.at(selectedIndex)?.entity || "",
};
useEffect(() => {
const valueChanged = selectedValue !== originalValue;
@ -90,7 +119,15 @@ function ExtractedResultsPage() {
const updatedData = { ...kpiData };
let baseObject;
if (selectedIndex >= 0) {
baseObject = kpiValues[selectedIndex];
// Das Originalobjekt mit allen Feldern für diesen Wert suchen
const original = kpiValues.find(v => v.entity.toLowerCase() === groupedKpiValues[selectedIndex].entity.toLowerCase()) as { status?: string; source?: string } | undefined;
baseObject = {
label: kpi.toUpperCase(),
entity: groupedKpiValues[selectedIndex].entity,
page: groupedKpiValues[selectedIndex].pages[0],
status: original?.status || "single-source",
source: original?.source || "auto",
};
} else {
baseObject = {
label: kpi.toUpperCase(),
@ -128,12 +165,14 @@ function ExtractedResultsPage() {
const value = event.target.value;
if (value === "custom") {
setSelectedIndex(-1);
setFocusHighlightOverride(null);
} else {
const index = Number.parseInt(value);
setSelectedIndex(index);
setCurrentPage(kpiValues[index].page);
setCurrentPage(groupedKpiValues[index].pages[0]);
setCustomValue("");
setCustomPage("");
setFocusHighlightOverride(null);
}
};
@ -143,7 +182,8 @@ function ExtractedResultsPage() {
const value = event.target.value;
setCustomValue(value);
setSelectedIndex(-1);
};
setFocusHighlightOverride(null);
}
const handleCustomPageChange = (
event: React.ChangeEvent<HTMLInputElement>,
@ -156,10 +196,19 @@ function ExtractedResultsPage() {
};
const handleRowClick = (index: number) => {
setCurrentPage(kpiValues[index].page);
setCurrentPage(groupedKpiValues[index].pages[0]);
setSelectedIndex(index);
setCustomValue("");
setCustomPage("");
setFocusHighlightOverride(null);
};
const handlePageClick = (page: number, entity: string) => {
setCurrentPage(page);
setFocusHighlightOverride({
page: page,
text: entity,
});
};
const handleBackClick = () => {
@ -195,10 +244,8 @@ function ExtractedResultsPage() {
setEditingCustomPage(true);
};
const handleCustomPageKeyPress = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
setEditingCustomPage(false);
} else if (e.key === "Escape") {
const handleCustomPageKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === "Escape") {
setEditingCustomPage(false);
}
};
@ -209,8 +256,8 @@ function ExtractedResultsPage() {
<IconButton onClick={handleBackClick} sx={{ mr: 2 }}>
<ArrowBackIcon fontSize="large" sx={{ color: "#383838" }} />
</IconButton>
<Typography variant="h5" fontWeight="bold">
Überprüfung der Kennzahl: {kpi}
<Typography variant="h5">
Überprüfung der Kennzahl: <b><u>{kpi}</u></b>
</Typography>
</Box>
@ -244,14 +291,14 @@ function ExtractedResultsPage() {
<strong>Gefundene Werte</strong>
</TableCell>
<TableCell align="center" width="15%">
<strong>Seite</strong>
<strong>Seiten</strong>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kpiValues.map((item, index) => (
{groupedKpiValues.map((item, index) => (
<TableRow
key={`${item.entity}_${item.page}_${index}`}
key={`${item.entity}_${item.pages.join('_')}_${index}`}
sx={{
"&:hover": { backgroundColor: "#f9f9f9" },
cursor: "pointer",
@ -290,16 +337,19 @@ function ExtractedResultsPage() {
</Box>
</TableCell>
<TableCell align="center">
<Link
component="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setCurrentPage(item.page);
}}
sx={{ cursor: "pointer" }}
>
{item.page}
</Link>
{item.pages.map((page: number, i: number) => (
<Link
key={page}
component="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handlePageClick(page, item.entity);
}}
sx={{ cursor: "pointer", ml: i > 0 ? 1 : 0 }}
>
{page}
</Link>
))}
</TableCell>
</TableRow>
))}
@ -319,6 +369,7 @@ function ExtractedResultsPage() {
}}
onClick={() => {
setSelectedIndex(-1);
setFocusHighlightOverride(null);
}}
>
<Radio
@ -335,24 +386,28 @@ function ExtractedResultsPage() {
},
}}
/>
<TextField
placeholder="Einen abweichenden Wert eingeben..."
value={customValue}
onChange={handleCustomValueChange}
variant="standard"
fullWidth
InputProps={{
disableUnderline: true,
}}
sx={{
"& .MuiInput-input": {
padding: 0,
},
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
}}
/>
<Box sx={{ width: '100%' }}>
<TextField
placeholder="Einen abweichenden Wert eingeben..."
value={customValue}
onChange={handleCustomValueChange}
variant="standard"
fullWidth
InputProps={{
disableUnderline: true,
}}
sx={{
"& .MuiInput-input": {
padding: 0,
},
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
}}
error={selectedIndex === -1 && customValue !== "" && customValue.trim() === ""}
helperText={selectedIndex === -1 && customValue !== "" && customValue.trim() === "" ? "Der Wert, der angegeben wurde, ist leer." : ""}
/>
</Box>
</Box>
</TableCell>
<TableCell align="center">
@ -397,7 +452,7 @@ function ExtractedResultsPage() {
<EditIcon
fontSize="small"
sx={{
color: "#666",
color: "black",
opacity: 0.7,
transition: "opacity 0.2s ease",
ml: 1
@ -440,20 +495,17 @@ function ExtractedResultsPage() {
pitchBookId={pitchBook}
currentPage={currentPage}
onPageChange={setCurrentPage}
highlight={Object.values(kpiValues)
.flat()
.map((k) => ({ page: k.page, text: k.entity }))}
focusHighlight={{
page: kpiValues.at(selectedIndex)?.page || -1,
text: kpiValues.at(selectedIndex)?.entity || "",
}}
highlight={groupedKpiValues
.map((k) => k.pages.map((page: number) => ({ page, text: k.entity })))
.reduce((acc, val) => acc.concat(val), [])}
focusHighlight={focusHighlight}
/>
</Paper>
<Box mt={2} display="flex" justifyContent="flex-end" gap={2}>
<Button
variant="contained"
onClick={handleAcceptReview}
disabled={!selectedValue}
disabled={isSelectedValueEmpty}
sx={{
backgroundColor: "#383838",
"&:hover": { backgroundColor: "#2e2e2e" },

View File

@ -4,4 +4,7 @@ import { API_HOST } from "./util/api";
// "undefined" means the URL will be computed from the `window.location` object
// const URL = process.env.NODE_ENV === 'production' ? undefined : 'http://localhost:4000';
export const socket = io(`${API_HOST}`);
const url = new URL(API_HOST);
export const socket = io(`${url.host}`, {
path: `${url.pathname.replace(/^\/+/, "")}/socket.io`,
});

View File

@ -1,13 +1,17 @@
export interface Kennzahl {
id: number;
name: string;
description: string;
mandatory: boolean;
type: string;
translation: string;
example: string;
position: number;
active: boolean;
exampleText?: string;
markedValue?: string;
examples?: {
sentence: string;
value: string;
}[];
is_trained?: boolean;
}
export const typeDisplayMapping: Record<string, string> = {

View File

@ -0,0 +1,11 @@
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const hours = String(date.getHours() + 2).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0"); // Months are zero-based
const day = String(date.getDate()).padStart(2, "0");
const year = date.getFullYear();
return `${hours}:${minutes} ${day}.${month}.${year}`;
};

View File

@ -5,6 +5,9 @@ import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), viteReact()],
build: {
chunkSizeWarningLimit: 1000, // default ist 500
},
test: {
globals: true,
environment: "jsdom",