feat(profile): add profile settings and LLM user context

This commit is contained in:
Piotr Oleszczyk 2026-03-05 15:57:21 +01:00
parent db3d9514d5
commit b99b9ed68e
25 changed files with 472 additions and 9 deletions

View file

@ -0,0 +1,44 @@
from datetime import date
from sqlmodel import Session, col, select
from innercontext.models import UserProfile
def get_user_profile(session: Session) -> UserProfile | None:
return session.exec(
select(UserProfile).order_by(col(UserProfile.created_at).desc())
).first()
def calculate_age(birth_date: date, reference_date: date) -> int:
years = reference_date.year - birth_date.year
if (reference_date.month, reference_date.day) < (birth_date.month, birth_date.day):
years -= 1
return years
def build_user_profile_context(session: Session, reference_date: date) -> str:
profile = get_user_profile(session)
if profile is None:
return "USER PROFILE: no data\n"
lines = ["USER PROFILE:"]
if profile.birth_date is not None:
age = calculate_age(profile.birth_date, reference_date)
lines.append(f" Age: {max(age, 0)}")
lines.append(f" Birth date: {profile.birth_date.isoformat()}")
else:
lines.append(" Age: unknown")
if profile.sex_at_birth is not None:
sex_value = (
profile.sex_at_birth.value
if hasattr(profile.sex_at_birth, "value")
else str(profile.sex_at_birth)
)
lines.append(f" Sex at birth: {sex_value}")
else:
lines.append(" Sex at birth: unknown")
return "\n".join(lines) + "\n"

View file

@ -12,6 +12,7 @@ from sqlmodel import Field, Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.api.llm_context import build_user_profile_context
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
@ -786,7 +787,8 @@ def _ev(v: object) -> str:
return str(v)
def _build_shopping_context(session: Session) -> str:
def _build_shopping_context(session: Session, reference_date: date) -> str:
profile_ctx = build_user_profile_context(session, reference_date=reference_date)
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
@ -854,7 +856,9 @@ def _build_shopping_context(session: Session) -> str:
f"targets: {targets}{actives_str}{effects_str}"
)
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
return (
profile_ctx + "\n" + "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
)
def _get_shopping_products(session: Session) -> list[Product]:
@ -1067,7 +1071,7 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(session: Session = Depends(get_session)):
context = _build_shopping_context(session)
context = _build_shopping_context(session, reference_date=date.today())
shopping_products = _get_shopping_products(session)
prompt = (

View file

@ -0,0 +1,62 @@
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends
from sqlmodel import Session, SQLModel
from db import get_session
from innercontext.api.llm_context import get_user_profile
from innercontext.models import SexAtBirth, UserProfile
router = APIRouter()
class UserProfileUpdate(SQLModel):
birth_date: Optional[date] = None
sex_at_birth: Optional[SexAtBirth] = None
class UserProfilePublic(SQLModel):
id: str
birth_date: date | None
sex_at_birth: SexAtBirth | None
created_at: datetime
updated_at: datetime
@router.get("", response_model=UserProfilePublic | None)
def get_profile(session: Session = Depends(get_session)):
profile = get_user_profile(session)
if profile is None:
return None
return UserProfilePublic(
id=str(profile.id),
birth_date=profile.birth_date,
sex_at_birth=profile.sex_at_birth,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
@router.patch("", response_model=UserProfilePublic)
def upsert_profile(data: UserProfileUpdate, session: Session = Depends(get_session)):
profile = get_user_profile(session)
payload = data.model_dump(exclude_unset=True)
if profile is None:
profile = UserProfile(**payload)
else:
for key, value in payload.items():
setattr(profile, key, value)
session.add(profile)
session.commit()
session.refresh(profile)
return UserProfilePublic(
id=str(profile.id),
birth_date=profile.birth_date,
sex_at_birth=profile.sex_at_birth,
created_at=profile.created_at,
updated_at=profile.updated_at,
)

View file

@ -10,6 +10,7 @@ from sqlmodel import Field, Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.api.llm_context import build_user_profile_context
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
@ -760,6 +761,7 @@ def suggest_routine(
):
weekday = data.routine_date.weekday()
skin_ctx = _build_skin_context(session)
profile_ctx = build_user_profile_context(session, reference_date=data.routine_date)
grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session)
day_ctx = _build_day_context(data.leaving_home)
@ -781,7 +783,7 @@ def suggest_routine(
f"na {data.routine_date} ({day_name}).\n\n"
f"{mode_line}\n"
"INPUT DATA:\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{day_ctx}\n{products_ctx}\n{objectives_ctx}"
"\nNARZEDZIA:\n"
"- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives.\n"
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n"
@ -924,6 +926,7 @@ def suggest_batch(
weekdays = list(
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
)
profile_ctx = build_user_profile_context(session, reference_date=data.from_date)
skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
history_ctx = _build_recent_history(session)
@ -950,7 +953,7 @@ def suggest_batch(
prompt = (
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n{mode_line}\n"
"INPUT DATA:\n"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}"
f"{profile_ctx}\n{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}"
f"{notes_line}{minimize_line}"
"\nZwróć JSON zgodny ze schematem."
)

View file

@ -10,6 +10,7 @@ from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.llm_context import build_user_profile_context
from innercontext.api.utils import get_or_404
from innercontext.llm import call_gemini, get_extraction_config
from innercontext.models import (
@ -136,6 +137,7 @@ MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
@router.post("/analyze-photos", response_model=SkinPhotoAnalysisResponse)
async def analyze_skin_photos(
photos: list[UploadFile] = File(...),
session: Session = Depends(get_session),
) -> SkinPhotoAnalysisResponse:
if not (1 <= len(photos) <= 3):
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
@ -166,6 +168,11 @@ async def analyze_skin_photos(
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
)
)
parts.append(
genai_types.Part.from_text(
text=build_user_profile_context(session, reference_date=date.today())
)
)
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
response = call_gemini(

View file

@ -14,6 +14,7 @@ from .enums import (
ProductCategory,
ResultFlag,
RoutineRole,
SexAtBirth,
SkinConcern,
SkinTexture,
SkinType,
@ -33,6 +34,7 @@ from .product import (
ProductWithInventory,
)
from .pricing import PricingRecalcJob
from .profile import UserProfile
from .routine import GroomingSchedule, Routine, RoutineStep
from .skincare import (
SkinConditionSnapshot,
@ -59,6 +61,7 @@ __all__ = [
"ProductCategory",
"ResultFlag",
"RoutineRole",
"SexAtBirth",
"SkinConcern",
"SkinTexture",
"SkinType",
@ -79,6 +82,7 @@ __all__ = [
"ProductPublic",
"ProductWithInventory",
"PricingRecalcJob",
"UserProfile",
# routine
"GroomingSchedule",
"Routine",

View file

@ -153,6 +153,12 @@ class MedicationKind(str, Enum):
OTHER = "other"
class SexAtBirth(str, Enum):
MALE = "male"
FEMALE = "female"
INTERSEX = "intersex"
# ---------------------------------------------------------------------------
# Routine
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,35 @@
from datetime import date, datetime
from typing import ClassVar
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, String
from sqlmodel import Field, SQLModel
from .base import utc_now
from .domain import Domain
from .enums import SexAtBirth
class UserProfile(SQLModel, table=True):
__tablename__ = "user_profiles"
__domains__: ClassVar[frozenset[Domain]] = frozenset(
{Domain.HEALTH, Domain.SKINCARE}
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
birth_date: date | None = Field(default=None)
sex_at_birth: SexAtBirth | None = Field(
default=None,
sa_column=Column(String(length=16), nullable=True, index=True),
)
created_at: datetime = Field(default_factory=utc_now, nullable=False)
updated_at: datetime = Field(
default_factory=utc_now,
sa_column=Column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
),
)