diff --git a/app.py b/app.py index 6a0f4c1..1623c19 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,10 @@ +import logging + from flask import Flask -from dto.requests import GameStartRequestDTO -from services.julius_baer_api_client import JuliusBaerApiClient +import config +from dto.requests import GameStartRequestDTO, GameDecisionRequestDTO +from services.player import Player app = Flask(__name__) @@ -12,9 +15,14 @@ def hello_world(): # put application's code here if __name__ == '__main__': - jb_client = JuliusBaerApiClient() - game_start_request = GameStartRequestDTO(player_name="Welch") - res = jb_client.start_game(game_start_request) - print(res) + + player = Player() + player.play_on_separate_thread() app.run() + + # res.session_id + # UUID('fde19363-a3d5-432e-8b87-54a6dd54f0dd') + # second test UUID('e3d58302-400a-4bc6-9772-ae50de43c9f4') + # UUID('f8b2a0a6-d4e0-45e6-900f-8ecb3c28f993') + # UUID('f8b2a0a6-d4e0-45e6-900f-8ecb3c28f993') \ No newline at end of file diff --git a/dto/client_data.py b/dto/client_data.py deleted file mode 100644 index e47274f..0000000 --- a/dto/client_data.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel - -class ClientData(BaseModel): - """ - Model for the client data attributes which need to be validated and compared for correspondence between - the data sources () - """ - name: str - - # TODO CONTINUE \ No newline at end of file diff --git a/services/julius_baer_api_client.py b/services/julius_baer_api_client.py index b19db6e..d8b5f0d 100644 --- a/services/julius_baer_api_client.py +++ b/services/julius_baer_api_client.py @@ -1,7 +1,6 @@ import requests import config import logging -from typing import Dict, Any from dto.requests import GameStartRequestDTO, GameDecisionRequestDTO from dto.responses import GameStartResponseDTO, GameDecisionResponseDTO @@ -14,8 +13,6 @@ class JuliusBaerApiClient: """ def __init__(self): - self.client_id = None - self.session_id = None self.api_uri = config.API_URI self.api_key = config.API_KEY self.api_team = config.API_TEAM @@ -33,63 +30,40 @@ class JuliusBaerApiClient: Start a new game session. """ logging.info("[+] Starting new game session") - start_url = f"{self.api_uri}/game/start" - payload = game_start_request.model_dump() # Convert GameStartRequestDTO to dict for JSON + start_uri = f"{self.api_uri}/game/start" + payload = game_start_request.model_dump_json() try: - response = requests.post(start_url, json=payload, headers=self.headers) - response.raise_for_status() # Raise exception for HTTP errors + response = requests.post(start_uri, data=payload, headers=self.headers) + response.raise_for_status() - response_data = response.json() - validated_response = GameStartResponseDTO.model_validate(response_data) + response_json = response.json() + validated_response = GameStartResponseDTO.model_validate(response_json) logging.info(f"Game started successfully. Session: {validated_response.session_id}, Client: {validated_response.client_id}") - - # Store session_id and client_id for future calls - self.session_id = validated_response.session_id - self.client_id = validated_response.client_id - return validated_response - except Exception as e: logging.error(f"[!] Failed to start game session: {e}") raise - def make_decision(self, game_decision_request: GameDecisionRequestDTO) -> GameDecisionResponseDTO: + def send_decision(self, game_decision_request: GameDecisionRequestDTO) -> GameDecisionResponseDTO: """ Make a game decision (Accept or Reject). - - Args: - decision: Either "Accept" or "Reject". - session_id: Unique session ID for the game. If None, uses the stored session_id. - client_id: Unique client ID for the game. If None, uses the stored client_id. - - Returns: - Dict containing the game decision response with status, score, etc. - - Raises: - ValueError: If decision is not "Accept" or "Reject". - ValueError: If session_id and client_id are not provided or stored from a previous start_game call. """ - if game_decision_request.decision not in ["Accept", "Reject"]: - raise ValueError('Decision must be either "Accept" or "Reject"') + logging.info("[+] Sending decision") + decision_uri = f"{self.api_uri}/game/decision" - # Use stored values if not provided - session_id = game_decision_request.session_id or self.session_id - client_id = game_decision_request.client_id or self.client_id + payload = game_decision_request.model_dump_json() - if not session_id or not client_id: - raise ValueError( - "Session ID and Client ID are required. Either provide them explicitly or call start_game first.") + try: + response = requests.post(decision_uri, data=payload, headers=self.headers) + response.raise_for_status() - url = f"{self.base_url}/game/decision" - payload = { - "decision": game_decision_request.decision, - "session_id": session_id, - "client_id": client_id - } + response_json = response.json() + validated_response = GameDecisionResponseDTO.model_validate(response_json) + logging.info("[+] Decision sent successfully") - response = requests.post(url, json=payload) - response.raise_for_status() # Raise exception for HTTP errors - - return response.json() + return validated_response + except Exception as e: + logging.error(f"[!] Failed to send a decision: {e}") + raise diff --git a/services/player.py b/services/player.py new file mode 100644 index 0000000..3cae73f --- /dev/null +++ b/services/player.py @@ -0,0 +1,66 @@ +import logging +import threading +import time +from typing import Literal, Dict, Any +import config +from dto.requests import GameStartRequestDTO, GameDecisionRequestDTO +from services.julius_baer_api_client import JuliusBaerApiClient + + +class Player: + + def __init__(self): + self.client = JuliusBaerApiClient() + self._thread = None + + def start(self): + self.play() + + def play_on_separate_thread(self): + if self._thread and self._thread.is_alive(): + logging.warning('Game loop already running.') + return self._thread + + self._thread = threading.Thread(target=self.play, daemon=True) + self._thread.start() + return self._thread + + def play(self): + print('playing') + payload = GameStartRequestDTO(player_name=config.API_TEAM) + start_response = self.client.start_game(payload) + logging.info(start_response) + + status = '' + decision = self.make_decision(start_response.client_data) + while status not in ['gameover', 'complete']: + + payload = GameDecisionRequestDTO( + decision=decision, + session_id=start_response.session_id, + client_id=start_response.client_id, + ) + + decision_response = self.client.send_decision(payload) + print(decision_response.status, decision_response.score) + status = decision_response.status + decision = self.make_decision(decision_response.client_data) + time.sleep(1.5) + + def make_decision(self, client_data: Dict[str, Any]) -> Literal["Accept", "Reject"]: + # Do your magic! + + return 'Accept' + + # import random + # return random.choice(["Accept", "Reject"]) + + +if __name__ == '__main__': + player = Player() + player.start() + + + + + diff --git a/tests/test_cross_validate.py b/tests/test_cross_validate.py new file mode 100644 index 0000000..9457c6e --- /dev/null +++ b/tests/test_cross_validate.py @@ -0,0 +1,19 @@ +from validation import FromAccount + + +account_data = FromAccount( + account_name="Astrid Janneke Willems", + account_holder_name="Astrid Janneke", + account_holder_surname="Willems", + passport_number="HW8642009", + reference_currency="EUR", + other_currency=None, + building_number="18", + street_name="Lijnbaan", + postal_code="7523 05", + city="Assen", + country="Netherlands", + name="Astrid Janneke Willems", + phone_number="+31 06 34579996", + email="astrid.willems@upcmail.nl" +) \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/validation/FromAccount.py b/validation/FromAccount.py new file mode 100644 index 0000000..91307c8 --- /dev/null +++ b/validation/FromAccount.py @@ -0,0 +1,36 @@ +from typing import Literal, Optional, Self +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator + + +class FromAccount(BaseModel): + """ + Fields which can be extracted from account.pdf + """ + model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) + + # From account.pdf + account_name: str = Field(..., min_length=1) + account_holder_name: str = Field(..., min_length=1) + account_holder_surname: str = Field(..., min_length=1) + + @model_validator(mode='after') + def check_account_name_is_name_surname(self) -> Self: + combined = f"{self.account_holder_name} {self.account_holder_surname}" + if combined != self.account_name: + raise ValueError(f'Account name is not name + surname: {self.account_name} != {combined}') + return self + + passport_number: str = Field(..., min_length=5) + + reference_currency: Literal["CHF", "EUR", "USD", "Other"] + other_currency: Optional[str] = None + + building_number: str = Field(..., min_length=1) + street_name: str = Field(..., min_length=1) + postal_code: str = Field(..., min_length=1) + city: str = Field(..., min_length=1) + country: str = Field(..., min_length=1) + + name: str = Field(..., min_length=1) + phone_number: str = Field(..., min_length=6) + email: EmailStr \ No newline at end of file diff --git a/validation/FromDescription.py b/validation/FromDescription.py new file mode 100644 index 0000000..e19420e --- /dev/null +++ b/validation/FromDescription.py @@ -0,0 +1,35 @@ +from typing import Literal, Optional +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class FromDescription(BaseModel): + """ + Fields which can be extracted from description.txt + """ + model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) + + + full_name: str = Field(..., min_length=1) + age: int = Field(..., ge=0, le=120) + nationality: str = Field(..., min_length=1) + + marital_status: Literal["single", "married", "divorced", "widowed"] + has_children: bool + + secondary_education_school: str + secondary_education_year: int = Field(..., ge=1900, le=2100) + university_name: str + university_graduation_year: int = Field(..., ge=1900, le=2100) + + occupation_title: str + employer: str + start_year: int = Field(..., ge=1900, le=2100) + annual_salary_eur: float = Field(..., ge=0) + + total_savings_eur: float = Field(..., ge=0) + has_properties: bool + + inheritance_amount_eur: float = Field(..., ge=0) + inheritance_year: int = Field(..., ge=1900, le=2100) + inheritance_source: str + diff --git a/validation/FromPassport.py b/validation/FromPassport.py new file mode 100644 index 0000000..773d135 --- /dev/null +++ b/validation/FromPassport.py @@ -0,0 +1,27 @@ +from datetime import date +from typing import Literal +from pydantic import BaseModel, ConfigDict, Field + + +class FromPassport(BaseModel): + """ + Fields which can be extracted from description.txt + """ + model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) + + country: str = Field(..., min_length=3, max_length=3) # ISO 3166-1 alpha-3 + passport_number: str = Field(..., min_length=9, max_length=9, regex=r"^[A-Z0-9]{9}$") + + surname: str = Field(..., min_length=1) + given_names: str = Field(..., min_length=1) + + birth_date: date + citizenship: str = Field(..., min_length=2) + sex: Literal["M", "F"] + + issue_date: date + expiry_date: date + + signature_present: bool + + machine_readable_zone: str = Field(..., min_length=44) \ No newline at end of file diff --git a/validation/FromProfile.py b/validation/FromProfile.py new file mode 100644 index 0000000..64abbee --- /dev/null +++ b/validation/FromProfile.py @@ -0,0 +1,65 @@ +from datetime import date +from typing import List, Literal, Optional +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class FromProfile(BaseModel): + """ + Fields which can be extracted from description.txt + """ + model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) + + first_name: str = Field(..., min_length=1) + last_name: str = Field(..., min_length=1) + date_of_birth: date + nationality: str + country_of_domicile: str + gender: Literal["Female", "Male"] + + # ID information + passport_number: str = Field(..., min_length=9, max_length=9, regex=r"^[A-Z0-9]{9}$") + id_type: Literal["passport"] + id_issue_date: date + id_expiry_date: date + + # Contact + phone: str = Field(..., min_length=8) + email: EmailStr + address: str + + # Personal info + politically_exposed_person: bool + marital_status: Literal["Single", "Married", "Divorced", "Widowed"] + highest_education: Literal["Tertiary", "Secondary", "Primary", "None"] + education_history: Optional[str] = None + + # Employment + employment_status: Literal["Employee", "Self-Employed", "Unemployed", "Retired", "Student", "Diplomat", "Military", "Homemaker", "Other"] + employment_since: Optional[int] = None + employer: Optional[str] = None + position: Optional[str] = None + annual_salary_eur: Optional[float] = None + + # Wealth background + total_wealth_range: Literal["<1.5m", "1.5m-5m", "5m-10m", "10m-20m", "20m-50m", ">50m"] + origin_of_wealth: List[Literal["Employment", "Inheritance", "Business", "Investments", "Sale of real estate", "Retirement package", "Other"]] + inheritance_details: Optional[str] = None + + # Assets + business_assets_eur: float = Field(..., ge=0) + + # Income + estimated_annual_income: Literal["<250k", "250k-500k", "500k-1m", ">1m"] + income_country: str + + # Account preferences + commercial_account: bool + investment_risk_profile: Literal["Low", "Moderate", "Considerable", "High"] + mandate_type: Literal["Advisory", "Discretionary"] + investment_experience: Literal["Inexperienced", "Experienced", "Expert"] + investment_horizon: Literal["Short", "Medium", "Long-Term"] + preferred_markets: List[str] + + # Assets under management + total_aum: float + aum_to_transfer: float \ No newline at end of file diff --git a/validation/__init__.py b/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/validation/cross_validate.py b/validation/cross_validate.py new file mode 100644 index 0000000..928c460 --- /dev/null +++ b/validation/cross_validate.py @@ -0,0 +1,60 @@ +from enum import StrEnum +from typing import Any, Callable, Optional +from pydantic import BaseModel + +from validation import FromAccount, FromDescription, FromPassport, FromProfile + + +class ExtractedData(BaseModel): + account: FromAccount + description: FromDescription + passport: FromPassport + profile: FromProfile + + +class DocType(StrEnum): + account = "account" + description = "description" + passport = "passport" + profile = "profile" + + +class XValFailure(BaseModel): + doc1_type: DocType + doc1_val: str + + doc2_type: DocType + doc2_val: str + + +def xval_name_account_description(data: ExtractedData) -> Optional[XValFailure]: + if data.account.account_holder_name != data.description.full_name: + return XValFailure( + doc1_type=DocType.account, + doc1_val=f"{data.account.account_holder_name=}", + doc2_type=DocType.description, + doc2_val=f"{data.description.full_name=}", + ) + + +def xval_email_account_profile(data: ExtractedData) -> Optional[XValFailure]: + if data.account.email != data.profile.email: + return XValFailure( + doc1_type=DocType.account, + doc1_val=f"{data.account.email=}", + doc2_type=DocType.profile, + doc2_val=f"{data.profile.email=}" + ) + + +def xref_all(data: ExtractedData) -> list[XValFailure]: + xref_validators: list[Callable[[ExtractedData], Optional[XValFailure]]] = [ + xval_name_account_description + ] + + validation_failures = [] + for validator in xref_validators: + failure = validator(data) + if not failure is None: + validation_failures.append(failure) + return validation_failures