diff --git a/alembic/versions/006_add_user_profiles.py b/alembic/versions/006_add_user_profiles.py new file mode 100644 index 0000000..5faef80 --- /dev/null +++ b/alembic/versions/006_add_user_profiles.py @@ -0,0 +1,41 @@ +"""Add user profiles + +Revision ID: 006 +Revises: 005 +Create Date: 2024-03-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +revision = '006' +down_revision = '005' +branch_labels = None +depends_on = None + +def upgrade(): + # Create profiles table + op.create_table('profiles', + 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), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('custom_fields', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('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(): + 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/router.py b/app/api/router.py index 0515c63..6cb2155 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import forms, input, jobs, llm, system, templates, weather, zipcode +from app.api.routes import forms, input, jobs, profiles, llm, system, templates, weather, zipcode from app.core.config import API_PREFIX api_router = APIRouter() @@ -11,4 +11,6 @@ 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=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 new file mode 100644 index 0000000..6435856 --- /dev/null +++ b/app/api/routes/profiles.py @@ -0,0 +1,49 @@ +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 + +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)): + 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.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 diff --git a/app/api/schemas/input.py b/app/api/schemas/input.py index 9d8b908..c0ad701 100644 --- a/app/api/schemas/input.py +++ b/app/api/schemas/input.py @@ -2,7 +2,6 @@ from datetime import date, datetime from uuid import UUID - from pydantic import BaseModel, Field from app.api.schemas.enums import InputStatus, InputType @@ -23,6 +22,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 +36,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 +50,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 0000000..170b320 --- /dev/null +++ b/app/api/schemas/profiles.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field, ConfigDict +from uuid import UUID +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: str | None = Field(None, description="Optional profile description") + custom_fields: dict | None = Field(default_factory=dict, description="Extensible fields") + +class ProfileCreate(ProfileBase): + pass + +class ProfileUpdate(ProfileBase): + name: str | None = None + profession: str | None = None + role: str | None = None + +class ProfileResponse(ProfileBase): + profile_id: UUID + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/app/models/models.py b/app/models/models.py index 15c1cc1..f80b519 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -21,6 +21,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 @@ -68,6 +81,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/form_filler/filler.py b/app/services/form_filler/filler.py index 57cde66..87d18c0 100644 --- a/app/services/form_filler/filler.py +++ b/app/services/form_filler/filler.py @@ -15,6 +15,7 @@ from pypdf import PdfWriter +from app.models.models import Profile from app.services import llm from . import template @@ -24,7 +25,30 @@ PROMPT = f.read() -def fill(pdf_path, narrative, out_path=None, model=None, description="", provider=None): +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 + + +def fill(pdf_path, narrative, out_path=None, model=None, description="", provider=None, profile: Profile = None): """Fill pdf_path from the narrative. Writes the PDF when out_path is given. Returns {schema, tables, groups, prompt, response_text, values}: the @@ -34,7 +58,7 @@ def fill(pdf_path, narrative, out_path=None, model=None, description="", provide schema, tables, groups = template.create_template(pdf_path) strip_empty_descriptions(schema["properties"]) - prompt = build_prompt(narrative, schema, description) + prompt = build_prompt(narrative, schema, description, profile) response_text = call_model(prompt, schema, model, provider) # Tables come back as arrays of rows; map them to widget names @@ -62,7 +86,7 @@ def strip_empty_descriptions(props): return props -def build_prompt(narrative, schema, description=""): +def build_prompt(narrative, schema, description="", profile: Profile = None): # Omitted entirely when blank so the model is not handed an empty block. context = f"\n{description}\n\n\n" if description else "" user_content = ( @@ -73,7 +97,8 @@ def build_prompt(narrative, schema, description=""): + json.dumps(schema["properties"], indent=2, ensure_ascii=False) + "\n" ) - return PROMPT.rstrip() + "\n\n" + user_content + base_prompt = PROMPT.rstrip() + "\n\n" + user_content + return append_profile_context(base_prompt, profile) def call_model(prompt, schema, model=None, provider=None): diff --git a/tests/test_migrations.py b/tests/test_migrations.py index c2861fe..1fb9610 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 new file mode 100644 index 0000000..252ffda --- /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.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