From 86aa689fe5541f0dfd5a44b98de6b3a5673ec0f4 Mon Sep 17 00:00:00 2001 From: Daehyun-Bigbread Date: Mon, 3 Mar 2025 16:42:31 +0900 Subject: [PATCH] TRI-172 api router changed flask -> fastapi. --- app/__init__.py | 30 ---- app/router/recommendation_api.py | 100 ++++++++++++ app/router/recommendation_endpoint.py | 120 --------------- app/schema/recommendation_schema.py | 8 +- app/services/model_trainer/train_model.py | 10 ++ catboost_info/catboost_training.json | 103 +++++++------ catboost_info/learn/events.out.tfevents | Bin 2398 -> 2398 bytes catboost_info/learn_error.tsv | 98 ++++++------ catboost_info/time_left.tsv | 96 ++++++------ logging_config.json | 144 ++++++++++-------- main.py | 134 +++++++++++----- requirements.txt | 5 +- .../recommendation_toby_1740987614.json | 110 +++++++++++++ 13 files changed, 547 insertions(+), 411 deletions(-) create mode 100644 app/router/recommendation_api.py delete mode 100644 app/router/recommendation_endpoint.py create mode 100644 storage/output_feedback_json/recommendation_toby_1740987614.json diff --git a/app/__init__.py b/app/__init__.py index a627129..e69de29 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,30 +0,0 @@ -# app/__init__.py -from flask import Flask -from flask_restx import Api -from app.router.recommendation_endpoint import api as recommendation_ns - -def create_app(): - app = Flask(__name__) - - # 앱 설정, 예: config.py에서 설정값 로드 (필요시) - # app.config.from_object('app.config') - - # Swagger UI는 기본적으로 /swagger에서 제공 (doc 인자를 조정 가능) - api = Api( - app, - version="1.0", - title="Recommendation API", - description="API for restaurant recommendation", - doc="/swagger" # Swagger UI 경로 - ) - - # 추천 API 네임스페이스 등록 - api.add_namespace(recommendation_ns, path="/recommend") - - # 전역 예외 핸들러 (선택 사항) - @app.errorhandler(Exception) - def handle_exception(e): - app.logger.error(f"Unhandled Exception: {e}", exc_info=True) - return {"error": "Internal Server Error", "message": str(e)}, 500 - - return app diff --git a/app/router/recommendation_api.py b/app/router/recommendation_api.py new file mode 100644 index 0000000..0258cf4 --- /dev/null +++ b/app/router/recommendation_api.py @@ -0,0 +1,100 @@ +import os +import time +import json +import logging +from fastapi import APIRouter, HTTPException +from app.config import UPLOAD_DIR, FEEDBACK_DIR +from app.schema.recommendation_schema import UserData, RecommendationItem, CATEGORY_MAPPING +from app.services.preprocess.data_loader import load_and_merge_json_files +from app.services.preprocess.preprocessor import preprocess_data +from app.services.model_trainer import train_model +from app.services.model_trainer.recommendation import generate_recommendations +from typing import List, Dict, Any + +# 라우터 설정 +logger = logging.getLogger("recommendation_api") +router = APIRouter() + +# 글로벌 변수 초기화 +globals_dict = {} + +# 초기 데이터 로딩 및 모델 학습 +def initialize_model(): + global globals_dict + json_directory = str(UPLOAD_DIR) + + try: + df_raw = load_and_merge_json_files(json_directory) + df_final = preprocess_data(df_raw) # 병합된 DataFrame 전달 + globals_dict = train_model(df_final) + logger.info("모델 초기화 성공") + except Exception as e: + logger.error(f"Error during initialization: {e}", exc_info=True) + globals_dict = {} + +# 서버 시작 시 모델 초기화 +initialize_model() + +@router.post("", + response_model=Dict[str, Any], # 구체적인 응답 모델이 필요하다면 별도로 정의 + responses={ + 200: {"description": "Success"}, + 400: {"description": "Bad Request"}, + 500: {"description": "Internal Server Error"} + }) + +async def recommend(user_data: UserData): + """사용자 데이터를 받아 추천 결과를 생성하고, 결과를 파일로 저장합니다.""" + try: + user_id = user_data.user_id + preferred_categories = user_data.preferred_categories + + if not user_id or not preferred_categories: + raise HTTPException(status_code=400, detail="user_id와 preferred_categories를 입력해주세요.") + + # 선호 카테고리 이름을 해당 번호로 변환 + preferred_ids = [CATEGORY_MAPPING.get(cat) for cat in preferred_categories if cat in CATEGORY_MAPPING] + + if not preferred_ids: + raise HTTPException(status_code=400, detail="유효한 선호 카테고리를 입력해주세요.") + + df_model = globals_dict.get("df_model") + if df_model is None: + raise HTTPException(status_code=500, detail="모델 데이터가 초기화되지 않았습니다.") + + filtered_df = df_model[df_model["category_id"].isin(preferred_ids)].copy() + if filtered_df.empty: + raise HTTPException(status_code=400, detail="해당 선호 카테고리에 해당하는 식당 데이터가 없습니다.") + + # 추천 결과 생성 (generate_recommendations는 JSON 문자열을 반환) + result_json = generate_recommendations( + filtered_df, + globals_dict["stacking_reg"], + globals_dict["model_features"], + user_id, + globals_dict["scaler"] + ) + + # 추천 결과를 FEEDBACK_DIR에 파일로 저장 + timestamp = int(time.time()) + feedback_filename = f"recommendation_{user_id}_{timestamp}.json" + feedback_filepath = os.path.join(str(FEEDBACK_DIR), feedback_filename) + + try: + os.makedirs(str(FEEDBACK_DIR), exist_ok=True) + with open(feedback_filepath, "w", encoding="utf-8") as f: + f.write(result_json) + logger.info(f"추천 결과가 {feedback_filepath}에 저장되었습니다.") + except Exception as file_err: + logger.error(f"추천 결과 저장 실패: {file_err}", exc_info=True) + + # JSON 문자열을 Python 객체로 변환하여 반환 + result_data = json.loads(result_json) + return result_data + + except HTTPException: + # 이미 생성된 HTTPException은 그대로 다시 발생시킴 + raise + except Exception as e: + logger.error(f"Error in recommendation endpoint: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/app/router/recommendation_endpoint.py b/app/router/recommendation_endpoint.py deleted file mode 100644 index 59f8e5b..0000000 --- a/app/router/recommendation_endpoint.py +++ /dev/null @@ -1,120 +0,0 @@ - -from flask_restx import Namespace, Resource, fields, abort -from flask import request, jsonify, Response -import json -from app.config import UPLOAD_DIR -from app.services.preprocess.data_loader import load_and_merge_json_files -from app.services.preprocess.preprocessor import preprocess_data -from app.services.model_trainer import train_model -from app.services.model_trainer.recommendation import generate_recommendations -import logging - -# 추천 결과를 FEEDBACK_DIR에 파일로 저장하는 코드 추가 -import os, time -from app.config import FEEDBACK_DIR - -# 라우터 설정 -logger = logging.getLogger(__name__) - -api = Namespace('recommendation', description="상위 15개 식당 추천 API") - -# 요청 모델 정의 (Swagger 문서용) -user_data_model = api.model('UserData', { - 'user_id': fields.String(required=True, description="사용자 ID"), - 'preferred_categories': fields.List(fields.String, required=True, min_items=1, max_items=3, description="선호 카테고리 목록") -}) - -# 응답 모델 정의 (참고용) -recommendation_item = api.model('RecommendationItem', { - 'restaurant_id': fields.Integer(description="식당 고유 식별자"), - 'category_id': fields.Integer(description="카테고리 ID"), - 'score': fields.Float(description="원래 평점"), - 'predicted_score': fields.Float(description="예측 평점"), - 'composite_score': fields.Float(description="최종 추천 점수") -}) -response_model = api.model('RecommendationResponse', { - 'user_id': fields.String(description="사용자 ID"), - 'recommendations': fields.List(fields.Nested(recommendation_item)) -}) - -json_directory = str(UPLOAD_DIR) - -try: - df_raw = load_and_merge_json_files(json_directory) - df_final = preprocess_data(df_raw) # 병합된 DataFrame 전달 - globals_dict = train_model(df_final) -except Exception as e: - logger.error(f"Error during initialization: {e}", exc_info=True) - # 초기화 실패 시 적절히 처리 (예: 프로그램 종료 등) - globals_dict = {} - -@api.route('', methods=['POST']) -class RecommendationResource(Resource): - @api.expect(user_data_model) - @api.response(200, 'Success', response_model) - @api.response(400, 'Bad Request') - @api.response(500, 'Internal Server Error') - def post(self): - """사용자 데이터를 받아 추천 결과를 생성하고, 결과를 파일로 저장합니다.""" - try: - - req_data = request.get_json() - user_id = req_data.get("user_id") - preferred_categories = req_data.get("preferred_categories") - - if not user_id or not preferred_categories: - abort(400, "user_id와 preferred_categories를 입력해주세요.") - - # 사용자 선호 카테고리를 기반으로 df_model 필터링 - category_mapping = { - "중식": 1, - "일식집": 2, - "브런치카페": 3, - "파스타": 4, - "이탈리안": 5, - "이자카야": 6, - "한식집": 7, - "치킨": 8, - "스테이크": 9, - "고깃집": 10, - "다이닝바": 11, - "오마카세": 12 - } - - # 선호 카테고리 이름을 해당 번호로 변환 - preferred_ids = [category_mapping.get(cat) for cat in preferred_categories if cat in category_mapping] - - df_model = globals_dict.get("df_model") - if df_model is None: - abort(500, "모델 데이터가 초기화되지 않았습니다.") - - filtered_df = df_model[df_model["category_id"].isin(preferred_ids)].copy() - if filtered_df.empty: - abort(400, "해당 선호 카테고리에 해당하는 식당 데이터가 없습니다.") - - # 추천 결과 생성 (generate_recommendations는 JSON 문자열을 반환) - result_json = generate_recommendations( - filtered_df, - globals_dict["stacking_reg"], - globals_dict["model_features"], - user_id, - globals_dict["scaler"] - ) - - # 추천 결과를 FEEDBACK_DIR에 파일로 저장 - timestamp = int(time.time()) - feedback_filename = f"recommendation_{user_id}_{timestamp}.json" - feedback_filepath = os.path.join(str(FEEDBACK_DIR), feedback_filename) - - try: - with open(feedback_filepath, "w", encoding="utf-8") as f: - f.write(result_json) - logger.info(f"추천 결과가 {feedback_filepath}에 저장되었습니다.") - except Exception as file_err: - logger.error(f"추천 결과 저장 실패: {file_err}", exc_info=True) - - return Response(result_json, mimetype='application/json') - - except Exception as e: - logger.error(f"Error in recommendation endpoint: {e}", exc_info=True) - abort(500, str(e)) \ No newline at end of file diff --git a/app/schema/recommendation_schema.py b/app/schema/recommendation_schema.py index c16f542..4983d21 100644 --- a/app/schema/recommendation_schema.py +++ b/app/schema/recommendation_schema.py @@ -1,7 +1,7 @@ # app/models/recommendation_schema.py -from pydantic import BaseModel, conlist -from typing import List +from pydantic import BaseModel, Field +from typing import List, Annotated class RecommendationItem(BaseModel): category_id: int @@ -28,5 +28,5 @@ class RecommendationItem(BaseModel): class UserData(BaseModel): user_id: str - # 선호 카테고리는 최소 1개, 최대 3개 - preferred_categories: conlist(str, min_items=1, max_items=3) + # 선호 카테고리는 최소 1개, 최대 3개 (Pydantic v2 방식) + preferred_categories: Annotated[List[str], Field(min_length=1, max_length=3)] diff --git a/app/services/model_trainer/train_model.py b/app/services/model_trainer/train_model.py index f022d4e..e8601ae 100644 --- a/app/services/model_trainer/train_model.py +++ b/app/services/model_trainer/train_model.py @@ -5,6 +5,16 @@ from .model_evaluation import evaluate_model import numpy as np import logging +import warnings +import atexit +import shutil +import os +import tempfile +from joblib import parallel_backend + +# joblib 경고 필터링 +warnings.filterwarnings("ignore", message="resource_tracker") +warnings.filterwarnings("ignore", message="There appear to be") logger = logging.getLogger(__name__) diff --git a/catboost_info/catboost_training.json b/catboost_info/catboost_training.json index dfeaf9e..a2ab02e 100644 --- a/catboost_info/catboost_training.json +++ b/catboost_info/catboost_training.json @@ -1,54 +1,57 @@ { "meta":{"test_sets":[],"test_metrics":[],"learn_metrics":[{"best_value":"Min","name":"RMSE"}],"launch_mode":"Train","parameters":"","iteration_count":50,"learn_sets":["learn"],"name":"experiment"}, "iterations":[ -{"learn":[1.041660961],"iteration":0,"passed_time":0.000387125,"remaining_time":0.018969125}, -{"learn":[1.03649279],"iteration":1,"passed_time":0.0007355416667,"remaining_time":0.017653}, -{"learn":[1.031012619],"iteration":2,"passed_time":0.00106775,"remaining_time":0.01672808333}, -{"learn":[1.026074549],"iteration":3,"passed_time":0.001428166667,"remaining_time":0.01642391667}, -{"learn":[1.022593925],"iteration":4,"passed_time":0.001692333333,"remaining_time":0.015231}, -{"learn":[1.019823421],"iteration":5,"passed_time":0.002072916667,"remaining_time":0.01520138889}, -{"learn":[1.016656153],"iteration":6,"passed_time":0.002374,"remaining_time":0.01458314286}, -{"learn":[1.014398135],"iteration":7,"passed_time":0.002720083333,"remaining_time":0.0142804375}, -{"learn":[1.011919826],"iteration":8,"passed_time":0.003048708333,"remaining_time":0.01388856019}, -{"learn":[1.009753542],"iteration":9,"passed_time":0.003359833333,"remaining_time":0.01343933333}, -{"learn":[1.007913933],"iteration":10,"passed_time":0.003663083333,"remaining_time":0.01298729545}, -{"learn":[1.006154154],"iteration":11,"passed_time":0.00402775,"remaining_time":0.01275454167}, -{"learn":[1.004688765],"iteration":12,"passed_time":0.004396083333,"remaining_time":0.01251192949}, -{"learn":[1.003690828],"iteration":13,"passed_time":0.004655958333,"remaining_time":0.01197246429}, -{"learn":[1.002848742],"iteration":14,"passed_time":0.004935083333,"remaining_time":0.01151519444}, -{"learn":[1.001808465],"iteration":15,"passed_time":0.005217208333,"remaining_time":0.01108656771}, -{"learn":[1.000337879],"iteration":16,"passed_time":0.00569825,"remaining_time":0.01106130882}, -{"learn":[0.9992846751],"iteration":17,"passed_time":0.006031,"remaining_time":0.01072177778}, -{"learn":[0.9980431336],"iteration":18,"passed_time":0.006510583333,"remaining_time":0.0106225307}, -{"learn":[0.9975941019],"iteration":19,"passed_time":0.006822583333,"remaining_time":0.010233875}, -{"learn":[0.9965668154],"iteration":20,"passed_time":0.007110708333,"remaining_time":0.009819549603}, -{"learn":[0.9959085662],"iteration":21,"passed_time":0.007417708333,"remaining_time":0.009440719697}, -{"learn":[0.9952216697],"iteration":22,"passed_time":0.007755666667,"remaining_time":0.009104478261}, -{"learn":[0.9935697235],"iteration":23,"passed_time":0.008235333333,"remaining_time":0.008921611111}, -{"learn":[0.9929913454],"iteration":24,"passed_time":0.008553458333,"remaining_time":0.008553458333}, -{"learn":[0.9920865001],"iteration":25,"passed_time":0.008893541667,"remaining_time":0.008209423077}, -{"learn":[0.9915112501],"iteration":26,"passed_time":0.009263875,"remaining_time":0.007891449074}, -{"learn":[0.9908434903],"iteration":27,"passed_time":0.009654083333,"remaining_time":0.00758535119}, -{"learn":[0.9901493036],"iteration":28,"passed_time":0.01008608333,"remaining_time":0.007303715517}, -{"learn":[0.9895396013],"iteration":29,"passed_time":0.01038954167,"remaining_time":0.006926361111}, -{"learn":[0.988461672],"iteration":30,"passed_time":0.01073883333,"remaining_time":0.006581865591}, -{"learn":[0.9864972226],"iteration":31,"passed_time":0.01105220833,"remaining_time":0.006216867187}, -{"learn":[0.9860557219],"iteration":32,"passed_time":0.01140370833,"remaining_time":0.005874637626}, -{"learn":[0.9849702579],"iteration":33,"passed_time":0.01171516667,"remaining_time":0.005513019608}, -{"learn":[0.9842232531],"iteration":34,"passed_time":0.01201945833,"remaining_time":0.005151196429}, -{"learn":[0.9834667705],"iteration":35,"passed_time":0.012320125,"remaining_time":0.004791159722}, -{"learn":[0.9830739831],"iteration":36,"passed_time":0.012686375,"remaining_time":0.004457375}, -{"learn":[0.9827276656],"iteration":37,"passed_time":0.01300920833,"remaining_time":0.004108171053}, -{"learn":[0.982392468],"iteration":38,"passed_time":0.01331341667,"remaining_time":0.003755066239}, -{"learn":[0.9816912276],"iteration":39,"passed_time":0.01370358333,"remaining_time":0.003425895833}, -{"learn":[0.9813869019],"iteration":40,"passed_time":0.014166625,"remaining_time":0.003109746951}, -{"learn":[0.9806031813],"iteration":41,"passed_time":0.01451766667,"remaining_time":0.002765269841}, -{"learn":[0.9797940078],"iteration":42,"passed_time":0.014996875,"remaining_time":0.002441351744}, -{"learn":[0.9787968043],"iteration":43,"passed_time":0.01526708333,"remaining_time":0.002081875}, -{"learn":[0.9785656214],"iteration":44,"passed_time":0.01554870833,"remaining_time":0.001727634259}, -{"learn":[0.9777627847],"iteration":45,"passed_time":0.01580079167,"remaining_time":0.001373981884}, -{"learn":[0.9772527321],"iteration":46,"passed_time":0.01614045833,"remaining_time":0.001030242021}, -{"learn":[0.9768006779],"iteration":47,"passed_time":0.016488625,"remaining_time":0.0006870260417}, -{"learn":[0.9760519369],"iteration":48,"passed_time":0.01681666667,"remaining_time":0.0003431972789}, -{"learn":[0.9757471956],"iteration":49,"passed_time":0.01774025,"remaining_time":0} +{"learn":[1.017646642],"iteration":0,"passed_time":0.000633679318,"remaining_time":0.03105028658}, +{"learn":[1.011603734],"iteration":1,"passed_time":0.001250399964,"remaining_time":0.03000959914}, +{"learn":[1.005283192],"iteration":2,"passed_time":0.001700450616,"remaining_time":0.02664039298}, +{"learn":[0.9996215147],"iteration":3,"passed_time":0.002098375227,"remaining_time":0.02413131511}, +{"learn":[0.9948680365],"iteration":4,"passed_time":0.002394172799,"remaining_time":0.021547555, +{, +{"learn":[0.9904453509],"iteration":5,"passed_time":0.002707095713,"remaining_time":0.0198520, +{"l, +{"learn":[0.9865144367],"iteration":6,"passed_time":0.003245773135,"remaining_time":0.0199383, +{"learn":[0.9895817305],"iteration":7,"passed_time":0.005525068641,"remaining_time":0.02900661036}, +{"learn":[0.9862797578],"iteration":8,"passed_time":0.006072954579,"remaining_time":0.02766568197}, +{"learn":[0.9826827108],"iteration":9,"passed_time":0.006616840438,"remaining_time":0.02646736175}, +{"learn":[0.9789153618],"iteration":10,"passed_time":0.007144100964,"remaining_time":0.02532908524}, +{"learn":[0.9766187678],"iteration":11,"passed_time":0.007760196598,"remaining_time":0.02457395589}, +{"learn":[0.9736712951],"iteration":12,"passed_time":0.008301374069,"remaining_time":0.02362698774}, +{"learn":[0.9715954757],"iteration":13,"passed_time":0.008856301815,"remaining_time":0.02277334752}, +{"learn":[0.9704534733],"iteration":14,"passed_time":0.009308019167,"remaining_time":0.02171871139}, +{"learn":[0.9695653121],"iteration":15,"passed_time":0.009502939725,"remaining_time":0.02019374692}, +{"learn":[0.9683020895],"iteration":16,"passed_time":0.01000378306,"remaining_time":0.01941910829}, +{"learn":[0.9669194499],"iteration":17,"passed_time":0.01040229101,"remaining_time":0.0184929618}, +{"learn":[0.9654271114],"iteration":18,"passed_time":0.01087780051,"remaining_time":0.0177479903}, +{"learn":[0.9633307278],"iteration":19,"passed_time":0.01136547691,"remaining_time":0.01704821537}, +{"learn":[0.9618691706],"iteration":20,"passed_time":0.01203319857,"remaining_time":0.01661727422}, +{"learn":[0.961005842],"iteration":21,"passed_time":0.01276683822,"remaining_time":0.01624870319}, +{"learn":[0.9591235279],"iteration":22,"passed_time":0.01357614605,"remaining_time":0.01593721492}, +{"learn":[0.9585316553],"iteration":23,"passed_time":0.01403844694,"remaining_time":0.01520831752}, +{"learn":[0.9573148648],"iteration":24,"passed_time":0.01443616322,"remaining_time":0.01443616322}, +{"learn":[0.9560791715],"iteration":25,"passed_time":0.01533268111,"remaining_time":0.01415324411}, +{"learn":[0.955590793],"iteration":26,"passed_time":0.01583994124,"remaining_time":0.01349328328}, +{"learn":[0.9539357914],"iteration":27,"passed_time":0.01632707597,"remaining_time":0.01282841683}, +{"learn":[0.9519096132],"iteration":28,"passed_time":0.01706475736,"remaining_time":0.01235723809}, +{"learn":[0.9509147509],"iteration":29,"passed_time":0.01764839401,"remaining_time":0.01176559601}, +{"learn":[0.9496869175],"iteration":30,"passed_time":0.01824911434,"remaining_time":0.01118494105}, +{"learn":[0.9492689716],"iteration":31,"passed_time":0.01871104023,"remaining_time":0.01052496013}, +{"learn":[0.9486499926],"iteration":32,"passed_time":0.019133507,"remaining_time":0.00985665512}, +{"learn":[0.9482030018],"iteration":33,"passed_time":0.01954801527,"remaining_time":0.009199066011}, +{"learn":[0.9468544997],"iteration":34,"passed_time":0.02002510813,"remaining_time":0.008582189199}, +{"learn":[0.9459224696],"iteration":35,"passed_time":0.02056311887,"remaining_time":0.007996768451}, +{"learn":[0.9444169549],"iteration":36,"passed_time":0.0210956295,"remaining_time":0.007411977934}, +{"learn":[0.9434100444],"iteration":37,"passed_time":0.02160843141,"remaining_time":0.006823715182}, +{"learn":[0.9423569439],"iteration":38,"passed_time":0.02352021958,"remaining_time":0.006633908086}, +{"learn":[0.9419094165],"iteration":39,"passed_time":0.02373264049,"remaining_time":0.005933160121}, +{"learn":[0.9411915517],"iteration":40,"passed_time":0.02402764638,"remaining_time":0.005274361399}, +{"learn":[0.940149036],"iteration":41,"passed_time":0.02487457995,"remaining_time":0.004738015229}, +{"learn":[0.9389662544],"iteration":42,"passed_time":0.02521000331,"remaining_time":0.004103954028}, +{"learn":[0.9381826109],"iteration":43,"passed_time":0.02563388678,"remaining_time":0.003495530015}, +{"learn":[0.9374298656],"iteration":44,"passed_time":0.02603764484,"remaining_time":0.002893071649}, +{"learn":[0.9351864641],"iteration":45,"passed_time":0.026546405,"remaining_time":0.002308383043}, +{"learn":[0.9342881765],"iteration":46,"passed_time":0.02706620704,"remaining_time":0.001727630237}, +{"learn":[0.9337759418],"iteration":47,"passed_time":0.02762625989,"remaining_time":0.001151094162}, +{"learn":[0.9316415182],"iteration":48,"passed_time":0.02817864592,"remaining_time":0.0005750744065}, +{"learn":[0.9294663575],"iteration":49,"passed_time":0.0286419885,"remaining_time":0} +]}ing_time":0} ]} \ No newline at end of file diff --git a/catboost_info/learn/events.out.tfevents b/catboost_info/learn/events.out.tfevents index bae3881ebed161a363c908944feb90ecaa73eb6d..c8e4d21d9f0569f8c5f56be4c6c2e356e4301bcd 100644 GIT binary patch literal 2398 zcmZ|MTSyaN7{+lAGj%%8=bBs2wluY}j8MxGLdw=^sktCBQ#^!C%|ofx(h!A=G>A&O zuq-XKjf$vYg%=ToNEZpSv_?pFQNpT=RM4WnH=c`k-_?8ZC3@}t9ibVS%^9hG&mQ^MC8f}D-|p`HuLE4cr!}1uKGI9yzyv3dT zP=4$_e%}{-Yvw7jUuMq<;k^ueqxGclcj`usy!Qj&XC(h)rmLU#{@{%%g*R!F#olx8%&~c^?EmO-)|eH>2f!FnC)}o#>HmTyWAK0^WZMdE3## zMSedNe4~WCYw4Js_hH}_?X{w(J1;7W_u=4k3(4Dxmdw150DmQjd}6q41MefjcaPMF z9!q}y6z`+JU+X1b^3lbE_shYnQplG_hFN&O0=&U;Li8lLX%xJdgI62L&mC&}!24+M z=4kR=z7@}TuK=IzPF{EMY8CI5;H~efMZdi=y`1+e!5g2E-(3-UkoPg*{ffzdvH5-G zy$bxihWz_`wNH5;3tsoKO7xH3>@@Ix6?lCMc};!#ciyYP@7YTJ;r%iz?=|4(mE=Pf zZBG7cHTYRarReuGr)=Z* zjH3V8q9Kp>iQtcvlGpDveEE{Q8Uc>%jY)j*I@mxpXz} P*Mo0%R0ywnHF@JVG;K!X literal 2398 zcmZ|QTSya77zc1|t8~`pb$M%Qd1JGKG$RnC9L zSM?LN_2C{eH<>hDt4s0<(H~D({dbHrONIi4R!>;c{%co9@I6h(grDq9y~+DI;Nz#u zg_pEd#q(YQK5rX&i*c}p_fqg#BS*#l^pwne-p>Vp_9XeDyv{h@&ja5bMBbpdr|12A z@I9}Oh&<;oy1k(bU?Yk9vA{MTagZ(2_D^Ii@bBXUs}}Y%zHoZ