From 3428885aaa879aea4dd2e51de739e62fc864c0c6 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 13:51:51 +0100 Subject: [PATCH 01/51] docs: add deploy.sh and rewrite DEPLOYMENT.md for local-build workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add deploy.sh: builds frontend locally, rsyncs build/ to server, restarts services via passwordless sudo - DEPLOYMENT.md: remove pnpm build from server setup (frontend is never built on the LXC — esbuild hangs on low-resource containers), add rsync to apt packages, document deploy.sh setup (SSH config, sudoers), replace manual update steps with ./deploy.sh invocation Co-Authored-By: Claude Sonnet 4.6 --- deploy.sh | 59 ++++++++++++++++++++++++ docs/DEPLOYMENT.md | 109 ++++++++++++++++++++++++++------------------- 2 files changed, 121 insertions(+), 47 deletions(-) create mode 100755 deploy.sh diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c1378a6 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Usage: ./deploy.sh [frontend|backend|all] +# default: all +# +# SSH config (~/.ssh/config) — recommended: +# Host innercontext +# HostName +# 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/..." + rsync -az --delete frontend/build/ "$SERVER:$REMOTE/frontend/build/" + + 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" + + 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." diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 36084bd..f33d219 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -14,6 +14,10 @@ Reverse proxy (existing) innercontext LXC (new, Debian 13) 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 - Proxmox VE host with an existing PostgreSQL LXC and a reverse proxy @@ -53,7 +57,7 @@ pct enter 200 # or SSH into the container ```bash apt update && apt upgrade -y -apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5 +apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5 rsync ``` ### Python 3.12+ + uv @@ -65,7 +69,10 @@ curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh Installing to `/usr/local/bin` makes `uv` available system-wide (required for `sudo -u innercontext uv sync`). -### Node.js 24 LTS + pnpm +### Node.js 24 LTS + +The server only needs Node.js to **run** the pre-built frontend bundle. +`pnpm` is **not** needed on the server — the frontend is always built locally. ```bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash @@ -75,23 +82,12 @@ nvm install 24 Copy `node` to `/usr/local/bin` so it is accessible system-wide (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: ```bash cp --remove-destination "$(nvm which current)" /usr/local/bin/node ``` -Install pnpm as a standalone binary from GitHub releases — self-contained, -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 -curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \ - -o /usr/local/bin/pnpm -chmod 755 /usr/local/bin/pnpm -``` - ### Application user ```bash @@ -194,11 +190,10 @@ systemctl status innercontext --- -## 7. Frontend build and setup +## 7. Frontend setup -```bash -cd /opt/innercontext/frontend -``` +The frontend is **built locally and uploaded** via `deploy.sh` — never built on the server. +This section only covers the one-time server-side configuration. ### Create `.env.production` @@ -211,25 +206,24 @@ chmod 600 /opt/innercontext/frontend/.env.production chown innercontext:innercontext /opt/innercontext/frontend/.env.production ``` -### Install dependencies and build +### Grant `innercontext` passwordless sudo for service restarts ```bash -sudo -u innercontext bash -c ' - cd /opt/innercontext/frontend - pnpm install - PUBLIC_API_BASE=http://innercontext.lan/api pnpm build -' +cat > /etc/sudoers.d/innercontext-deploy << 'EOF' +innercontext ALL=(root) NOPASSWD: \ + /usr/bin/systemctl restart innercontext, \ + /usr/bin/systemctl restart innercontext-node +EOF +chmod 440 /etc/sudoers.d/innercontext-deploy ``` -The production build lands in `/opt/innercontext/frontend/build/`. - ### Install systemd service ```bash cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/ systemctl daemon-reload -systemctl enable --now innercontext-node -systemctl status innercontext-node +systemctl enable innercontext-node +# Do NOT start yet — build/ is empty until the first deploy.sh run ``` --- @@ -276,7 +270,40 @@ Reload your reverse proxy after applying the change. --- -## 10. Verification +## 10. First deploy from local machine + +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 + 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 # From any machine on the LAN: @@ -290,30 +317,18 @@ The web UI should be accessible at `http://innercontext.lan`. --- -## 11. Updating the application +## 12. Updating the application ```bash -cd /opt/innercontext -git pull - -# 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 +# From the repo root on your local machine: +./deploy.sh # full deploy (frontend + backend) +./deploy.sh frontend # frontend only +./deploy.sh backend # backend only ``` --- -## 12. Troubleshooting +## 13. Troubleshooting ### 502 Bad Gateway on `/api/*` @@ -328,7 +343,7 @@ journalctl -u innercontext -n 50 ```bash systemctl status innercontext-node journalctl -u innercontext-node -n 50 -# Verify /opt/innercontext/frontend/build/index.js exists (pnpm build ran successfully) +# Verify /opt/innercontext/frontend/build/index.js exists (deploy.sh ran successfully) ``` ### MCP endpoint not responding From 91bc9e86d79a6bf22c34c7a373b15a51cc594fe5 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 13:56:28 +0100 Subject: [PATCH 02/51] fix(deploy): install frontend prod deps on server after deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adapter-node bundles the SvelteKit framework but leaves package.json `dependencies` (clsx, bits-ui, etc.) external — they must be present in node_modules on the server at runtime. - deploy.sh: rsync package.json + pnpm-lock.yaml, run pnpm install --prod - DEPLOYMENT.md: add pnpm to server setup with explanation Co-Authored-By: Claude Sonnet 4.6 --- deploy.sh | 6 +++++- docs/DEPLOYMENT.md | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/deploy.sh b/deploy.sh index c1378a6..2e6e668 100755 --- a/deploy.sh +++ b/deploy.sh @@ -21,8 +21,12 @@ deploy_frontend() { echo "==> [frontend] Building locally..." (cd frontend && pnpm run build) - echo "==> [frontend] Uploading 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" echo "==> [frontend] Restarting service..." ssh "$SERVER" "sudo systemctl restart innercontext-node && echo OK" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f33d219..1eb7005 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -69,10 +69,12 @@ curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh Installing to `/usr/local/bin` makes `uv` available system-wide (required for `sudo -u innercontext uv sync`). -### Node.js 24 LTS +### Node.js 24 LTS + pnpm -The server only needs Node.js to **run** the pre-built frontend bundle. -`pnpm` is **not** needed on the server — the frontend is always built locally. +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 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash @@ -88,6 +90,15 @@ Use `--remove-destination` to replace any existing symlink with a real file: cp --remove-destination "$(nvm which current)" /usr/local/bin/node ``` +Install pnpm as a standalone binary — self-contained, no wrapper scripts, +works system-wide: + +```bash +curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \ + -o /usr/local/bin/pnpm +chmod 755 /usr/local/bin/pnpm +``` + ### Application user ```bash From 5e2536138bf3f39a2a7d1b6e3090d65f0bf52859 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 13:57:46 +0100 Subject: [PATCH 03/51] fix(deploy): suppress prepare script noise with --ignore-scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit svelte-kit sync runs as a prepare hook but svelte-kit is a devDep, not installed during prod install — add --ignore-scripts to silence it. Co-Authored-By: Claude Sonnet 4.6 --- deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy.sh b/deploy.sh index 2e6e668..5a23fdf 100755 --- a/deploy.sh +++ b/deploy.sh @@ -26,7 +26,7 @@ deploy_frontend() { 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" + 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" From 4b0fedde35d2b7df776f4343d88af58fd8791cce Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 17:21:59 +0100 Subject: [PATCH 04/51] feat(frontend): add drag-and-drop reordering and inline editing for routine steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install svelte-dnd-action v0.9.69 - Use dragHandleZone + dragHandle for per-step ⋮⋮ drag handles - PATCH only steps whose order_index changed after a drop - Inline edit mode (✎ button) expands step in-place: product steps show product/dose/region selects; action steps show action_type/notes - DnD disabled while a step is being edited Co-Authored-By: Claude Sonnet 4.6 --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 12 + .../src/routes/routines/[id]/+page.svelte | 260 ++++++++++++++++-- 3 files changed, 243 insertions(+), 30 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 0d75051..0fb782f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "clsx": "^2.1.1", "lucide-svelte": "^0.575.0", "mode-watcher": "^1.1.0", + "svelte-dnd-action": "^0.9.69", "tailwind-merge": "^3.5.0" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f5aa872..abe2517 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.53.5) + svelte-dnd-action: + specifier: ^0.9.69 + version: 0.9.69(svelte@5.53.5) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -968,6 +971,11 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' + svelte-dnd-action@0.9.69: + resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==} + peerDependencies: + svelte: '>=3.23.0 || ^5.0.0-next.0' + svelte-toolbelt@0.10.6: resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} engines: {node: '>=18', pnpm: '>=8.7.0'} @@ -1828,6 +1836,10 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-dnd-action@0.9.69(svelte@5.53.5): + dependencies: + svelte: 5.53.5 + svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5): dependencies: clsx: 2.1.1 diff --git a/frontend/src/routes/routines/[id]/+page.svelte b/frontend/src/routes/routines/[id]/+page.svelte index 767349f..e8cc4e4 100644 --- a/frontend/src/routes/routines/[id]/+page.svelte +++ b/frontend/src/routes/routines/[id]/+page.svelte @@ -1,5 +1,8 @@ Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext @@ -43,7 +127,7 @@
-

{m.routines_steps({ count: routine.steps.length })}

+

{m.routines_steps({ count: steps.length })}

@@ -89,33 +173,143 @@ {/if} - {#if routine.steps.length} -
- {#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)} -
-
- {step.order_index} -
- {#if step.product_id} - {@const product = products.find((p) => p.id === step.product_id)} -

{product?.name ?? step.product_id}

- {#if product?.brand}

{product.brand}

{/if} - {:else if step.action_type} -

{step.action_type.replace(/_/g, ' ')}

+ {#if steps.length} +
+ {#each steps as step (step.id)} +
+ {#if editingStepId === step.id} + +
+ {#if step.product_id !== undefined} + +
+ + +
+
+
+ + (editDraft.dose = e.currentTarget.value)} + placeholder={m["routines_dosePlaceholder"]()} + /> +
+
+ + (editDraft.region = e.currentTarget.value)} + placeholder={m["routines_regionPlaceholder"]()} + /> +
+
{:else} -

{m["routines_unknownStep"]()}

+ +
+ + +
+
+ + (editDraft.action_notes = e.currentTarget.value)} + placeholder="optional notes" + /> +
{/if} + + {#if editError} +

{editError}

+ {/if} + +
+ + +
- {#if step.dose} - {step.dose} - {/if} -
-
- - -
+ {:else} + +
+ ⋮⋮ + {step.order_index + 1}. +
+ {#if step.product_id} + {@const product = products.find((p) => p.id === step.product_id)} +

{product?.name ?? step.product_id}

+ {#if product?.brand}

{product.brand}

{/if} + {:else if step.action_type} +

{step.action_type.replace(/_/g, ' ')}

+ {:else} +

{m["routines_unknownStep"]()}

+ {/if} +
+ {#if step.dose} + {step.dose} + {/if} + +
+ + +
+
+ {/if}
{/each}
@@ -126,8 +320,14 @@ -
{ if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); }}> + { + if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); + }} + >
From 5cb44b2c65afda278ec697c771a83baef3946e00 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 17:27:07 +0100 Subject: [PATCH 05/51] fix(backend): apply black/isort formatting and fix ruff noqa annotations Co-Authored-By: Claude Sonnet 4.6 --- backend/alembic/env.py | 5 +- .../versions/c2d626a2b36c_initial_schema.py | 642 ++++++++++++------ backend/innercontext/api/routines.py | 66 +- backend/innercontext/api/skincare.py | 12 +- backend/innercontext/llm.py | 1 - backend/innercontext/mcp_server.py | 19 +- 6 files changed, 515 insertions(+), 230 deletions(-) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 76f7495..4253c54 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,15 +1,16 @@ import os from logging.config import fileConfig -from alembic import context from dotenv import load_dotenv from sqlalchemy import engine_from_config, pool from sqlmodel import SQLModel +from alembic import context + load_dotenv() # Import all models so their tables are registered in SQLModel.metadata -import innercontext.models # noqa: F401 +import innercontext.models # noqa: F401, E402 config = context.config diff --git a/backend/alembic/versions/c2d626a2b36c_initial_schema.py b/backend/alembic/versions/c2d626a2b36c_initial_schema.py index 093aca0..7407207 100644 --- a/backend/alembic/versions/c2d626a2b36c_initial_schema.py +++ b/backend/alembic/versions/c2d626a2b36c_initial_schema.py @@ -1,19 +1,20 @@ """initial_schema Revision ID: c2d626a2b36c -Revises: +Revises: Create Date: 2026-02-28 20:13:55.499494 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa import sqlmodel.sql.sqltypes +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'c2d626a2b36c' +revision: str = "c2d626a2b36c" down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,217 +23,456 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('grooming_schedule', - sa.Column('id', sa.Uuid(), 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('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.PrimaryKeyConstraint('id') + op.create_table( + "grooming_schedule", + sa.Column("id", sa.Uuid(), 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("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_grooming_schedule_day_of_week'), 'grooming_schedule', ['day_of_week'], unique=False) - op.create_table('lab_results', - sa.Column('record_id', sa.Uuid(), nullable=False), - sa.Column('collected_at', sa.DateTime(), nullable=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_index( + op.f("ix_grooming_schedule_day_of_week"), + "grooming_schedule", + ["day_of_week"], + unique=False, ) - op.create_index(op.f('ix_lab_results_collected_at'), 'lab_results', ['collected_at'], unique=False) - op.create_index(op.f('ix_lab_results_flag'), 'lab_results', ['flag'], unique=False) - op.create_index(op.f('ix_lab_results_lab'), 'lab_results', ['lab'], unique=False) - op.create_index(op.f('ix_lab_results_test_code'), 'lab_results', ['test_code'], unique=False) - op.create_table('medication_entries', - sa.Column('record_id', sa.Uuid(), nullable=False), - 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('active_substance', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('formulation', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - 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_table( + "lab_results", + sa.Column("record_id", sa.Uuid(), nullable=False), + sa.Column("collected_at", sa.DateTime(), nullable=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_index(op.f('ix_medication_entries_active_substance'), 'medication_entries', ['active_substance'], unique=False) - op.create_index(op.f('ix_medication_entries_kind'), 'medication_entries', ['kind'], unique=False) - op.create_index(op.f('ix_medication_entries_product_name'), 'medication_entries', ['product_name'], unique=False) - op.create_table('products', - 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_collected_at"), + "lab_results", + ["collected_at"], + unique=False, ) - 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_lab_results_flag"), "lab_results", ["flag"], unique=False) + op.create_index(op.f("ix_lab_results_lab"), "lab_results", ["lab"], unique=False) + op.create_index( + op.f("ix_lab_results_test_code"), "lab_results", ["test_code"], unique=False ) - 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_table( + "medication_entries", + sa.Column("record_id", sa.Uuid(), nullable=False), + 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( + "active_substance", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column("formulation", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + 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.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_entries_active_substance"), + "medication_entries", + ["active_substance"], + unique=False, ) - 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_medication_entries_kind"), "medication_entries", ["kind"], unique=False ) - 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_medication_entries_product_name"), + "medication_entries", + ["product_name"], + unique=False, + ) + op.create_table( + "products", + 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_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 ### def downgrade() -> None: """Downgrade schema.""" # ### 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_product_id'), table_name='routine_steps') - op.drop_table('routine_steps') - op.drop_index(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_index(op.f('ix_medication_usages_valid_from'), table_name='medication_usages') - op.drop_index(op.f('ix_medication_usages_medication_record_id'), table_name='medication_usages') - op.drop_index(op.f('ix_medication_usages_as_needed'), 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_table('skin_condition_snapshots') - op.drop_index(op.f('ix_routines_routine_date'), table_name='routines') - op.drop_index(op.f('ix_routines_part_of_day'), table_name='routines') - op.drop_table('routines') - op.drop_index(op.f('ix_products_price_tier'), table_name='products') - 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') + 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_table("routine_steps") + op.drop_index( + 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_index( + op.f("ix_medication_usages_valid_from"), table_name="medication_usages" + ) + op.drop_index( + op.f("ix_medication_usages_medication_record_id"), + table_name="medication_usages", + ) + op.drop_index( + op.f("ix_medication_usages_as_needed"), 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_table("skin_condition_snapshots") + op.drop_index(op.f("ix_routines_routine_date"), table_name="routines") + op.drop_index(op.f("ix_routines_part_of_day"), table_name="routines") + op.drop_table("routines") + op.drop_index(op.f("ix_products_price_tier"), table_name="products") + 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 ### diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 5cc129f..98b03f1 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -11,7 +11,13 @@ from sqlmodel import Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 from innercontext.llm import get_gemini_client -from innercontext.models import GroomingSchedule, Product, Routine, RoutineStep, SkinConditionSnapshot +from innercontext.models import ( + GroomingSchedule, + Product, + Routine, + RoutineStep, + SkinConditionSnapshot, +) from innercontext.models.enums import GroomingAction, PartOfDay router = APIRouter() @@ -135,16 +141,30 @@ class _BatchOut(PydanticBase): # Prompt helpers # --------------------------------------------------------------------------- -_DAY_NAMES = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"] +_DAY_NAMES = [ + "poniedziałek", + "wtorek", + "środa", + "czwartek", + "piątek", + "sobota", + "niedziela", +] def _ev(v: object) -> str: - return v.value if v is not None and hasattr(v, "value") else str(v) if v is not None else "" + return ( + v.value + if v is not None and hasattr(v, "value") + else str(v) if v is not None else "" + ) def _build_skin_context(session: Session) -> str: snapshot = session.exec( - select(SkinConditionSnapshot).order_by(col(SkinConditionSnapshot.snapshot_date).desc()) + select(SkinConditionSnapshot).order_by( + col(SkinConditionSnapshot.snapshot_date).desc() + ) ).first() if snapshot is None: return "STAN SKÓRY: brak danych\n" @@ -160,16 +180,24 @@ def _build_skin_context(session: Session) -> str: ) -def _build_grooming_context(session: Session, weekdays: Optional[list[int]] = None) -> str: - entries = session.exec(select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)).all() +def _build_grooming_context( + session: Session, weekdays: Optional[list[int]] = None +) -> str: + entries = session.exec( + select(GroomingSchedule).order_by(GroomingSchedule.day_of_week) + ).all() if not entries: return "HARMONOGRAM PIELĘGNACJI: brak\n" lines = ["HARMONOGRAM PIELĘGNACJI:"] for e in entries: if weekdays is not None and e.day_of_week not in weekdays: continue - day_name = _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 "")) + day_name = ( + _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 "") + ) if len(lines) == 1: lines.append(" (brak wpisów dla podanych dni)") return "\n".join(lines) + "\n" @@ -198,12 +226,18 @@ def _build_recent_history(session: Session) -> str: step_names.append(p.name if p else str(s.product_id)) elif s.action_type: step_names.append(_ev(s.action_type)) - lines.append(f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}") + lines.append( + f" {r.routine_date} {_ev(r.part_of_day).upper()}: {', '.join(step_names)}" + ) return "\n".join(lines) + "\n" def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str: - stmt = select(Product).where(Product.is_medication == False).where(Product.is_tool == False) # noqa: E712 + stmt = ( + select(Product) + .where(Product.is_medication == False) # noqa: E712 + .where(Product.is_tool == False) # noqa: E712 + ) products = session.exec(stmt).all() lines = ["DOSTĘPNE PRODUKTY:"] for p in products: @@ -353,13 +387,17 @@ def suggest_batch( ): delta = (data.to_date - data.from_date).days + 1 if delta > 14: - raise HTTPException(status_code=400, detail="Date range must not exceed 14 days.") + raise HTTPException( + status_code=400, detail="Date range must not exceed 14 days." + ) if data.from_date > data.to_date: raise HTTPException(status_code=400, detail="from_date must be <= to_date.") client, model = get_gemini_client() - weekdays = list({(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) grooming_ctx = _build_grooming_context(session, weekdays=weekdays) history_ctx = _build_recent_history(session) @@ -437,7 +475,9 @@ def suggest_batch( ) ) - return BatchSuggestion(days=days, overall_reasoning=parsed.get("overall_reasoning", "")) + return BatchSuggestion( + days=days, overall_reasoning=parsed.get("overall_reasoning", "") + ) # Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 57b60b1..2f70a30 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -146,11 +146,17 @@ async def analyze_skin_photos( parts: list[genai_types.Part] = [] for photo in photos: if photo.content_type not in allowed: - raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}") + raise HTTPException( + status_code=422, detail=f"Unsupported type: {photo.content_type}" + ) data = await photo.read() if len(data) > MAX_IMAGE_BYTES: - raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.") - parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type)) + raise HTTPException( + 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_text( text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment." diff --git a/backend/innercontext/llm.py b/backend/innercontext/llm.py index 428b744..dd0ea99 100644 --- a/backend/innercontext/llm.py +++ b/backend/innercontext/llm.py @@ -5,7 +5,6 @@ import os from fastapi import HTTPException from google import genai - _DEFAULT_MODEL = "gemini-flash-latest" diff --git a/backend/innercontext/mcp_server.py b/backend/innercontext/mcp_server.py index 618f267..01f3826 100644 --- a/backend/innercontext/mcp_server.py +++ b/backend/innercontext/mcp_server.py @@ -287,9 +287,9 @@ def get_medications() -> list[dict]: "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_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 @@ -323,9 +323,9 @@ def get_expiring_inventory(days: int = 30) -> list[dict]: "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, + "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 @@ -384,9 +384,7 @@ 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) + select(LabResult).order_by(col(LabResult.collected_at).desc()).limit(limit) ).all() return [_lab_result_to_dict(r) for r in results] @@ -394,7 +392,8 @@ def get_recent_lab_results(limit: int = 30) -> list[dict]: @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.""" + 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] = {} From d4e30406748d85f83b0911021a0f8e7d19cf0178 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 17:34:00 +0100 Subject: [PATCH 06/51] fix(frontend): fix step numbering and client-side API base URL - Step numbers now use each-block index (i+1) instead of order_index+1, fixing display when order_index starts from 1 in existing data - api.ts: browser-side requests use /api (nginx proxy) instead of PUBLIC_API_BASE (localhost:8000), fixing PATCH/client calls on remote hosts Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api.ts | 6 +++++- frontend/src/routes/routines/[id]/+page.svelte | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dc0f712..547bae7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,3 +1,4 @@ +import { browser } from '$app/environment'; import { PUBLIC_API_BASE } from '$env/static/public'; import type { ActiveIngredient, @@ -21,7 +22,10 @@ import type { // ─── Core fetch helpers ────────────────────────────────────────────────────── async function request(path: string, init: RequestInit = {}): Promise { - const url = `${PUBLIC_API_BASE}${path}`; + // Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000). + // 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, { headers: { 'Content-Type': 'application/json', ...init.headers }, ...init diff --git a/frontend/src/routes/routines/[id]/+page.svelte b/frontend/src/routes/routines/[id]/+page.svelte index e8cc4e4..d7ac90a 100644 --- a/frontend/src/routes/routines/[id]/+page.svelte +++ b/frontend/src/routes/routines/[id]/+page.svelte @@ -180,7 +180,7 @@ onfinalize={handleFinalize} class="space-y-2" > - {#each steps as step (step.id)} + {#each steps as step, i (step.id)}
{#if editingStepId === step.id} @@ -277,7 +277,7 @@ class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground" aria-label="drag to reorder" >⋮⋮ - {step.order_index + 1}. + {i + 1}.
{#if step.product_id} {@const product = products.find((p) => p.id === step.product_id)} From 78c67b6179546924fe37e422deb158996e87ff9f Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 17:39:33 +0100 Subject: [PATCH 07/51] fix(backend): include steps in list_routines response Batch-load all routine steps in a single query and attach them to each routine dict, mirroring the detail endpoint pattern. Fixes "0 steps" shown on the routines list page. Co-Authored-By: Claude Sonnet 4.6 --- backend/innercontext/api/routines.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 98b03f1..7e9d133 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -290,7 +290,7 @@ ZASADY: # --------------------------------------------------------------------------- -@router.get("", response_model=list[Routine]) +@router.get("") def list_routines( from_date: Optional[date] = None, to_date: Optional[date] = None, @@ -304,7 +304,25 @@ def list_routines( stmt = stmt.where(Routine.routine_date <= to_date) if part_of_day is not None: stmt = stmt.where(Routine.part_of_day == part_of_day) - return session.exec(stmt).all() + routines = 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(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) From 3aa03b412b30f6fff71b6e4236fbb656d8064fe7 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 18:22:51 +0100 Subject: [PATCH 08/51] feat(frontend): group product selector by category in routine step forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Products in the Add/Edit step dropdowns are now grouped by category (Cleanser → Serum → Moisturizer → …) using SelectGroup/SelectGroupHeading, and sorted alphabetically by brand then name within each group. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/routines/[id]/+page.svelte | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/routines/[id]/+page.svelte b/frontend/src/routes/routines/[id]/+page.svelte index d7ac90a..b61124f 100644 --- a/frontend/src/routes/routines/[id]/+page.svelte +++ b/frontend/src/routes/routines/[id]/+page.svelte @@ -10,7 +10,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; + import { + Select, + SelectContent, + SelectGroup, + SelectGroupHeading, + SelectItem, + SelectTrigger + } from '$lib/components/ui/select'; import { Separator } from '$lib/components/ui/separator'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -103,6 +110,34 @@ let selectedProductId = $state(''); const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling']; + + 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 Map(); + 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); + }); Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext @@ -150,8 +185,13 @@ {/if} - {#each products as p (p.id)} - {p.name} ({p.brand}) + {#each groupedProducts as [cat, items]} + + {formatCategory(cat)} + {#each items as p (p.id)} + {p.name} · {p.brand} + {/each} + {/each} @@ -202,8 +242,13 @@ {/if} - {#each products as p (p.id)} - {p.name} ({p.brand}) + {#each groupedProducts as [cat, items]} + + {formatCategory(cat)} + {#each items as p (p.id)} + {p.name} · {p.brand} + {/each} + {/each} From 75ef1bca5627c3bba77fbb7c0c2beb5501834c8a Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Sun, 1 Mar 2026 19:46:07 +0100 Subject: [PATCH 09/51] feat(routines): add minoxidil beard/mustache option to routine suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add include_minoxidil_beard flag to SuggestRoutineRequest and SuggestBatchRequest - Detect minoxidil products by scanning name, brand, INCI and actives; pass them to the LLM even though they are medications - Inject CELE UŻYTKOWNIKA context block into prompts when flag is enabled - Add _build_objectives_context() returning empty string when flag is off - Add call_gemini() helper that centralises Gemini API calls and logs every request/response to a new ai_call_logs table (AICallLog model + /ai-logs router) - Nginx: raise client_max_body_size to 16 MB for photo uploads Co-Authored-By: Claude Sonnet 4.6 --- .../versions/a1b2c3d4e5f6_add_ai_call_logs.py | 51 ++++++++ backend/innercontext/api/ai_logs.py | 48 ++++++++ backend/innercontext/api/products.py | 8 +- backend/innercontext/api/routines.py | 112 ++++++++++++------ backend/innercontext/api/skincare.py | 33 +++--- backend/innercontext/llm.py | 70 +++++++++++ backend/innercontext/models/__init__.py | 3 + backend/innercontext/models/ai_log.py | 27 +++++ backend/main.py | 2 + frontend/messages/en.json | 2 + frontend/messages/pl.json | 2 + frontend/src/lib/api.ts | 2 + .../routes/routines/suggest/+page.server.ts | 11 +- .../src/routes/routines/suggest/+page.svelte | 27 ++++- nginx/innercontext.conf | 1 + 15 files changed, 337 insertions(+), 62 deletions(-) create mode 100644 backend/alembic/versions/a1b2c3d4e5f6_add_ai_call_logs.py create mode 100644 backend/innercontext/api/ai_logs.py create mode 100644 backend/innercontext/models/ai_log.py diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_ai_call_logs.py b/backend/alembic/versions/a1b2c3d4e5f6_add_ai_call_logs.py new file mode 100644 index 0000000..37d6f73 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_ai_call_logs.py @@ -0,0 +1,51 @@ +"""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") diff --git a/backend/innercontext/api/ai_logs.py b/backend/innercontext/api/ai_logs.py new file mode 100644 index 0000000..184be95 --- /dev/null +++ b/backend/innercontext/api/ai_logs.py @@ -0,0 +1,48 @@ +from typing import 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() + + +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 + 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) + return session.exec(stmt).all() + + +@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") + return log diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index 945ab53..06b7aa9 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -10,7 +10,7 @@ from sqlmodel import Session, SQLModel, select from db import get_session from innercontext.api.utils import get_or_404 -from innercontext.llm import get_gemini_client +from innercontext.llm import call_gemini from innercontext.models import ( Product, ProductBase, @@ -367,9 +367,8 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine): @router.post("/parse-text", response_model=ProductParseResponse) def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: - client, model = get_gemini_client() - response = client.models.generate_content( - model=model, + response = call_gemini( + endpoint="products/parse-text", contents=f"Extract product data from this text:\n\n{data.text}", config=genai_types.GenerateContentConfig( system_instruction=_product_parse_system_prompt(), @@ -378,6 +377,7 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: max_output_tokens=16384, temperature=0.0, ), + user_input=data.text, ) raw = response.text if not raw: diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 7e9d133..8f171fd 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -10,7 +10,7 @@ from sqlmodel import Session, SQLModel, col, select from db import get_session from innercontext.api.utils import get_or_404 -from innercontext.llm import get_gemini_client +from innercontext.llm import call_gemini from innercontext.models import ( GroomingSchedule, Product, @@ -82,6 +82,7 @@ class SuggestRoutineRequest(SQLModel): routine_date: date part_of_day: PartOfDay notes: Optional[str] = None + include_minoxidil_beard: bool = False class RoutineSuggestion(SQLModel): @@ -93,6 +94,7 @@ class SuggestBatchRequest(SQLModel): from_date: date to_date: date notes: Optional[str] = None + include_minoxidil_beard: bool = False class DayPlan(SQLModel): @@ -152,6 +154,36 @@ _DAY_NAMES = [ ] +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: return ( v.value @@ -233,14 +265,12 @@ def _build_recent_history(session: Session) -> str: def _build_products_context(session: Session, time_filter: Optional[str] = None) -> str: - stmt = ( - select(Product) - .where(Product.is_medication == False) # noqa: E712 - .where(Product.is_tool == False) # noqa: E712 - ) + stmt = select(Product).where(Product.is_tool == False) # noqa: E712 products = session.exec(stmt).all() lines = ["DOSTĘPNE PRODUKTY:"] for p in products: + if p.is_medication and not _is_minoxidil_product(p): + continue if time_filter and _ev(p.recommended_time) not in (time_filter, "both"): continue ctx = p.to_llm_context() @@ -266,12 +296,24 @@ def _build_products_context(session: Session, time_filter: Optional[str] = None) return "\n".join(lines) + "\n" +def _build_objectives_context(include_minoxidil_beard: bool) -> str: + if include_minoxidil_beard: + return ( + "CELE UŻYTKOWNIKA:\n" + " - Priorytet: poprawa gęstości brody i wąsów\n" + " - Jeśli dostępny produkt z minoksydylem, uwzględnij go zgodnie z zasadami bezpieczeństwa\n" + ) + return "" + + _RULES = """\ ZASADY: - Kolejność warstw: cleanser → toner → essence → serum → moisturizer → [SPF dla AM] - Respektuj incompatible_with (scope: same_step / same_day / same_period) - Respektuj context_rules (safe_after_shaving, safe_after_acids itp.) - Respektuj min_interval_hours i max_frequency_per_week + - Jeśli notatki użytkownika mówią o poprawie gęstości brody/wąsów, rozważ minoksydyl (jeśli jest dostępny na liście produktów) + - Dla minoksydylu respektuj usage_notes i ustaw region na obszar zarostu (broda/wąsy), jeśli to adekwatne - 4–7 kroków na rutynę - product_id musi być UUID produktu z listy lub null dla czynności pielęgnacyjnych - action_type: tylko shaving_razor | shaving_oneblade | dermarolling (lub null) @@ -344,13 +386,12 @@ def suggest_routine( data: SuggestRoutineRequest, session: Session = Depends(get_session), ): - client, model = get_gemini_client() - weekday = data.routine_date.weekday() skin_ctx = _build_skin_context(session) grooming_ctx = _build_grooming_context(session, weekdays=[weekday]) history_ctx = _build_recent_history(session) products_ctx = _build_products_context(session, time_filter=data.part_of_day.value) + objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) notes_line = f"\nKONTEKST OD UŻYTKOWNIKA: {data.notes}\n" if data.notes else "" day_name = _DAY_NAMES[weekday] @@ -358,23 +399,21 @@ def suggest_routine( prompt = ( f"Zaproponuj rutynę pielęgnacyjną {data.part_of_day.value.upper()} " f"na {data.routine_date} ({day_name}).\n\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}\n{_RULES}{notes_line}" "\nZwróć JSON zgodny ze schematem." ) - try: - response = client.models.generate_content( - model=model, - contents=prompt, - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_SuggestionOut, - max_output_tokens=4096, - temperature=0.4, - ), - ) - except Exception as e: - raise HTTPException(status_code=502, detail=f"Gemini API error: {e}") + response = call_gemini( + endpoint="routines/suggest", + contents=prompt, + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_SuggestionOut, + max_output_tokens=4096, + temperature=0.4, + ), + user_input=prompt, + ) raw = response.text if not raw: @@ -411,8 +450,6 @@ def suggest_batch( if data.from_date > data.to_date: raise HTTPException(status_code=400, detail="from_date must be <= to_date.") - client, model = get_gemini_client() - weekdays = list( {(data.from_date + timedelta(days=i)).weekday() for i in range(delta)} ) @@ -420,6 +457,7 @@ def suggest_batch( grooming_ctx = _build_grooming_context(session, weekdays=weekdays) history_ctx = _build_recent_history(session) products_ctx = _build_products_context(session) + objectives_ctx = _build_objectives_context(data.include_minoxidil_beard) date_range_lines = [] for i in range(delta): @@ -431,7 +469,7 @@ def suggest_batch( prompt = ( f"Zaproponuj plan pielęgnacji AM + PM dla każdego dnia z zakresu:\n{dates_str}\n\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}\n{_RULES}{notes_line}" "\nDodatkowe zasady dla planu wielodniowego:\n" " - 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" @@ -441,19 +479,17 @@ def suggest_batch( "\nZwróć JSON zgodny ze schematem." ) - try: - response = client.models.generate_content( - model=model, - contents=prompt, - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_BatchOut, - max_output_tokens=8192, - temperature=0.4, - ), - ) - except Exception as e: - raise HTTPException(status_code=502, detail=f"Gemini API error: {e}") + response = call_gemini( + endpoint="routines/suggest-batch", + contents=prompt, + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_BatchOut, + max_output_tokens=8192, + temperature=0.4, + ), + user_input=prompt, + ) raw = response.text if not raw: diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 2f70a30..19a3e7b 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -11,7 +11,7 @@ from sqlmodel import Session, SQLModel, select from db import get_session from innercontext.api.utils import get_or_404 -from innercontext.llm import get_gemini_client +from innercontext.llm import call_gemini from innercontext.models import ( SkinConditionSnapshot, SkinConditionSnapshotBase, @@ -140,8 +140,6 @@ async def analyze_skin_photos( if not (1 <= len(photos) <= 3): raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.") - client, model = get_gemini_client() - allowed = {"image/jpeg", "image/png", "image/webp"} parts: list[genai_types.Part] = [] for photo in photos: @@ -163,20 +161,21 @@ async def analyze_skin_photos( ) ) - try: - response = client.models.generate_content( - model=model, - contents=parts, - config=genai_types.GenerateContentConfig( - system_instruction=_skin_photo_system_prompt(), - response_mime_type="application/json", - response_schema=_SkinAnalysisOut, - max_output_tokens=2048, - temperature=0.0, - ), - ) - except Exception as e: - raise HTTPException(status_code=502, detail=f"Gemini API error: {e}") + image_summary = ( + f"{len(photos)} image(s): {', '.join(p.content_type for p in photos)}" + ) + response = call_gemini( + endpoint="skincare/analyze-photos", + contents=parts, + config=genai_types.GenerateContentConfig( + system_instruction=_skin_photo_system_prompt(), + response_mime_type="application/json", + response_schema=_SkinAnalysisOut, + max_output_tokens=2048, + temperature=0.0, + ), + user_input=image_summary, + ) try: parsed = json.loads(response.text) diff --git a/backend/innercontext/llm.py b/backend/innercontext/llm.py index dd0ea99..90fe12d 100644 --- a/backend/innercontext/llm.py +++ b/backend/innercontext/llm.py @@ -1,9 +1,12 @@ """Shared helpers for Gemini API access.""" import os +import time +from contextlib import suppress from fastapi import HTTPException from google import genai +from google.genai import types as genai_types _DEFAULT_MODEL = "gemini-flash-latest" @@ -18,3 +21,70 @@ def get_gemini_client() -> tuple[genai.Client, str]: raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured") model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL) return genai.Client(api_key=api_key), model + + +def call_gemini( + *, + endpoint: str, + contents, + config: genai_types.GenerateContentConfig, + user_input: str | 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 = True, None, None + try: + response = client.models.generate_content( + model=model, contents=contents, config=config + ) + 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, + 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, + success=success, + error_detail=error_detail, + ) + with Session(engine) as s: + s.add(log) + s.commit() + return response diff --git a/backend/innercontext/models/__init__.py b/backend/innercontext/models/__init__.py index 1ffe287..5fa50b0 100644 --- a/backend/innercontext/models/__init__.py +++ b/backend/innercontext/models/__init__.py @@ -1,3 +1,4 @@ +from .ai_log import AICallLog from .domain import Domain from .enums import ( AbsorptionSpeed, @@ -41,6 +42,8 @@ from .skincare import ( ) __all__ = [ + # ai logs + "AICallLog", # domain "Domain", # enums diff --git a/backend/innercontext/models/ai_log.py b/backend/innercontext/models/ai_log.py new file mode 100644 index 0000000..769f71d --- /dev/null +++ b/backend/innercontext/models/ai_log.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import ClassVar +from uuid import UUID, uuid4 + +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) + success: bool = Field(default=True, index=True) + error_detail: str | None = Field(default=None) diff --git a/backend/main.py b/backend/main.py index 93c035a..c785189 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402 from db import create_db_and_tables # noqa: E402 from innercontext.api import ( # noqa: E402 + ai_logs, health, inventory, products, @@ -45,6 +46,7 @@ app.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) app.include_router(health.router, prefix="/health", tags=["health"]) app.include_router(routines.router, prefix="/routines", tags=["routines"]) 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) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a57eba2..d888a45 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -132,6 +132,8 @@ "suggest_contextLabel": "Additional context for AI", "suggest_contextOptional": "(optional)", "suggest_contextPlaceholder": "e.g. party night, focusing on hydration...", + "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_generating": "Generating…", "suggest_proposalTitle": "Suggestion", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index ba11dfb..b9ddb87 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -132,6 +132,8 @@ "suggest_contextLabel": "Dodatkowy kontekst dla AI", "suggest_contextOptional": "(opcjonalny)", "suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...", + "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_generating": "Generuję…", "suggest_proposalTitle": "Propozycja", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 547bae7..cda76e5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -141,12 +141,14 @@ export const suggestRoutine = (body: { routine_date: string; part_of_day: PartOfDay; notes?: string; + include_minoxidil_beard?: boolean; }): Promise => api.post('/routines/suggest', body); export const suggestBatch = (body: { from_date: string; to_date: string; notes?: string; + include_minoxidil_beard?: boolean; }): Promise => api.post('/routines/suggest-batch', body); export const getGroomingSchedule = (): Promise => diff --git a/frontend/src/routes/routines/suggest/+page.server.ts b/frontend/src/routes/routines/suggest/+page.server.ts index f4d022a..93e3309 100644 --- a/frontend/src/routes/routines/suggest/+page.server.ts +++ b/frontend/src/routes/routines/suggest/+page.server.ts @@ -14,13 +14,19 @@ export const actions: Actions = { const routine_date = form.get('routine_date') as string; const part_of_day = form.get('part_of_day') as 'am' | 'pm'; const notes = (form.get('notes') as string) || undefined; + const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on'; if (!routine_date || !part_of_day) { return fail(400, { error: 'Data i pora dnia są wymagane.' }); } try { - const suggestion = await suggestRoutine({ routine_date, part_of_day, notes }); + const suggestion = await suggestRoutine({ + routine_date, + part_of_day, + notes, + include_minoxidil_beard + }); return { suggestion, routine_date, part_of_day }; } catch (e) { return fail(502, { error: (e as Error).message }); @@ -32,6 +38,7 @@ export const actions: Actions = { const from_date = form.get('from_date') as string; const to_date = form.get('to_date') as string; const notes = (form.get('notes') as string) || undefined; + const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on'; if (!from_date || !to_date) { return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' }); @@ -44,7 +51,7 @@ export const actions: Actions = { } try { - const batch = await suggestBatch({ from_date, to_date, notes }); + const batch = await suggestBatch({ from_date, to_date, notes, include_minoxidil_beard }); return { batch, from_date, to_date }; } catch (e) { return fail(502, { error: (e as Error).message }); diff --git a/frontend/src/routes/routines/suggest/+page.svelte b/frontend/src/routes/routines/suggest/+page.svelte index d86ae9f..832a859 100644 --- a/frontend/src/routes/routines/suggest/+page.svelte +++ b/frontend/src/routes/routines/suggest/+page.svelte @@ -1,5 +1,6 @@ -
- -