From 67d77167b558cb845c1e76b19c60e5d338a7738f Mon Sep 17 00:00:00 2001 From: Yash Rastogi Date: Sat, 26 Sep 2026 08:28:02 +0000 Subject: [PATCH 1/3] feat: implement user profiles for form fillers (#614) - Add Profile SQLModel and schemas - Create CRUD endpoints (POST, GET, PATCH) - Link profile_id to Input schemas - Inject user context into LLM prompt builder - Add automated test suite for endpoints and LLM builder --- app/api/router.py | 4 +- app/api/routes/profiles.py | 39 +++++++++++++++++++ app/api/schemas/input.py | 6 ++- app/api/schemas/profiles.py | 27 +++++++++++++ app/models/models.py | 14 +++++++ app/services/llm.py | 52 +++++++++++++++++-------- tests/test_v1_profiles.py | 77 +++++++++++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 app/api/routes/profiles.py create mode 100644 app/api/schemas/profiles.py create mode 100644 tests/test_v1_profiles.py diff --git a/app/api/router.py b/app/api/router.py index cc59847a..4b4c7c01 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,6 +2,7 @@ from app.api.routes import forms, input, jobs, system, templates, weather, zipcode from app.core.config import API_PREFIX +from app.api.routes import profiles api_router = APIRouter() api_router.include_router(templates.router, prefix=API_PREFIX) @@ -10,4 +11,5 @@ api_router.include_router(jobs.router, prefix=API_PREFIX) api_router.include_router(weather.router, prefix=API_PREFIX) api_router.include_router(zipcode.router, prefix=API_PREFIX) -api_router.include_router(input.router, prefix=API_PREFIX) \ No newline at end of file +api_router.include_router(input.router, prefix=API_PREFIX) +api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"]) \ No newline at end of file diff --git a/app/api/routes/profiles.py b/app/api/routes/profiles.py new file mode 100644 index 00000000..a4bcc549 --- /dev/null +++ b/app/api/routes/profiles.py @@ -0,0 +1,39 @@ +from sqlmodel import select +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from uuid import UUID + +from app.api.deps import get_db +from app.models.models import Profile +from app.api.schemas.profiles import ProfileCreate, ProfileUpdate, ProfileResponse + +router = APIRouter() + +@router.post("/", response_model=ProfileResponse, status_code=201) +def create_profile(profile: ProfileCreate, db: Session = Depends(get_db)): + db_profile = Profile(**profile.model_dump()) + db.add(db_profile) + db.commit() + db.refresh(db_profile) + return db_profile + +@router.get("/{profile_id}", response_model=ProfileResponse) +def get_profile(profile_id: UUID, db: Session = Depends(get_db)): + db_profile = db.exec(select(Profile).where(Profile.profile_id == profile_id)).first() + if not db_profile: + raise HTTPException(status_code=404, detail="Profile not found") + return db_profile + +@router.patch("/{profile_id}", response_model=ProfileResponse) +def update_profile(profile_id: UUID, profile_update: ProfileUpdate, db: Session = Depends(get_db)): + db_profile = db.query(Profile).filter(Profile.profile_id == profile_id).first() + if not db_profile: + raise HTTPException(status_code=404, detail="Profile not found") + + update_data = profile_update.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_profile, key, value) + + db.commit() + db.refresh(db_profile) + return db_profile \ No newline at end of file diff --git a/app/api/schemas/input.py b/app/api/schemas/input.py index 9d8b908c..1da6d03a 100644 --- a/app/api/schemas/input.py +++ b/app/api/schemas/input.py @@ -2,7 +2,7 @@ from datetime import date, datetime from uuid import UUID - +from typing import Optional from pydantic import BaseModel, Field from app.api.schemas.enums import InputStatus, InputType @@ -23,6 +23,7 @@ class TextInputRequest(BaseModel): station_id: str | None = None responder_badge: str | None = None incident_date_hint: date | None = None + profile_id: UUID | None = Field(None, description="ID of the user profile filling the form") class TextInputResponse(BaseModel): @@ -36,6 +37,7 @@ class TextInputResponse(BaseModel): class InputRecordResponse(BaseModel): input_id: UUID + profile_id: UUID | None = None input_type: InputType status: InputStatus transcript: str | None = None @@ -49,4 +51,4 @@ class InputRecordResponse(BaseModel): error_detail: str | None = None retry_after_seconds: int | None = None created_at: datetime | None = None - updated_at: datetime | None = None + updated_at: datetime | None = None \ No newline at end of file diff --git a/app/api/schemas/profiles.py b/app/api/schemas/profiles.py new file mode 100644 index 00000000..17c55ef4 --- /dev/null +++ b/app/api/schemas/profiles.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, Field +from uuid import UUID +from typing import Optional, Dict, Any +from datetime import datetime + +class ProfileBase(BaseModel): + name: str = Field(..., description="Name of the person filling the form") + profession: str = Field(..., description="Profession of the person") + role: str = Field(..., description="Role of the person") + description: Optional[str] = Field(None, description="Optional profile description") + custom_fields: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Extensible fields") + +class ProfileCreate(ProfileBase): + pass + +class ProfileUpdate(ProfileBase): + name: Optional[str] = None + profession: Optional[str] = None + role: Optional[str] = None + +class ProfileResponse(ProfileBase): + profile_id: UUID + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/models/models.py b/app/models/models.py index 8358b08d..c5ba6469 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -19,6 +19,19 @@ ) +class Profile(SQLModel, table=True): + __tablename__ = "profiles" + + profile_id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str + profession: str + role: str + description: str | None = None + custom_fields: dict | None = Field(default=None, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class Template(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) name: str @@ -59,6 +72,7 @@ class Input(SQLModel, table=True): __tablename__ = "inputs" input_id: UUID = Field(default_factory=uuid4, primary_key=True) + profile_id: UUID | None = Field(default=None, foreign_key="profiles.profile_id") # sa_column required on all str-Enum fields: without it SQLModel emits # sa.Enum(native_enum=True) which creates a Postgres ENUM type — hard to # migrate and inconsistent with the VARCHAR approach used in migration 001. diff --git a/app/services/llm.py b/app/services/llm.py index 85e168c7..de3febd6 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -6,29 +6,53 @@ from app.core.config import OLLAMA_HOST, OLLAMA_MODEL from app.core.logging import get_logger +from app.models.models import Profile logger = get_logger(__name__) +def append_profile_context(base_prompt: str, profile: Profile = None) -> str: + if not profile: + return base_prompt + + desc = profile.description if profile.description else "N/A" + + profile_context = ( + "=== USER PROFILE CONTEXT ===\n" + "The form is being filled out by a user with the following profile:\n" + f"- Name: {profile.name}\n" + f"- Profession: {profile.profession}\n" + f"- Role: {profile.role}\n" + f"- Description: {desc}\n" + ) + + if profile.custom_fields: + for key, value in profile.custom_fields.items(): + profile_context += f"- {key.capitalize()}: {value}\n" + + profile_context += "\nPlease use this context to provide more relevant and tailored field suggestions and validations.\n\n" + return profile_context + base_prompt + + class LLM: - def __init__(self, transcript_text: str=None, target_fields: list=None, json_dict: dict=None, model: str=None): + def __init__(self, transcript_text: str=None, target_fields: list=None, json_dict: dict=None, model: str=None, profile: Profile=None): self._transcript_text = transcript_text self._target_fields = target_fields self._json = json_dict if json_dict is not None else {} - # Optional per-request model override; falls back to OLLAMA_MODEL env. self._model = model - + self._profile = profile + def build_prompt(self, current_field: str, current_type: str = "string"): - """ - This method is in charge of the prompt engineering. It creates a specific prompt for each target field. - @params: current_field -> represents the current element of the json that is being prompted. - @params: current_type -> hint to the LLM about the expected value shape (date, number, etc.). - """ prompt_path = os.path.join(os.path.dirname(__file__), "prompt.txt") - with open(prompt_path, "r") as f: - template = f.read() + # In case prompt.txt isn't in Colab, we'll use a fallback for testing + try: + with open(prompt_path, "r") as f: + template = f.read() + except FileNotFoundError: + template = "Extract the {field} ({type}) from the following text:\n{text}" - return template.format(field=current_field, type=current_type, text=self._transcript_text) + base_prompt = template.format(field=current_field, type=current_type, text=self._transcript_text) + return append_profile_context(base_prompt, self._profile) def main_loop(self): timeout = 45 @@ -78,10 +102,6 @@ def main_loop(self): return self def add_response_to_json(self, field: str, value: str): - """ - this method adds the following value under the specified field, - or under a new field if the field doesn't exist, to the json dict - """ value = value.strip().replace('"', "") parsed_value = None @@ -93,7 +113,5 @@ def add_response_to_json(self, field: str, value: str): else: self._json[field] = parsed_value - - def get_data(self): return self._json diff --git a/tests/test_v1_profiles.py b/tests/test_v1_profiles.py new file mode 100644 index 00000000..97e298d5 --- /dev/null +++ b/tests/test_v1_profiles.py @@ -0,0 +1,77 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session, select +from uuid import UUID + +from app.models.models import Profile +from app.services.llm import append_profile_context + +def test_profile_crud(client: TestClient, db: Session): + # 1. TEST CREATE (POST) + payload = { + "name": "Jane Doe", + "profession": "Firefighter", + "role": "Captain", + "description": "Station 42 commander", + "custom_fields": {"badge_number": "12345"} + } + + response = client.post("/api/v1/profiles/", json=payload) + if response.status_code == 404: + response = client.post("/profiles/", json=payload) + + assert response.status_code == 201 + profile_data = response.json() + profile_id = profile_data["profile_id"] + assert profile_data["name"] == "Jane Doe" + + # 2. TEST READ (GET) + get_url = f"/api/v1/profiles/{profile_id}" if "/api/v1/" in response.url.path else f"/profiles/{profile_id}" + response = client.get(get_url) + assert response.status_code == 200 + assert response.json()["role"] == "Captain" + + # 3. TEST UPDATE (PATCH) + update_payload = { + "role": "Battalion Chief", + "description": "Promoted to Battalion Chief" + } + response = client.patch(get_url, json=update_payload) + assert response.status_code == 200 + updated_data = response.json() + assert updated_data["role"] == "Battalion Chief" + assert updated_data["description"] == "Promoted to Battalion Chief" + + # 4. Verify it actually saved to the database (UUID cast fixes the SQLite string quirk) + db_profile = db.exec(select(Profile).where(Profile.profile_id == UUID(profile_id))).first() + assert db_profile.role == "Battalion Chief" + + +def test_append_profile_context(): + """Unit test to ensure the LLM prompt is correctly formatted with profile data.""" + base_prompt = "Extract the incident details." + + # 1. Test fallback when no profile is provided + assert append_profile_context(base_prompt, None) == base_prompt + + # 2. Test context injection with a valid profile + mock_profile = Profile( + name="John Smith", + profession="Paramedic", + role="Lead Medic", + description="Night shift supervisor", + custom_fields={"certification": "ALS", "unit": "Medic 9"} + ) + + result_prompt = append_profile_context(base_prompt, mock_profile) + + # Verify core fields were injected + assert "Name: John Smith" in result_prompt + assert "Profession: Paramedic" in result_prompt + assert "Role: Lead Medic" in result_prompt + + # Verify custom fields were injected and capitalized properly + assert "Certification: ALS" in result_prompt + assert "Unit: Medic 9" in result_prompt + + # Verify the original prompt is still at the end + assert result_prompt.endswith(base_prompt) \ No newline at end of file From 9544a86609fc1de4721065847ec6c1319d7dc81f Mon Sep 17 00:00:00 2001 From: CoDe-ReDz Date: Wed, 21 Oct 2026 08:59:39 +0000 Subject: [PATCH 2/3] fix: resolve pydantic warnings, update test imports, and add valid 006 migration --- ...r_profiles.py => 006_add_user_profiles.py} | 23 +-- app/api/schemas/profiles.py | 5 +- tests/test_migrations.py | 1 + tests/test_v1_profiles.py | 152 +++++++++--------- 4 files changed, 92 insertions(+), 89 deletions(-) rename alembic/versions/{003_add_user_profiles.py => 006_add_user_profiles.py} (61%) diff --git a/alembic/versions/003_add_user_profiles.py b/alembic/versions/006_add_user_profiles.py similarity index 61% rename from alembic/versions/003_add_user_profiles.py rename to alembic/versions/006_add_user_profiles.py index cc958731..5faef807 100644 --- a/alembic/versions/003_add_user_profiles.py +++ b/alembic/versions/006_add_user_profiles.py @@ -1,7 +1,7 @@ """Add user profiles -Revision ID: 003 -Revises: 002 +Revision ID: 006 +Revises: 005 Create Date: 2024-03-01 00:00:00.000000 """ @@ -9,15 +9,15 @@ import sqlalchemy as sa import sqlmodel -revision = '003' -down_revision = '002' +revision = '006' +down_revision = '005' branch_labels = None depends_on = None def upgrade(): # Create profiles table op.create_table('profiles', - sa.Column('profile_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('profile_id', sa.Uuid(), nullable=False), sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('profession', sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False), @@ -28,11 +28,14 @@ def upgrade(): sa.PrimaryKeyConstraint('profile_id') ) - # Add profile_id to inputs table - op.add_column('inputs', sa.Column('profile_id', sqlmodel.sql.sqltypes.GUID(), nullable=True)) - op.create_foreign_key('fk_inputs_profiles', 'inputs', 'profiles', ['profile_id'], ['profile_id']) + # Add profile_id to inputs table and create foreign key using batch mode + with op.batch_alter_table('inputs', schema=None) as batch_op: + batch_op.add_column(sa.Column('profile_id', sa.Uuid(), nullable=True)) + batch_op.create_foreign_key('fk_inputs_profiles', 'profiles', ['profile_id'], ['profile_id']) def downgrade(): - op.drop_constraint('fk_inputs_profiles', 'inputs', type_='foreignkey') - op.drop_column('inputs', 'profile_id') + with op.batch_alter_table('inputs', schema=None) as batch_op: + batch_op.drop_constraint('fk_inputs_profiles', type_='foreignkey') + batch_op.drop_column('profile_id') + op.drop_table('profiles') diff --git a/app/api/schemas/profiles.py b/app/api/schemas/profiles.py index 510e2c9d..170b3203 100644 --- a/app/api/schemas/profiles.py +++ b/app/api/schemas/profiles.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from uuid import UUID from datetime import datetime @@ -22,5 +22,4 @@ class ProfileResponse(ProfileBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index c2861fe4..1fb96103 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -168,6 +168,7 @@ def test_inputs_columns(alembic_cfg, alembic_engine): "error_detail", "created_at", "updated_at", + "profile_id", } diff --git a/tests/test_v1_profiles.py b/tests/test_v1_profiles.py index 97e298d5..252ffda8 100644 --- a/tests/test_v1_profiles.py +++ b/tests/test_v1_profiles.py @@ -1,77 +1,77 @@ -from fastapi.testclient import TestClient -from sqlmodel import Session, select -from uuid import UUID - -from app.models.models import Profile -from app.services.llm import append_profile_context - -def test_profile_crud(client: TestClient, db: Session): - # 1. TEST CREATE (POST) - payload = { - "name": "Jane Doe", - "profession": "Firefighter", - "role": "Captain", - "description": "Station 42 commander", - "custom_fields": {"badge_number": "12345"} - } - - response = client.post("/api/v1/profiles/", json=payload) - if response.status_code == 404: - response = client.post("/profiles/", json=payload) - - assert response.status_code == 201 - profile_data = response.json() - profile_id = profile_data["profile_id"] - assert profile_data["name"] == "Jane Doe" - - # 2. TEST READ (GET) - get_url = f"/api/v1/profiles/{profile_id}" if "/api/v1/" in response.url.path else f"/profiles/{profile_id}" - response = client.get(get_url) - assert response.status_code == 200 - assert response.json()["role"] == "Captain" - - # 3. TEST UPDATE (PATCH) - update_payload = { - "role": "Battalion Chief", - "description": "Promoted to Battalion Chief" - } - response = client.patch(get_url, json=update_payload) - assert response.status_code == 200 - updated_data = response.json() - assert updated_data["role"] == "Battalion Chief" - assert updated_data["description"] == "Promoted to Battalion Chief" - - # 4. Verify it actually saved to the database (UUID cast fixes the SQLite string quirk) - db_profile = db.exec(select(Profile).where(Profile.profile_id == UUID(profile_id))).first() - assert db_profile.role == "Battalion Chief" - - -def test_append_profile_context(): - """Unit test to ensure the LLM prompt is correctly formatted with profile data.""" - base_prompt = "Extract the incident details." - - # 1. Test fallback when no profile is provided - assert append_profile_context(base_prompt, None) == base_prompt - - # 2. Test context injection with a valid profile - mock_profile = Profile( - name="John Smith", - profession="Paramedic", - role="Lead Medic", - description="Night shift supervisor", - custom_fields={"certification": "ALS", "unit": "Medic 9"} - ) - - result_prompt = append_profile_context(base_prompt, mock_profile) - - # Verify core fields were injected - assert "Name: John Smith" in result_prompt - assert "Profession: Paramedic" in result_prompt - assert "Role: Lead Medic" in result_prompt - - # Verify custom fields were injected and capitalized properly - assert "Certification: ALS" in result_prompt - assert "Unit: Medic 9" in result_prompt - - # Verify the original prompt is still at the end +from fastapi.testclient import TestClient +from sqlmodel import Session, select +from uuid import UUID + +from app.models.models import Profile +from app.services.form_filler.filler import append_profile_context + +def test_profile_crud(client: TestClient, db: Session): + # 1. TEST CREATE (POST) + payload = { + "name": "Jane Doe", + "profession": "Firefighter", + "role": "Captain", + "description": "Station 42 commander", + "custom_fields": {"badge_number": "12345"} + } + + response = client.post("/api/v1/profiles/", json=payload) + if response.status_code == 404: + response = client.post("/profiles/", json=payload) + + assert response.status_code == 201 + profile_data = response.json() + profile_id = profile_data["profile_id"] + assert profile_data["name"] == "Jane Doe" + + # 2. TEST READ (GET) + get_url = f"/api/v1/profiles/{profile_id}" if "/api/v1/" in response.url.path else f"/profiles/{profile_id}" + response = client.get(get_url) + assert response.status_code == 200 + assert response.json()["role"] == "Captain" + + # 3. TEST UPDATE (PATCH) + update_payload = { + "role": "Battalion Chief", + "description": "Promoted to Battalion Chief" + } + response = client.patch(get_url, json=update_payload) + assert response.status_code == 200 + updated_data = response.json() + assert updated_data["role"] == "Battalion Chief" + assert updated_data["description"] == "Promoted to Battalion Chief" + + # 4. Verify it actually saved to the database (UUID cast fixes the SQLite string quirk) + db_profile = db.exec(select(Profile).where(Profile.profile_id == UUID(profile_id))).first() + assert db_profile.role == "Battalion Chief" + + +def test_append_profile_context(): + """Unit test to ensure the LLM prompt is correctly formatted with profile data.""" + base_prompt = "Extract the incident details." + + # 1. Test fallback when no profile is provided + assert append_profile_context(base_prompt, None) == base_prompt + + # 2. Test context injection with a valid profile + mock_profile = Profile( + name="John Smith", + profession="Paramedic", + role="Lead Medic", + description="Night shift supervisor", + custom_fields={"certification": "ALS", "unit": "Medic 9"} + ) + + result_prompt = append_profile_context(base_prompt, mock_profile) + + # Verify core fields were injected + assert "Name: John Smith" in result_prompt + assert "Profession: Paramedic" in result_prompt + assert "Role: Lead Medic" in result_prompt + + # Verify custom fields were injected and capitalized properly + assert "Certification: ALS" in result_prompt + assert "Unit: Medic 9" in result_prompt + + # Verify the original prompt is still at the end assert result_prompt.endswith(base_prompt) \ No newline at end of file From 9437c6008551ca9c684f43a51fe788016bf5d18c Mon Sep 17 00:00:00 2001 From: marcvergees Date: Sun, 25 Oct 2026 10:18:16 +0100 Subject: [PATCH 3/3] fix: :bug: settings tab wasn't loading because error "map is undefined" --- app/api/router.py | 3 ++- app/api/routes/profiles.py | 26 ++++++++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/api/router.py b/app/api/router.py index 03ef6fc7..6cb2155c 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -12,4 +12,5 @@ api_router.include_router(weather.router, prefix=API_PREFIX) api_router.include_router(zipcode.router, prefix=API_PREFIX) api_router.include_router(input.router, prefix=API_PREFIX) -api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"]) \ No newline at end of file +api_router.include_router(profiles.router, prefix=API_PREFIX) +api_router.include_router(profiles.router) \ No newline at end of file diff --git a/app/api/routes/profiles.py b/app/api/routes/profiles.py index a4bcc549..64358562 100644 --- a/app/api/routes/profiles.py +++ b/app/api/routes/profiles.py @@ -1,13 +1,20 @@ -from sqlmodel import select -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from datetime import datetime, timezone from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + from app.api.deps import get_db +from app.api.schemas.profiles import ProfileCreate, ProfileResponse, ProfileUpdate from app.models.models import Profile -from app.api.schemas.profiles import ProfileCreate, ProfileUpdate, ProfileResponse -router = APIRouter() +router = APIRouter(prefix="/profiles", tags=["Profiles"]) + + +@router.get("/", response_model=ProfileResponse | None) +def get_current_profile(db: Session = Depends(get_db)): + return db.exec(select(Profile).order_by(Profile.updated_at.desc())).first() + @router.post("/", response_model=ProfileResponse, status_code=201) def create_profile(profile: ProfileCreate, db: Session = Depends(get_db)): @@ -17,6 +24,7 @@ def create_profile(profile: ProfileCreate, db: Session = Depends(get_db)): db.refresh(db_profile) return db_profile + @router.get("/{profile_id}", response_model=ProfileResponse) def get_profile(profile_id: UUID, db: Session = Depends(get_db)): db_profile = db.exec(select(Profile).where(Profile.profile_id == profile_id)).first() @@ -24,16 +32,18 @@ def get_profile(profile_id: UUID, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="Profile not found") return db_profile + @router.patch("/{profile_id}", response_model=ProfileResponse) def update_profile(profile_id: UUID, profile_update: ProfileUpdate, db: Session = Depends(get_db)): - db_profile = db.query(Profile).filter(Profile.profile_id == profile_id).first() + db_profile = db.exec(select(Profile).where(Profile.profile_id == profile_id)).first() if not db_profile: raise HTTPException(status_code=404, detail="Profile not found") - + update_data = profile_update.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(db_profile, key, value) - + db_profile.updated_at = datetime.now(timezone.utc) + db.commit() db.refresh(db_profile) return db_profile \ No newline at end of file