Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions alembic/versions/006_add_user_profiles.py
Original file line number Diff line number Diff line change
@@ -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')
6 changes: 4 additions & 2 deletions app/api/router.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
api_router.include_router(input.router, prefix=API_PREFIX)
api_router.include_router(profiles.router, prefix=API_PREFIX)
api_router.include_router(profiles.router)
49 changes: 49 additions & 0 deletions app/api/routes/profiles.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions app/api/schemas/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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
25 changes: 25 additions & 0 deletions app/api/schemas/profiles.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions app/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 29 additions & 4 deletions app/services/form_filler/filler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from pypdf import PdfWriter

from app.models.models import Profile
from app.services import llm

from . import template
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"<template_description>\n{description}\n</template_description>\n\n" if description else ""
user_content = (
Expand All @@ -73,7 +97,8 @@ def build_prompt(narrative, schema, description=""):
+ json.dumps(schema["properties"], indent=2, ensure_ascii=False)
+ "\n</target_json_schema>"
)
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):
Expand Down
1 change: 1 addition & 0 deletions tests/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def test_inputs_columns(alembic_cfg, alembic_engine):
"error_detail",
"created_at",
"updated_at",
"profile_id",
}


Expand Down
77 changes: 77 additions & 0 deletions tests/test_v1_profiles.py
Original file line number Diff line number Diff line change
@@ -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)
Loading