Compare commits

..

No commits in common. "1d8a8eafb8f03982501cc3759538c7359eb99b22" and "99584521a11935728541549ee9e43ed5e0e29707" have entirely different histories.

70 changed files with 3784 additions and 8306 deletions

View file

@ -1,21 +1,17 @@
# AGENTS.md # CLAUDE.md
This file provides guidance to AI coding agents when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repository Structure ## Repository structure
This is a monorepo with **backend** and **frontend** directories. This is a monorepo. The backend lives in `backend/`; a frontend will be added in the future.
## Commit Guidelines
This repository uses Conventional Commits (e.g., `feat(api): ...`, `fix(frontend): ...`, `test(models): ...`). Always format commit messages accordingly and ensure you include the correct scope to indicate which part of the monorepo is affected.
## Commands ## Commands
Run the backend from the `backend/` directory: Run all backend commands from the `backend/` directory:
```bash ```bash
# Backend # Run scripts
cd backend && uv run python main.py cd backend && uv run python main.py
# Linting / formatting # Linting / formatting
@ -24,27 +20,11 @@ cd backend && uv run black .
cd backend && uv run isort . cd backend && uv run isort .
``` ```
Run the frontend from the `frontend/` directory: No test suite exists yet.
```bash
# Frontend
cd frontend && pnpm dev
# Type checking / linting / formatting
cd frontend && pnpm check
cd frontend && pnpm lint
cd frontend && pnpm format
```
No test suite exists yet (backend has some test files but they're not integrated into CI).
## Architecture ## Architecture
**innercontext** collects personal health and skincare data and exposes it to an LLM agent. **innercontext** collects personal health and skincare data and exposes it via MCP to an LLM agent. Stack: Python 3.12, SQLModel (0.0.37) + SQLAlchemy, Pydantic v2, FastAPI, PostgreSQL (psycopg3).
**Backend Stack:** Python 3.12, SQLModel (0.0.37) + SQLAlchemy, Pydantic v2, FastAPI, PostgreSQL (psycopg3).
**Frontend Stack:** SvelteKit 5, Tailwind CSS v4, bits-ui, inlang/paraglide (i18n), svelte-dnd-action.
### Models (`backend/innercontext/models/`) ### Models (`backend/innercontext/models/`)
@ -55,7 +35,7 @@ No test suite exists yet (backend has some test files but they're not integrated
| `routine.py` | `routines`, `routine_steps` | | `routine.py` | `routines`, `routine_steps` |
| `skincare.py` | `skin_condition_snapshots` | | `skincare.py` | `skin_condition_snapshots` |
**`Product`** is the core model. JSON columns store `inci` (list), `actives` (list of `ActiveIngredient`), `recommended_for`, `targets`, `incompatible_with`, `synergizes_with`, `context_rules`, and `product_effect_profile`. The `to_llm_context()` method returns a token-optimised dict for LLM usage. **`Product`** is the core model. JSON columns store `inci` (list), `actives` (list of `ActiveIngredient`), `recommended_for`, `targets`, `incompatible_with`, `synergizes_with`, `context_rules`, and `product_effect_profile`. The `to_llm_context()` method returns a token-optimised dict for MCP.
**`ProductInventory`** tracks physical packages (opened status, expiry, remaining weight). One product → many inventory entries. **`ProductInventory`** tracks physical packages (opened status, expiry, remaining weight). One product → many inventory entries.
@ -63,7 +43,7 @@ No test suite exists yet (backend has some test files but they're not integrated
**`SkinConditionSnapshot`** is a weekly LLM-filled record (skin state, metrics 15, active concerns). **`SkinConditionSnapshot`** is a weekly LLM-filled record (skin state, metrics 15, active concerns).
### Key Conventions ### Key conventions
- All `table=True` models use `Column(DateTime(timezone=True), onupdate=utc_now)` for `updated_at` via raw SQLAlchemy column — do not use plain `Field(default_factory=...)` for auto-update. - All `table=True` models use `Column(DateTime(timezone=True), onupdate=utc_now)` for `updated_at` via raw SQLAlchemy column — do not use plain `Field(default_factory=...)` for auto-update.
- List/complex fields stored as JSON use `sa_column=Column(JSON, nullable=...)` pattern (DB-agnostic; not JSONB). - List/complex fields stored as JSON use `sa_column=Column(JSON, nullable=...)` pattern (DB-agnostic; not JSONB).

View file

@ -1,11 +1,11 @@
# innercontext # innercontext
Personal health and skincare data hub. Collects structured data (products, routines, lab results, medications, skin snapshots) and exposes it via a REST API and a web UI to an LLM agent. Personal health and skincare data hub. Collects structured data (products, routines, lab results, medications, skin snapshots) and exposes it via a REST API, MCP, and a web UI to an LLM agent.
## Repository layout ## Repository layout
``` ```
backend/ Python backend — FastAPI REST API + SQLModel models backend/ Python backend — FastAPI REST API + MCP server + SQLModel models
frontend/ SvelteKit web UI (Svelte 5, TypeScript, Tailwind CSS v4) frontend/ SvelteKit web UI (Svelte 5, TypeScript, Tailwind CSS v4)
docs/ Deployment guides docs/ Deployment guides
nginx/ nginx config for production nginx/ nginx config for production
@ -60,6 +60,14 @@ UI available at `http://localhost:5173`.
| `/skincare` | Weekly skin condition snapshots | | `/skincare` | Weekly skin condition snapshots |
| `/health-check` | Liveness probe | | `/health-check` | Liveness probe |
## MCP server
innercontext exposes 14 tools via [FastMCP](https://github.com/jlowin/fastmcp) at the StreamableHTTP endpoint `http://localhost:8000/mcp/mcp`.
Tools include: `get_products`, `get_product`, `get_open_inventory`, `get_recent_routines`, `get_latest_skin_snapshot`, `get_skin_history`, `get_medications`, `get_expiring_inventory`, `get_grooming_schedule`, `get_recent_lab_results`, and more.
Connect an MCP-compatible LLM agent by pointing it at `http://<host>/mcp/mcp`.
## Frontend routes ## Frontend routes
| Route | Description | | Route | Description |
@ -94,6 +102,7 @@ uv run pytest
## Stack ## Stack
- **Backend:** Python 3.12, FastAPI, Uvicorn, SQLModel 0.0.37 + SQLAlchemy, Pydantic v2, PostgreSQL (psycopg3) - **Backend:** Python 3.12, FastAPI, Uvicorn, SQLModel 0.0.37 + SQLAlchemy, Pydantic v2, PostgreSQL (psycopg3)
- **MCP:** FastMCP 3.0.2 (StreamableHTTP transport)
- **Frontend:** SvelteKit 2, Svelte 5 (Runes), TypeScript, Tailwind CSS v4, shadcn-svelte - **Frontend:** SvelteKit 2, Svelte 5 (Runes), TypeScript, Tailwind CSS v4, shadcn-svelte
## Deployment ## Deployment

View file

@ -1,16 +1,15 @@
import os import os
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel from sqlmodel import SQLModel
from alembic import context
load_dotenv() load_dotenv()
# Import all models so their tables are registered in SQLModel.metadata # Import all models so their tables are registered in SQLModel.metadata
import innercontext.models # noqa: F401, E402 import innercontext.models # noqa: F401
config = context.config config = context.config

View file

@ -1,51 +0,0 @@
"""add_ai_call_logs
Revision ID: a1b2c3d4e5f6
Revises: c2d626a2b36c
Create Date: 2026-03-01 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "c2d626a2b36c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ai_call_logs",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("endpoint", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("model", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("system_prompt", sa.Text(), nullable=True),
sa.Column("user_input", sa.Text(), nullable=True),
sa.Column("response_text", sa.Text(), nullable=True),
sa.Column("prompt_tokens", sa.Integer(), nullable=True),
sa.Column("completion_tokens", sa.Integer(), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("error_detail", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_ai_call_logs_endpoint"), "ai_call_logs", ["endpoint"], unique=False
)
op.create_index(
op.f("ix_ai_call_logs_success"), "ai_call_logs", ["success"], unique=False
)
def downgrade() -> None:
op.drop_index(op.f("ix_ai_call_logs_success"), table_name="ai_call_logs")
op.drop_index(op.f("ix_ai_call_logs_endpoint"), table_name="ai_call_logs")
op.drop_table("ai_call_logs")

View file

@ -1,29 +0,0 @@
"""add_finish_reason_to_ai_call_logs
Revision ID: b2c3d4e5f6a1
Revises: a1b2c3d4e5f6
Create Date: 2026-03-01 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b2c3d4e5f6a1"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ai_call_logs",
sa.Column("finish_reason", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("ai_call_logs", "finish_reason")

View file

@ -5,16 +5,15 @@ Revises:
Create Date: 2026-02-28 20:13:55.499494 Create Date: 2026-02-28 20:13:55.499494
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel.sql.sqltypes import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "c2d626a2b36c" revision: str = 'c2d626a2b36c'
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -23,456 +22,217 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table( op.create_table('grooming_schedule',
"grooming_schedule", sa.Column('id', sa.Uuid(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False), sa.Column('day_of_week', sa.Integer(), nullable=False),
sa.Column("day_of_week", sa.Integer(), nullable=False), sa.Column('action', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=False),
sa.Column( sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
"action", sa.PrimaryKeyConstraint('id')
sa.Enum(
"SHAVING_RAZOR",
"SHAVING_ONEBLADE",
"DERMAROLLING",
name="groomingaction",
),
nullable=False,
),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint("id"),
) )
op.create_index( op.create_index(op.f('ix_grooming_schedule_day_of_week'), 'grooming_schedule', ['day_of_week'], unique=False)
op.f("ix_grooming_schedule_day_of_week"), op.create_table('lab_results',
"grooming_schedule", sa.Column('record_id', sa.Uuid(), nullable=False),
["day_of_week"], sa.Column('collected_at', sa.DateTime(), nullable=False),
unique=False, sa.Column('test_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('test_name_original', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('test_name_loinc', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('value_num', sa.Float(), nullable=True),
sa.Column('value_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('value_bool', sa.Boolean(), nullable=True),
sa.Column('unit_original', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('unit_ucum', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('ref_low', sa.Float(), nullable=True),
sa.Column('ref_high', sa.Float(), nullable=True),
sa.Column('ref_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('flag', sa.Enum('NORMAL', 'ABNORMAL', 'POSITIVE', 'NEGATIVE', 'LOW', 'HIGH', name='resultflag'), nullable=True),
sa.Column('lab', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('record_id')
) )
op.create_table( op.create_index(op.f('ix_lab_results_collected_at'), 'lab_results', ['collected_at'], unique=False)
"lab_results", op.create_index(op.f('ix_lab_results_flag'), 'lab_results', ['flag'], unique=False)
sa.Column("record_id", sa.Uuid(), nullable=False), op.create_index(op.f('ix_lab_results_lab'), 'lab_results', ['lab'], unique=False)
sa.Column("collected_at", sa.DateTime(), nullable=False), op.create_index(op.f('ix_lab_results_test_code'), 'lab_results', ['test_code'], unique=False)
sa.Column("test_code", sqlmodel.sql.sqltypes.AutoString(), nullable=False), op.create_table('medication_entries',
sa.Column( sa.Column('record_id', sa.Uuid(), nullable=False),
"test_name_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True sa.Column('kind', sa.Enum('PRESCRIPTION', 'OTC', 'SUPPLEMENT', 'HERBAL', 'OTHER', name='medicationkind'), nullable=False),
), sa.Column('product_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("test_name_loinc", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('active_substance', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("value_num", sa.Float(), nullable=True), sa.Column('formulation', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("value_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('route', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("value_bool", sa.Boolean(), nullable=True), sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("unit_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("unit_ucum", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column("ref_low", sa.Float(), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column("ref_high", sa.Float(), nullable=True), sa.PrimaryKeyConstraint('record_id')
sa.Column("ref_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"flag",
sa.Enum(
"NORMAL",
"ABNORMAL",
"POSITIVE",
"NEGATIVE",
"LOW",
"HIGH",
name="resultflag",
),
nullable=True,
),
sa.Column("lab", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("record_id"),
) )
op.create_index( op.create_index(op.f('ix_medication_entries_active_substance'), 'medication_entries', ['active_substance'], unique=False)
op.f("ix_lab_results_collected_at"), op.create_index(op.f('ix_medication_entries_kind'), 'medication_entries', ['kind'], unique=False)
"lab_results", op.create_index(op.f('ix_medication_entries_product_name'), 'medication_entries', ['product_name'], unique=False)
["collected_at"], op.create_table('products',
unique=False, sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('brand', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('line_name', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True),
sa.Column('sku', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
sa.Column('barcode', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column('category', sa.Enum('CLEANSER', 'TONER', 'ESSENCE', 'SERUM', 'MOISTURIZER', 'SPF', 'MASK', 'EXFOLIANT', 'HAIR_TREATMENT', 'TOOL', 'SPOT_TREATMENT', 'OIL', name='productcategory'), nullable=False),
sa.Column('recommended_time', sa.Enum('AM', 'PM', 'BOTH', name='daytime'), nullable=False),
sa.Column('texture', sa.Enum('WATERY', 'GEL', 'EMULSION', 'CREAM', 'OIL', 'BALM', 'FOAM', 'FLUID', name='texturetype'), nullable=True),
sa.Column('absorption_speed', sa.Enum('VERY_FAST', 'FAST', 'MODERATE', 'SLOW', 'VERY_SLOW', name='absorptionspeed'), nullable=True),
sa.Column('leave_on', sa.Boolean(), nullable=False),
sa.Column('size_ml', sa.Float(), nullable=True),
sa.Column('full_weight_g', sa.Float(), nullable=True),
sa.Column('empty_weight_g', sa.Float(), nullable=True),
sa.Column('pao_months', sa.Integer(), nullable=True),
sa.Column('price_tier', sa.Enum('BUDGET', 'MID', 'PREMIUM', 'LUXURY', name='pricetier'), nullable=True),
sa.Column('inci', sa.JSON(), nullable=False),
sa.Column('actives', sa.JSON(), nullable=True),
sa.Column('recommended_for', sa.JSON(), nullable=False),
sa.Column('targets', sa.JSON(), nullable=False),
sa.Column('contraindications', sa.JSON(), nullable=False),
sa.Column('usage_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('fragrance_free', sa.Boolean(), nullable=True),
sa.Column('essential_oils_free', sa.Boolean(), nullable=True),
sa.Column('alcohol_denat_free', sa.Boolean(), nullable=True),
sa.Column('pregnancy_safe', sa.Boolean(), nullable=True),
sa.Column('product_effect_profile', sa.JSON(), nullable=False),
sa.Column('ph_min', sa.Float(), nullable=True),
sa.Column('ph_max', sa.Float(), nullable=True),
sa.Column('incompatible_with', sa.JSON(), nullable=True),
sa.Column('synergizes_with', sa.JSON(), nullable=True),
sa.Column('context_rules', sa.JSON(), nullable=True),
sa.Column('min_interval_hours', sa.Integer(), nullable=True),
sa.Column('max_frequency_per_week', sa.Integer(), nullable=True),
sa.Column('is_medication', sa.Boolean(), nullable=False),
sa.Column('is_tool', sa.Boolean(), nullable=False),
sa.Column('needle_length_mm', sa.Float(), nullable=True),
sa.Column('personal_tolerance_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('personal_repurchase_intent', sa.Boolean(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f("ix_lab_results_flag"), "lab_results", ["flag"], unique=False) op.create_index(op.f('ix_products_price_tier'), 'products', ['price_tier'], unique=False)
op.create_index(op.f("ix_lab_results_lab"), "lab_results", ["lab"], unique=False) op.create_table('routines',
op.create_index( sa.Column('id', sa.Uuid(), nullable=False),
op.f("ix_lab_results_test_code"), "lab_results", ["test_code"], unique=False sa.Column('routine_date', sa.Date(), nullable=False),
sa.Column('part_of_day', sa.Enum('AM', 'PM', name='partofday'), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('routine_date', 'part_of_day', name='uq_routine_date_part_of_day')
) )
op.create_table( op.create_index(op.f('ix_routines_part_of_day'), 'routines', ['part_of_day'], unique=False)
"medication_entries", op.create_index(op.f('ix_routines_routine_date'), 'routines', ['routine_date'], unique=False)
sa.Column("record_id", sa.Uuid(), nullable=False), op.create_table('skin_condition_snapshots',
sa.Column( sa.Column('snapshot_date', sa.Date(), nullable=False),
"kind", sa.Column('overall_state', sa.Enum('EXCELLENT', 'GOOD', 'FAIR', 'POOR', name='overallskinstate'), nullable=True),
sa.Enum( sa.Column('skin_type', sa.Enum('DRY', 'OILY', 'COMBINATION', 'SENSITIVE', 'NORMAL', 'ACNE_PRONE', name='skintype'), nullable=True),
"PRESCRIPTION", sa.Column('texture', sa.Enum('SMOOTH', 'ROUGH', 'FLAKY', 'BUMPY', name='skintexture'), nullable=True),
"OTC", sa.Column('hydration_level', sa.Integer(), nullable=True),
"SUPPLEMENT", sa.Column('sebum_tzone', sa.Integer(), nullable=True),
"HERBAL", sa.Column('sebum_cheeks', sa.Integer(), nullable=True),
"OTHER", sa.Column('sensitivity_level', sa.Integer(), nullable=True),
name="medicationkind", sa.Column('barrier_state', sa.Enum('INTACT', 'MILDLY_COMPROMISED', 'COMPROMISED', name='barrierstate'), nullable=True),
), sa.Column('active_concerns', sa.JSON(), nullable=False),
nullable=False, sa.Column('risks', sa.JSON(), nullable=False),
), sa.Column('priorities', sa.JSON(), nullable=False),
sa.Column("product_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column( sa.Column('id', sa.Uuid(), nullable=False),
"active_substance", sqlmodel.sql.sqltypes.AutoString(), nullable=True sa.Column('created_at', sa.DateTime(), nullable=False),
), sa.PrimaryKeyConstraint('id'),
sa.Column("formulation", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.UniqueConstraint('snapshot_date', name='uq_skin_snapshot_date')
sa.Column("route", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("record_id"),
) )
op.create_index( op.create_index(op.f('ix_skin_condition_snapshots_snapshot_date'), 'skin_condition_snapshots', ['snapshot_date'], unique=False)
op.f("ix_medication_entries_active_substance"), op.create_table('medication_usages',
"medication_entries", sa.Column('record_id', sa.Uuid(), nullable=False),
["active_substance"], sa.Column('medication_record_id', sa.Uuid(), nullable=False),
unique=False, sa.Column('dose_value', sa.Float(), nullable=True),
sa.Column('dose_unit', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('frequency', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('schedule_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('as_needed', sa.Boolean(), nullable=False),
sa.Column('valid_from', sa.DateTime(), nullable=False),
sa.Column('valid_to', sa.DateTime(), nullable=True),
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['medication_record_id'], ['medication_entries.record_id'], ),
sa.PrimaryKeyConstraint('record_id')
) )
op.create_index( op.create_index(op.f('ix_medication_usages_as_needed'), 'medication_usages', ['as_needed'], unique=False)
op.f("ix_medication_entries_kind"), "medication_entries", ["kind"], unique=False op.create_index(op.f('ix_medication_usages_medication_record_id'), 'medication_usages', ['medication_record_id'], unique=False)
op.create_index(op.f('ix_medication_usages_valid_from'), 'medication_usages', ['valid_from'], unique=False)
op.create_index(op.f('ix_medication_usages_valid_to'), 'medication_usages', ['valid_to'], unique=False)
op.create_table('product_inventory',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('product_id', sa.Uuid(), nullable=False),
sa.Column('is_opened', sa.Boolean(), nullable=False),
sa.Column('opened_at', sa.Date(), nullable=True),
sa.Column('finished_at', sa.Date(), nullable=True),
sa.Column('expiry_date', sa.Date(), nullable=True),
sa.Column('current_weight_g', sa.Float(), nullable=True),
sa.Column('last_weighed_at', sa.Date(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
) )
op.create_index( op.create_index(op.f('ix_product_inventory_product_id'), 'product_inventory', ['product_id'], unique=False)
op.f("ix_medication_entries_product_name"), op.create_table('routine_steps',
"medication_entries", sa.Column('id', sa.Uuid(), nullable=False),
["product_name"], sa.Column('routine_id', sa.Uuid(), nullable=False),
unique=False, sa.Column('product_id', sa.Uuid(), nullable=True),
) sa.Column('order_index', sa.Integer(), nullable=False),
op.create_table( sa.Column('action_type', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=True),
"products", sa.Column('action_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('dose', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("brand", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column('region', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column( sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
"line_name", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True sa.ForeignKeyConstraint(['routine_id'], ['routines.id'], ),
), sa.PrimaryKeyConstraint('id')
sa.Column("sku", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
sa.Column(
"barcode", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
),
sa.Column(
"category",
sa.Enum(
"CLEANSER",
"TONER",
"ESSENCE",
"SERUM",
"MOISTURIZER",
"SPF",
"MASK",
"EXFOLIANT",
"HAIR_TREATMENT",
"TOOL",
"SPOT_TREATMENT",
"OIL",
name="productcategory",
),
nullable=False,
),
sa.Column(
"recommended_time",
sa.Enum("AM", "PM", "BOTH", name="daytime"),
nullable=False,
),
sa.Column(
"texture",
sa.Enum(
"WATERY",
"GEL",
"EMULSION",
"CREAM",
"OIL",
"BALM",
"FOAM",
"FLUID",
name="texturetype",
),
nullable=True,
),
sa.Column(
"absorption_speed",
sa.Enum(
"VERY_FAST",
"FAST",
"MODERATE",
"SLOW",
"VERY_SLOW",
name="absorptionspeed",
),
nullable=True,
),
sa.Column("leave_on", sa.Boolean(), nullable=False),
sa.Column("size_ml", sa.Float(), nullable=True),
sa.Column("full_weight_g", sa.Float(), nullable=True),
sa.Column("empty_weight_g", sa.Float(), nullable=True),
sa.Column("pao_months", sa.Integer(), nullable=True),
sa.Column(
"price_tier",
sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"),
nullable=True,
),
sa.Column("inci", sa.JSON(), nullable=False),
sa.Column("actives", sa.JSON(), nullable=True),
sa.Column("recommended_for", sa.JSON(), nullable=False),
sa.Column("targets", sa.JSON(), nullable=False),
sa.Column("contraindications", sa.JSON(), nullable=False),
sa.Column("usage_notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("fragrance_free", sa.Boolean(), nullable=True),
sa.Column("essential_oils_free", sa.Boolean(), nullable=True),
sa.Column("alcohol_denat_free", sa.Boolean(), nullable=True),
sa.Column("pregnancy_safe", sa.Boolean(), nullable=True),
sa.Column("product_effect_profile", sa.JSON(), nullable=False),
sa.Column("ph_min", sa.Float(), nullable=True),
sa.Column("ph_max", sa.Float(), nullable=True),
sa.Column("incompatible_with", sa.JSON(), nullable=True),
sa.Column("synergizes_with", sa.JSON(), nullable=True),
sa.Column("context_rules", sa.JSON(), nullable=True),
sa.Column("min_interval_hours", sa.Integer(), nullable=True),
sa.Column("max_frequency_per_week", sa.Integer(), nullable=True),
sa.Column("is_medication", sa.Boolean(), nullable=False),
sa.Column("is_tool", sa.Boolean(), nullable=False),
sa.Column("needle_length_mm", sa.Float(), nullable=True),
sa.Column(
"personal_tolerance_notes",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
sa.Column("personal_repurchase_intent", sa.Boolean(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False
)
op.create_table(
"routines",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("routine_date", sa.Date(), nullable=False),
sa.Column("part_of_day", sa.Enum("AM", "PM", name="partofday"), nullable=False),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"routine_date", "part_of_day", name="uq_routine_date_part_of_day"
),
)
op.create_index(
op.f("ix_routines_part_of_day"), "routines", ["part_of_day"], unique=False
)
op.create_index(
op.f("ix_routines_routine_date"), "routines", ["routine_date"], unique=False
)
op.create_table(
"skin_condition_snapshots",
sa.Column("snapshot_date", sa.Date(), nullable=False),
sa.Column(
"overall_state",
sa.Enum("EXCELLENT", "GOOD", "FAIR", "POOR", name="overallskinstate"),
nullable=True,
),
sa.Column(
"skin_type",
sa.Enum(
"DRY",
"OILY",
"COMBINATION",
"SENSITIVE",
"NORMAL",
"ACNE_PRONE",
name="skintype",
),
nullable=True,
),
sa.Column(
"texture",
sa.Enum("SMOOTH", "ROUGH", "FLAKY", "BUMPY", name="skintexture"),
nullable=True,
),
sa.Column("hydration_level", sa.Integer(), nullable=True),
sa.Column("sebum_tzone", sa.Integer(), nullable=True),
sa.Column("sebum_cheeks", sa.Integer(), nullable=True),
sa.Column("sensitivity_level", sa.Integer(), nullable=True),
sa.Column(
"barrier_state",
sa.Enum("INTACT", "MILDLY_COMPROMISED", "COMPROMISED", name="barrierstate"),
nullable=True,
),
sa.Column("active_concerns", sa.JSON(), nullable=False),
sa.Column("risks", sa.JSON(), nullable=False),
sa.Column("priorities", sa.JSON(), nullable=False),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),
)
op.create_index(
op.f("ix_skin_condition_snapshots_snapshot_date"),
"skin_condition_snapshots",
["snapshot_date"],
unique=False,
)
op.create_table(
"medication_usages",
sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column("medication_record_id", sa.Uuid(), nullable=False),
sa.Column("dose_value", sa.Float(), nullable=True),
sa.Column("dose_unit", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("frequency", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("schedule_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("as_needed", sa.Boolean(), nullable=False),
sa.Column("valid_from", sa.DateTime(), nullable=False),
sa.Column("valid_to", sa.DateTime(), nullable=True),
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["medication_record_id"],
["medication_entries.record_id"],
),
sa.PrimaryKeyConstraint("record_id"),
)
op.create_index(
op.f("ix_medication_usages_as_needed"),
"medication_usages",
["as_needed"],
unique=False,
)
op.create_index(
op.f("ix_medication_usages_medication_record_id"),
"medication_usages",
["medication_record_id"],
unique=False,
)
op.create_index(
op.f("ix_medication_usages_valid_from"),
"medication_usages",
["valid_from"],
unique=False,
)
op.create_index(
op.f("ix_medication_usages_valid_to"),
"medication_usages",
["valid_to"],
unique=False,
)
op.create_table(
"product_inventory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("product_id", sa.Uuid(), nullable=False),
sa.Column("is_opened", sa.Boolean(), nullable=False),
sa.Column("opened_at", sa.Date(), nullable=True),
sa.Column("finished_at", sa.Date(), nullable=True),
sa.Column("expiry_date", sa.Date(), nullable=True),
sa.Column("current_weight_g", sa.Float(), nullable=True),
sa.Column("last_weighed_at", sa.Date(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_product_inventory_product_id"),
"product_inventory",
["product_id"],
unique=False,
)
op.create_table(
"routine_steps",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("routine_id", sa.Uuid(), nullable=False),
sa.Column("product_id", sa.Uuid(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column(
"action_type",
sa.Enum(
"SHAVING_RAZOR",
"SHAVING_ONEBLADE",
"DERMAROLLING",
name="groomingaction",
),
nullable=True,
),
sa.Column("action_notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("dose", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("region", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.ForeignKeyConstraint(
["routine_id"],
["routines.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_routine_steps_product_id"),
"routine_steps",
["product_id"],
unique=False,
)
op.create_index(
op.f("ix_routine_steps_routine_id"),
"routine_steps",
["routine_id"],
unique=False,
) )
op.create_index(op.f('ix_routine_steps_product_id'), 'routine_steps', ['product_id'], unique=False)
op.create_index(op.f('ix_routine_steps_routine_id'), 'routine_steps', ['routine_id'], unique=False)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_routine_steps_routine_id"), table_name="routine_steps") op.drop_index(op.f('ix_routine_steps_routine_id'), table_name='routine_steps')
op.drop_index(op.f("ix_routine_steps_product_id"), table_name="routine_steps") op.drop_index(op.f('ix_routine_steps_product_id'), table_name='routine_steps')
op.drop_table("routine_steps") op.drop_table('routine_steps')
op.drop_index( op.drop_index(op.f('ix_product_inventory_product_id'), table_name='product_inventory')
op.f("ix_product_inventory_product_id"), table_name="product_inventory" op.drop_table('product_inventory')
) op.drop_index(op.f('ix_medication_usages_valid_to'), table_name='medication_usages')
op.drop_table("product_inventory") op.drop_index(op.f('ix_medication_usages_valid_from'), table_name='medication_usages')
op.drop_index(op.f("ix_medication_usages_valid_to"), table_name="medication_usages") op.drop_index(op.f('ix_medication_usages_medication_record_id'), table_name='medication_usages')
op.drop_index( op.drop_index(op.f('ix_medication_usages_as_needed'), table_name='medication_usages')
op.f("ix_medication_usages_valid_from"), table_name="medication_usages" op.drop_table('medication_usages')
) op.drop_index(op.f('ix_skin_condition_snapshots_snapshot_date'), table_name='skin_condition_snapshots')
op.drop_index( op.drop_table('skin_condition_snapshots')
op.f("ix_medication_usages_medication_record_id"), op.drop_index(op.f('ix_routines_routine_date'), table_name='routines')
table_name="medication_usages", op.drop_index(op.f('ix_routines_part_of_day'), table_name='routines')
) op.drop_table('routines')
op.drop_index( op.drop_index(op.f('ix_products_price_tier'), table_name='products')
op.f("ix_medication_usages_as_needed"), table_name="medication_usages" op.drop_table('products')
) op.drop_index(op.f('ix_medication_entries_product_name'), table_name='medication_entries')
op.drop_table("medication_usages") op.drop_index(op.f('ix_medication_entries_kind'), table_name='medication_entries')
op.drop_index( op.drop_index(op.f('ix_medication_entries_active_substance'), table_name='medication_entries')
op.f("ix_skin_condition_snapshots_snapshot_date"), op.drop_table('medication_entries')
table_name="skin_condition_snapshots", op.drop_index(op.f('ix_lab_results_test_code'), table_name='lab_results')
) op.drop_index(op.f('ix_lab_results_lab'), table_name='lab_results')
op.drop_table("skin_condition_snapshots") op.drop_index(op.f('ix_lab_results_flag'), table_name='lab_results')
op.drop_index(op.f("ix_routines_routine_date"), table_name="routines") op.drop_index(op.f('ix_lab_results_collected_at'), table_name='lab_results')
op.drop_index(op.f("ix_routines_part_of_day"), table_name="routines") op.drop_table('lab_results')
op.drop_table("routines") op.drop_index(op.f('ix_grooming_schedule_day_of_week'), table_name='grooming_schedule')
op.drop_index(op.f("ix_products_price_tier"), table_name="products") op.drop_table('grooming_schedule')
op.drop_table("products")
op.drop_index(
op.f("ix_medication_entries_product_name"), table_name="medication_entries"
)
op.drop_index(op.f("ix_medication_entries_kind"), table_name="medication_entries")
op.drop_index(
op.f("ix_medication_entries_active_substance"), table_name="medication_entries"
)
op.drop_table("medication_entries")
op.drop_index(op.f("ix_lab_results_test_code"), table_name="lab_results")
op.drop_index(op.f("ix_lab_results_lab"), table_name="lab_results")
op.drop_index(op.f("ix_lab_results_flag"), table_name="lab_results")
op.drop_index(op.f("ix_lab_results_collected_at"), table_name="lab_results")
op.drop_table("lab_results")
op.drop_index(
op.f("ix_grooming_schedule_day_of_week"), table_name="grooming_schedule"
)
op.drop_table("grooming_schedule")
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -1,29 +0,0 @@
"""add_tool_trace_to_ai_call_logs
Revision ID: d3e4f5a6b7c8
Revises: b2c3d4e5f6a1
Create Date: 2026-03-04 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, None] = "b2c3d4e5f6a1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ai_call_logs",
sa.Column("tool_trace", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("ai_call_logs", "tool_trace")

View file

@ -1,83 +0,0 @@
import json
from typing import Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel, col, select
from db import get_session
from innercontext.models.ai_log import AICallLog
router = APIRouter()
def _normalize_tool_trace(value: object) -> dict[str, Any] | None:
if value is None:
return None
if isinstance(value, dict):
return {str(k): v for k, v in value.items()}
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return None
if isinstance(parsed, dict):
return {str(k): v for k, v in parsed.items()}
return None
return None
class AICallLogPublic(SQLModel):
"""List-friendly view: omits large text fields."""
id: UUID
created_at: object
endpoint: str
model: str
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None
duration_ms: Optional[int] = None
tool_trace: Optional[dict[str, Any]] = None
success: bool
error_detail: Optional[str] = None
@router.get("", response_model=list[AICallLogPublic])
def list_ai_logs(
endpoint: Optional[str] = None,
success: Optional[bool] = None,
limit: int = 50,
session: Session = Depends(get_session),
):
stmt = select(AICallLog).order_by(col(AICallLog.created_at).desc()).limit(limit)
if endpoint is not None:
stmt = stmt.where(AICallLog.endpoint == endpoint)
if success is not None:
stmt = stmt.where(AICallLog.success == success)
logs = session.exec(stmt).all()
return [
AICallLogPublic(
id=log.id,
created_at=log.created_at,
endpoint=log.endpoint,
model=log.model,
prompt_tokens=log.prompt_tokens,
completion_tokens=log.completion_tokens,
total_tokens=log.total_tokens,
duration_ms=log.duration_ms,
tool_trace=_normalize_tool_trace(getattr(log, "tool_trace", None)),
success=log.success,
error_detail=log.error_detail,
)
for log in logs
]
@router.get("/{log_id}", response_model=AICallLog)
def get_ai_log(log_id: UUID, session: Session = Depends(get_session)):
log = session.get(AICallLog, log_id)
if log is None:
raise HTTPException(status_code=404, detail="Log not found")
log.tool_trace = _normalize_tool_trace(getattr(log, "tool_trace", None))
return log

View file

@ -5,18 +5,12 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session, SQLModel, col, select from sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404 from innercontext.api.utils import get_or_404
from innercontext.llm import ( from innercontext.llm import get_gemini_client
call_gemini,
call_gemini_with_function_tools,
get_creative_config,
get_extraction_config,
)
from innercontext.models import ( from innercontext.models import (
Product, Product,
ProductBase, ProductBase,
@ -25,7 +19,6 @@ from innercontext.models import (
ProductPublic, ProductPublic,
ProductWithInventory, ProductWithInventory,
SkinConcern, SkinConcern,
SkinConditionSnapshot,
) )
from innercontext.models.enums import ( from innercontext.models.enums import (
AbsorptionSpeed, AbsorptionSpeed,
@ -150,19 +143,6 @@ class ProductParseResponse(SQLModel):
needle_length_mm: Optional[float] = None needle_length_mm: Optional[float] = None
class AIActiveIngredient(ActiveIngredient):
# Gemini API rejects int-enum values in response_schema; override with plain int.
strength_level: Optional[int] = None # type: ignore[assignment]
irritation_potential: Optional[int] = None # type: ignore[assignment]
class ProductParseLLMResponse(ProductParseResponse):
# Gemini response schema currently requires enum values to be strings.
# Strength fields are numeric in our domain (1-3), so keep them as ints here
# and convert via ProductParseResponse validation afterward.
actives: Optional[list[AIActiveIngredient]] = None # type: ignore[assignment]
class InventoryCreate(SQLModel): class InventoryCreate(SQLModel):
is_opened: bool = False is_opened: bool = False
opened_at: Optional[date] = None opened_at: Optional[date] = None
@ -183,41 +163,6 @@ class InventoryUpdate(SQLModel):
notes: Optional[str] = None notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Shopping suggestion schemas
# ---------------------------------------------------------------------------
class ProductSuggestion(PydanticBase):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class ShoppingSuggestionResponse(PydanticBase):
suggestions: list[ProductSuggestion]
reasoning: str
class _ProductSuggestionOut(PydanticBase):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class _ShoppingSuggestionsOut(PydanticBase):
suggestions: list[_ProductSuggestionOut]
reasoning: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Product routes # Product routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -260,9 +205,7 @@ def list_products(
product_ids = [p.id for p in products] product_ids = [p.id for p in products]
inventory_rows = ( inventory_rows = (
session.exec( session.exec(
select(ProductInventory).where( select(ProductInventory).where(ProductInventory.product_id.in_(product_ids))
col(ProductInventory.product_id).in_(product_ids)
)
).all() ).all()
if product_ids if product_ids
else [] else []
@ -424,15 +367,17 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
@router.post("/parse-text", response_model=ProductParseResponse) @router.post("/parse-text", response_model=ProductParseResponse)
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
response = call_gemini( client, model = get_gemini_client()
endpoint="products/parse-text", response = client.models.generate_content(
model=model,
contents=f"Extract product data from this text:\n\n{data.text}", contents=f"Extract product data from this text:\n\n{data.text}",
config=get_extraction_config( config=genai_types.GenerateContentConfig(
system_instruction=_product_parse_system_prompt(), system_instruction=_product_parse_system_prompt(),
response_schema=ProductParseLLMResponse, response_mime_type="application/json",
response_schema=ProductParseResponse,
max_output_tokens=16384, max_output_tokens=16384,
temperature=0.0,
), ),
user_input=data.text,
) )
raw = response.text raw = response.text
if not raw: if not raw:
@ -442,8 +387,7 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try: try:
llm_parsed = ProductParseLLMResponse.model_validate(parsed) return ProductParseResponse.model_validate(parsed)
return ProductParseResponse.model_validate(llm_parsed.model_dump())
except ValidationError as e: except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors()) raise HTTPException(status_code=422, detail=e.errors())
@ -509,425 +453,3 @@ def create_product_inventory(
session.commit() session.commit()
session.refresh(entry) session.refresh(entry)
return entry return entry
# ---------------------------------------------------------------------------
# Shopping suggestion
# ---------------------------------------------------------------------------
def _ev(v: object) -> str:
if v is None:
return ""
value = getattr(v, "value", None)
if isinstance(value, str):
return value
return str(v)
def _build_shopping_context(session: Session) -> str:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
skin_lines = ["STAN SKÓRY:"]
if snapshot:
skin_lines.append(f" Data: {snapshot.snapshot_date}")
skin_lines.append(f" Ogólny stan: {_ev(snapshot.overall_state)}")
skin_lines.append(f" Typ skóry: {_ev(snapshot.skin_type)}")
skin_lines.append(f" Nawilżenie: {snapshot.hydration_level}/5")
skin_lines.append(f" Wrażliwość: {snapshot.sensitivity_level}/5")
skin_lines.append(f" Bariera: {_ev(snapshot.barrier_state)}")
concerns = ", ".join(_ev(c) for c in (snapshot.active_concerns or []))
skin_lines.append(f" Aktywne problemy: {concerns or 'brak'}")
if snapshot.priorities:
skin_lines.append(f" Priorytety: {', '.join(snapshot.priorities)}")
else:
skin_lines.append(" (brak danych)")
products = _get_shopping_products(session)
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
)
inv_by_product: dict = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
products_lines = ["POSIADANE PRODUKTY:"]
products_lines.append(
" Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie"
)
for p in products:
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
stock = "" if has_stock else ""
actives = _extract_active_names(p)
actives_str = f", actives: {actives}" if actives else ""
ep = p.product_effect_profile
if isinstance(ep, dict):
effects = {k.replace("_strength", ""): v for k, v in ep.items() if v >= 3}
else:
effects = {
k.replace("_strength", ""): v
for k, v in ep.model_dump().items()
if v >= 3
}
effects_str = f", effects: {effects}" if effects else ""
targets = [_ev(t) for t in (p.targets or [])]
products_lines.append(
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
f"targets: {targets}{actives_str}{effects_str}"
)
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
def _get_shopping_products(session: Session) -> list[Product]:
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
return [p for p in products if not p.is_medication]
def _extract_active_names(product: Product) -> list[str]:
names: list[str] = []
for active in product.actives or []:
if isinstance(active, dict):
name = str(active.get("name") or "").strip()
else:
name = str(getattr(active, "name", "") or "").strip()
if not name:
continue
if name in names:
continue
names.append(name)
if len(names) >= 12:
break
return names
def _extract_requested_product_ids(
args: dict[str, object], max_ids: int = 8
) -> list[str]:
raw_ids = args.get("product_ids")
if not isinstance(raw_ids, list):
return []
requested_ids: list[str] = []
seen: set[str] = set()
for raw_id in raw_ids:
if not isinstance(raw_id, str):
continue
if raw_id in seen:
continue
seen.add(raw_id)
requested_ids.append(raw_id)
if len(requested_ids) >= max_ids:
break
return requested_ids
def _build_product_details_tool_handler(products: list[Product], mapper):
available_by_id = {str(p.id): p for p in products}
def _handler(args: dict[str, object]) -> dict[str, object]:
requested_ids = _extract_requested_product_ids(args)
products_payload = []
for pid in requested_ids:
product = available_by_id.get(pid)
if product is None:
continue
products_payload.append(mapper(product, pid))
return {"products": products_payload}
return _handler
def _build_inci_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
inci = product.inci or []
compact_inci = [str(i)[:120] for i in inci[:128]]
return {"id": pid, "name": product.name, "inci": compact_inci}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_actives_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
payload = []
for active in product.actives or []:
if isinstance(active, dict):
name = str(active.get("name") or "").strip()
if not name:
continue
item = {"name": name}
percent = active.get("percent")
if percent is not None:
item["percent"] = percent
functions = active.get("functions")
if isinstance(functions, list):
item["functions"] = [str(f) for f in functions[:4]]
strength_level = active.get("strength_level")
if strength_level is not None:
item["strength_level"] = str(strength_level)
payload.append(item)
continue
name = str(getattr(active, "name", "") or "").strip()
if not name:
continue
item = {"name": name}
percent = getattr(active, "percent", None)
if percent is not None:
item["percent"] = percent
functions = getattr(active, "functions", None)
if isinstance(functions, list):
item["functions"] = [_ev(f) for f in functions[:4]]
strength_level = getattr(active, "strength_level", None)
if strength_level is not None:
item["strength_level"] = _ev(strength_level)
payload.append(item)
return {"id": pid, "name": product.name, "actives": payload[:24]}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_usage_notes_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
notes = " ".join(str(product.usage_notes or "").split())
if len(notes) > 500:
notes = notes[:497] + "..."
return {"id": pid, "name": product.name, "usage_notes": notes}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_safety_rules_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
ctx = product.to_llm_context()
return {
"id": pid,
"name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {},
"min_interval_hours": ctx.get("min_interval_hours"),
"max_frequency_per_week": ctx.get("max_frequency_per_week"),
}
return _build_product_details_tool_handler(products, mapper=_mapper)
_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_inci",
description=(
"Return exact INCI ingredient lists for selected product UUIDs from "
"POSIADANE PRODUKTY."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules",
description=(
"Return safety and compatibility rules for selected product UUIDs, "
"including incompatible_with, contraindications, context_rules and safety flags."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_ACTIVES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_actives",
description=(
"Return detailed active ingredients for selected product UUIDs, "
"including concentration and functions when available."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_USAGE_NOTES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_usage_notes",
description=(
"Return compact usage notes for selected product UUIDs "
"(timing, application method and cautions)."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
LEGENDA:
- [] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
- [] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
ZASADY:
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
2. Produkty oznaczone [] to te, których NIE MA w magazynie - możesz je zasugerować
3. Produkty oznaczone [] już dostępne - nie sugeruj ich ponownie
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
6. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów)
9. Odpowiadaj w języku polskim
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)
shopping_products = _get_shopping_products(session)
prompt = (
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
f"{context}\n\n"
"NARZEDZIA:\n"
"- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives, get_product_usage_notes.\n"
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do oceny konfliktow skladnikow lub ryzyka podraznien.\n"
"- Grupuj UUID: staraj sie pobierac dane dla wielu produktow jednym wywolaniem.\n"
f"Zwróć wyłącznie JSON zgodny ze schematem."
)
config = get_creative_config(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=4096,
).model_copy(
update={
"tools": [
genai_types.Tool(
function_declarations=[
_INCI_FUNCTION_DECLARATION,
_SAFETY_RULES_FUNCTION_DECLARATION,
_ACTIVES_FUNCTION_DECLARATION,
_USAGE_NOTES_FUNCTION_DECLARATION,
]
)
],
"tool_config": genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode=genai_types.FunctionCallingConfigMode.AUTO,
)
),
}
)
function_handlers = {
"get_product_inci": _build_inci_tool_handler(shopping_products),
"get_product_safety_rules": _build_safety_rules_tool_handler(shopping_products),
"get_product_actives": _build_actives_tool_handler(shopping_products),
"get_product_usage_notes": _build_usage_notes_tool_handler(shopping_products),
}
try:
response = call_gemini_with_function_tools(
endpoint="products/suggest",
contents=prompt,
config=config,
function_handlers=function_handlers,
user_input=prompt,
max_tool_roundtrips=3,
)
except HTTPException as exc:
if (
exc.status_code != 502
or str(exc.detail) != "Gemini requested too many function calls"
):
raise
conservative_prompt = (
f"{prompt}\n\n"
"TRYB AWARYJNY (KONSERWATYWNY):\n"
"- Osiagnieto limit wywolan narzedzi.\n"
"- Nie wywoluj narzedzi ponownie.\n"
"- Zasugeruj tylko najbardziej bezpieczne i realistyczne typy produktow do uzupelnienia brakow,"
" unikaj agresywnych aktywnych przy niepelnych danych.\n"
)
response = call_gemini(
endpoint="products/suggest",
contents=conservative_prompt,
config=get_creative_config(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=4096,
),
user_input=conservative_prompt,
tool_trace={
"mode": "fallback_conservative",
"reason": "max_tool_roundtrips_exceeded",
},
)
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
return ShoppingSuggestionResponse(
suggestions=[ProductSuggestion(**s) for s in parsed.get("suggestions", [])],
reasoning=parsed.get("reasoning", ""),
)

View file

@ -6,23 +6,12 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase from pydantic import BaseModel as PydanticBase
from sqlmodel import Field, Session, SQLModel, col, select from sqlmodel import Session, SQLModel, col, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404 from innercontext.api.utils import get_or_404
from innercontext.llm import ( from innercontext.llm import get_gemini_client
call_gemini, from innercontext.models import GroomingSchedule, Product, Routine, RoutineStep, SkinConditionSnapshot
call_gemini_with_function_tools,
get_creative_config,
)
from innercontext.models import (
GroomingSchedule,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
from innercontext.models.enums import GroomingAction, PartOfDay from innercontext.models.enums import GroomingAction, PartOfDay
router = APIRouter() router = APIRouter()
@ -81,36 +70,23 @@ class SuggestedStep(SQLModel):
action_notes: Optional[str] = None action_notes: Optional[str] = None
dose: Optional[str] = None dose: Optional[str] = None
region: Optional[str] = None region: Optional[str] = None
why_this_step: Optional[str] = None
optional: Optional[bool] = None
class SuggestRoutineRequest(SQLModel): class SuggestRoutineRequest(SQLModel):
routine_date: date routine_date: date
part_of_day: PartOfDay part_of_day: PartOfDay
notes: Optional[str] = None notes: Optional[str] = None
include_minoxidil_beard: bool = False
leaving_home: Optional[bool] = None
class RoutineSuggestionSummary(SQLModel):
primary_goal: str = ""
constraints_applied: list[str] = Field(default_factory=list)
confidence: float = 0.0
class RoutineSuggestion(SQLModel): class RoutineSuggestion(SQLModel):
steps: list[SuggestedStep] steps: list[SuggestedStep]
reasoning: str reasoning: str
summary: Optional[RoutineSuggestionSummary] = None
class SuggestBatchRequest(SQLModel): class SuggestBatchRequest(SQLModel):
from_date: date from_date: date
to_date: date to_date: date
notes: Optional[str] = None notes: Optional[str] = None
include_minoxidil_beard: bool = False
minimize_products: Optional[bool] = None
class DayPlan(SQLModel): class DayPlan(SQLModel):
@ -130,40 +106,23 @@ class BatchSuggestion(SQLModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class _SingleStepOut(PydanticBase): class _StepOut(PydanticBase):
product_id: Optional[str] = None product_id: Optional[str] = None
action_type: Optional[GroomingAction] = None action_type: Optional[GroomingAction] = None
dose: Optional[str] = None dose: Optional[str] = None
region: Optional[str] = None region: Optional[str] = None
action_notes: Optional[str] = None action_notes: Optional[str] = None
why_this_step: Optional[str] = None
optional: Optional[bool] = None
class _BatchStepOut(PydanticBase):
product_id: Optional[str] = None
action_type: Optional[GroomingAction] = None
dose: Optional[str] = None
region: Optional[str] = None
action_notes: Optional[str] = None
class _SummaryOut(PydanticBase):
primary_goal: str
constraints_applied: list[str]
confidence: float
class _SuggestionOut(PydanticBase): class _SuggestionOut(PydanticBase):
steps: list[_SingleStepOut] steps: list[_StepOut]
reasoning: str reasoning: str
summary: _SummaryOut
class _DayPlanOut(PydanticBase): class _DayPlanOut(PydanticBase):
date: str date: str
am_steps: list[_BatchStepOut] am_steps: list[_StepOut]
pm_steps: list[_BatchStepOut] pm_steps: list[_StepOut]
reasoning: str reasoning: str
@ -176,96 +135,43 @@ class _BatchOut(PydanticBase):
# Prompt helpers # Prompt helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_DAY_NAMES = [ _DAY_NAMES = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"]
"poniedziałek",
"wtorek",
"środa",
"czwartek",
"piątek",
"sobota",
"niedziela",
]
def _contains_minoxidil_text(value: Optional[str]) -> bool:
if not value:
return False
text = value.lower()
return "minoxidil" in text or "minoksydyl" in text
def _is_minoxidil_product(product: Product) -> bool:
if _contains_minoxidil_text(product.name):
return True
if _contains_minoxidil_text(product.brand):
return True
if _contains_minoxidil_text(product.line_name):
return True
if _contains_minoxidil_text(product.usage_notes):
return True
if any(_contains_minoxidil_text(i) for i in (product.inci or [])):
return True
actives = product.actives or []
for a in actives:
if isinstance(a, dict):
if _contains_minoxidil_text(str(a.get("name", ""))):
return True
continue
if _contains_minoxidil_text(a.name):
return True
return False
def _ev(v: object) -> str: def _ev(v: object) -> str:
if v is None: return v.value if v is not None and hasattr(v, "value") else str(v) if v is not None else ""
return ""
value = getattr(v, "value", None)
if isinstance(value, str):
return value
return str(v)
def _build_skin_context(session: Session) -> str: def _build_skin_context(session: Session) -> str:
snapshot = session.exec( snapshot = session.exec(
select(SkinConditionSnapshot).order_by( select(SkinConditionSnapshot).order_by(col(SkinConditionSnapshot.snapshot_date).desc())
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first() ).first()
if snapshot is None: if snapshot is None:
return "SKIN CONDITION: no data\n" return "STAN SKÓRY: brak danych\n"
ev = _ev ev = _ev
return ( return (
f"SKIN CONDITION (snapshot from {snapshot.snapshot_date}):\n" f"STAN SKÓRY (snapshot z {snapshot.snapshot_date}):\n"
f" Overall state: {ev(snapshot.overall_state)}\n" f" Ogólny stan: {ev(snapshot.overall_state)}\n"
f" Hydration: {snapshot.hydration_level}/5\n" f" Nawilżenie: {snapshot.hydration_level}/5\n"
f" Barrier: {ev(snapshot.barrier_state)}\n" f" Bariera: {ev(snapshot.barrier_state)}\n"
f" Active concerns: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n" f" Aktywne problemy: {', '.join(ev(c) for c in (snapshot.active_concerns or []))}\n"
f" Priorities: {', '.join(snapshot.priorities or [])}\n" f" Priorytety: {', '.join(snapshot.priorities or [])}\n"
f" Notes: {snapshot.notes or 'none'}\n" f" Uwagi: {snapshot.notes or 'brak'}\n"
) )
def _build_grooming_context( def _build_grooming_context(session: Session, weekdays: Optional[list[int]] = None) -> str:
session: Session, weekdays: Optional[list[int]] = None entries = session.exec(select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)).all()
) -> str:
entries = session.exec(
select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week))
).all()
if not entries: if not entries:
return "GROOMING SCHEDULE: none\n" return "HARMONOGRAM PIELĘGNACJI: brak\n"
lines = ["GROOMING SCHEDULE:"] lines = ["HARMONOGRAM PIELĘGNACJI:"]
for e in entries: for e in entries:
if weekdays is not None and e.day_of_week not in weekdays: if weekdays is not None and e.day_of_week not in weekdays:
continue continue
day_name = ( day_name = _DAY_NAMES[e.day_of_week] if 0 <= e.day_of_week <= 6 else str(e.day_of_week)
_DAY_NAMES[e.day_of_week] if 0 <= e.day_of_week <= 6 else str(e.day_of_week) lines.append(f" {day_name}: {_ev(e.action)}" + (f" ({e.notes})" if e.notes else ""))
)
lines.append(
f" {day_name}: {_ev(e.action)}" + (f" ({e.notes})" if e.notes else "")
)
if len(lines) == 1: if len(lines) == 1:
lines.append(" (no entries for specified days)") lines.append(" (brak wpisów dla podanych dni)")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@ -277,462 +183,66 @@ def _build_recent_history(session: Session) -> str:
.order_by(col(Routine.routine_date).desc()) .order_by(col(Routine.routine_date).desc())
).all() ).all()
if not routines: if not routines:
return "RECENT ROUTINES: none\n" return "OSTATNIE RUTYNY (7 dni): brak\n"
lines = ["RECENT ROUTINES:"] lines = ["OSTATNIE RUTYNY (7 dni):"]
for r in routines: for r in routines:
steps = session.exec( steps = session.exec(
select(RoutineStep) select(RoutineStep)
.where(RoutineStep.routine_id == r.id) .where(RoutineStep.routine_id == r.id)
.order_by(col(RoutineStep.order_index)) .order_by(RoutineStep.order_index)
).all() ).all()
step_names = [] step_names = []
for s in steps: for s in steps:
if s.product_id: if s.product_id:
p = session.get(Product, s.product_id) p = session.get(Product, s.product_id)
if p: step_names.append(p.name if p else str(s.product_id))
short_id = str(p.id)[:8]
step_names.append(f"{_ev(p.category)} [{short_id}]")
else:
step_names.append(f"unknown [{str(s.product_id)[:8]}]")
elif s.action_type: elif s.action_type:
step_names.append(f"action: {_ev(s.action_type)}") step_names.append(_ev(s.action_type))
lines.append( lines.append(f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}")
f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}"
)
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def _build_products_context( def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str:
session: Session, stmt = select(Product).where(Product.is_medication == False).where(Product.is_tool == False) # noqa: E712
time_filter: Optional[str] = None, products = session.exec(stmt).all()
reference_date: Optional[date] = None, lines = ["DOSTĘPNE PRODUKTY:"]
) -> str:
products = _get_available_products(session, time_filter=time_filter)
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
)
inv_by_product: dict[UUID, list[ProductInventory]] = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
recent_usage_counts: dict[UUID, int] = {}
if reference_date is not None:
cutoff = reference_date - timedelta(days=7)
recent_usage = session.exec(
select(RoutineStep.product_id)
.join(Routine)
.where(col(Routine.routine_date) > cutoff)
.where(col(Routine.routine_date) <= reference_date)
).all()
for pid in recent_usage:
if pid:
recent_usage_counts[pid] = recent_usage_counts.get(pid, 0) + 1
lines = ["AVAILABLE PRODUCTS:"]
for p in products: for p in products:
p.inventory = inv_by_product.get(p.id, []) if time_filter and _ev(p.recommended_time) not in (time_filter, "both"):
continue
ctx = p.to_llm_context() ctx = p.to_llm_context()
entry = ( entry = (
f' - id={ctx["id"]} name="{ctx["name"]}" brand="{ctx["brand"]}"' f" - id={ctx['id']} name=\"{ctx['name']}\" brand=\"{ctx['brand']}\""
f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}" f" category={ctx.get('category', '')} recommended_time={ctx.get('recommended_time', '')}"
f" leave_on={ctx.get('leave_on', '')}"
f" targets={ctx.get('targets', [])}" f" targets={ctx.get('targets', [])}"
) )
active_names = _extract_active_names(p) profile = ctx.get("product_effect_profile", {})
if active_names:
entry += f" actives={active_names}"
active_inventory = [inv for inv in p.inventory if inv.finished_at is None]
open_inventory = [inv for inv in active_inventory if inv.is_opened]
sealed_inventory = [inv for inv in active_inventory if not inv.is_opened]
entry += (
" inventory_status={"
f"active:{len(active_inventory)},opened:{len(open_inventory)},sealed:{len(sealed_inventory)}"
"}"
)
if open_inventory:
expiry_dates = sorted(
inv.expiry_date.isoformat() for inv in open_inventory if inv.expiry_date
)
if expiry_dates:
entry += f" nearest_open_expiry={expiry_dates[0]}"
if p.pao_months is not None:
pao_deadlines = sorted(
(inv.opened_at + timedelta(days=30 * p.pao_months)).isoformat()
for inv in open_inventory
if inv.opened_at
)
if pao_deadlines:
entry += f" nearest_open_pao_deadline={pao_deadlines[0]}"
if p.pao_months is not None:
entry += f" pao_months={p.pao_months}"
profile = ctx.get("effect_profile", {})
if profile: if profile:
notable = {k: v for k, v in profile.items() if v and v > 0} notable = {k: v for k, v in profile.items() if v and v > 0}
if notable: if notable:
entry += f" effects={notable}" entry += f" effects={notable}"
if ctx.get("incompatible_with"): if ctx.get("incompatible_with"):
entry += f" incompatible_with={ctx['incompatible_with']}" entry += f" incompatible_with={ctx['incompatible_with']}"
if ctx.get("contraindications"):
entry += f" contraindications={ctx['contraindications']}"
if ctx.get("context_rules"): if ctx.get("context_rules"):
entry += f" context_rules={ctx['context_rules']}" entry += f" context_rules={ctx['context_rules']}"
safety = ctx.get("safety") or {}
if isinstance(safety, dict):
not_safe = {k: v for k, v in safety.items() if v is False}
if not_safe:
entry += f" safety_alerts={not_safe}"
if ctx.get("min_interval_hours"): if ctx.get("min_interval_hours"):
entry += f" min_interval_hours={ctx['min_interval_hours']}" entry += f" min_interval_hours={ctx['min_interval_hours']}"
if ctx.get("max_frequency_per_week"): if ctx.get("max_frequency_per_week"):
entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}" entry += f" max_frequency_per_week={ctx['max_frequency_per_week']}"
usage_count = recent_usage_counts.get(p.id, 0)
entry += f" used_in_last_7_days={usage_count}"
lines.append(entry) lines.append(entry)
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def _get_available_products( _RULES = """\
session: Session, ZASADY:
time_filter: Optional[str] = None, - Kolejność warstw: cleanser toner essence serum moisturizer [SPF dla AM]
) -> list[Product]: - Respektuj incompatible_with (scope: same_step / same_day / same_period)
stmt = select(Product).where(col(Product.is_tool).is_(False)) - Respektuj context_rules (safe_after_shaving, safe_after_acids itp.)
products = session.exec(stmt).all() - Respektuj min_interval_hours i max_frequency_per_week
result: list[Product] = [] - 47 kroków na rutynę
for p in products: - product_id musi być UUID produktu z listy lub null dla czynności pielęgnacyjnych
if p.is_medication and not _is_minoxidil_product(p): - action_type: tylko shaving_razor | shaving_oneblade | dermarolling (lub null)
continue - Nie używaj retinoidów i kwasów w tej samej rutynie
if time_filter and _ev(p.recommended_time) not in (time_filter, "both"): - W AM zawsze uwzględnij SPF jeśli dostępny
continue
result.append(p)
return result
def _build_inci_tool_handler(
products: list[Product],
):
def _mapper(product: Product, pid: str) -> dict[str, object]:
inci = product.inci or []
compact_inci = [str(i)[:120] for i in inci[:128]]
return {
"id": pid,
"name": product.name,
"inci": compact_inci,
}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_actives_tool_handler(
products: list[Product],
):
def _mapper(product: Product, pid: str) -> dict[str, object]:
actives_payload = []
for a in product.actives or []:
if isinstance(a, dict):
active_name = str(a.get("name") or "").strip()
if not active_name:
continue
item = {"name": active_name}
percent = a.get("percent")
if percent is not None:
item["percent"] = percent
functions = a.get("functions")
if isinstance(functions, list):
item["functions"] = [str(f) for f in functions[:4]]
strength_level = a.get("strength_level")
if strength_level is not None:
item["strength_level"] = str(strength_level)
actives_payload.append(item)
continue
active_name = str(getattr(a, "name", "") or "").strip()
if not active_name:
continue
item = {"name": active_name}
percent = getattr(a, "percent", None)
if percent is not None:
item["percent"] = percent
functions = getattr(a, "functions", None)
if isinstance(functions, list):
item["functions"] = [_ev(f) for f in functions[:4]]
strength_level = getattr(a, "strength_level", None)
if strength_level is not None:
item["strength_level"] = _ev(strength_level)
actives_payload.append(item)
return {
"id": pid,
"name": product.name,
"actives": actives_payload[:24],
}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_usage_notes_tool_handler(
products: list[Product],
):
def _mapper(product: Product, pid: str) -> dict[str, object]:
notes = " ".join(str(product.usage_notes or "").split())
if len(notes) > 500:
notes = notes[:497] + "..."
return {
"id": pid,
"name": product.name,
"usage_notes": notes,
}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_safety_rules_tool_handler(
products: list[Product],
):
def _mapper(product: Product, pid: str) -> dict[str, object]:
ctx = product.to_llm_context()
return {
"id": pid,
"name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {},
"min_interval_hours": ctx.get("min_interval_hours"),
"max_frequency_per_week": ctx.get("max_frequency_per_week"),
}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_product_details_tool_handler(
products: list[Product],
mapper,
):
available_by_id = {str(p.id): p for p in products}
def _handler(args: dict[str, object]) -> dict[str, object]:
requested_ids = _extract_requested_product_ids(args)
products_payload = []
for pid in requested_ids:
product = available_by_id.get(pid)
if product is None:
continue
products_payload.append(mapper(product, pid))
return {"products": products_payload}
return _handler
def _extract_requested_product_ids(
args: dict[str, object], max_ids: int = 8
) -> list[str]:
raw_ids = args.get("product_ids")
if not isinstance(raw_ids, list):
return []
requested_ids: list[str] = []
seen: set[str] = set()
for raw_id in raw_ids:
if not isinstance(raw_id, str):
continue
if raw_id in seen:
continue
seen.add(raw_id)
requested_ids.append(raw_id)
if len(requested_ids) >= max_ids:
break
return requested_ids
def _extract_active_names(product: Product) -> list[str]:
names: list[str] = []
for a in product.actives or []:
if isinstance(a, dict):
name = str(a.get("name") or "").strip()
else:
name = str(getattr(a, "name", "") or "").strip()
if not name:
continue
if name in names:
continue
names.append(name)
if len(names) >= 12:
break
return names
_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_inci",
description=(
"Return exact INCI ingredient lists for products identified by UUID from "
"the AVAILABLE PRODUCTS list."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from AVAILABLE PRODUCTS.",
)
},
required=["product_ids"],
),
)
_ACTIVES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_actives",
description=(
"Return detailed active ingredients (name, strength, concentration, functions) "
"for selected product UUIDs."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from AVAILABLE PRODUCTS.",
)
},
required=["product_ids"],
),
)
_USAGE_NOTES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_usage_notes",
description=(
"Return compact usage notes for selected product UUIDs (application method, "
"timing, and cautions)."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from AVAILABLE PRODUCTS.",
)
},
required=["product_ids"],
),
)
_SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules",
description=(
"Return safety and compatibility rules for selected product UUIDs: "
"incompatible_with, contraindications, context_rules and safety flags."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from AVAILABLE PRODUCTS.",
)
},
required=["product_ids"],
),
)
def _build_objectives_context(include_minoxidil_beard: bool) -> str:
if include_minoxidil_beard:
return (
"USER OBJECTIVES:\n"
" - Priority: improve beard and mustache density\n"
" - If a product with minoxidil is available, include it adhering strictly to safety rules\n"
)
return ""
def _build_day_context(leaving_home: Optional[bool]) -> str:
if leaving_home is None:
return ""
val = "yes" if leaving_home else "no"
return f"DAY CONTEXT:\n Leaving home: {val}\n"
_ROUTINES_SYSTEM_PROMPT = """\
Jesteś ekspertem planowania pielęgnacji.
CEL:
Twórz realistyczne, bezpieczne i krótkie rutyny o wysokiej zgodności z danymi wejściowymi.
PRIORYTETY DECYZYJNE (od najwyższego):
1) Bezpieczeństwo (brak realnego ryzyka klinicznego)
2) Cel terapeutyczny użytkownika
3) Reguły częstotliwości i odstępów
4) Zarządzanie inwentarzem
5) Prostota
> Cel terapeutyczny oznacza maksymalizację realnego efektu klinicznego,
> nie tylko zgodność z deklarowanymi targetami produktu.
WYMAGANIA ODPOWIEDZI:
- Zwracaj wyłącznie poprawny JSON (bez markdown, bez komentarzy, bez preambuły).
- Trzymaj się dokładnie przekazanego schematu odpowiedzi.
- Nie używaj żadnych pól spoza schematu.
- Nie twórz produktów spoza listy wejściowej.
- Jeśli nie da się bezpiecznie dodać kroku, pomiń go zamiast zgadywać.
ZASADY PLANOWANIA:
- Kolejność warstw: cleanser -> toner -> essence -> serum -> moisturizer -> [SPF dla AM].
- Respektuj: incompatible_with (same_step / same_day / same_period), context_rules,
min_interval_hours, max_frequency_per_week, usage_notes.
- Zarządzanie inwentarzem:
- Preferuj produkty już otwarte (miękka preferencja).
- Unikaj funkcjonalnej redundancji (np. wielokrotne źródła panthenolu, ceramidów lub niacynamidu w tej samej rutynie),
chyba że istnieje wyraźne uzasadnienie terapeutyczne.
- Maksymalnie 2 serum w rutynie.
Jeśli 2: jedno jako główny aktywny bodziec, drugie wyłącznie wspierające.
- Dla mildly_compromised nie eliminuj automatycznie umiarkowanych aktywnych;
decyzję opieraj na effect_profile (irritation_risk, barrier_disruption_risk) i regułach bezpieczeństwa.
- Nie zwiększaj intensywności terapii (retinoid/kwasy) dzień po dniu,
jeśli brak wyraźnej poprawy stanu skóry lub brak wskazań klinicznych.
- Nie łącz retinoidów i kwasów w tej samej rutynie ani tego samego dnia (dla planu wielodniowego).
- W AM zawsze uwzględnij SPF, jeśli kompatybilny produkt SPF istnieje na liście.
Wybór filtra (na podstawie KONTEKST DNIA):
- "Wyjście z domu: tak" najwyższy współczynnik dostępny w nazwie (SPF50+, SPF50, SPF30);
- "Wyjście z domu: nie" SPF30 wystarczy; wyższy dopuszczalny jeśli brak SPF30;
- brak KONTEKST DNIA wybierz najwyższy dostępny.
- Dla minoksydylu (jeśli celem jest zarost i produkt jest dostępny): ustaw adekwatny region
broda/wąsy i nie naruszaj ograniczeń bezpieczeństwa.
- Preferuj 4-7 kroków na pojedynczą rutynę; unikaj zbędnych duplikatów aktywnych.
- Jeśli krok to produkt: podaj poprawny UUID z listy.
- Jeśli krok to czynność pielęgnacyjna: product_id = null. Dozwolone akcje ściśle określone w schemacie (action_type).
- Nie zwracaj "pustych" kroków: każdy krok musi mieć product_id albo action_type.
- Pole region uzupełniaj tylko gdy ma znaczenie kliniczne/praktyczne (np. broda, wąsy, okolica oczu, szyja).
Dla standardowych kroków pielęgnacji całej twarzy pozostaw region puste.
JAK ROZWIĄZYWAĆ KONFLIKTY:
- Bezpieczeństwo > wszystko.
- Jeśli MODE=travel: logistyka podróży > różnorodność terapeutyczna.
- W MODE=travel odejdź od minimalizacji produktów tylko gdy wymaga tego bezpieczeństwo
lub bez dodatkowego produktu nie da się osiągnąć głównego celu terapeutycznego.
- Jeśli MODE=standard i bezpieczeństwo jest zachowane, preferuj różnorodność terapeutyczną.
- Przy niepełnych danych wybierz wariant konserwatywny.
"""
_ROUTINES_SINGLE_EXTRA = """\
DODATKOWE WYMAGANIA DLA TRYBU JEDNEJ RUTYNY:
- Każdy krok powinien mieć zwięzłe why_this_step (maks. jedno zdanie).
- Pole optional ustawiaj na true tylko dla kroków niekrytycznych.
- Uzupełnij summary:
- primary_goal: główny cel tej rutyny,
- constraints_applied: lista kluczowych ograniczeń zastosowanych przy planowaniu,
- confidence: liczba 0-1.
""" """
@ -746,7 +256,7 @@ DODATKOWE WYMAGANIA DLA TRYBU JEDNEJ RUTYNY:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("") @router.get("", response_model=list[Routine])
def list_routines( def list_routines(
from_date: Optional[date] = None, from_date: Optional[date] = None,
to_date: Optional[date] = None, to_date: Optional[date] = None,
@ -760,25 +270,7 @@ def list_routines(
stmt = stmt.where(Routine.routine_date <= to_date) stmt = stmt.where(Routine.routine_date <= to_date)
if part_of_day is not None: if part_of_day is not None:
stmt = stmt.where(Routine.part_of_day == part_of_day) stmt = stmt.where(Routine.part_of_day == part_of_day)
routines = session.exec(stmt).all() return session.exec(stmt).all()
routine_ids = [r.id for r in routines]
steps_by_routine: dict = {}
if routine_ids:
all_steps = session.exec(
select(RoutineStep).where(col(RoutineStep.routine_id).in_(routine_ids))
).all()
for step in all_steps:
steps_by_routine.setdefault(step.routine_id, []).append(step)
result = []
for r in routines:
data = r.model_dump(mode="json")
data["steps"] = [
s.model_dump(mode="json") for s in steps_by_routine.get(r.id, [])
]
result.append(data)
return result
@router.post("", response_model=Routine, status_code=201) @router.post("", response_model=Routine, status_code=201)
@ -800,112 +292,37 @@ def suggest_routine(
data: SuggestRoutineRequest, data: SuggestRoutineRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
client, model = get_gemini_client()
weekday = data.routine_date.weekday() weekday = data.routine_date.weekday()
skin_ctx = _build_skin_context(session) skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) grooming_ctx = _build_grooming_context(session, weekdays=[weekday])
history_ctx = _build_recent_history(session) history_ctx = _build_recent_history(session)
day_ctx = _build_day_context(data.leaving_home) products_ctx = _build_products_context(session, time_filter=data.part_of_day.value)
products_ctx = _build_products_context(
session, time_filter=data.part_of_day.value, reference_date=data.routine_date
)
available_products = _get_available_products(
session,
time_filter=data.part_of_day.value,
)
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
mode_line = "MODE: standard" notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
notes_line = f"USER CONTEXT: {data.notes}\n" if data.notes else ""
day_name = _DAY_NAMES[weekday] day_name = _DAY_NAMES[weekday]
prompt = ( prompt = (
f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} " f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} "
f"na {data.routine_date} ({day_name}).\n\n" f"na {data.routine_date} ({day_name}).\n\n"
f"{mode_line}\n" f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
"INPUT DATA:\n" "\nZwróć JSON zgodny ze schematem."
f"{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, get_product_usage_notes.\n"
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do decyzji klinicznej/bezpieczenstwa.\n"
"- Staraj sie grupowac zapytania: podawaj wszystkie potrzebne UUID w jednym wywolaniu narzedzia.\n"
"- Nie zgaduj detali skladu i zasad bezpieczenstwa; jesli potrzebujesz szczegolow, wywolaj odpowiednie narzedzie.\n"
f"{notes_line}"
f"{_ROUTINES_SINGLE_EXTRA}\n"
"Zwróć JSON zgodny ze schematem."
) )
config = get_creative_config(
system_instruction=_ROUTINES_SYSTEM_PROMPT,
response_schema=_SuggestionOut,
max_output_tokens=4096,
).model_copy(
update={
"tools": [
genai_types.Tool(
function_declarations=[
_INCI_FUNCTION_DECLARATION,
_SAFETY_RULES_FUNCTION_DECLARATION,
_ACTIVES_FUNCTION_DECLARATION,
_USAGE_NOTES_FUNCTION_DECLARATION,
],
)
],
"tool_config": genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode=genai_types.FunctionCallingConfigMode.AUTO,
)
),
}
)
function_handlers = {
"get_product_inci": _build_inci_tool_handler(available_products),
"get_product_safety_rules": _build_safety_rules_tool_handler(
available_products
),
"get_product_actives": _build_actives_tool_handler(available_products),
"get_product_usage_notes": _build_usage_notes_tool_handler(available_products),
}
try: try:
response = call_gemini_with_function_tools( response = client.models.generate_content(
endpoint="routines/suggest", model=model,
contents=prompt, contents=prompt,
config=config, config=genai_types.GenerateContentConfig(
function_handlers=function_handlers, response_mime_type="application/json",
user_input=prompt,
max_tool_roundtrips=3,
)
except HTTPException as exc:
if (
exc.status_code != 502
or str(exc.detail) != "Gemini requested too many function calls"
):
raise
conservative_prompt = (
f"{prompt}\n\n"
"TRYB AWARYJNY (KONSERWATYWNY):\n"
"- Osiagnieto limit wywolan narzedzi.\n"
"- Nie wywoluj narzedzi ponownie.\n"
"- Zaproponuj maksymalnie konserwatywna, bezpieczna rutyne na podstawie dostepnych juz danych,"
" preferujac lagodne produkty wspierajace bariere i fotoprotekcje.\n"
"- Gdy masz watpliwosci, pomijaj ryzykowne aktywne kroki.\n"
)
response = call_gemini(
endpoint="routines/suggest",
contents=conservative_prompt,
config=get_creative_config(
system_instruction=_ROUTINES_SYSTEM_PROMPT,
response_schema=_SuggestionOut, response_schema=_SuggestionOut,
max_output_tokens=4096, max_output_tokens=4096,
temperature=0.4,
), ),
user_input=conservative_prompt,
tool_trace={
"mode": "fallback_conservative",
"reason": "max_tool_roundtrips_exceeded",
},
) )
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
raw = response.text raw = response.text
if not raw: if not raw:
@ -923,34 +340,10 @@ def suggest_routine(
action_notes=s.get("action_notes"), action_notes=s.get("action_notes"),
dose=s.get("dose"), dose=s.get("dose"),
region=s.get("region"), region=s.get("region"),
why_this_step=s.get("why_this_step"),
optional=s.get("optional"),
) )
for s in parsed.get("steps", []) for s in parsed.get("steps", [])
] ]
return RoutineSuggestion(steps=steps, reasoning=parsed.get("reasoning", ""))
summary_raw = parsed.get("summary") or {}
confidence_raw = summary_raw.get("confidence", 0)
try:
confidence = float(confidence_raw)
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
constraints_applied = summary_raw.get("constraints_applied") or []
if not isinstance(constraints_applied, list):
constraints_applied = []
summary = RoutineSuggestionSummary(
primary_goal=str(summary_raw.get("primary_goal") or ""),
constraints_applied=[str(x) for x in constraints_applied],
confidence=confidence,
)
return RoutineSuggestion(
steps=steps,
reasoning=parsed.get("reasoning", ""),
summary=summary,
)
@router.post("/suggest-batch", response_model=BatchSuggestion) @router.post("/suggest-batch", response_model=BatchSuggestion)
@ -960,20 +353,17 @@ def suggest_batch(
): ):
delta = (data.to_date - data.from_date).days + 1 delta = (data.to_date - data.from_date).days + 1
if delta > 14: if delta > 14:
raise HTTPException( raise HTTPException(status_code=400, detail="Date range must not exceed 14 days.")
status_code=400, detail="Date range must not exceed 14 days."
)
if data.from_date > data.to_date: if data.from_date > data.to_date:
raise HTTPException(status_code=400, detail="from_date must be <= to_date.") raise HTTPException(status_code=400, detail="from_date must be <= to_date.")
weekdays = list( client, model = get_gemini_client()
{(data.from_date + timedelta(days=i)).weekday() for i in range(delta)}
) weekdays = list({(data.from_date + timedelta(days=i)).weekday() for i in range(delta)})
skin_ctx = _build_skin_context(session) skin_ctx = _build_skin_context(session)
grooming_ctx = _build_grooming_context(session, weekdays=weekdays) grooming_ctx = _build_grooming_context(session, weekdays=weekdays)
history_ctx = _build_recent_history(session) history_ctx = _build_recent_history(session)
products_ctx = _build_products_context(session, reference_date=data.from_date) products_ctx = _build_products_context(session)
objectives_ctx = _build_objectives_context(data.include_minoxidil_beard)
date_range_lines = [] date_range_lines = []
for i in range(delta): for i in range(delta):
@ -981,35 +371,33 @@ def suggest_batch(
date_range_lines.append(f" {d} ({_DAY_NAMES[d.weekday()]})") date_range_lines.append(f" {d} ({_DAY_NAMES[d.weekday()]})")
dates_str = "\n".join(date_range_lines) dates_str = "\n".join(date_range_lines)
notes_line = f"USER CONTEXT: {data.notes}\n" if data.notes else "" notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else ""
mode_line = "MODE: travel" if data.minimize_products else "MODE: standard"
minimize_line = (
"\nCONSTRAINTS (TRAVEL MODE):\n"
"- To tryb podróżny: minimalizuj liczbę unikalnych produktów w całym planie (wszystkie dni, AM+PM).\n"
"- Preferuj reużycie tych samych produktów między dniami, jeśli bezpieczeństwo i główny cel terapeutyczny są zachowane.\n"
"- Dodaj nowy produkt tylko gdy to konieczne dla bezpieczeństwa albo realizacji głównego celu terapeutycznego.\n"
if data.minimize_products
else ""
)
prompt = ( prompt = (
f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n{mode_line}\n" f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\n"
"INPUT DATA:\n" f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{_RULES}{notes_line}"
f"{skin_ctx}\n{grooming_ctx}\n{history_ctx}\n{products_ctx}\n{objectives_ctx}" "\nDodatkowe zasady dla planu wielodniowego:\n"
f"{notes_line}{minimize_line}" " - Retinol/retinoidy: przestrzegaj max_frequency_per_week i min_interval_hours między użyciami\n"
" - Nie stosuj kwasów i retinoidów tego samego dnia\n"
" - Uwzględnij safe_after_shaving dla dni golenia\n"
" - Zmienność aktywnych składników przez dni dla lepszej tolerancji\n"
" - Pole date w każdym dniu MUSI być w formacie YYYY-MM-DD\n"
"\nZwróć JSON zgodny ze schematem." "\nZwróć JSON zgodny ze schematem."
) )
response = call_gemini( try:
endpoint="routines/suggest-batch", response = client.models.generate_content(
model=model,
contents=prompt, contents=prompt,
config=get_creative_config( config=genai_types.GenerateContentConfig(
system_instruction=_ROUTINES_SYSTEM_PROMPT, response_mime_type="application/json",
response_schema=_BatchOut, response_schema=_BatchOut,
max_output_tokens=8192, max_output_tokens=8192,
temperature=0.4,
), ),
user_input=prompt,
) )
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
raw = response.text raw = response.text
if not raw: if not raw:
@ -1030,8 +418,6 @@ def suggest_batch(
action_notes=s.get("action_notes"), action_notes=s.get("action_notes"),
dose=s.get("dose"), dose=s.get("dose"),
region=s.get("region"), region=s.get("region"),
why_this_step=s.get("why_this_step"),
optional=s.get("optional"),
) )
) )
return result return result
@ -1051,9 +437,7 @@ def suggest_batch(
) )
) )
return BatchSuggestion( return BatchSuggestion(days=days, overall_reasoning=parsed.get("overall_reasoning", ""))
days=days, overall_reasoning=parsed.get("overall_reasoning", "")
)
# Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed # Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed

View file

@ -11,7 +11,7 @@ from sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404 from innercontext.api.utils import get_or_404
from innercontext.llm import call_gemini, get_extraction_config from innercontext.llm import get_gemini_client
from innercontext.models import ( from innercontext.models import (
SkinConditionSnapshot, SkinConditionSnapshot,
SkinConditionSnapshotBase, SkinConditionSnapshotBase,
@ -140,44 +140,37 @@ async def analyze_skin_photos(
if not (1 <= len(photos) <= 3): if not (1 <= len(photos) <= 3):
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.") raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
allowed = { client, model = get_gemini_client()
"image/heic",
"image/heif", allowed = {"image/jpeg", "image/png", "image/webp"}
"image/jpeg",
"image/png",
"image/webp",
}
parts: list[genai_types.Part] = [] parts: list[genai_types.Part] = []
for photo in photos: for photo in photos:
if photo.content_type not in allowed: if photo.content_type not in allowed:
raise HTTPException( raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}")
status_code=422, detail=f"Unsupported type: {photo.content_type}"
)
data = await photo.read() data = await photo.read()
if len(data) > MAX_IMAGE_BYTES: if len(data) > MAX_IMAGE_BYTES:
raise HTTPException( raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.")
status_code=413, detail=f"{photo.filename} exceeds 5 MB." parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type))
)
parts.append(
genai_types.Part.from_bytes(data=data, mime_type=photo.content_type)
)
parts.append( parts.append(
genai_types.Part.from_text( genai_types.Part.from_text(
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment." text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
) )
) )
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}" try:
response = call_gemini( response = client.models.generate_content(
endpoint="skincare/analyze-photos", model=model,
contents=parts, contents=parts,
config=get_extraction_config( config=genai_types.GenerateContentConfig(
system_instruction=_skin_photo_system_prompt(), system_instruction=_skin_photo_system_prompt(),
response_mime_type="application/json",
response_schema=_SkinAnalysisOut, response_schema=_SkinAnalysisOut,
max_output_tokens=2048, max_output_tokens=2048,
temperature=0.0,
), ),
user_input=image_summary,
) )
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
try: try:
parsed = json.loads(response.text) parsed = json.loads(response.text)

View file

@ -1,53 +1,12 @@
"""Shared helpers for Gemini API access.""" """Shared helpers for Gemini API access."""
import os import os
import time
from collections.abc import Callable
from contextlib import suppress
from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from google import genai from google import genai
from google.genai import types as genai_types
_DEFAULT_MODEL = "gemini-3-flash-preview"
def get_extraction_config( _DEFAULT_MODEL = "gemini-flash-latest"
system_instruction: str,
response_schema: Any,
max_output_tokens: int = 8192,
) -> genai_types.GenerateContentConfig:
"""Config for strict data extraction (deterministic, minimal thinking)."""
return genai_types.GenerateContentConfig(
system_instruction=system_instruction,
response_mime_type="application/json",
response_schema=response_schema,
max_output_tokens=max_output_tokens,
temperature=0.0,
thinking_config=genai_types.ThinkingConfig(
thinking_level=genai_types.ThinkingLevel.MINIMAL
),
)
def get_creative_config(
system_instruction: str,
response_schema: Any,
max_output_tokens: int = 4096,
) -> genai_types.GenerateContentConfig:
"""Config for creative tasks like recommendations (balanced creativity)."""
return genai_types.GenerateContentConfig(
system_instruction=system_instruction,
response_mime_type="application/json",
response_schema=response_schema,
max_output_tokens=max_output_tokens,
temperature=0.4,
top_p=0.8,
thinking_config=genai_types.ThinkingConfig(
thinking_level=genai_types.ThinkingLevel.LOW
),
)
def get_gemini_client() -> tuple[genai.Client, str]: def get_gemini_client() -> tuple[genai.Client, str]:
@ -60,196 +19,3 @@ def get_gemini_client() -> tuple[genai.Client, str]:
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured") raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL) model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
return genai.Client(api_key=api_key), model return genai.Client(api_key=api_key), model
def call_gemini(
*,
endpoint: str,
contents,
config: genai_types.GenerateContentConfig,
user_input: str | None = None,
tool_trace: dict[str, Any] | None = None,
):
"""Call Gemini, log full request + response to DB, return response unchanged."""
from sqlmodel import Session
from db import engine # deferred to avoid circular import at module load
from innercontext.models.ai_log import AICallLog
client, model = get_gemini_client()
sys_prompt = None
if config.system_instruction:
raw = config.system_instruction
sys_prompt = raw if isinstance(raw, str) else str(raw)
if user_input is None:
with suppress(Exception):
user_input = str(contents)
start = time.monotonic()
success, error_detail, response, finish_reason = True, None, None, None
try:
response = client.models.generate_content(
model=model, contents=contents, config=config
)
candidates = getattr(response, "candidates", None)
if candidates:
first_candidate = candidates[0]
reason = getattr(first_candidate, "finish_reason", None)
reason_name = getattr(reason, "name", None)
if isinstance(reason_name, str):
finish_reason = reason_name
if finish_reason and finish_reason != "STOP":
success = False
error_detail = f"finish_reason: {finish_reason}"
raise HTTPException(
status_code=502,
detail=f"Gemini stopped early (finish_reason={finish_reason})",
)
except HTTPException:
raise
except Exception as exc:
success = False
error_detail = str(exc)
raise HTTPException(status_code=502, detail=f"Gemini API error: {exc}") from exc
finally:
duration_ms = int((time.monotonic() - start) * 1000)
with suppress(Exception):
log = AICallLog(
endpoint=endpoint,
model=model,
system_prompt=sys_prompt,
user_input=user_input,
response_text=response.text if response else None,
tool_trace=tool_trace,
prompt_tokens=(
response.usage_metadata.prompt_token_count
if response and response.usage_metadata
else None
),
completion_tokens=(
response.usage_metadata.candidates_token_count
if response and response.usage_metadata
else None
),
total_tokens=(
response.usage_metadata.total_token_count
if response and response.usage_metadata
else None
),
duration_ms=duration_ms,
finish_reason=finish_reason,
success=success,
error_detail=error_detail,
)
with Session(engine) as s:
s.add(log)
s.commit()
return response
def call_gemini_with_function_tools(
*,
endpoint: str,
contents,
config: genai_types.GenerateContentConfig,
function_handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]],
user_input: str | None = None,
max_tool_roundtrips: int = 2,
):
"""Call Gemini with function-calling loop until final response text is produced."""
if max_tool_roundtrips < 0:
raise ValueError("max_tool_roundtrips must be >= 0")
history = list(contents) if isinstance(contents, list) else [contents]
rounds = 0
trace_events: list[dict[str, Any]] = []
while True:
response = call_gemini(
endpoint=endpoint,
contents=history,
config=config,
user_input=user_input,
tool_trace={
"mode": "function_tools",
"round": rounds,
"events": trace_events,
},
)
function_calls = list(getattr(response, "function_calls", None) or [])
if not function_calls:
return response
if rounds >= max_tool_roundtrips:
raise HTTPException(
status_code=502,
detail="Gemini requested too many function calls",
)
candidate_content = None
candidates = getattr(response, "candidates", None) or []
if candidates:
candidate_content = getattr(candidates[0], "content", None)
if candidate_content is not None:
history.append(candidate_content)
else:
history.append(
genai_types.ModelContent(
parts=[genai_types.Part(function_call=fc) for fc in function_calls]
)
)
response_parts: list[genai_types.Part] = []
for fc in function_calls:
name = getattr(fc, "name", None)
if not isinstance(name, str) or not name:
raise HTTPException(
status_code=502,
detail="Gemini requested a function without a valid name",
)
handler = function_handlers.get(name)
if handler is None:
raise HTTPException(
status_code=502,
detail=f"Gemini requested unknown function: {name}",
)
args = getattr(fc, "args", None) or {}
if not isinstance(args, dict):
raise HTTPException(
status_code=502,
detail=f"Gemini returned invalid arguments for function: {name}",
)
tool_response = handler(args)
if not isinstance(tool_response, dict):
raise HTTPException(
status_code=502,
detail=f"Function handler must return an object for: {name}",
)
trace_event: dict[str, Any] = {
"round": rounds + 1,
"function": name,
}
product_ids = args.get("product_ids")
if isinstance(product_ids, list):
clean_ids = [x for x in product_ids if isinstance(x, str)]
trace_event["requested_ids_count"] = len(clean_ids)
trace_event["requested_ids"] = clean_ids[:8]
products = tool_response.get("products")
if isinstance(products, list):
trace_event["returned_products_count"] = len(products)
trace_events.append(trace_event)
response_parts.append(
genai_types.Part.from_function_response(
name=name,
response=tool_response,
)
)
history.append(genai_types.UserContent(parts=response_parts))
rounds += 1

View file

@ -0,0 +1,438 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Optional
from uuid import UUID
from fastmcp import FastMCP
from sqlmodel import Session, col, select
from db import engine
from innercontext.models import (
GroomingSchedule,
LabResult,
MedicationEntry,
MedicationUsage,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
mcp = FastMCP("innercontext")
# ── Products ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_products(
category: Optional[str] = None,
is_medication: bool = False,
is_tool: bool = False,
) -> list[dict]:
"""List products. By default returns skincare products (excludes medications and tools).
Pass is_medication=True or is_tool=True to retrieve those categories instead."""
with Session(engine) as session:
stmt = select(Product)
if category is not None:
stmt = stmt.where(Product.category == category)
stmt = stmt.where(Product.is_medication == is_medication)
stmt = stmt.where(Product.is_tool == is_tool)
products = session.exec(stmt).all()
return [p.to_llm_context() for p in products]
@mcp.tool()
def get_product(product_id: str) -> dict:
"""Get full context for a single product (UUID) including all inventory entries."""
with Session(engine) as session:
product = session.get(Product, UUID(product_id))
if product is None:
return {"error": f"Product {product_id} not found"}
ctx = product.to_llm_context()
entries = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product.id)
).all()
ctx["inventory"] = [
{
"id": str(inv.id),
"is_opened": inv.is_opened,
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
"finished_at": inv.finished_at.isoformat() if inv.finished_at else None,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
"current_weight_g": inv.current_weight_g,
}
for inv in entries
]
return ctx
# ── Inventory ─────────────────────────────────────────────────────────────────
@mcp.tool()
def get_open_inventory() -> list[dict]:
"""Return all currently open packages (is_opened=True, finished_at=None)
with product name, opening date, weight, and expiry date."""
with Session(engine) as session:
stmt = (
select(ProductInventory, Product)
.join(Product, ProductInventory.product_id == Product.id)
.where(ProductInventory.is_opened == True) # noqa: E712
.where(ProductInventory.finished_at == None) # noqa: E711
)
rows = session.exec(stmt).all()
return [
{
"inventory_id": str(inv.id),
"product_id": str(product.id),
"product_name": product.name,
"brand": product.brand,
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
"current_weight_g": inv.current_weight_g,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
}
for inv, product in rows
]
# ── Routines ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_recent_routines(days: int = 14) -> list[dict]:
"""Get skincare routines from the last N days, newest first.
Each routine includes its ordered steps with product name or action."""
with Session(engine) as session:
cutoff = date.today() - timedelta(days=days)
routines = session.exec(
select(Routine)
.where(Routine.routine_date >= cutoff)
.order_by(col(Routine.routine_date).desc())
).all()
result = []
for routine in routines:
steps = session.exec(
select(RoutineStep)
.where(RoutineStep.routine_id == routine.id)
.order_by(RoutineStep.order_index)
).all()
steps_data = []
for step in steps:
step_dict: dict = {"order": step.order_index}
if step.product_id:
product = session.get(Product, step.product_id)
if product:
step_dict["product"] = product.name
step_dict["product_id"] = str(product.id)
if step.action_type:
step_dict["action"] = (
step.action_type.value
if hasattr(step.action_type, "value")
else str(step.action_type)
)
if step.action_notes:
step_dict["notes"] = step.action_notes
if step.dose:
step_dict["dose"] = step.dose
if step.region:
step_dict["region"] = step.region
steps_data.append(step_dict)
result.append(
{
"id": str(routine.id),
"date": routine.routine_date.isoformat(),
"part_of_day": (
routine.part_of_day.value
if hasattr(routine.part_of_day, "value")
else str(routine.part_of_day)
),
"notes": routine.notes,
"steps": steps_data,
}
)
return result
# ── Skin snapshots ────────────────────────────────────────────────────────────
def _snapshot_to_dict(s: SkinConditionSnapshot, *, full: bool) -> dict:
def ev(v: object) -> object:
return v.value if v is not None and hasattr(v, "value") else v
d: dict = {
"id": str(s.id),
"date": s.snapshot_date.isoformat(),
"overall_state": ev(s.overall_state),
"hydration_level": s.hydration_level,
"sensitivity_level": s.sensitivity_level,
"barrier_state": ev(s.barrier_state),
"active_concerns": [ev(c) for c in (s.active_concerns or [])],
}
if full:
d.update(
{
"skin_type": ev(s.skin_type),
"texture": ev(s.texture),
"sebum_tzone": s.sebum_tzone,
"sebum_cheeks": s.sebum_cheeks,
"risks": s.risks or [],
"priorities": s.priorities or [],
"notes": s.notes,
}
)
return d
@mcp.tool()
def get_latest_skin_snapshot() -> dict | None:
"""Get the most recent skin condition snapshot with all metrics."""
with Session(engine) as session:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
if snapshot is None:
return None
return _snapshot_to_dict(snapshot, full=True)
@mcp.tool()
def get_skin_history(weeks: int = 8) -> list[dict]:
"""Get skin condition snapshots from the last N weeks with key metrics."""
with Session(engine) as session:
cutoff = date.today() - timedelta(weeks=weeks)
snapshots = session.exec(
select(SkinConditionSnapshot)
.where(SkinConditionSnapshot.snapshot_date >= cutoff)
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
).all()
return [_snapshot_to_dict(s, full=False) for s in snapshots]
@mcp.tool()
def get_skin_snapshot_dates() -> list[str]:
"""List all dates (YYYY-MM-DD) for which skin snapshots exist, newest first."""
with Session(engine) as session:
snapshots = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).all()
return [s.snapshot_date.isoformat() for s in snapshots]
@mcp.tool()
def get_skin_snapshot(snapshot_date: str) -> dict | None:
"""Get the full skin condition snapshot for a specific date (YYYY-MM-DD)."""
with Session(engine) as session:
target = date.fromisoformat(snapshot_date)
snapshot = session.exec(
select(SkinConditionSnapshot).where(
SkinConditionSnapshot.snapshot_date == target
)
).first()
if snapshot is None:
return None
return _snapshot_to_dict(snapshot, full=True)
# ── Health / medications ───────────────────────────────────────────────────────
@mcp.tool()
def get_medications() -> list[dict]:
"""Get all medication entries with their currently active usage records
(valid_to IS NULL or >= today)."""
with Session(engine) as session:
medications = session.exec(select(MedicationEntry)).all()
today = date.today()
result = []
for med in medications:
usages = session.exec(
select(MedicationUsage)
.where(MedicationUsage.medication_record_id == med.record_id)
.where(
(MedicationUsage.valid_to == None) # noqa: E711
| (col(MedicationUsage.valid_to) >= today)
)
).all()
result.append(
{
"id": str(med.record_id),
"product_name": med.product_name,
"kind": (
med.kind.value if hasattr(med.kind, "value") else str(med.kind)
),
"active_substance": med.active_substance,
"formulation": med.formulation,
"route": med.route,
"notes": med.notes,
"active_usages": [
{
"id": str(u.record_id),
"dose": (
f"{u.dose_value} {u.dose_unit}"
if u.dose_value is not None and u.dose_unit
else None
),
"frequency": u.frequency,
"schedule_text": u.schedule_text,
"as_needed": u.as_needed,
"valid_from": u.valid_from.isoformat()
if u.valid_from
else None,
"valid_to": u.valid_to.isoformat() if u.valid_to else None,
}
for u in usages
],
}
)
return result
# ── Expiring inventory ────────────────────────────────────────────────────────
@mcp.tool()
def get_expiring_inventory(days: int = 30) -> list[dict]:
"""List open packages whose expiry date falls within the next N days.
Sorted by days remaining (soonest first)."""
with Session(engine) as session:
cutoff = date.today() + timedelta(days=days)
stmt = (
select(ProductInventory, Product)
.join(Product, ProductInventory.product_id == Product.id)
.where(ProductInventory.is_opened == True) # noqa: E712
.where(ProductInventory.finished_at == None) # noqa: E711
.where(ProductInventory.expiry_date != None) # noqa: E711
.where(col(ProductInventory.expiry_date) <= cutoff)
)
rows = session.exec(stmt).all()
today = date.today()
result = [
{
"product_name": product.name,
"brand": product.brand,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
"days_remaining": (inv.expiry_date - today).days
if inv.expiry_date
else None,
"current_weight_g": inv.current_weight_g,
}
for inv, product in rows
]
return sorted(result, key=lambda x: x["days_remaining"] or 0)
# ── Grooming schedule ─────────────────────────────────────────────────────────
@mcp.tool()
def get_grooming_schedule() -> list[dict]:
"""Get the full grooming schedule sorted by day of week (0=Monday, 6=Sunday)."""
with Session(engine) as session:
entries = session.exec(
select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)
).all()
return [
{
"id": str(e.id),
"day_of_week": e.day_of_week,
"action": (
e.action.value if hasattr(e.action, "value") else str(e.action)
),
"notes": e.notes,
}
for e in entries
]
# ── Lab results ───────────────────────────────────────────────────────────────
def _lab_result_to_dict(r: LabResult) -> dict:
return {
"id": str(r.record_id),
"collected_at": r.collected_at.isoformat(),
"test_code": r.test_code,
"test_name_loinc": r.test_name_loinc,
"test_name_original": r.test_name_original,
"value_num": r.value_num,
"value_text": r.value_text,
"value_bool": r.value_bool,
"unit": r.unit_ucum or r.unit_original,
"ref_low": r.ref_low,
"ref_high": r.ref_high,
"ref_text": r.ref_text,
"flag": r.flag.value if r.flag and hasattr(r.flag, "value") else r.flag,
"lab": r.lab,
"notes": r.notes,
}
@mcp.tool()
def get_recent_lab_results(limit: int = 30) -> list[dict]:
"""Get the most recent lab results sorted by collection date descending."""
with Session(engine) as session:
results = session.exec(
select(LabResult)
.order_by(col(LabResult.collected_at).desc())
.limit(limit)
).all()
return [_lab_result_to_dict(r) for r in results]
@mcp.tool()
def get_available_lab_tests() -> list[dict]:
"""List all distinct lab tests ever performed, grouped by LOINC test_code.
Returns test_code, LOINC name, original lab names, result count, and last collection date."""
with Session(engine) as session:
results = session.exec(select(LabResult)).all()
tests: dict[str, dict] = {}
for r in results:
code = r.test_code
if code not in tests:
tests[code] = {
"test_code": code,
"test_name_loinc": r.test_name_loinc,
"test_names_original": set(),
"count": 0,
"last_collected_at": r.collected_at,
}
tests[code]["count"] += 1
if r.test_name_original:
tests[code]["test_names_original"].add(r.test_name_original)
if r.collected_at > tests[code]["last_collected_at"]:
tests[code]["last_collected_at"] = r.collected_at
return [
{
"test_code": v["test_code"],
"test_name_loinc": v["test_name_loinc"],
"test_names_original": sorted(v["test_names_original"]),
"count": v["count"],
"last_collected_at": v["last_collected_at"].isoformat(),
}
for v in sorted(tests.values(), key=lambda x: x["test_code"])
]
@mcp.tool()
def get_lab_results_for_test(test_code: str) -> list[dict]:
"""Get the full chronological history of results for a specific LOINC test code."""
with Session(engine) as session:
results = session.exec(
select(LabResult)
.where(LabResult.test_code == test_code)
.order_by(col(LabResult.collected_at).asc())
).all()
return [_lab_result_to_dict(r) for r in results]

View file

@ -1,4 +1,3 @@
from .ai_log import AICallLog
from .domain import Domain from .domain import Domain
from .enums import ( from .enums import (
AbsorptionSpeed, AbsorptionSpeed,
@ -42,8 +41,6 @@ from .skincare import (
) )
__all__ = [ __all__ = [
# ai logs
"AICallLog",
# domain # domain
"Domain", "Domain",
# enums # enums

View file

@ -1,33 +0,0 @@
from datetime import datetime
from typing import Any, ClassVar
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
from .base import utc_now
from .domain import Domain
class AICallLog(SQLModel, table=True):
__tablename__ = "ai_call_logs"
__domains__: ClassVar[frozenset[Domain]] = frozenset()
id: UUID = Field(default_factory=uuid4, primary_key=True)
created_at: datetime = Field(default_factory=utc_now, nullable=False)
endpoint: str = Field(index=True)
model: str
system_prompt: str | None = Field(default=None)
user_input: str | None = Field(default=None)
response_text: str | None = Field(default=None)
prompt_tokens: int | None = Field(default=None)
completion_tokens: int | None = Field(default=None)
total_tokens: int | None = Field(default=None)
duration_ms: int | None = Field(default=None)
finish_reason: str | None = Field(default=None)
tool_trace: dict[str, Any] | None = Field(
default=None,
sa_column=Column(JSON, nullable=True),
)
success: bool = Field(default=True, index=True)
error_detail: str | None = Field(default=None)

View file

@ -1,5 +1,4 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncIterator
from dotenv import load_dotenv from dotenv import load_dotenv
@ -7,27 +6,30 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402
from db import create_db_and_tables # noqa: E402 from db import create_db_and_tables # noqa: E402
from innercontext.api import ( # noqa: E402 from innercontext.api import ( # noqa: E402
ai_logs,
health, health,
inventory, inventory,
products, products,
routines, routines,
skincare, skincare,
) )
from innercontext.mcp_server import mcp # noqa: E402
mcp_app = mcp.http_app(path="/mcp")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI):
create_db_and_tables() create_db_and_tables()
yield yield
app = FastAPI( app = FastAPI(
title="innercontext API", title="innercontext API",
lifespan=lifespan, lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
redirect_slashes=False, redirect_slashes=False,
) )
@ -43,7 +45,9 @@ app.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
app.include_router(health.router, prefix="/health", tags=["health"]) app.include_router(health.router, prefix="/health", tags=["health"])
app.include_router(routines.router, prefix="/routines", tags=["routines"]) app.include_router(routines.router, prefix="/routines", tags=["routines"])
app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) app.include_router(skincare.router, prefix="/skincare", tags=["skincare"])
app.include_router(ai_logs.router, prefix="/ai-logs", tags=["ai-logs"])
app.mount("/mcp", mcp_app)
@app.get("/health-check") @app.get("/health-check")

View file

@ -7,6 +7,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.14", "alembic>=1.14",
"fastapi>=0.132.0", "fastapi>=0.132.0",
"fastmcp>=2.0",
"google-genai>=1.65.0", "google-genai>=1.65.0",
"psycopg>=3.3.3", "psycopg>=3.3.3",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
@ -21,7 +22,6 @@ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"isort>=8.0.0", "isort>=8.0.0",
"pytest>=9.0.2", "pytest>=9.0.2",
"pytest-cov>=6.0.0",
"ruff>=0.15.2", "ruff>=0.15.2",
"ty>=0.0.18", "ty>=0.0.18",
] ]
@ -29,7 +29,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."] pythonpath = ["."]
addopts = "-v --tb=short --cov=innercontext --cov-report=term-missing --cov-report=html" addopts = "-v --tb=short"
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View file

@ -1,25 +0,0 @@
from datetime import date, timedelta
from sqlmodel import select
from db import get_session
from innercontext.models import Routine, RoutineStep
def run():
session = next(get_session())
ref_date = date.today()
cutoff = ref_date - timedelta(days=7)
recent_usage = session.exec(
select(RoutineStep.product_id)
.join(Routine, Routine.id == RoutineStep.routine_id)
.where(Routine.routine_date >= cutoff)
.where(Routine.routine_date <= ref_date)
).all()
print("Found:", len(recent_usage))
if __name__ == "__main__":
run()

View file

@ -1,4 +1,5 @@
import os import os
from contextlib import asynccontextmanager
# Must be set before importing db (which calls create_engine at module level) # Must be set before importing db (which calls create_engine at module level)
os.environ.setdefault("DATABASE_URL", "sqlite://") os.environ.setdefault("DATABASE_URL", "sqlite://")
@ -13,6 +14,18 @@ from db import get_session
from main import app from main import app
@asynccontextmanager
async def _db_only_lifespan(a):
"""Lifespan without the MCP server for test isolation.
StreamableHTTPSessionManager.run() can only be called once per instance,
which conflicts with the per-test TestClient lifecycle. We replace the
combined (db + MCP) lifespan with one that only does DB setup.
"""
db_module.create_db_and_tables()
yield
@pytest.fixture() @pytest.fixture()
def session(monkeypatch): def session(monkeypatch):
"""Per-test fresh SQLite in-memory database with full isolation.""" """Per-test fresh SQLite in-memory database with full isolation."""
@ -34,6 +47,9 @@ def session(monkeypatch):
@pytest.fixture() @pytest.fixture()
def client(session, monkeypatch): def client(session, monkeypatch):
"""TestClient using the per-test session for every request.""" """TestClient using the per-test session for every request."""
# Replace combined (db+MCP) lifespan with DB-only to avoid the
# StreamableHTTPSessionManager single-run limitation.
monkeypatch.setattr(app.router, "lifespan_context", _db_only_lifespan)
def _override(): def _override():
yield session yield session

View file

@ -1,44 +0,0 @@
import uuid
from typing import Any, cast
from innercontext.models.ai_log import AICallLog
def test_list_ai_logs_normalizes_tool_trace_string(client, session):
log = AICallLog(
id=uuid.uuid4(),
endpoint="routines/suggest",
model="gemini-3-flash-preview",
success=True,
)
log.tool_trace = cast(
Any,
'{"mode":"function_tools","events":[{"function":"get_product_inci"}]}',
)
session.add(log)
session.commit()
response = client.get("/ai-logs")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["tool_trace"]["mode"] == "function_tools"
assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci"
def test_get_ai_log_normalizes_tool_trace_string(client, session):
log = AICallLog(
id=uuid.uuid4(),
endpoint="routines/suggest",
model="gemini-3-flash-preview",
success=True,
)
log.tool_trace = cast(Any, '{"mode":"function_tools","round":1}')
session.add(log)
session.commit()
response = client.get(f"/ai-logs/{log.id}")
assert response.status_code == 200
payload = response.json()
assert payload["tool_trace"]["mode"] == "function_tools"
assert payload["tool_trace"]["round"] == 1

View file

@ -199,22 +199,3 @@ def test_create_inventory(client, created_product):
def test_create_inventory_product_not_found(client): def test_create_inventory_product_not_found(client):
r = client.post(f"/products/{uuid.uuid4()}/inventory", json={}) r = client.post(f"/products/{uuid.uuid4()}/inventory", json={})
assert r.status_code == 404 assert r.status_code == 404
def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
from innercontext.api import products as products_api
class _FakeResponse:
text = (
'{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,'
'"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}'
)
monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse())
r = client.post("/products/parse-text", json={"text": "dummy input"})
assert r.status_code == 200
data = r.json()
assert data["name"] == "Test Serum"
assert data["actives"][0]["strength_level"] == 2
assert data["actives"][0]["irritation_potential"] == 1

View file

@ -1,157 +0,0 @@
import uuid
from datetime import date
from unittest.mock import patch
from sqlmodel import Session
from innercontext.api.products import (
_build_actives_tool_handler,
_build_inci_tool_handler,
_build_safety_rules_tool_handler,
_build_shopping_context,
_build_usage_notes_tool_handler,
_extract_requested_product_ids,
)
from innercontext.models import Product, ProductInventory, SkinConditionSnapshot
def test_build_shopping_context(session: Session):
# Empty context
ctx = _build_shopping_context(session)
assert "(brak danych)" in ctx
assert "POSIADANE PRODUKTY" in ctx
# Add snapshot
snap = SkinConditionSnapshot(
id=uuid.uuid4(),
snapshot_date=date.today(),
overall_state="fair",
skin_type="combination",
hydration_level=3,
sensitivity_level=4,
barrier_state="mildly_compromised",
active_concerns=["redness"],
priorities=["soothing"],
)
session.add(snap)
# Add product
p = Product(
id=uuid.uuid4(),
name="Soothing Serum",
brand="BrandX",
category="serum",
recommended_time="both",
leave_on=True,
targets=["redness"],
product_effect_profile={"soothing_strength": 4, "hydration_immediate": 1},
actives=[{"name": "Centella"}],
)
session.add(p)
session.commit()
# Add inventory
inv = ProductInventory(id=uuid.uuid4(), product_id=p.id, is_opened=True)
session.add(inv)
session.commit()
ctx = _build_shopping_context(session)
assert "Typ skóry: combination" in ctx
assert "Nawilżenie: 3/5" in ctx
assert "Wrażliwość: 4/5" in ctx
assert "Aktywne problemy: redness" in ctx
assert "Priorytety: soothing" in ctx
# Check product
assert "[✓] id=" in ctx
assert "Soothing Serum" in ctx
assert f"id={p.id}" in ctx
assert "BrandX" in ctx
assert "targets: ['redness']" in ctx
assert "actives: ['Centella']" in ctx
assert "effects: {'soothing': 4}" in ctx
def test_suggest_shopping(client, session):
with patch(
"innercontext.api.products.call_gemini_with_function_tools"
) as mock_gemini:
mock_response = type(
"Response",
(),
{
"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}'
},
)
mock_gemini.return_value = mock_response
r = client.post("/products/suggest")
assert r.status_code == 200
data = r.json()
assert len(data["suggestions"]) == 1
assert data["suggestions"][0]["product_type"] == "cleanser"
assert data["reasoning"] == "Test shopping"
kwargs = mock_gemini.call_args.kwargs
assert "function_handlers" in kwargs
assert "get_product_inci" in kwargs["function_handlers"]
assert "get_product_safety_rules" in kwargs["function_handlers"]
assert "get_product_actives" in kwargs["function_handlers"]
assert "get_product_usage_notes" in kwargs["function_handlers"]
def test_shopping_context_medication_skip(session: Session):
p = Product(
id=uuid.uuid4(),
name="Epiduo",
brand="Galderma",
category="serum",
recommended_time="pm",
leave_on=True,
is_medication=True,
product_effect_profile={},
)
session.add(p)
session.commit()
ctx = _build_shopping_context(session)
assert "Epiduo" not in ctx
def test_extract_requested_product_ids_dedupes_and_limits():
ids = _extract_requested_product_ids(
{"product_ids": ["a", "b", "a", 1, "c", "d"]},
max_ids=3,
)
assert ids == ["a", "b", "c"]
def test_shopping_tool_handlers_return_payloads(session: Session):
product = Product(
id=uuid.uuid4(),
name="Test Product",
brand="Brand",
category="serum",
recommended_time="both",
leave_on=True,
usage_notes="Use AM and PM on clean skin.",
inci=["Water", "Niacinamide"],
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
payload = {"product_ids": [str(product.id)]}
inci_data = _build_inci_tool_handler([product])(payload)
assert inci_data["products"][0]["inci"] == ["Water", "Niacinamide"]
actives_data = _build_actives_tool_handler([product])(payload)
assert actives_data["products"][0]["actives"][0]["name"] == "Niacinamide"
notes_data = _build_usage_notes_tool_handler([product])(payload)
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
safety_data = _build_safety_rules_tool_handler([product])(payload)
assert "incompatible_with" in safety_data["products"][0]
assert "context_rules" in safety_data["products"][0]

View file

@ -1,5 +1,4 @@
import uuid import uuid
from unittest.mock import patch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routines # Routines
@ -217,82 +216,3 @@ def test_delete_grooming_schedule(client):
def test_delete_grooming_schedule_not_found(client): def test_delete_grooming_schedule_not_found(client):
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}") r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
assert r.status_code == 404 assert r.status_code == 404
def test_suggest_routine(client, session):
with patch(
"innercontext.api.routines.call_gemini_with_function_tools"
) as mock_gemini:
# Mock the Gemini response
mock_response = type(
"Response",
(),
{
"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}'
},
)
mock_gemini.return_value = mock_response
r = client.post(
"/routines/suggest",
json={
"routine_date": "2026-03-03",
"part_of_day": "am",
"notes": "Testing",
"include_minoxidil_beard": True,
},
)
assert r.status_code == 200
data = r.json()
assert len(data["steps"]) == 1
assert data["steps"][0]["action_type"] == "shaving_razor"
assert data["reasoning"] == "because"
kwargs = mock_gemini.call_args.kwargs
assert "function_handlers" in kwargs
assert "get_product_inci" in kwargs["function_handlers"]
assert "get_product_safety_rules" in kwargs["function_handlers"]
assert "get_product_actives" in kwargs["function_handlers"]
assert "get_product_usage_notes" in kwargs["function_handlers"]
def test_suggest_batch(client, session):
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
# Mock the Gemini response
mock_response = type(
"Response",
(),
{
"text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}'
},
)
mock_gemini.return_value = mock_response
r = client.post(
"/routines/suggest-batch",
json={
"from_date": "2026-03-03",
"to_date": "2026-03-04",
"minimize_products": True,
},
)
assert r.status_code == 200
data = r.json()
assert len(data["days"]) == 1
assert data["days"][0]["date"] == "2026-03-03"
assert data["overall_reasoning"] == "batch test"
def test_suggest_batch_invalid_date_range(client):
r = client.post(
"/routines/suggest-batch",
json={"from_date": "2026-03-04", "to_date": "2026-03-03"},
)
assert r.status_code == 400
def test_suggest_batch_too_long(client):
r = client.post(
"/routines/suggest-batch",
json={"from_date": "2026-03-01", "to_date": "2026-03-20"},
)
assert r.status_code == 400

View file

@ -1,408 +0,0 @@
import uuid
from datetime import date, timedelta
from sqlmodel import Session
from innercontext.api.routines import (
_build_actives_tool_handler,
_build_day_context,
_build_grooming_context,
_build_inci_tool_handler,
_build_objectives_context,
_build_products_context,
_build_recent_history,
_build_safety_rules_tool_handler,
_build_skin_context,
_build_usage_notes_tool_handler,
_contains_minoxidil_text,
_ev,
_extract_active_names,
_extract_requested_product_ids,
_get_available_products,
_is_minoxidil_product,
)
from innercontext.models import (
GroomingSchedule,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
def test_contains_minoxidil_text():
assert _contains_minoxidil_text(None) is False
assert _contains_minoxidil_text("") is False
assert _contains_minoxidil_text("some random text") is False
assert _contains_minoxidil_text("contains MINOXIDIL here") is True
assert _contains_minoxidil_text("minoksydyl 5%") is True
def test_is_minoxidil_product():
# Setup product
p = Product(id=uuid.uuid4(), name="Test", brand="Brand", is_medication=True)
assert _is_minoxidil_product(p) is False
p.name = "Minoxidil 5%"
assert _is_minoxidil_product(p) is True
p.name = "Test"
p.brand = "Brand with minoksydyl"
assert _is_minoxidil_product(p) is True
p.brand = "Brand"
p.line_name = "Minoxidil Line"
assert _is_minoxidil_product(p) is True
p.line_name = None
p.usage_notes = "Use minoxidil daily"
assert _is_minoxidil_product(p) is True
p.usage_notes = None
p.inci = ["water", "minoxidil"]
assert _is_minoxidil_product(p) is True
p.inci = None
p.actives = [{"name": "minoxidil", "strength": "5%"}]
assert _is_minoxidil_product(p) is True
# As Pydantic model representation isn't exactly a dict in db sometimes, we just test dict
p.actives = [{"name": "Retinol", "strength": "1%"}]
assert _is_minoxidil_product(p) is False
def test_ev():
class DummyEnum:
value = "dummy"
assert _ev(None) == ""
assert _ev(DummyEnum()) == "dummy"
assert _ev("string") == "string"
def test_build_skin_context(session: Session):
# Empty
assert _build_skin_context(session) == "SKIN CONDITION: no data\n"
# With data
snap = SkinConditionSnapshot(
id=uuid.uuid4(),
snapshot_date=date.today(),
overall_state="good",
hydration_level=4,
barrier_state="intact",
active_concerns=["acne", "dryness"],
priorities=["hydration"],
notes="Feeling good",
)
session.add(snap)
session.commit()
ctx = _build_skin_context(session)
assert "SKIN CONDITION (snapshot from" in ctx
assert "Overall state: good" in ctx
assert "Hydration: 4/5" in ctx
assert "Barrier: intact" in ctx
assert "Active concerns: acne, dryness" in ctx
assert "Priorities: hydration" in ctx
assert "Notes: Feeling good" in ctx
def test_build_grooming_context(session: Session):
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
sch = GroomingSchedule(
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
)
session.add(sch)
session.commit()
ctx = _build_grooming_context(session)
assert "GROOMING SCHEDULE:" in ctx
assert "poniedziałek: shaving_oneblade (Morning)" in ctx
# Test weekdays filter
ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday
assert "(no entries for specified days)" in ctx2
def test_build_recent_history(session: Session):
assert _build_recent_history(session) == "RECENT ROUTINES: none\n"
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
session.add(r)
p = Product(
id=uuid.uuid4(),
name="Cleanser",
category="cleanser",
brand="Test",
recommended_time="both",
leave_on=False,
product_effect_profile={},
)
session.add(p)
session.commit()
s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id)
s2 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor"
)
# Step with non-existent product
s3 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4()
)
session.add_all([s1, s2, s3])
session.commit()
ctx = _build_recent_history(session)
assert "RECENT ROUTINES:" in ctx
assert "AM:" in ctx
assert "cleanser [" in ctx
assert "action: shaving_razor" in ctx
assert "unknown [" in ctx
def test_build_products_context(session: Session):
p1 = Product(
id=uuid.uuid4(),
name="Regaine",
category="serum",
is_medication=True,
brand="J&J",
recommended_time="both",
leave_on=True,
product_effect_profile={},
)
p2 = Product(
id=uuid.uuid4(),
name="Sunscreen",
category="spf",
brand="Test",
leave_on=True,
recommended_time="am",
pao_months=6,
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
incompatible_with=[{"target": "retinol", "scope": "same_routine"}],
context_rules={"safe_after_shaving": False},
min_interval_hours=12,
max_frequency_per_week=7,
)
session.add_all([p1, p2])
session.commit()
# Inventory
inv1 = ProductInventory(
id=uuid.uuid4(),
product_id=p2.id,
is_opened=True,
opened_at=date.today() - timedelta(days=10),
expiry_date=date.today() + timedelta(days=365),
)
inv2 = ProductInventory(id=uuid.uuid4(), product_id=p2.id, is_opened=False)
session.add_all([inv1, inv2])
session.commit()
# Usage
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
session.add(r)
session.commit()
s = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p2.id)
session.add(s)
session.commit()
ctx = _build_products_context(
session, time_filter="am", reference_date=date.today()
)
# p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped
assert "Regaine" not in ctx
# Let's fix p1 to be minoxidil
p1.name = "Regaine Minoxidil"
session.add(p1)
session.commit()
ctx = _build_products_context(
session, time_filter="am", reference_date=date.today()
)
assert "Regaine Minoxidil" in ctx
assert "Sunscreen" in ctx
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
assert "nearest_open_expiry=" in ctx
assert "nearest_open_pao_deadline=" in ctx
assert "pao_months=6" in ctx
assert "effects={'hydration_immediate': 2}" in ctx
assert "incompatible_with=['avoid retinol (same_routine)']" in ctx
assert "context_rules={'safe_after_shaving': False}" in ctx
assert "min_interval_hours=12" in ctx
assert "max_frequency_per_week=7" in ctx
assert "used_in_last_7_days=1" in ctx
def test_build_objectives_context():
assert _build_objectives_context(False) == ""
assert "improve beard" in _build_objectives_context(True)
def test_build_day_context():
assert _build_day_context(None) == ""
assert "Leaving home: yes" in _build_day_context(True)
assert "Leaving home: no" in _build_day_context(False)
def test_get_available_products_respects_filters(session: Session):
regular_med = Product(
id=uuid.uuid4(),
name="Tretinoin",
category="serum",
is_medication=True,
brand="Test",
recommended_time="pm",
leave_on=True,
product_effect_profile={},
)
minoxidil_med = Product(
id=uuid.uuid4(),
name="Minoxidil 5%",
category="serum",
is_medication=True,
brand="Test",
recommended_time="both",
leave_on=True,
product_effect_profile={},
)
am_product = Product(
id=uuid.uuid4(),
name="AM SPF",
category="spf",
brand="Test",
recommended_time="am",
leave_on=True,
product_effect_profile={},
)
pm_product = Product(
id=uuid.uuid4(),
name="PM Cream",
category="moisturizer",
brand="Test",
recommended_time="pm",
leave_on=True,
product_effect_profile={},
)
session.add_all([regular_med, minoxidil_med, am_product, pm_product])
session.commit()
am_available = _get_available_products(session, time_filter="am")
am_names = {p.name for p in am_available}
assert "Tretinoin" not in am_names
assert "Minoxidil 5%" in am_names
assert "AM SPF" in am_names
assert "PM Cream" not in am_names
def test_build_inci_tool_handler_returns_only_available_ids(session: Session):
available = Product(
id=uuid.uuid4(),
name="Available",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
inci=["Water", "Niacinamide"],
product_effect_profile={},
)
unavailable = Product(
id=uuid.uuid4(),
name="Unavailable",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
inci=["Water", "Retinol"],
product_effect_profile={},
)
handler = _build_inci_tool_handler([available])
payload = handler(
{
"product_ids": [
str(available.id),
str(unavailable.id),
str(available.id),
123,
]
}
)
assert "products" in payload
products = payload["products"]
assert len(products) == 1
assert products[0]["id"] == str(available.id)
assert products[0]["name"] == "Available"
assert products[0]["inci"] == ["Water", "Niacinamide"]
def test_extract_requested_product_ids_dedupes_and_limits():
ids = _extract_requested_product_ids(
{
"product_ids": [
"id-1",
"id-2",
"id-1",
3,
"id-3",
"id-4",
]
},
max_ids=3,
)
assert ids == ["id-1", "id-2", "id-3"]
def test_extract_active_names_uses_compact_distinct_names(session: Session):
p = Product(
id=uuid.uuid4(),
name="Test",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
actives=[
{"name": "Niacinamide", "percent": 10},
{"name": "Niacinamide", "percent": 5},
{"name": "Zinc PCA", "percent": 1},
],
product_effect_profile={},
)
names = _extract_active_names(p)
assert names == ["Niacinamide", "Zinc PCA"]
def test_additional_tool_handlers_return_product_payloads(session: Session):
p = Product(
id=uuid.uuid4(),
name="Detail Product",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
usage_notes="Apply morning and evening.",
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Retinol", "scope": "same_step"}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
ids_payload = {"product_ids": [str(p.id)]}
actives_out = _build_actives_tool_handler([p])(ids_payload)
assert actives_out["products"][0]["actives"][0]["name"] == "Niacinamide"
notes_out = _build_usage_notes_tool_handler([p])(ids_payload)
assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening."
safety_out = _build_safety_rules_tool_handler([p])(ids_payload)
assert "incompatible_with" in safety_out["products"][0]
assert "context_rules" in safety_out["products"][0]

733
backend/uv.lock generated
View file

@ -2,6 +2,18 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]]
name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" version = "1.18.4"
@ -47,6 +59,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "authlib"
version = "1.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" },
]
[[package]]
name = "beartype"
version = "0.22.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]] [[package]]
name = "black" name = "black"
version = "26.1.0" version = "26.1.0"
@ -79,6 +121,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
] ]
[[package]]
name = "cachetools"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
]
[[package]]
name = "caio"
version = "0.9.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
@ -223,90 +289,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.5" version = "46.0.5"
@ -360,6 +342,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
] ]
[[package]]
name = "cyclopts"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "docstring-parser" },
{ name = "rich" },
{ name = "rich-rst" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724, upload-time = "2026-02-23T15:44:49.286Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518, upload-time = "2026-02-23T15:44:47.854Z" },
]
[[package]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" version = "1.9.0"
@ -369,6 +366,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
] ]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "docutils"
version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.132.0" version = "0.132.0"
@ -385,6 +434,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" },
] ]
[[package]]
name = "fastmcp"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "opentelemetry-api" },
{ name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "uvicorn" },
{ name = "watchfiles" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
]
[[package]] [[package]]
name = "google-auth" name = "google-auth"
version = "2.48.0" version = "2.48.0"
@ -530,6 +610,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
] ]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@ -539,6 +628,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@ -555,6 +656,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastmcp" },
{ name = "google-genai" }, { name = "google-genai" },
{ name = "psycopg" }, { name = "psycopg" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@ -569,7 +671,6 @@ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "isort" }, { name = "isort" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "ty" }, { name = "ty" },
] ]
@ -578,6 +679,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "fastapi", specifier = ">=0.132.0" }, { name = "fastapi", specifier = ">=0.132.0" },
{ name = "fastmcp", specifier = ">=2.0" },
{ name = "google-genai", specifier = ">=1.65.0" }, { name = "google-genai", specifier = ">=1.65.0" },
{ name = "psycopg", specifier = ">=3.3.3" }, { name = "psycopg", specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
@ -592,7 +694,6 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=8.0.0" }, { name = "isort", specifier = ">=8.0.0" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.15.2" }, { name = "ruff", specifier = ">=0.15.2" },
{ name = "ty", specifier = ">=0.0.18" }, { name = "ty", specifier = ">=0.0.18" },
] ]
@ -606,6 +707,115 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/ea/cf3aad99dd12c026e2d6835d559efb6fc50ccfd5b46d42d5fec2608b116a/isort-8.0.0-py3-none-any.whl", hash = "sha256:184916a933041c7cf718787f7e52064f3c06272aff69a5cb4dc46497bd8911d9", size = 89715, upload-time = "2026-02-19T16:31:57.745Z" }, { url = "https://files.pythonhosted.org/packages/74/ea/cf3aad99dd12c026e2d6835d559efb6fc50ccfd5b46d42d5fec2608b116a/isort-8.0.0-py3-none-any.whl", hash = "sha256:184916a933041c7cf718787f7e52064f3c06272aff69a5cb4dc46497bd8911d9", size = 89715, upload-time = "2026-02-19T16:31:57.745Z" },
] ]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jsonref"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-path"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pathable" },
{ name = "pyyaml" },
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/b4/41315eea8301a5353bca3578792767135b8edbc081b20618a3f0b4d78307/jsonschema_path-0.4.4.tar.gz", hash = "sha256:4c55842890fc384262a59fb63a25c86cc0e2b059e929c18b851c1d19ef612026", size = 14923, upload-time = "2026-02-28T11:58:26.289Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/36/cb2cd6543776d02875de600f12fcd81611daf359544c9ad2abb12d3122a5/jsonschema_path-0.4.4-py3-none-any.whl", hash = "sha256:669bb69cb92cd4c54acf38ee2ff7c3d9ab6b69991698f7a2f17d2bb0e5c9c394", size = 19226, upload-time = "2026-02-28T11:58:25.143Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.10" version = "1.3.10"
@ -618,6 +828,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
] ]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@ -681,6 +903,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "mcp"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
version = "1.1.0" version = "1.1.0"
@ -690,6 +955,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
] ]
[[package]]
name = "openapi-pydantic"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@ -699,6 +989,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "pathable"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
]
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.0.4" version = "1.0.4"
@ -739,6 +1038,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
] ]
[[package]]
name = "py-key-value-aio"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
]
[package.optional-dependencies]
filetree = [
{ name = "aiofile" },
{ name = "anyio" },
]
keyring = [
{ name = "keyring" },
]
memory = [
{ name = "cachetools" },
]
[[package]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.2" version = "0.6.2"
@ -784,6 +1108,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
] ]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.41.5" version = "2.41.5"
@ -855,6 +1184,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
] ]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@ -864,6 +1207,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pyperclip"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
@ -880,20 +1246,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
] ]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@ -941,6 +1293,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
] ]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@ -987,6 +1364,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@ -1002,6 +1393,113 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
] ]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-rst"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "4.9.1" version = "4.9.1"
@ -1039,6 +1537,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
] ]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
@ -1103,6 +1614,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/e1/7c8d18e737433f3b5bbe27b56a9072a9fcb36342b48f1bef34b6da1d61f2/sqlmodel-0.0.37-py3-none-any.whl", hash = "sha256:2137a4045ef3fd66a917a7717ada959a1ceb3630d95e1f6aaab39dd2c0aef278", size = 27224, upload-time = "2026-02-21T16:39:47.781Z" }, { url = "https://files.pythonhosted.org/packages/b1/e1/7c8d18e737433f3b5bbe27b56a9072a9fcb36342b48f1bef34b6da1d61f2/sqlmodel-0.0.37-py3-none-any.whl", hash = "sha256:2137a4045ef3fd66a917a7717ada959a1ceb3630d95e1f6aaab39dd2c0aef278", size = 27224, upload-time = "2026-02-21T16:39:47.781Z" },
] ]
[[package]]
name = "sse-starlette"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.52.1" version = "0.52.1"
@ -1358,3 +1882,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
] ]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]

View file

@ -1,63 +0,0 @@
#!/usr/bin/env bash
# Usage: ./deploy.sh [frontend|backend|all]
# default: all
#
# SSH config (~/.ssh/config) — recommended:
# Host innercontext
# HostName <IP_LXC>
# User innercontext
#
# The innercontext user needs passwordless sudo for systemctl only:
# /etc/sudoers.d/innercontext-deploy:
# innercontext ALL=(root) NOPASSWD: /usr/bin/systemctl restart innercontext, /usr/bin/systemctl restart innercontext-node, /usr/bin/systemctl is-active innercontext, /usr/bin/systemctl is-active innercontext-node
set -euo pipefail
SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host
REMOTE="/opt/innercontext"
SCOPE="${1:-all}"
# ── Frontend ───────────────────────────────────────────────────────────────
deploy_frontend() {
echo "==> [frontend] Building locally..."
(cd frontend && pnpm run build)
echo "==> [frontend] Uploading build/ and package files..."
rsync -az --delete frontend/build/ "$SERVER:$REMOTE/frontend/build/"
rsync -az frontend/package.json frontend/pnpm-lock.yaml "$SERVER:$REMOTE/frontend/"
echo "==> [frontend] Installing production dependencies on server..."
ssh "$SERVER" "cd $REMOTE/frontend && pnpm install --prod --frozen-lockfile --ignore-scripts"
echo "==> [frontend] Restarting service..."
ssh "$SERVER" "sudo systemctl restart innercontext-node && echo OK"
}
# ── Backend ────────────────────────────────────────────────────────────────
deploy_backend() {
echo "==> [backend] Uploading source..."
rsync -az --delete \
--exclude='.venv/' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='.env' \
backend/ "$SERVER:$REMOTE/backend/"
echo "==> [backend] Syncing dependencies..."
ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable"
echo "==> [backend] Restarting service (alembic runs on start)..."
ssh "$SERVER" "sudo systemctl restart innercontext && echo OK"
}
# ── Dispatch ───────────────────────────────────────────────────────────────
case "$SCOPE" in
frontend) deploy_frontend ;;
backend) deploy_backend ;;
all) deploy_frontend; deploy_backend ;;
*)
echo "Usage: $0 [frontend|backend|all]"
exit 1
;;
esac
echo "==> Done."

View file

@ -7,16 +7,13 @@ Reverse proxy (existing) innercontext LXC (new, Debian 13)
┌──────────────────────┐ ┌────────────────────────────────────┐ ┌──────────────────────┐ ┌────────────────────────────────────┐
│ reverse proxy │────────────▶│ nginx :80 │ │ reverse proxy │────────────▶│ nginx :80 │
│ innercontext.lan → * │ │ /api/* → uvicorn :8000/* │ │ innercontext.lan → * │ │ /api/* → uvicorn :8000/* │
└──────────────────────┘ │ /* → SvelteKit Node :3000 │ └──────────────────────┘ │ /mcp/* → uvicorn :8000/mcp/* │
│ /* → SvelteKit Node :3000 │
└────────────────────────────────────┘ └────────────────────────────────────┘
│ │ │ │
FastAPI SvelteKit Node FastAPI + MCP SvelteKit Node
``` ```
> **Frontend is never built on the server.** The `vite build` + `adapter-node`
> esbuild step is CPU/RAM-intensive and will hang on a small LXC. Build locally,
> deploy the `build/` artifact via `deploy.sh`.
## 1. Prerequisites ## 1. Prerequisites
- Proxmox VE host with an existing PostgreSQL LXC and a reverse proxy - Proxmox VE host with an existing PostgreSQL LXC and a reverse proxy
@ -56,7 +53,7 @@ pct enter 200 # or SSH into the container
```bash ```bash
apt update && apt upgrade -y apt update && apt upgrade -y
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5 rsync apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5
``` ```
### Python 3.12+ + uv ### Python 3.12+ + uv
@ -70,11 +67,6 @@ Installing to `/usr/local/bin` makes `uv` available system-wide (required for `s
### Node.js 24 LTS + pnpm ### Node.js 24 LTS + pnpm
The server needs Node.js to **run** the pre-built frontend bundle, and pnpm to
**install production runtime dependencies** (`clsx`, `bits-ui`, etc. —
`adapter-node` bundles the SvelteKit framework but leaves these external).
The frontend is never **built** on the server.
```bash ```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
. "$HOME/.nvm/nvm.sh" . "$HOME/.nvm/nvm.sh"
@ -83,14 +75,16 @@ nvm install 24
Copy `node` to `/usr/local/bin` so it is accessible system-wide Copy `node` to `/usr/local/bin` so it is accessible system-wide
(required for `sudo -u innercontext` and for systemd). (required for `sudo -u innercontext` and for systemd).
Symlinking into `/root/.nvm/` won't work — other users can't traverse `/root/`.
Use `--remove-destination` to replace any existing symlink with a real file: Use `--remove-destination` to replace any existing symlink with a real file:
```bash ```bash
cp --remove-destination "$(nvm which current)" /usr/local/bin/node cp --remove-destination "$(nvm which current)" /usr/local/bin/node
``` ```
Install pnpm as a standalone binary — self-contained, no wrapper scripts, Install pnpm as a standalone binary from GitHub releases — self-contained,
works system-wide: no wrapper scripts, works system-wide. Do **not** use `corepack enable pnpm`
(the shim requires its nvm directory structure and breaks when copied/linked):
```bash ```bash
curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \ curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \
@ -200,10 +194,11 @@ systemctl status innercontext
--- ---
## 7. Frontend setup ## 7. Frontend build and setup
The frontend is **built locally and uploaded** via `deploy.sh` — never built on the server. ```bash
This section only covers the one-time server-side configuration. cd /opt/innercontext/frontend
```
### Create `.env.production` ### Create `.env.production`
@ -216,24 +211,25 @@ chmod 600 /opt/innercontext/frontend/.env.production
chown innercontext:innercontext /opt/innercontext/frontend/.env.production chown innercontext:innercontext /opt/innercontext/frontend/.env.production
``` ```
### Grant `innercontext` passwordless sudo for service restarts ### Install dependencies and build
```bash ```bash
cat > /etc/sudoers.d/innercontext-deploy << 'EOF' sudo -u innercontext bash -c '
innercontext ALL=(root) NOPASSWD: \ cd /opt/innercontext/frontend
/usr/bin/systemctl restart innercontext, \ pnpm install
/usr/bin/systemctl restart innercontext-node PUBLIC_API_BASE=http://innercontext.lan/api pnpm build
EOF '
chmod 440 /etc/sudoers.d/innercontext-deploy
``` ```
The production build lands in `/opt/innercontext/frontend/build/`.
### Install systemd service ### Install systemd service
```bash ```bash
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/ cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
systemctl enable innercontext-node systemctl enable --now innercontext-node
# Do NOT start yet — build/ is empty until the first deploy.sh run systemctl status innercontext-node
``` ```
--- ---
@ -280,64 +276,44 @@ Reload your reverse proxy after applying the change.
--- ---
## 10. First deploy from local machine ## 10. Verification
All subsequent deploys (including the first one) use `deploy.sh` from your local machine.
### SSH config
Add to `~/.ssh/config` on your local machine:
```
Host innercontext
HostName <innercontext-lxc-ip>
User innercontext
```
Make sure your SSH public key is in `/home/innercontext/.ssh/authorized_keys` on the server.
### Run the first deploy
```bash
# From the repo root on your local machine:
./deploy.sh
```
This will:
1. Build the frontend locally (`pnpm run build`)
2. Upload `frontend/build/` to the server via rsync
3. Restart `innercontext-node`
4. Upload `backend/` source to the server
5. Run `uv sync --frozen` on the server
6. Restart `innercontext` (runs alembic migrations on start)
---
## 11. Verification
```bash ```bash
# From any machine on the LAN: # From any machine on the LAN:
curl http://innercontext.lan/api/health-check # {"status":"ok"} curl http://innercontext.lan/api/health-check # {"status":"ok"}
curl http://innercontext.lan/api/products # [] curl http://innercontext.lan/api/products # []
curl http://innercontext.lan/ # SvelteKit HTML shell curl http://innercontext.lan/ # SvelteKit HTML shell
curl -N http://innercontext.lan/mcp/mcp # MCP StreamableHTTP endpoint
``` ```
The web UI should be accessible at `http://innercontext.lan`. The web UI should be accessible at `http://innercontext.lan`.
--- ---
## 12. Updating the application ## 11. Updating the application
```bash ```bash
# From the repo root on your local machine: cd /opt/innercontext
./deploy.sh # full deploy (frontend + backend) git pull
./deploy.sh frontend # frontend only
./deploy.sh backend # backend only # Sync backend dependencies if pyproject.toml changed:
cd backend && sudo -u innercontext uv sync && cd ..
# Apply any new DB migrations (runs automatically via ExecStartPre, but safe to run manually first):
sudo -u innercontext bash -c 'cd /opt/innercontext/backend && uv run alembic upgrade head'
# Rebuild frontend:
cd frontend && sudo -u innercontext bash -c '
pnpm install
PUBLIC_API_BASE=http://innercontext.lan/api pnpm build
'
systemctl restart innercontext innercontext-node
``` ```
--- ---
## 13. Troubleshooting ## 12. Troubleshooting
### 502 Bad Gateway on `/api/*` ### 502 Bad Gateway on `/api/*`
@ -352,7 +328,17 @@ journalctl -u innercontext -n 50
```bash ```bash
systemctl status innercontext-node systemctl status innercontext-node
journalctl -u innercontext-node -n 50 journalctl -u innercontext-node -n 50
# Verify /opt/innercontext/frontend/build/index.js exists (deploy.sh ran successfully) # Verify /opt/innercontext/frontend/build/index.js exists (pnpm build ran successfully)
```
### MCP endpoint not responding
```bash
# MCP uses SSE — disable buffering is already in nginx config
# Verify the backend started successfully:
curl http://127.0.0.1:8000/health-check
# Check FastAPI logs:
journalctl -u innercontext -n 50
``` ```
### Database connection refused ### Database connection refused

View file

@ -1,10 +0,0 @@
{
"mcpServers": {
"svelte": {
"type": "stdio",
"command": "npx",
"env": {},
"args": ["-y", "@sveltejs/mcp"]
}
}
}

View file

@ -1,8 +0,0 @@
node_modules
.svelte-kit
paraglide
build
dist
.env
.env.*
!.env.example

View file

@ -1,9 +0,0 @@
{
"plugins": ["svelte"],
"overrides": [
{
"files": ["*.svelte"],
"parser": "svelte-eslint-parser"
}
]
}

View file

@ -25,7 +25,7 @@ The backend must be running at `http://localhost:8000`. See `../backend/` for se
## Environment variables ## Environment variables
| Variable | Description | Default | | Variable | Description | Default |
| ----------------- | ------------------------------- | ----------------------- | |---|---|---|
| `PUBLIC_API_BASE` | Base URL of the FastAPI backend | `http://localhost:8000` | | `PUBLIC_API_BASE` | Base URL of the FastAPI backend | `http://localhost:8000` |
Set `PUBLIC_API_BASE` at **build time** for production: Set `PUBLIC_API_BASE` at **build time** for production:
@ -52,7 +52,7 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
## Routes ## Routes
| Route | Description | | Route | Description |
| --------------------- | ------------------------ | |---|---|
| `/` | Dashboard | | `/` | Dashboard |
| `/products` | Product list | | `/products` | Product list |
| `/products/new` | Add product | | `/products/new` | Add product |
@ -67,7 +67,7 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
## Key files ## Key files
| File | Purpose | | File | Purpose |
| ------------------ | --------------------------------- | |---|---|
| `src/lib/api.ts` | API client (typed fetch wrappers) | | `src/lib/api.ts` | API client (typed fetch wrappers) |
| `src/lib/types.ts` | Shared TypeScript types | | `src/lib/types.ts` | Shared TypeScript types |
| `src/app.css` | Tailwind v4 theme + global styles | | `src/app.css` | Tailwind v4 theme + global styles |

View file

@ -1,47 +0,0 @@
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import ts from "typescript-eslint";
import globals from "globals";
export default [
{
ignores: [
".svelte-kit",
"node_modules",
"build",
"dist",
"**/paraglide/**",
"**/lib/paraglide/**",
],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
{
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
},
},
rules: {
"svelte/no-at-html-tags": "off",
"svelte/require-each-key": "off",
// TODO: Set ignoreGoto to false when https://github.com/sveltejs/eslint-plugin-svelte/issues/1327 is fixed
// The rule doesn't detect resolve() when used with string concatenation for query params
"svelte/no-navigation-without-resolve": [
"error",
{ ignoreLinks: true, ignoreGoto: true },
],
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
];

View file

@ -31,28 +31,8 @@
"dashboard_noRoutines": "No routines in the past 2 weeks.", "dashboard_noRoutines": "No routines in the past 2 weeks.",
"products_title": "Products", "products_title": "Products",
"products_count": [ "products_count": "{count} products",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} product",
"countPlural=*": "{count} products"
}
}
],
"products_addNew": "+ Add product", "products_addNew": "+ Add product",
"products_suggest": "Suggest",
"products_suggestTitle": "Shopping suggestions",
"products_suggestSubtitle": "What to buy?",
"products_suggestDescription": "Based on your skin condition and products you own, I'll suggest product types that could complement your routine.",
"products_suggestGenerating": "Analyzing...",
"products_suggestBtn": "Generate suggestions",
"products_suggestResults": "Suggestions",
"products_suggestTime": "Time",
"products_suggestFrequency": "Frequency",
"products_suggestRegenerate": "Regenerate",
"products_suggestNoResults": "No suggestions.",
"products_noProducts": "No products found.", "products_noProducts": "No products found.",
"products_filterAll": "All", "products_filterAll": "All",
"products_filterOwned": "Owned", "products_filterOwned": "Owned",
@ -92,16 +72,7 @@
"inventory_confirmDelete": "Delete this package?", "inventory_confirmDelete": "Delete this package?",
"routines_title": "Routines", "routines_title": "Routines",
"routines_count": [ "routines_count": "{count} routines (last 30 days)",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} routine (last 30 days)",
"countPlural=*": "{count} routines (last 30 days)"
}
}
],
"routines_suggestAI": "Suggest AI routine", "routines_suggestAI": "Suggest AI routine",
"routines_addNew": "+ New routine", "routines_addNew": "+ New routine",
"routines_noRoutines": "No routines found.", "routines_noRoutines": "No routines found.",
@ -161,10 +132,6 @@
"suggest_contextLabel": "Additional context for AI", "suggest_contextLabel": "Additional context for AI",
"suggest_contextOptional": "(optional)", "suggest_contextOptional": "(optional)",
"suggest_contextPlaceholder": "e.g. party night, focusing on hydration...", "suggest_contextPlaceholder": "e.g. party night, focusing on hydration...",
"suggest_leavingHomeLabel": "Going outside today",
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
"suggest_generateBtn": "Generate suggestion", "suggest_generateBtn": "Generate suggestion",
"suggest_generating": "Generating…", "suggest_generating": "Generating…",
"suggest_proposalTitle": "Suggestion", "suggest_proposalTitle": "Suggestion",
@ -178,16 +145,7 @@
"suggest_batchContextPlaceholder": "e.g. sunny trip to Italy, active mountain vacation...", "suggest_batchContextPlaceholder": "e.g. sunny trip to Italy, active mountain vacation...",
"suggest_generatePlan": "Generate plan", "suggest_generatePlan": "Generate plan",
"suggest_generatingPlan": "Generating plan…", "suggest_generatingPlan": "Generating plan…",
"suggest_planTitle": [ "suggest_planTitle": "Plan ({count} days)",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Plan ({count} day)",
"countPlural=*": "Plan ({count} days)"
}
}
],
"suggest_saveAllRoutines": "Save all routines", "suggest_saveAllRoutines": "Save all routines",
"suggest_amSteps": "steps", "suggest_amSteps": "steps",
"suggest_pmSteps": "steps", "suggest_pmSteps": "steps",
@ -198,22 +156,9 @@
"suggest_errorSave": "Error saving.", "suggest_errorSave": "Error saving.",
"suggest_amMorning": "AM (morning)", "suggest_amMorning": "AM (morning)",
"suggest_pmEvening": "PM (evening)", "suggest_pmEvening": "PM (evening)",
"suggest_summaryPrimaryGoal": "Primary goal",
"suggest_summaryConfidence": "Confidence",
"suggest_summaryConstraints": "Constraints",
"suggest_stepOptionalBadge": "optional",
"medications_title": "Medications", "medications_title": "Medications",
"medications_count": [ "medications_count": "{count} entries",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} entry",
"countPlural=*": "{count} entries"
}
}
],
"medications_addNew": "+ Add medication", "medications_addNew": "+ Add medication",
"medications_newTitle": "New medication", "medications_newTitle": "New medication",
"medications_kind": "Kind", "medications_kind": "Kind",
@ -223,16 +168,7 @@
"medications_activeSubstancePlaceholder": "e.g. cholecalciferol", "medications_activeSubstancePlaceholder": "e.g. cholecalciferol",
"medications_notes": "Notes", "medications_notes": "Notes",
"medications_added": "Medication added.", "medications_added": "Medication added.",
"medications_usages": [ "medications_usages": "{count} usages",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} usage",
"countPlural=*": "{count} usages"
}
}
],
"medications_noMedications": "No medications recorded.", "medications_noMedications": "No medications recorded.",
"medications_kindPrescription": "Prescription", "medications_kindPrescription": "Prescription",
"medications_kindOtc": "OTC", "medications_kindOtc": "OTC",
@ -241,16 +177,7 @@
"medications_kindOther": "Other", "medications_kindOther": "Other",
"labResults_title": "Lab Results", "labResults_title": "Lab Results",
"labResults_count": [ "labResults_count": "{count} results",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} result",
"countPlural=*": "{count} results"
}
}
],
"labResults_addNew": "+ Add result", "labResults_addNew": "+ Add result",
"labResults_newTitle": "New lab result", "labResults_newTitle": "New lab result",
"labResults_flagFilter": "Flag:", "labResults_flagFilter": "Flag:",
@ -276,16 +203,7 @@
"labResults_noResults": "No lab results found.", "labResults_noResults": "No lab results found.",
"skin_title": "Skin Snapshots", "skin_title": "Skin Snapshots",
"skin_count": [ "skin_count": "{count} snapshots",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} snapshot",
"countPlural=*": "{count} snapshots"
}
}
],
"skin_addNew": "+ Add snapshot", "skin_addNew": "+ Add snapshot",
"skin_aiAnalysisTitle": "AI analysis from photos", "skin_aiAnalysisTitle": "AI analysis from photos",
"skin_aiUploadText": "Upload 13 photos of your skin. AI will pre-fill the form fields below.", "skin_aiUploadText": "Upload 13 photos of your skin. AI will pre-fill the form fields below.",
@ -303,9 +221,6 @@
"skin_sebumCheeks": "Sebum cheeks (15)", "skin_sebumCheeks": "Sebum cheeks (15)",
"skin_activeConcerns": "Active concerns (comma-separated)", "skin_activeConcerns": "Active concerns (comma-separated)",
"skin_activeConcernsPlaceholder": "acne, redness, dehydration", "skin_activeConcernsPlaceholder": "acne, redness, dehydration",
"skin_priorities": "Priorities (comma-separated)",
"skin_prioritiesPlaceholder": "strengthen barrier, reduce redness",
"skin_prioritiesLabel": "Priorities",
"skin_notes": "Notes", "skin_notes": "Notes",
"skin_addSnapshot": "Add snapshot", "skin_addSnapshot": "Add snapshot",
"skin_snapshotAdded": "Snapshot added.", "skin_snapshotAdded": "Snapshot added.",

View file

@ -28,33 +28,11 @@
"dashboard_latestSnapshot": "Ostatni stan skóry", "dashboard_latestSnapshot": "Ostatni stan skóry",
"dashboard_recentRoutines": "Ostatnie rutyny", "dashboard_recentRoutines": "Ostatnie rutyny",
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.", "dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
"dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.", "dashboard_noRoutines": "Brak rutyno w ciągu ostatnich 2 tygodni.",
"products_title": "Produkty", "products_title": "Produkty",
"products_count": [ "products_count": "{count} produktów",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} produkt",
"countPlural=few": "{count} produkty",
"countPlural=many": "{count} produktów",
"countPlural=*": "{count} produktów"
}
}
],
"products_addNew": "+ Dodaj produkt", "products_addNew": "+ Dodaj produkt",
"products_suggest": "Sugeruj",
"products_suggestTitle": "Sugestie zakupowe",
"products_suggestSubtitle": "Co warto kupić?",
"products_suggestDescription": "Na podstawie Twojego stanu skóry i posiadanych produktów zasugeruję typy produktów, które mogłyby uzupełnić Twoją rutynę.",
"products_suggestGenerating": "Analizuję...",
"products_suggestBtn": "Generuj sugestie",
"products_suggestResults": "Propozycje",
"products_suggestTime": "Pora",
"products_suggestFrequency": "Częstotliwość",
"products_suggestRegenerate": "Wygeneruj ponownie",
"products_suggestNoResults": "Brak propozycji.",
"products_noProducts": "Nie znaleziono produktów.", "products_noProducts": "Nie znaleziono produktów.",
"products_filterAll": "Wszystkie", "products_filterAll": "Wszystkie",
"products_filterOwned": "Posiadane", "products_filterOwned": "Posiadane",
@ -94,21 +72,10 @@
"inventory_confirmDelete": "Usunąć to opakowanie?", "inventory_confirmDelete": "Usunąć to opakowanie?",
"routines_title": "Rutyny", "routines_title": "Rutyny",
"routines_count": [ "routines_count": "{count} rutyno (ostatnie 30 dni)",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} rutyna (ostatnie 30 dni)",
"countPlural=few": "{count} rutyny (ostatnie 30 dni)",
"countPlural=many": "{count} rutyn (ostatnie 30 dni)",
"countPlural=*": "{count} rutyn (ostatnie 30 dni)"
}
}
],
"routines_suggestAI": "Zaproponuj rutynę AI", "routines_suggestAI": "Zaproponuj rutynę AI",
"routines_addNew": "+ Nowa rutyna", "routines_addNew": "+ Nowa rutyna",
"routines_noRoutines": "Nie znaleziono rutyn.", "routines_noRoutines": "Nie znaleziono rutyno.",
"routines_newTitle": "Nowa rutyna", "routines_newTitle": "Nowa rutyna",
"routines_backToList": "← Rutyny", "routines_backToList": "← Rutyny",
"routines_detailsTitle": "Szczegóły rutyny", "routines_detailsTitle": "Szczegóły rutyny",
@ -165,10 +132,6 @@
"suggest_contextLabel": "Dodatkowy kontekst dla AI", "suggest_contextLabel": "Dodatkowy kontekst dla AI",
"suggest_contextOptional": "(opcjonalny)", "suggest_contextOptional": "(opcjonalny)",
"suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...", "suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...",
"suggest_leavingHomeLabel": "Wychodzę dziś z domu",
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)",
"suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.",
"suggest_generateBtn": "Generuj propozycję", "suggest_generateBtn": "Generuj propozycję",
"suggest_generating": "Generuję…", "suggest_generating": "Generuję…",
"suggest_proposalTitle": "Propozycja", "suggest_proposalTitle": "Propozycja",
@ -182,18 +145,7 @@
"suggest_batchContextPlaceholder": "np. słoneczna podróż do Włoch, aktywny urlop górski...", "suggest_batchContextPlaceholder": "np. słoneczna podróż do Włoch, aktywny urlop górski...",
"suggest_generatePlan": "Generuj plan", "suggest_generatePlan": "Generuj plan",
"suggest_generatingPlan": "Generuję plan…", "suggest_generatingPlan": "Generuję plan…",
"suggest_planTitle": [ "suggest_planTitle": "Plan ({count} dni)",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Plan ({count} dzień)",
"countPlural=few": "Plan ({count} dni)",
"countPlural=many": "Plan ({count} dni)",
"countPlural=*": "Plan ({count} dni)"
}
}
],
"suggest_saveAllRoutines": "Zapisz wszystkie rutyny", "suggest_saveAllRoutines": "Zapisz wszystkie rutyny",
"suggest_amSteps": "kroków", "suggest_amSteps": "kroków",
"suggest_pmSteps": "kroków", "suggest_pmSteps": "kroków",
@ -204,24 +156,9 @@
"suggest_errorSave": "Błąd podczas zapisywania.", "suggest_errorSave": "Błąd podczas zapisywania.",
"suggest_amMorning": "AM (rano)", "suggest_amMorning": "AM (rano)",
"suggest_pmEvening": "PM (wieczór)", "suggest_pmEvening": "PM (wieczór)",
"suggest_summaryPrimaryGoal": "Główny cel",
"suggest_summaryConfidence": "Pewność",
"suggest_summaryConstraints": "Ograniczenia",
"suggest_stepOptionalBadge": "opcjonalny",
"medications_title": "Leki", "medications_title": "Leki",
"medications_count": [ "medications_count": "{count} wpisów",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wpis",
"countPlural=few": "{count} wpisy",
"countPlural=many": "{count} wpisów",
"countPlural=*": "{count} wpisów"
}
}
],
"medications_addNew": "+ Dodaj lek", "medications_addNew": "+ Dodaj lek",
"medications_newTitle": "Nowy lek", "medications_newTitle": "Nowy lek",
"medications_kind": "Rodzaj", "medications_kind": "Rodzaj",
@ -231,18 +168,7 @@
"medications_activeSubstancePlaceholder": "np. cholekalcyferol", "medications_activeSubstancePlaceholder": "np. cholekalcyferol",
"medications_notes": "Notatki", "medications_notes": "Notatki",
"medications_added": "Lek dodany.", "medications_added": "Lek dodany.",
"medications_usages": [ "medications_usages": "{count} użyć",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} użycie",
"countPlural=few": "{count} użycia",
"countPlural=many": "{count} użyć",
"countPlural=*": "{count} użyć"
}
}
],
"medications_noMedications": "Brak leków.", "medications_noMedications": "Brak leków.",
"medications_kindPrescription": "Na receptę", "medications_kindPrescription": "Na receptę",
"medications_kindOtc": "OTC (bez recepty)", "medications_kindOtc": "OTC (bez recepty)",
@ -251,18 +177,7 @@
"medications_kindOther": "Inne", "medications_kindOther": "Inne",
"labResults_title": "Wyniki badań", "labResults_title": "Wyniki badań",
"labResults_count": [ "labResults_count": "{count} wyników",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wynik",
"countPlural=few": "{count} wyniki",
"countPlural=many": "{count} wyników",
"countPlural=*": "{count} wyników"
}
}
],
"labResults_addNew": "+ Dodaj wynik", "labResults_addNew": "+ Dodaj wynik",
"labResults_newTitle": "Nowy wynik badania", "labResults_newTitle": "Nowy wynik badania",
"labResults_flagFilter": "Flaga:", "labResults_flagFilter": "Flaga:",
@ -288,18 +203,7 @@
"labResults_noResults": "Nie znaleziono wyników badań.", "labResults_noResults": "Nie znaleziono wyników badań.",
"skin_title": "Stan skóry", "skin_title": "Stan skóry",
"skin_count": [ "skin_count": "{count} wpisów",
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wpis",
"countPlural=few": "{count} wpisy",
"countPlural=many": "{count} wpisów",
"countPlural=*": "{count} wpisów"
}
}
],
"skin_addNew": "+ Dodaj wpis", "skin_addNew": "+ Dodaj wpis",
"skin_aiAnalysisTitle": "Analiza AI ze zdjęć", "skin_aiAnalysisTitle": "Analiza AI ze zdjęć",
"skin_aiUploadText": "Prześlij 13 zdjęcia skóry. AI wypełni pola formularza poniżej.", "skin_aiUploadText": "Prześlij 13 zdjęcia skóry. AI wypełni pola formularza poniżej.",
@ -317,9 +221,6 @@
"skin_sebumCheeks": "Sebum policzki (15)", "skin_sebumCheeks": "Sebum policzki (15)",
"skin_activeConcerns": "Aktywne problemy (przecinek)", "skin_activeConcerns": "Aktywne problemy (przecinek)",
"skin_activeConcernsPlaceholder": "trądzik, zaczerwienienie, odwodnienie", "skin_activeConcernsPlaceholder": "trądzik, zaczerwienienie, odwodnienie",
"skin_priorities": "Priorytety (przecinek)",
"skin_prioritiesPlaceholder": "wzmocnić barierę, redukować zaczerwienienie",
"skin_prioritiesLabel": "Priorytety",
"skin_notes": "Notatki", "skin_notes": "Notatki",
"skin_addSnapshot": "Dodaj wpis", "skin_addSnapshot": "Dodaj wpis",
"skin_snapshotAdded": "Wpis dodany.", "skin_snapshotAdded": "Wpis dodany.",

View file

@ -9,30 +9,20 @@
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"lint": "eslint .",
"format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.11.0", "@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.561.0", "@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"eslint": "^10.0.2",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.51.0", "svelte": "^5.51.0",
"svelte-check": "^4.3.6", "svelte-check": "^4.3.6",
"svelte-eslint-parser": "^1.5.1",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
@ -41,7 +31,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-svelte": "^0.575.0", "lucide-svelte": "^0.575.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"svelte-dnd-action": "^0.9.69",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
} }
} }

3135
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { paraglideMiddleware } from "$lib/paraglide/server.js"; import { paraglideMiddleware } from '$lib/paraglide/server.js';
import type { Handle } from "@sveltejs/kit"; import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
return paraglideMiddleware(event.request, () => resolve(event)); return paraglideMiddleware(event.request, () => resolve(event));

View file

@ -1,5 +1,4 @@
import { browser } from "$app/environment"; import { PUBLIC_API_BASE } from '$env/static/public';
import { PUBLIC_API_BASE } from "$env/static/public";
import type { import type {
ActiveIngredient, ActiveIngredient,
BatchSuggestion, BatchSuggestion,
@ -16,19 +15,16 @@ import type {
Routine, Routine,
RoutineSuggestion, RoutineSuggestion,
RoutineStep, RoutineStep,
SkinConditionSnapshot, SkinConditionSnapshot
} from "./types"; } from './types';
// ─── Core fetch helpers ────────────────────────────────────────────────────── // ─── Core fetch helpers ──────────────────────────────────────────────────────
async function request<T>(path: string, init: RequestInit = {}): Promise<T> { async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
// Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000). const url = `${PUBLIC_API_BASE}${path}`;
// Browser-side uses /api so nginx proxies the request on the correct host.
const base = browser ? "/api" : PUBLIC_API_BASE;
const url = `${base}${path}`;
const res = await fetch(url, { const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...init.headers }, headers: { 'Content-Type': 'application/json', ...init.headers },
...init, ...init
}); });
if (!res.ok) { if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText })); const detail = await res.json().catch(() => ({ detail: res.statusText }));
@ -41,10 +37,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
export const api = { export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }), request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }), request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
del: (path: string) => request<void>(path, { method: "DELETE" }), del: (path: string) => request<void>(path, { method: 'DELETE' })
}; };
// ─── Products ──────────────────────────────────────────────────────────────── // ─── Products ────────────────────────────────────────────────────────────────
@ -57,88 +53,54 @@ export interface ProductListParams {
is_tool?: boolean; is_tool?: boolean;
} }
export function getProducts( export function getProducts(params: ProductListParams = {}): Promise<Product[]> {
params: ProductListParams = {},
): Promise<Product[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.category) q.set("category", params.category); if (params.category) q.set('category', params.category);
if (params.brand) q.set("brand", params.brand); if (params.brand) q.set('brand', params.brand);
if (params.targets) params.targets.forEach((t) => q.append("targets", t)); if (params.targets) params.targets.forEach((t) => q.append('targets', t));
if (params.is_medication != null) if (params.is_medication != null) q.set('is_medication', String(params.is_medication));
q.set("is_medication", String(params.is_medication)); if (params.is_tool != null) q.set('is_tool', String(params.is_tool));
if (params.is_tool != null) q.set("is_tool", String(params.is_tool));
const qs = q.toString(); const qs = q.toString();
return api.get(`/products${qs ? `?${qs}` : ""}`); return api.get(`/products${qs ? `?${qs}` : ''}`);
} }
export const getProduct = (id: string): Promise<Product> => export const getProduct = (id: string): Promise<Product> => api.get(`/products/${id}`);
api.get(`/products/${id}`); export const createProduct = (body: Record<string, unknown>): Promise<Product> =>
export const createProduct = ( api.post('/products', body);
body: Record<string, unknown>, export const updateProduct = (id: string, body: Record<string, unknown>): Promise<Product> =>
): Promise<Product> => api.post("/products", body); api.patch(`/products/${id}`, body);
export const updateProduct = ( export const deleteProduct = (id: string): Promise<void> => api.del(`/products/${id}`);
id: string,
body: Record<string, unknown>,
): Promise<Product> => api.patch(`/products/${id}`, body);
export const deleteProduct = (id: string): Promise<void> =>
api.del(`/products/${id}`);
export const getInventory = (productId: string): Promise<ProductInventory[]> => export const getInventory = (productId: string): Promise<ProductInventory[]> =>
api.get(`/products/${productId}/inventory`); api.get(`/products/${productId}/inventory`);
export const createInventory = ( export const createInventory = (
productId: string, productId: string,
body: Record<string, unknown>, body: Record<string, unknown>
): Promise<ProductInventory> => ): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body);
api.post(`/products/${productId}/inventory`, body); export const updateInventory = (id: string, body: Record<string, unknown>): Promise<ProductInventory> =>
export const updateInventory = ( api.patch(`/inventory/${id}`, body);
id: string, export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
body: Record<string, unknown>,
): Promise<ProductInventory> => api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> =>
api.del(`/inventory/${id}`);
export interface ProductParseResponse { export interface ProductParseResponse {
name?: string; name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
brand?: string; category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
line_name?: string; leave_on?: boolean; price_tier?: string;
sku?: string; size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
url?: string; inci?: string[]; actives?: ActiveIngredient[];
barcode?: string; recommended_for?: string[]; targets?: string[];
category?: string; contraindications?: string[]; usage_notes?: string;
recommended_time?: string; fragrance_free?: boolean; essential_oils_free?: boolean;
texture?: string; alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
absorption_speed?: string;
leave_on?: boolean;
price_tier?: string;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci?: string[];
actives?: ActiveIngredient[];
recommended_for?: string[];
targets?: string[];
contraindications?: string[];
usage_notes?: string;
fragrance_free?: boolean;
essential_oils_free?: boolean;
alcohol_denat_free?: boolean;
pregnancy_safe?: boolean;
product_effect_profile?: ProductEffectProfile; product_effect_profile?: ProductEffectProfile;
ph_min?: number; ph_min?: number; ph_max?: number;
ph_max?: number; incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext; context_rules?: ProductContext;
min_interval_hours?: number; min_interval_hours?: number; max_frequency_per_week?: number;
max_frequency_per_week?: number; is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
is_medication?: boolean;
is_tool?: boolean;
needle_length_mm?: number;
} }
export const parseProductText = (text: string): Promise<ProductParseResponse> => export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post("/products/parse-text", { text }); api.post('/products/parse-text', { text });
// ─── Routines ──────────────────────────────────────────────────────────────── // ─── Routines ────────────────────────────────────────────────────────────────
@ -148,37 +110,26 @@ export interface RoutineListParams {
part_of_day?: string; part_of_day?: string;
} }
export function getRoutines( export function getRoutines(params: RoutineListParams = {}): Promise<Routine[]> {
params: RoutineListParams = {},
): Promise<Routine[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.from_date) q.set("from_date", params.from_date); if (params.from_date) q.set('from_date', params.from_date);
if (params.to_date) q.set("to_date", params.to_date); if (params.to_date) q.set('to_date', params.to_date);
if (params.part_of_day) q.set("part_of_day", params.part_of_day); if (params.part_of_day) q.set('part_of_day', params.part_of_day);
const qs = q.toString(); const qs = q.toString();
return api.get(`/routines${qs ? `?${qs}` : ""}`); return api.get(`/routines${qs ? `?${qs}` : ''}`);
} }
export const getRoutine = (id: string): Promise<Routine> => export const getRoutine = (id: string): Promise<Routine> => api.get(`/routines/${id}`);
api.get(`/routines/${id}`); export const createRoutine = (body: Record<string, unknown>): Promise<Routine> =>
export const createRoutine = ( api.post('/routines', body);
body: Record<string, unknown>, export const updateRoutine = (id: string, body: Record<string, unknown>): Promise<Routine> =>
): Promise<Routine> => api.post("/routines", body); api.patch(`/routines/${id}`, body);
export const updateRoutine = ( export const deleteRoutine = (id: string): Promise<void> => api.del(`/routines/${id}`);
id: string,
body: Record<string, unknown>,
): Promise<Routine> => api.patch(`/routines/${id}`, body);
export const deleteRoutine = (id: string): Promise<void> =>
api.del(`/routines/${id}`);
export const addRoutineStep = ( export const addRoutineStep = (routineId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
routineId: string, api.post(`/routines/${routineId}/steps`, body);
body: Record<string, unknown>, export const updateRoutineStep = (stepId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
): Promise<RoutineStep> => api.post(`/routines/${routineId}/steps`, body); api.patch(`/routines/steps/${stepId}`, body);
export const updateRoutineStep = (
stepId: string,
body: Record<string, unknown>,
): Promise<RoutineStep> => api.patch(`/routines/steps/${stepId}`, body);
export const deleteRoutineStep = (stepId: string): Promise<void> => export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`); api.del(`/routines/steps/${stepId}`);
@ -186,27 +137,19 @@ export const suggestRoutine = (body: {
routine_date: string; routine_date: string;
part_of_day: PartOfDay; part_of_day: PartOfDay;
notes?: string; notes?: string;
include_minoxidil_beard?: boolean; }): Promise<RoutineSuggestion> => api.post('/routines/suggest', body);
leaving_home?: boolean;
}): Promise<RoutineSuggestion> => api.post("/routines/suggest", body);
export const suggestBatch = (body: { export const suggestBatch = (body: {
from_date: string; from_date: string;
to_date: string; to_date: string;
notes?: string; notes?: string;
include_minoxidil_beard?: boolean; }): Promise<BatchSuggestion> => api.post('/routines/suggest-batch', body);
minimize_products?: boolean;
}): Promise<BatchSuggestion> => api.post("/routines/suggest-batch", body);
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> => export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get("/routines/grooming-schedule"); api.get('/routines/grooming-schedule');
export const createGroomingScheduleEntry = ( export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
body: Record<string, unknown>, api.post('/routines/grooming-schedule', body);
): Promise<GroomingSchedule> => api.post("/routines/grooming-schedule", body); export const updateGroomingScheduleEntry = (id: string, body: Record<string, unknown>): Promise<GroomingSchedule> =>
export const updateGroomingScheduleEntry = (
id: string,
body: Record<string, unknown>,
): Promise<GroomingSchedule> =>
api.patch(`/routines/grooming-schedule/${id}`, body); api.patch(`/routines/grooming-schedule/${id}`, body);
export const deleteGroomingScheduleEntry = (id: string): Promise<void> => export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
api.del(`/routines/grooming-schedule/${id}`); api.del(`/routines/grooming-schedule/${id}`);
@ -218,37 +161,31 @@ export interface MedicationListParams {
product_name?: string; product_name?: string;
} }
export function getMedications( export function getMedications(params: MedicationListParams = {}): Promise<MedicationEntry[]> {
params: MedicationListParams = {},
): Promise<MedicationEntry[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.kind) q.set("kind", params.kind); if (params.kind) q.set('kind', params.kind);
if (params.product_name) q.set("product_name", params.product_name); if (params.product_name) q.set('product_name', params.product_name);
const qs = q.toString(); const qs = q.toString();
return api.get(`/health/medications${qs ? `?${qs}` : ""}`); return api.get(`/health/medications${qs ? `?${qs}` : ''}`);
} }
export const getMedication = (id: string): Promise<MedicationEntry> => export const getMedication = (id: string): Promise<MedicationEntry> =>
api.get(`/health/medications/${id}`); api.get(`/health/medications/${id}`);
export const createMedication = ( export const createMedication = (body: Record<string, unknown>): Promise<MedicationEntry> =>
body: Record<string, unknown>, api.post('/health/medications', body);
): Promise<MedicationEntry> => api.post("/health/medications", body);
export const updateMedication = ( export const updateMedication = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body); ): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
export const deleteMedication = (id: string): Promise<void> => export const deleteMedication = (id: string): Promise<void> =>
api.del(`/health/medications/${id}`); api.del(`/health/medications/${id}`);
export const getMedicationUsages = ( export const getMedicationUsages = (medicationId: string): Promise<MedicationUsage[]> =>
medicationId: string,
): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`); api.get(`/health/medications/${medicationId}/usages`);
export const createMedicationUsage = ( export const createMedicationUsage = (
medicationId: string, medicationId: string,
body: Record<string, unknown>, body: Record<string, unknown>
): Promise<MedicationUsage> => ): Promise<MedicationUsage> => api.post(`/health/medications/${medicationId}/usages`, body);
api.post(`/health/medications/${medicationId}/usages`, body);
// ─── Health Lab results ──────────────────────────────────────────────────── // ─── Health Lab results ────────────────────────────────────────────────────
@ -260,28 +197,23 @@ export interface LabResultListParams {
to_date?: string; to_date?: string;
} }
export function getLabResults( export function getLabResults(params: LabResultListParams = {}): Promise<LabResult[]> {
params: LabResultListParams = {},
): Promise<LabResult[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.test_code) q.set("test_code", params.test_code); if (params.test_code) q.set('test_code', params.test_code);
if (params.flag) q.set("flag", params.flag); if (params.flag) q.set('flag', params.flag);
if (params.lab) q.set("lab", params.lab); if (params.lab) q.set('lab', params.lab);
if (params.from_date) q.set("from_date", params.from_date); if (params.from_date) q.set('from_date', params.from_date);
if (params.to_date) q.set("to_date", params.to_date); if (params.to_date) q.set('to_date', params.to_date);
const qs = q.toString(); const qs = q.toString();
return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); return api.get(`/health/lab-results${qs ? `?${qs}` : ''}`);
} }
export const getLabResult = (id: string): Promise<LabResult> => export const getLabResult = (id: string): Promise<LabResult> =>
api.get(`/health/lab-results/${id}`); api.get(`/health/lab-results/${id}`);
export const createLabResult = ( export const createLabResult = (body: Record<string, unknown>): Promise<LabResult> =>
body: Record<string, unknown>, api.post('/health/lab-results', body);
): Promise<LabResult> => api.post("/health/lab-results", body); export const updateLabResult = (id: string, body: Record<string, unknown>): Promise<LabResult> =>
export const updateLabResult = ( api.patch(`/health/lab-results/${id}`, body);
id: string,
body: Record<string, unknown>,
): Promise<LabResult> => api.patch(`/health/lab-results/${id}`, body);
export const deleteLabResult = (id: string): Promise<void> => export const deleteLabResult = (id: string): Promise<void> =>
api.del(`/health/lab-results/${id}`); api.del(`/health/lab-results/${id}`);
@ -293,28 +225,24 @@ export interface SnapshotListParams {
overall_state?: string; overall_state?: string;
} }
export function getSkinSnapshots( export function getSkinSnapshots(params: SnapshotListParams = {}): Promise<SkinConditionSnapshot[]> {
params: SnapshotListParams = {},
): Promise<SkinConditionSnapshot[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.from_date) q.set("from_date", params.from_date); if (params.from_date) q.set('from_date', params.from_date);
if (params.to_date) q.set("to_date", params.to_date); if (params.to_date) q.set('to_date', params.to_date);
if (params.overall_state) q.set("overall_state", params.overall_state); if (params.overall_state) q.set('overall_state', params.overall_state);
const qs = q.toString(); const qs = q.toString();
return api.get(`/skincare${qs ? `?${qs}` : ""}`); return api.get(`/skincare${qs ? `?${qs}` : ''}`);
} }
export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> => export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> =>
api.get(`/skincare/${id}`); api.get(`/skincare/${id}`);
export const createSkinSnapshot = ( export const createSkinSnapshot = (body: Record<string, unknown>): Promise<SkinConditionSnapshot> =>
body: Record<string, unknown>, api.post('/skincare', body);
): Promise<SkinConditionSnapshot> => api.post("/skincare", body);
export const updateSkinSnapshot = ( export const updateSkinSnapshot = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body); ): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (id: string): Promise<void> => export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
api.del(`/skincare/${id}`);
export interface SkinPhotoAnalysisResponse { export interface SkinPhotoAnalysisResponse {
overall_state?: string; overall_state?: string;
@ -331,16 +259,10 @@ export interface SkinPhotoAnalysisResponse {
notes?: string; notes?: string;
} }
export async function analyzeSkinPhotos( export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> {
files: File[],
): Promise<SkinPhotoAnalysisResponse> {
const body = new FormData(); const body = new FormData();
for (const file of files) body.append("photos", file); for (const file of files) body.append('photos', file);
const base = browser ? "/api" : PUBLIC_API_BASE; const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body });
const res = await fetch(`${base}/skincare/analyze-photos`, {
method: "POST",
body,
});
if (!res.ok) { if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText })); const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText); throw new Error(detail?.detail ?? res.statusText);

View file

@ -451,7 +451,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label> <Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} /> <Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
@ -461,7 +461,7 @@
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} /> <Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label> <Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} /> <Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
@ -471,7 +471,7 @@
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} /> <Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label> <Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} /> <Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
@ -488,7 +488,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent> <CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="col-span-2 space-y-2"> <div class="col-span-2 space-y-2">
<Label>{m["productForm_category"]()}</Label> <Label>{m["productForm_category"]()}</Label>
<input type="hidden" name="category" value={category} /> <input type="hidden" name="category" value={category} />
@ -564,7 +564,7 @@
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_recommendedFor"]()}</Label> <Label>{m["productForm_recommendedFor"]()}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div class="grid grid-cols-3 gap-2">
{#each skinTypes as st} {#each skinTypes as st}
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input <input
@ -588,7 +588,7 @@
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_targetConcerns"]()}</Label> <Label>{m["productForm_targetConcerns"]()}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div class="grid grid-cols-3 gap-2">
{#each skinConcerns as sc} {#each skinConcerns as sc}
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input <input
@ -641,7 +641,7 @@
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-center justify-between">
<Label>{m["productForm_activeIngredients"]()}</Label> <Label>{m["productForm_activeIngredients"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button> <Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div> </div>
@ -650,23 +650,14 @@
{#each actives as active, i} {#each actives as active, i}
<div class="rounded-md border border-border p-3 space-y-3"> <div class="rounded-md border border-border p-3 space-y-3">
<div class="flex items-end gap-2"> <div class="grid grid-cols-[1fr_100px_120px_120px_auto] gap-2 items-end">
<div class="min-w-0 flex-1 space-y-1"> <div class="space-y-1">
<Label class="text-xs">{m["productForm_activeName"]()}</Label> <Label class="text-xs">{m["productForm_activeName"]()}</Label>
<Input <Input
placeholder="e.g. Niacinamide" placeholder="e.g. Niacinamide"
bind:value={active.name} bind:value={active.name}
/> />
</div> </div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
>✕</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1"> <div class="space-y-1">
<Label class="text-xs">{m["productForm_activePercent"]()}</Label> <Label class="text-xs">{m["productForm_activePercent"]()}</Label>
<Input <Input
@ -696,11 +687,18 @@
<option value="3">{m["productForm_strengthHigh"]()}</option> <option value="3">{m["productForm_strengthHigh"]()}</option>
</select> </select>
</div> </div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label> <Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4"> <div class="grid grid-cols-4 gap-1">
{#each ingFunctions as fn} {#each ingFunctions as fn}
<label class="flex cursor-pointer items-center gap-1.5 text-xs"> <label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input <input
@ -766,7 +764,7 @@
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-center justify-between">
<Label>{m["productForm_incompatibleWith"]()}</Label> <Label>{m["productForm_incompatibleWith"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}> <Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{m["productForm_addIncompatibility"]()} {m["productForm_addIncompatibility"]()}
@ -776,7 +774,7 @@
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} /> <input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i} {#each incompatibleWith as row, i}
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]"> <div class="grid grid-cols-[1fr_140px_1fr_auto] gap-2 items-end">
<div class="space-y-1"> <div class="space-y-1">
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label> <Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} /> <Input placeholder="e.g. Vitamin C" bind:value={row.target} />
@ -815,7 +813,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent> <CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_ctxAfterShaving"]()}</Label> <Label>{m["productForm_ctxAfterShaving"]()}</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} /> <input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
@ -886,7 +884,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-3 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label> <Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} /> <input type="hidden" name="price_tier" value={priceTier} />
@ -921,7 +919,7 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="ph_min">{m["productForm_phMin"]()}</Label> <Label for="ph_min">{m["productForm_phMin"]()}</Label>
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} /> <Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
@ -950,7 +948,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent> <CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_fragranceFree"]()}</Label> <Label>{m["productForm_fragranceFree"]()}</Label>
<input type="hidden" name="fragrance_free" value={fragranceFree} /> <input type="hidden" name="fragrance_free" value={fragranceFree} />
@ -1010,7 +1008,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label> <Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} /> <Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />

View file

@ -1,92 +1,65 @@
// ─── Enums ────────────────────────────────────────────────────────────────── // ─── Enums ──────────────────────────────────────────────────────────────────
export type AbsorptionSpeed = export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow';
| "very_fast" export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised';
| "fast" export type DayTime = 'am' | 'pm' | 'both';
| "moderate" export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling';
| "slow"
| "very_slow";
export type BarrierState = "intact" | "mildly_compromised" | "compromised";
export type DayTime = "am" | "pm" | "both";
export type GroomingAction =
| "shaving_razor"
| "shaving_oneblade"
| "dermarolling";
export type IngredientFunction = export type IngredientFunction =
| "humectant" | 'humectant'
| "emollient" | 'emollient'
| "occlusive" | 'occlusive'
| "exfoliant_aha" | 'exfoliant_aha'
| "exfoliant_bha" | 'exfoliant_bha'
| "exfoliant_pha" | 'exfoliant_pha'
| "retinoid" | 'retinoid'
| "antioxidant" | 'antioxidant'
| "soothing" | 'soothing'
| "barrier_support" | 'barrier_support'
| "brightening" | 'brightening'
| "anti_acne" | 'anti_acne'
| "ceramide" | 'ceramide'
| "niacinamide" | 'niacinamide'
| "sunscreen" | 'sunscreen'
| "peptide" | 'peptide'
| "hair_growth_stimulant" | 'hair_growth_stimulant'
| "prebiotic" | 'prebiotic'
| "vitamin_c" | 'vitamin_c'
| "anti_aging"; | 'anti_aging';
export type InteractionScope = "same_step" | "same_day" | "same_period"; export type InteractionScope = 'same_step' | 'same_day' | 'same_period';
export type MedicationKind = export type MedicationKind = 'prescription' | 'otc' | 'supplement' | 'herbal' | 'other';
| "prescription" export type OverallSkinState = 'excellent' | 'good' | 'fair' | 'poor';
| "otc" export type PartOfDay = 'am' | 'pm';
| "supplement" export type PriceTier = 'budget' | 'mid' | 'premium' | 'luxury';
| "herbal"
| "other";
export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
export type PartOfDay = "am" | "pm";
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
export type ProductCategory = export type ProductCategory =
| "cleanser" | 'cleanser'
| "toner" | 'toner'
| "essence" | 'essence'
| "serum" | 'serum'
| "moisturizer" | 'moisturizer'
| "spf" | 'spf'
| "mask" | 'mask'
| "exfoliant" | 'exfoliant'
| "hair_treatment" | 'hair_treatment'
| "tool" | 'tool'
| "spot_treatment" | 'spot_treatment'
| "oil"; | 'oil';
export type ResultFlag = "N" | "ABN" | "POS" | "NEG" | "L" | "H"; export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
export type SkinConcern = export type SkinConcern =
| "acne" | 'acne'
| "rosacea" | 'rosacea'
| "hyperpigmentation" | 'hyperpigmentation'
| "aging" | 'aging'
| "dehydration" | 'dehydration'
| "redness" | 'redness'
| "damaged_barrier" | 'damaged_barrier'
| "pore_visibility" | 'pore_visibility'
| "uneven_texture" | 'uneven_texture'
| "hair_growth" | 'hair_growth'
| "sebum_excess"; | 'sebum_excess';
export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy"; export type SkinTexture = 'smooth' | 'rough' | 'flaky' | 'bumpy';
export type SkinType = export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
| "dry"
| "oily"
| "combination"
| "sensitive"
| "normal"
| "acne_prone";
export type StrengthLevel = 1 | 2 | 3; export type StrengthLevel = 1 | 2 | 3;
export type TextureType = export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
| "watery"
| "gel"
| "emulsion"
| "cream"
| "oil"
| "balm"
| "foam"
| "fluid";
// ─── Product types ─────────────────────────────────────────────────────────── // ─── Product types ───────────────────────────────────────────────────────────
export interface ActiveIngredient { export interface ActiveIngredient {
@ -224,20 +197,11 @@ export interface SuggestedStep {
action_notes?: string; action_notes?: string;
dose?: string; dose?: string;
region?: string; region?: string;
why_this_step?: string;
optional?: boolean;
}
export interface RoutineSuggestionSummary {
primary_goal: string;
constraints_applied: string[];
confidence: number;
} }
export interface RoutineSuggestion { export interface RoutineSuggestion {
steps: SuggestedStep[]; steps: SuggestedStep[];
reasoning: string; reasoning: string;
summary?: RoutineSuggestionSummary;
} }
export interface DayPlan { export interface DayPlan {
@ -252,23 +216,6 @@ export interface BatchSuggestion {
overall_reasoning: string; overall_reasoning: string;
} }
// ─── Shopping suggestion types ───────────────────────────────────────────────
export interface ProductSuggestion {
category: string;
product_type: string;
key_ingredients: string[];
target_concerns: string[];
why_needed: string;
recommended_time: string;
frequency: string;
}
export interface ShoppingSuggestionResponse {
suggestions: ProductSuggestion[];
reasoning: string;
}
// ─── Health types ──────────────────────────────────────────────────────────── // ─── Health types ────────────────────────────────────────────────────────────
export interface MedicationUsage { export interface MedicationUsage {

View file

@ -1,22 +1,19 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
let { children } = $props(); let { children } = $props();
let mobileMenuOpen = $state(false);
const navItems = $derived([ const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' }, { href: '/', label: m.nav_dashboard(), icon: '🏠' },
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' }, { href: '/products', label: m.nav_products(), icon: '🧴' },
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' }, { href: '/routines', label: m.nav_routines(), icon: '📋' },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' }, { href: '/routines/grooming-schedule', label: m.nav_grooming(), icon: '🪒' },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' }, { href: '/health/medications', label: m.nav_medications(), icon: '💊' },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' }, { href: '/health/lab-results', label: m["nav_labResults"](), icon: '🔬' },
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' } { href: '/skin', label: m.nav_skin(), icon: '✨' }
]); ]);
function isActive(href: string) { function isActive(href: string) {
@ -31,68 +28,9 @@
} }
</script> </script>
<div class="flex min-h-screen flex-col bg-background md:flex-row"> <div class="flex min-h-screen bg-background">
<!-- Mobile header --> <!-- Sidebar -->
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden"> <nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6">
<div>
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
</div>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Toggle menu"
>
{#if mobileMenuOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
{/if}
</button>
</header>
<!-- Mobile drawer overlay -->
{#if mobileMenuOpen}
<!-- Backdrop: closes drawer on click -->
<button
type="button"
class="fixed inset-0 z-50 bg-black/50 md:hidden"
onclick={() => (mobileMenuOpen = false)}
aria-label={m.common_cancel()}
></button>
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
<nav
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
>
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.href}
onclick={() => (mobileMenuOpen = false)}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
{isActive(item.href)
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span class="text-base">{item.icon}</span>
{item.label}
</a>
</li>
{/each}
</ul>
<div class="mt-6 px-3">
<LanguageSwitcher />
</div>
</nav>
{/if}
<!-- Desktop Sidebar -->
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
<div class="mb-8 px-3"> <div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1> <h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p> <p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
@ -119,7 +57,7 @@
</nav> </nav>
<!-- Main content --> <!-- Main content -->
<main class="flex-1 overflow-auto p-4 md:p-8"> <main class="flex-1 overflow-auto p-8">
{@render children()} {@render children()}
</main> </main>
</div> </div>

View file

@ -1,5 +1,4 @@
import { getRoutines, getSkinSnapshots } from '$lib/api'; import { getRoutines, getSkinSnapshots } from '$lib/api';
import type { SkinConditionSnapshot } from '$lib/types';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
@ -9,7 +8,7 @@ export const load: PageServerLoad = async () => {
]); ]);
return { return {
recentRoutines: routines.slice(0, 10), recentRoutines: routines.slice(0, 10),
latestSnapshot: getFreshestSnapshot(snapshots) latestSnapshot: snapshots.at(-1) ?? null
}; };
}; };
@ -18,12 +17,3 @@ function recentDate(daysAgo: number): string {
d.setDate(d.getDate() - daysAgo); d.setDate(d.getDate() - daysAgo);
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
function getFreshestSnapshot(snapshots: SkinConditionSnapshot[]): SkinConditionSnapshot | null {
if (!snapshots.length) return null;
return snapshots.reduce((freshest, current) => {
if (current.snapshot_date > freshest.snapshot_date) return current;
if (current.snapshot_date < freshest.snapshot_date) return freshest;
return current.created_at > freshest.created_at ? current : freshest;
});
}

View file

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
@ -17,6 +16,7 @@
TableHeader, TableHeader,
TableRow TableRow
} from '$lib/components/ui/table'; } from '$lib/components/ui/table';
import { goto } from '$app/navigation';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -33,12 +33,6 @@
let showForm = $state(false); let showForm = $state(false);
let selectedFlag = $state(''); let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? ''); let filterFlag = $derived(data.flag ?? '');
function onFlagChange(v: string) {
const base = resolve('/health/lab-results');
const url = v ? base + '?flag=' + v : base;
goto(url, { replaceState: true });
}
</script> </script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
@ -67,7 +61,9 @@
<Select <Select
type="single" type="single"
value={filterFlag} value={filterFlag}
onValueChange={onFlagChange} onValueChange={(v) => {
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
}}
> >
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger> <SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
<SelectContent> <SelectContent>
@ -83,7 +79,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
<CardContent> <CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label> <Label for="collected_at">{m["labResults_date"]()}</Label>
<Input id="collected_at" name="collected_at" type="date" required /> <Input id="collected_at" name="collected_at" type="date" required />
@ -129,8 +125,7 @@
</Card> </Card>
{/if} {/if}
<!-- Desktop: table --> <div class="rounded-md border border-border">
<div class="hidden rounded-md border border-border md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -178,34 +173,4 @@
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
{#each data.results as r (r.record_id)}
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
{#if r.flag}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
{r.flag}
</span>
{/if}
</div>
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
<div class="flex items-center gap-2 text-sm">
<span class="font-mono text-xs text-muted-foreground">{r.test_code}</span>
{#if r.value_num != null}
<span>{r.value_num} {r.unit_original ?? ''}</span>
{:else if r.value_text}
<span>{r.value_text}</span>
{/if}
</div>
{#if r.lab}
<p class="text-xs text-muted-foreground">{r.lab}</p>
{/if}
</div>
{:else}
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
{/each}
</div>
</div> </div>

View file

@ -56,7 +56,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
<CardContent> <CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1 col-span-2"> <div class="space-y-1 col-span-2">
<Label>{m.medications_kind()}</Label> <Label>{m.medications_kind()}</Label>
<input type="hidden" name="kind" value={kind} /> <input type="hidden" name="kind" value={kind} />

View file

@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Product } from '$lib/types'; import type { Product } from '$lib/types';
import { resolve } from '$app/paths';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -39,7 +37,7 @@
return bc !== 0 ? bc : a.name.localeCompare(b.name); return bc !== 0 ? bc : a.name.localeCompare(b.name);
}); });
const map = new SvelteMap<string, Product[]>(); const map = new Map<string, Product[]>();
for (const p of items) { for (const p of items) {
if (!map.has(p.category)) map.set(p.category, []); if (!map.has(p.category)) map.set(p.category, []);
map.get(p.category)!.push(p); map.get(p.category)!.push(p);
@ -65,13 +63,10 @@
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p> <p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div> </div>
<div class="flex gap-2"> <Button href="/products/new">{m["products_addNew"]()}</Button>
<Button href={resolve('/products/suggest')} variant="outline">{m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div>
</div> </div>
<div class="flex flex-wrap gap-1"> <div class="flex gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)} {#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button <Button
variant={ownershipFilter === f ? 'default' : 'outline'} variant={ownershipFilter === f ? 'default' : 'outline'}
@ -83,8 +78,7 @@
{/each} {/each}
</div> </div>
<!-- Desktop: table --> <div class="rounded-md border border-border">
<div class="hidden rounded-md border border-border md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -134,41 +128,4 @@
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
{#if totalCount === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else}
{#each groupedProducts as [category, products] (category)}
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category.replace(/_/g, ' ')}
</div>
{#each products as product (product.id)}
<a
href="/products/{product.id}"
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
</div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
</div>
{#if product.targets.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t (t)}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each}
{#if product.targets.length > 3}
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
{/if}
</div>
{/if}
</a>
{/each}
{/each}
{/if}
</div>
</div> </div>

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -21,9 +20,10 @@
<svelte:head><title>{product.name} — innercontext</title></svelte:head> <svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6"> <div class="max-w-2xl space-y-6">
<div> <div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> <a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2> <h2 class="text-2xl font-bold tracking-tight">{product.name}</h2>
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
</div> </div>
{#if form?.error} {#if form?.error}

View file

@ -184,8 +184,9 @@ export const actions: Actions = {
const contextRules = parseContextRules(form); const contextRules = parseContextRules(form);
if (contextRules) payload.context_rules = contextRules; if (contextRules) payload.context_rules = contextRules;
let product;
try { try {
await createProduct(payload); product = await createProduct(payload);
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData } from './$types'; import type { ActionData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -21,7 +20,7 @@
<div class="max-w-2xl space-y-6"> <div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a> <a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
</div> </div>

View file

@ -1,25 +0,0 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
suggest: async ({ fetch }) => {
try {
const res = await fetch('/api/products/suggest', {
method: 'POST',
});
if (!res.ok) {
const err = await res.text();
return fail(res.status, { error: err || 'Failed to get suggestions' });
}
const data = await res.json();
return {
suggestions: data.suggestions,
reasoning: data.reasoning,
};
} catch (e) {
return fail(500, { error: String(e) });
}
},
} satisfies Actions;

View file

@ -1,123 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ProductSuggestion } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
let loading = $state(false);
let errorMsg = $state<string | null>(null);
function enhanceForm() {
loading = true;
errorMsg = null;
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
loading = false;
if (result.type === 'success' && result.data?.suggestions) {
suggestions = result.data.suggestions as ProductSuggestion[];
reasoning = result.data.reasoning as string;
errorMsg = null;
} else if (result.type === 'failure') {
errorMsg = (result.data?.error as string) ?? m["suggest_errorDefault"]();
}
await update({ reset: false });
};
}
</script>
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
</div>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
{/if}
<Card>
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
<p class="text-sm text-muted-foreground">
{m["products_suggestDescription"]()}
</p>
<Button type="submit" disabled={loading} class="w-full">
{#if loading}
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["products_suggestGenerating"]()}
{:else}
{m["products_suggestBtn"]()}
{/if}
</Button>
</form>
</CardContent>
</Card>
{#if suggestions && suggestions.length > 0}
{#if reasoning}
<Card class="border-muted bg-muted/30">
<CardContent class="pt-4">
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>
<CardContent class="pt-4">
<div class="space-y-3">
<div class="flex items-start justify-between gap-2">
<h4 class="font-medium">{s.product_type}</h4>
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
</div>
{#if s.key_ingredients.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.key_ingredients as ing (ing)}
<Badge variant="outline" class="text-xs">{ing}</Badge>
{/each}
</div>
{/if}
{#if s.target_concerns.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.target_concerns as concern (concern)}
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
<p class="text-sm text-muted-foreground">{s.why_needed}</p>
<div class="flex gap-4 text-xs text-muted-foreground">
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
<form method="POST" action="?/suggest" use:enhance={enhanceForm}>
<Button variant="outline" type="submit" disabled={loading}>
{m["products_suggestRegenerate"]()}
</Button>
</form>
{:else if suggestions && suggestions.length === 0}
<Card>
<CardContent class="py-8 text-center text-muted-foreground">
{m["products_suggestNoResults"]()}
</CardContent>
</Card>
{/if}
</div>

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -23,14 +22,14 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p> <p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex gap-2">
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button> <Button href="/routines/suggest" variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button> <Button href="/routines/new">{m["routines_addNew"]()}</Button>
</div> </div>
</div> </div>

View file

@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -10,132 +7,18 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data); let { routine, products } = $derived(data);
// ── Steps local state (synced from server data) ───────────────
let steps = $derived([...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index));
const nextOrderIndex = $derived(
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
);
// ── Drag & drop reordering ────────────────────────────────────
let dndSaving = $state(false);
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
steps = e.detail.items;
}
async function handleFinalize(e: CustomEvent<DndEvent<RoutineStep>>) {
const newItems = e.detail.items;
// Assign new order_index = position in array, detect which changed
const updated = newItems.map((s, i) => ({ ...s, order_index: i }));
const changed = updated.filter((s, i) => s.order_index !== newItems[i].order_index);
steps = updated;
if (changed.length) {
dndSaving = true;
try {
await Promise.all(
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
);
} finally {
dndSaving = false;
}
}
}
// ── Inline editing ────────────────────────────────────────────
let editingStepId = $state<string | null>(null);
let editDraft = $state<Partial<RoutineStep>>({});
let editSaving = $state(false);
let editError = $state('');
function startEdit(step: RoutineStep) {
editingStepId = step.id;
editDraft = {
dose: step.dose ?? '',
region: step.region ?? '',
product_id: step.product_id,
action_type: step.action_type,
action_notes: step.action_notes ?? ''
};
editError = '';
}
async function saveEdit(step: RoutineStep) {
editSaving = true;
editError = '';
try {
const payload: Record<string, unknown> = {};
if (step.product_id !== undefined) {
payload.product_id = editDraft.product_id;
payload.dose = editDraft.dose || null;
payload.region = editDraft.region || null;
} else {
payload.action_type = editDraft.action_type;
payload.action_notes = editDraft.action_notes || null;
}
const updatedStep = await updateRoutineStep(step.id, payload);
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
editingStepId = null;
} catch (err) {
editError = (err as Error).message;
} finally {
editSaving = false;
}
}
function cancelEdit() {
editingStepId = null;
editDraft = {};
editError = '';
}
// ── Add step form ─────────────────────────────────────────────
let showStepForm = $state(false); let showStepForm = $state(false);
let selectedProductId = $state(''); let selectedProductId = $state('');
const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling']; const nextOrderIndex = $derived(
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
const CATEGORY_ORDER = [ );
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'spot_treatment', 'oil',
'hair_treatment', 'tool', 'other'
];
function formatCategory(cat: string): string {
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
}
const groupedProducts = $derived.by(() => {
const groups = new SvelteMap<string, typeof products>();
for (const p of products) {
const key = p.category ?? 'other';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(p);
}
for (const list of groups.values()) {
list.sort((a, b) => {
const b_cmp = (a.brand ?? '').localeCompare(b.brand ?? '');
return b_cmp !== 0 ? b_cmp : a.name.localeCompare(b.name);
});
}
return CATEGORY_ORDER
.filter((c) => groups.has(c))
.map((c) => [c, groups.get(c)!] as const);
});
</script> </script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head> <svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
@ -160,7 +43,7 @@
<!-- Steps --> <!-- Steps -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3> <h3 class="text-lg font-semibold">{m.routines_steps({ count: routine.steps.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}> <Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
{showStepForm ? m.common_cancel() : m["routines_addStep"]()} {showStepForm ? m.common_cancel() : m["routines_addStep"]()}
</Button> </Button>
@ -183,13 +66,8 @@
{/if} {/if}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{#each groupedProducts as [cat, items]} {#each products as p (p.id)}
<SelectGroup> <SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>
@ -211,121 +89,17 @@
</Card> </Card>
{/if} {/if}
{#if steps.length} {#if routine.steps.length}
<div <div class="space-y-2">
use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }} {#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)}
onconsider={handleConsider} <div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
onfinalize={handleFinalize} <div class="flex items-center gap-3">
class="space-y-2" <span class="text-xs text-muted-foreground w-4">{step.order_index}</span>
> <div>
{#each steps as step, i (step.id)}
<div class="rounded-md border border-border bg-background">
{#if editingStepId === step.id}
<!-- ── Edit mode ── -->
<div class="px-4 py-3 space-y-3">
{#if step.product_id !== undefined}
<!-- Product step: change product / dose / region -->
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<Select
type="single"
value={editDraft.product_id ?? ''}
onValueChange={(v) => (editDraft.product_id = v || undefined)}
>
<SelectTrigger>
{#if editDraft.product_id}
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label>{m.routines_dose()}</Label>
<Input
value={editDraft.dose ?? ''}
oninput={(e) => (editDraft.dose = e.currentTarget.value)}
placeholder={m["routines_dosePlaceholder"]()}
/>
</div>
<div class="space-y-1">
<Label>{m.routines_region()}</Label>
<Input
value={editDraft.region ?? ''}
oninput={(e) => (editDraft.region = e.currentTarget.value)}
placeholder={m["routines_regionPlaceholder"]()}
/>
</div>
</div>
{:else}
<!-- Action step: change action_type / notes -->
<div class="space-y-1">
<Label>Action</Label>
<Select
type="single"
value={editDraft.action_type ?? ''}
onValueChange={(v) =>
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
>
<SelectTrigger>
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
</SelectTrigger>
<SelectContent>
{#each GROOMING_ACTIONS as action (action)}
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>Notes</Label>
<Input
value={editDraft.action_notes ?? ''}
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
placeholder="optional notes"
/>
</div>
{/if}
{#if editError}
<p class="text-sm text-destructive">{editError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => saveEdit(step)} disabled={editSaving}>
{m.common_save()}
</Button>
<Button size="sm" variant="outline" onclick={cancelEdit} disabled={editSaving}>
{m.common_cancel()}
</Button>
</div>
</div>
{:else}
<!-- ── View mode ── -->
<div class="flex items-center gap-1 px-2 py-3">
<span
use:dragHandle
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
aria-label="drag to reorder"
>⋮⋮</span>
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
<div class="flex-1 min-w-0 px-1">
{#if step.product_id} {#if step.product_id}
{@const product = products.find((p) => p.id === step.product_id)} {@const product = products.find((p) => p.id === step.product_id)}
<p class="text-sm font-medium truncate">{product?.name ?? step.product_id}</p> <p class="text-sm font-medium">{product?.name ?? step.product_id}</p>
{#if product?.brand}<p class="text-xs text-muted-foreground truncate">{product.brand}</p>{/if} {#if product?.brand}<p class="text-xs text-muted-foreground">{product.brand}</p>{/if}
{:else if step.action_type} {:else if step.action_type}
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p> <p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
{:else} {:else}
@ -333,27 +107,16 @@
{/if} {/if}
</div> </div>
{#if step.dose} {#if step.dose}
<span class="shrink-0 text-xs text-muted-foreground">{step.dose}</span> <span class="text-xs text-muted-foreground">{step.dose}</span>
{/if} {/if}
<Button </div>
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onclick={() => startEdit(step)}
aria-label="edit step"
>✎</Button>
<form method="POST" action="?/removeStep" use:enhance> <form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} /> <input type="hidden" name="step_id" value={step.id} />
<Button <Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
type="submit" ×
variant="ghost" </Button>
size="sm"
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
>×</Button>
</form> </form>
</div> </div>
{/if}
</div>
{/each} {/each}
</div> </div>
{:else} {:else}
@ -363,14 +126,8 @@
<Separator /> <Separator />
<form <form method="POST" action="?/delete" use:enhance
method="POST" onsubmit={(e) => { if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); }}>
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button> <Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
</form> </form>
</div> </div>

View file

@ -14,22 +14,13 @@ export const actions: Actions = {
const routine_date = form.get('routine_date') as string; const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as 'am' | 'pm'; const part_of_day = form.get('part_of_day') as 'am' | 'pm';
const notes = (form.get('notes') as string) || undefined; const notes = (form.get('notes') as string) || undefined;
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on';
const leaving_home =
part_of_day === 'am' ? form.get('leaving_home') === 'on' : undefined;
if (!routine_date || !part_of_day) { if (!routine_date || !part_of_day) {
return fail(400, { error: 'Data i pora dnia są wymagane.' }); return fail(400, { error: 'Data i pora dnia są wymagane.' });
} }
try { try {
const suggestion = await suggestRoutine({ const suggestion = await suggestRoutine({ routine_date, part_of_day, notes });
routine_date,
part_of_day,
notes,
include_minoxidil_beard,
leaving_home
});
return { suggestion, routine_date, part_of_day }; return { suggestion, routine_date, part_of_day };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); return fail(502, { error: (e as Error).message });
@ -41,8 +32,6 @@ export const actions: Actions = {
const from_date = form.get('from_date') as string; const from_date = form.get('from_date') as string;
const to_date = form.get('to_date') as string; const to_date = form.get('to_date') as string;
const notes = (form.get('notes') as string) || undefined; const notes = (form.get('notes') as string) || undefined;
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on';
const minimize_products = form.get('minimize_products') === 'on';
if (!from_date || !to_date) { if (!from_date || !to_date) {
return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' }); return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' });
@ -55,7 +44,7 @@ export const actions: Actions = {
} }
try { try {
const batch = await suggestBatch({ from_date, to_date, notes, include_minoxidil_beard, minimize_products }); const batch = await suggestBatch({ from_date, to_date, notes });
return { batch, from_date, to_date }; return { batch, from_date, to_date };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); return fail(502, { error: (e as Error).message });

View file

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types'; import type { ActionData, PageData } from './$types';
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types'; import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -13,7 +12,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
let { data }: { data: PageData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p]))); const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
@ -45,7 +44,7 @@
function stepMeta(step: SuggestedStep): string { function stepMeta(step: SuggestedStep): string {
const parts: string[] = []; const parts: string[] = [];
if (step.dose) parts.push(step.dose); if (step.dose) parts.push(step.dose);
if (step.region && step.region.toLowerCase() !== 'face') parts.push(step.region); if (step.region) parts.push(step.region);
if (step.action_notes && !step.action_type) parts.push(step.action_notes); if (step.action_notes && !step.action_type) parts.push(step.action_notes);
return parts.join(' · '); return parts.join(' · ');
} }
@ -105,7 +104,7 @@
<div class="max-w-2xl space-y-6"> <div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a> <a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
</div> </div>
@ -153,32 +152,6 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
></textarea> ></textarea>
</div> </div>
{#if partOfDay === 'am'}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_leaving_home"
name="leaving_home"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
</div>
</div>
{/if}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<Button type="submit" disabled={loadingSingle} class="w-full"> <Button type="submit" disabled={loadingSingle} class="w-full">
{#if loadingSingle} {#if loadingSingle}
@ -209,20 +182,6 @@
</CardContent> </CardContent>
</Card> </Card>
{#if suggestion.summary}
<Card class="border-muted bg-muted/30">
<CardContent class="space-y-2 pt-4">
<p class="text-sm"><span class="font-medium">{m["suggest_summaryPrimaryGoal"]()}:</span> {suggestion.summary.primary_goal || '—'}</p>
<p class="text-sm"><span class="font-medium">{m["suggest_summaryConfidence"]()}:</span> {Math.round((suggestion.summary.confidence ?? 0) * 100)}%</p>
{#if suggestion.summary.constraints_applied?.length}
<p class="text-xs text-muted-foreground">
{m["suggest_summaryConstraints"]()}: {suggestion.summary.constraints_applied.join(' · ')}
</p>
{/if}
</CardContent>
</Card>
{/if}
<!-- Steps --> <!-- Steps -->
<div class="space-y-2"> <div class="space-y-2">
{#each suggestion.steps as step, i (i)} {#each suggestion.steps as step, i (i)}
@ -231,18 +190,10 @@
{i + 1} {i + 1}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{stepLabel(step)}</p> <p class="text-sm font-medium">{stepLabel(step)}</p>
{#if step.optional}
<Badge variant="secondary">{m["suggest_stepOptionalBadge"]()}</Badge>
{/if}
</div>
{#if stepMeta(step)} {#if stepMeta(step)}
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p> <p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
{/if} {/if}
{#if step.why_this_step}
<p class="text-xs text-muted-foreground italic">{step.why_this_step}</p>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@ -296,30 +247,6 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
></textarea> ></textarea>
</div> </div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_minimize_products"
name="minimize_products"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
</div>
</div>
<Button type="submit" disabled={loadingBatch} class="w-full"> <Button type="submit" disabled={loadingBatch} class="w-full">
{#if loadingBatch} {#if loadingBatch}

View file

@ -19,7 +19,6 @@ export const actions: Actions = {
const sensitivity_level = form.get('sensitivity_level') as string; const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string; const barrier_state = form.get('barrier_state') as string;
const active_concerns_raw = form.get('active_concerns') as string; const active_concerns_raw = form.get('active_concerns') as string;
const priorities_raw = form.get('priorities') as string;
if (!snapshot_date) { if (!snapshot_date) {
return fail(400, { error: 'Date is required' }); return fail(400, { error: 'Date is required' });
@ -30,16 +29,11 @@ export const actions: Actions = {
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean) ?? []; .filter(Boolean) ?? [];
const priorities = priorities_raw
?.split(',')
.map((p) => p.trim())
.filter(Boolean) ?? [];
const skin_type = form.get('skin_type') as string; const skin_type = form.get('skin_type') as string;
const sebum_tzone = form.get('sebum_tzone') as string; const sebum_tzone = form.get('sebum_tzone') as string;
const sebum_cheeks = form.get('sebum_cheeks') as string; const sebum_cheeks = form.get('sebum_cheeks') as string;
const body: Record<string, unknown> = { snapshot_date, active_concerns, priorities }; const body: Record<string, unknown> = { snapshot_date, active_concerns };
if (overall_state) body.overall_state = overall_state; if (overall_state) body.overall_state = overall_state;
if (texture) body.texture = texture; if (texture) body.texture = texture;
if (notes) body.notes = notes; if (notes) body.notes = notes;
@ -69,7 +63,6 @@ export const actions: Actions = {
const sensitivity_level = form.get('sensitivity_level') as string; const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string; const barrier_state = form.get('barrier_state') as string;
const active_concerns_raw = form.get('active_concerns') as string; const active_concerns_raw = form.get('active_concerns') as string;
const priorities_raw = form.get('priorities') as string;
const skin_type = form.get('skin_type') as string; const skin_type = form.get('skin_type') as string;
const sebum_tzone = form.get('sebum_tzone') as string; const sebum_tzone = form.get('sebum_tzone') as string;
const sebum_cheeks = form.get('sebum_cheeks') as string; const sebum_cheeks = form.get('sebum_cheeks') as string;
@ -81,12 +74,7 @@ export const actions: Actions = {
.map((c) => c.trim()) .map((c) => c.trim())
.filter(Boolean) ?? []; .filter(Boolean) ?? [];
const priorities = priorities_raw const body: Record<string, unknown> = { active_concerns };
?.split(',')
.map((p) => p.trim())
.filter(Boolean) ?? [];
const body: Record<string, unknown> = { active_concerns, priorities };
if (snapshot_date) body.snapshot_date = snapshot_date; if (snapshot_date) body.snapshot_date = snapshot_date;
if (overall_state) body.overall_state = overall_state; if (overall_state) body.overall_state = overall_state;
if (texture) body.texture = texture; if (texture) body.texture = texture;

View file

@ -66,7 +66,6 @@
let sebumTzone = $state(''); let sebumTzone = $state('');
let sebumCheeks = $state(''); let sebumCheeks = $state('');
let activeConcernsRaw = $state(''); let activeConcernsRaw = $state('');
let prioritiesRaw = $state('');
let notes = $state(''); let notes = $state('');
// Edit state // Edit state
@ -81,7 +80,6 @@
let editSebumTzone = $state(''); let editSebumTzone = $state('');
let editSebumCheeks = $state(''); let editSebumCheeks = $state('');
let editActiveConcernsRaw = $state(''); let editActiveConcernsRaw = $state('');
let editPrioritiesRaw = $state('');
let editNotes = $state(''); let editNotes = $state('');
function startEdit(snap: (typeof data.snapshots)[number]) { function startEdit(snap: (typeof data.snapshots)[number]) {
@ -96,7 +94,6 @@
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : ''; editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : ''; editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? ''; editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
editNotes = snap.notes ?? ''; editNotes = snap.notes ?? '';
showForm = false; showForm = false;
} }
@ -135,7 +132,6 @@
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone); if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks); if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', '); if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes; if (r.notes) notes = r.notes;
aiPanelOpen = false; aiPanelOpen = false;
} catch (e) { } catch (e) {
@ -192,7 +188,7 @@
</p> </p>
<input <input
type="file" type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp"
multiple multiple
onchange={handleFileSelect} onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground class="block w-full text-sm text-muted-foreground
@ -224,7 +220,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent> <CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<Label for="snapshot_date">{m.skin_date()}</Label> <Label for="snapshot_date">{m.skin_date()}</Label>
<Input <Input
@ -303,10 +299,6 @@
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label> <Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} /> <Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
</div> </div>
<div class="space-y-1 col-span-2">
<Label for="priorities">{m["skin_priorities"]()}</Label>
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
</div>
<div class="space-y-1 col-span-2"> <div class="space-y-1 col-span-2">
<Label for="notes">{m.skin_notes()}</Label> <Label for="notes">{m.skin_notes()}</Label>
<Input id="notes" name="notes" bind:value={notes} /> <Input id="notes" name="notes" bind:value={notes} />
@ -332,7 +324,7 @@
await update(); await update();
if (result.type === 'success') editingId = null; if (result.type === 'success') editingId = null;
}} }}
class="grid grid-cols-1 sm:grid-cols-2 gap-4" class="grid grid-cols-2 gap-4"
> >
<input type="hidden" name="id" value={snap.id} /> <input type="hidden" name="id" value={snap.id} />
<div class="space-y-1"> <div class="space-y-1">
@ -407,10 +399,6 @@
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label> <Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} /> <Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
</div> </div>
<div class="space-y-1 col-span-2">
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
</div>
<div class="space-y-1 col-span-2"> <div class="space-y-1 col-span-2">
<Label for="edit_notes">{m.skin_notes()}</Label> <Label for="edit_notes">{m.skin_notes()}</Label>
<Input id="edit_notes" name="notes" bind:value={editNotes} /> <Input id="edit_notes" name="notes" bind:value={editNotes} />
@ -422,19 +410,9 @@
</form> </form>
{:else} {:else}
<!-- Read view --> <!-- Read view -->
<div class="mb-3 space-y-1.5"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center justify-between">
<span class="font-medium">{snap.snapshot_date}</span> <span class="font-medium">{snap.snapshot_date}</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-2">
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
</form>
</div>
</div>
{#if snap.overall_state || snap.texture}
<div class="flex flex-wrap items-center gap-1.5">
{#if snap.overall_state} {#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}"> <span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{stateLabels[snap.overall_state]?.() ?? snap.overall_state} {stateLabels[snap.overall_state]?.() ?? snap.overall_state}
@ -443,8 +421,16 @@
{#if snap.texture} {#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge> <Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
{/if} {/if}
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 px-2 text-xs">
{m.common_edit()}
</Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive hover:text-destructive">
{m.common_delete()}
</Button>
</form>
</div> </div>
{/if}
</div> </div>
<div class="grid grid-cols-3 gap-3 text-sm mb-3"> <div class="grid grid-cols-3 gap-3 text-sm mb-3">
{#if snap.hydration_level != null} {#if snap.hydration_level != null}
@ -473,16 +459,6 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if snap.priorities?.length}
<div class="mt-2">
<p class="text-xs text-muted-foreground mb-1">{m["skin_prioritiesLabel"]()}</p>
<div class="flex flex-wrap gap-1">
{#each snap.priorities as p (p)}
<Badge variant="outline" class="text-xs">{p}</Badge>
{/each}
</div>
</div>
{/if}
{#if snap.notes} {#if snap.notes}
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p> <p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
{/if} {/if}

View file

@ -4,7 +4,6 @@ server {
# FastAPI backend — strip /api/ prefix # FastAPI backend — strip /api/ prefix
location /api/ { location /api/ {
client_max_body_size 16m; # up to 3 × 5 MB photos
proxy_pass http://127.0.0.1:8000/; proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -12,6 +11,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# MCP endpoint — keep /mcp/ prefix; disable buffering for SSE streaming
location /mcp/ {
proxy_pass http://127.0.0.1:8000/mcp/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# SvelteKit Node server # SvelteKit Node server
location / { location / {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;