chore(devops): каркас — uv, docker compose, Makefile, CI #26

Merged
Eleboba merged 1 commits from chore/1-skeleton into dev 2026-10-03 14:50:05 +03:00
32 changed files with 2252 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.claude
.venv
**/__pycache__
.pytest_cache
.ruff_cache
.env
docs/source/*.pdf
*.pdf
+31
View File
@@ -0,0 +1,31 @@
# Copy to .env. Secrets only here; .env is gitignored.
# --- LLM (152-FZ: GigaChat only). mock = offline demo without network (ADR-004)
LLM_PROVIDER=mock
GIGACHAT_CREDENTIALS=
GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
# --- Databases (docker compose overrides the URLs to point at the postgres service)
POSTGRES_PASSWORD=postgres
IDENTITY_DB_PASSWORD=identity_app
CLINICAL_DB_PASSWORD=clinical_app
IDENTITY_DB_URL=postgresql+asyncpg://identity_app:identity_app@localhost:5432/identity
CLINICAL_DB_URL=postgresql+asyncpg://clinical_app:clinical_app@localhost:5432/clinical
# --- Telegram: prototype on synthetic data only; prod channel is MAX / Gosuslugi. Empty = bot disabled.
TG_BOT_TOKEN=
DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005)
DEMO_CLOCK=true
TZ=Europe/Moscow
# --- Gitea (for Claude Code / team workflow, not used by the app)
GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER=
GITEA_TOKEN=
+21
View File
@@ -0,0 +1,21 @@
name: CI
on:
pull_request:
branches: [dev, main]
push:
branches: [dev, main]
jobs:
lint-and-test:
name: ruff · pytest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: "0.8.4"
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest -q
+1
View File
@@ -0,0 +1 @@
3.12
+11
View File
@@ -0,0 +1,11 @@
# One image for both the API/bot process and the Streamlit dashboard.
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PROJECT_ENVIRONMENT=/opt/venv PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /srv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev
EXPOSE 8000 8501
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+32
View File
@@ -0,0 +1,32 @@
.PHONY: install up down logs seed demo-reset lint fmt test
install:
uv sync
up:
@test -f .env || cp .env.example .env
docker compose up -d --build
@echo "API: http://localhost:8000/docs Dashboard: http://localhost:8501"
down:
docker compose down
logs:
docker compose logs -f app dashboard
seed:
docker compose exec app python -m scripts.seed_demo
demo-reset:
docker compose exec app python -m scripts.seed_demo --reset
lint:
uv run ruff check .
uv run ruff format --check .
fmt:
uv run ruff check --fix .
uv run ruff format .
test:
uv run pytest -q
View File
View File
View File
+46
View File
@@ -0,0 +1,46 @@
"""Typed application settings. The only place that reads environment variables."""
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
# Empty values in .env (e.g. `TG_BOT_TOKEN=`) fall back to defaults instead of failing validation.
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True)
# Two separate databases (ADR-003): identity holds direct identifiers, clinical holds pseudonymised data.
identity_db_url: str = "postgresql+asyncpg://identity_app:identity_app@localhost:5432/identity"
clinical_db_url: str = "postgresql+asyncpg://clinical_app:clinical_app@localhost:5432/clinical"
# HMAC key linking identity and clinical records (hex, 32+ bytes).
pseudo_key: str = Field(default="dev-only-insecure-key-change-me-0000000000000000", min_length=32)
# LLM: GigaChat only (152-FZ: no foreign APIs). `mock` is the offline default (ADR-004).
llm_provider: Literal["mock", "gigachat"] = "mock"
gigachat_credentials: str = ""
gigachat_scope: str = "GIGACHAT_API_PERS"
gigachat_model: str = "GigaChat"
gigachat_verify_ssl: bool = False
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None
# Virtual clock for the demo (ADR-005).
demo_clock: bool = True
tz: str = "Europe/Moscow"
api_base_url: str = "http://localhost:8000"
config_dir: Path = ROOT_DIR / "config"
data_dir: Path = ROOT_DIR / "data"
@lru_cache
def get_settings() -> Settings:
return Settings()
View File
View File
View File
View File
View File
+29
View File
@@ -0,0 +1,29 @@
"""Application entrypoint: FastAPI API (+ scheduler and Telegram bot once implemented)."""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import get_settings
# Logs carry only tokens, states and event codes — never PII or raw message text.
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("app")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
settings = get_settings()
log.info("startup llm_provider=%s demo_clock=%s", settings.llm_provider, settings.demo_clock)
# DB init (#6), scheduler (#13) and Telegram bot (#11) hook in here.
yield
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
View File
+9
View File
@@ -0,0 +1,9 @@
# config/
Конфигурация клинической логики (ADR-008). Схемы задаёт зона `core`, значения утверждает психиатр (`clinical`).
- `scales/` — шкалы: пункты, варианты, градации, флаги
- `rules.yaml` — правила алертов и действий
- `modules.yaml` — модули и расписания шкал
- `lexicon/risk_ru.yaml` — лексикон риска
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
View File
View File
View File
+18
View File
@@ -0,0 +1,18 @@
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API."""
import os
import httpx
import streamlit as st
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
st.title("Кабинет врача")
st.caption("Прототип на синтетических данных")
try:
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
except httpx.HTTPError:
status = "недоступен"
st.write(f"API: **{status}**")
+7
View File
@@ -0,0 +1,7 @@
# data/
Только синтетические данные.
- `synthetic/` — сценарии персон (jsonl)
- `meds/` — выдержки инструкций ГРЛС, мок назначений МИС
- `eval/` — разметка психиатра для оценки извлечения
View File
View File
View File
+51
View File
@@ -0,0 +1,51 @@
# Local / demo stack. Works offline once images are built: LLM_PROVIDER=mock by default (ADR-004).
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
IDENTITY_DB_PASSWORD: ${IDENTITY_DB_PASSWORD:-identity_app}
CLINICAL_DB_PASSWORD: ${CLINICAL_DB_PASSWORD:-clinical_app}
TZ: ${TZ:-Europe/Moscow}
volumes:
- pgdata:/var/lib/postgresql/data
- ./infra/postgres/init.sh:/docker-entrypoint-initdb.d/10-init.sh:ro
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 20
app:
build: .
env_file: .env
environment:
IDENTITY_DB_URL: postgresql+asyncpg://identity_app:${IDENTITY_DB_PASSWORD:-identity_app}@postgres:5432/identity
CLINICAL_DB_URL: postgresql+asyncpg://clinical_app:${CLINICAL_DB_PASSWORD:-clinical_app}@postgres:5432/clinical
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 3s
retries: 20
dashboard:
build: .
command: ["streamlit", "run", "dashboard/app.py", "--server.port=8501", "--server.address=0.0.0.0", "--browser.gatherUsageStats=false"]
environment:
API_BASE_URL: http://app:8000
depends_on:
app:
condition: service_healthy
ports:
- "8501:8501"
volumes:
pgdata:
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Creates two isolated databases (ADR-003): identity (direct identifiers) and clinical (pseudonymised data + audit).
# Each has its own role; neither role can connect to the other database.
set -euo pipefail
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<SQL
CREATE ROLE identity_app LOGIN PASSWORD '${IDENTITY_DB_PASSWORD}';
CREATE ROLE clinical_app LOGIN PASSWORD '${CLINICAL_DB_PASSWORD}';
CREATE DATABASE identity OWNER identity_app;
CREATE DATABASE clinical OWNER clinical_app;
REVOKE CONNECT ON DATABASE identity FROM PUBLIC;
REVOKE CONNECT ON DATABASE clinical FROM PUBLIC;
GRANT CONNECT ON DATABASE identity TO identity_app;
GRANT CONNECT ON DATABASE clinical TO clinical_app;
SQL
+49
View File
@@ -0,0 +1,49 @@
[project]
name = "ptsr-ai-agent"
version = "0.1.0"
description = "AI companion for patients with PTSD and depression between doctor visits (MedITron hackathon, synthetic data only)"
requires-python = ">=3.11,<3.14"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"pyyaml>=6.0",
"aiogram>=3.10",
"gigachat>=0.1.33",
"apscheduler>=3.10,<4",
"httpx>=0.27",
"streamlit>=1.38",
"plotly>=5.22",
"pandas>=2.2",
"qrcode[pil]>=7.4",
]
[dependency-groups]
dev = [
"ruff>=0.6",
"pytest>=8.2",
"pytest-asyncio>=0.23",
"aiosqlite>=0.20",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 110
target-version = "py311"
extend-exclude = [".claude", "docs"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "ASYNC"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
+15
View File
@@ -0,0 +1,15 @@
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import app
def test_health() -> None:
with TestClient(app) as client:
assert client.get("/health").json() == {"status": "ok"}
def test_offline_defaults() -> None:
settings = Settings(_env_file=None)
assert settings.llm_provider == "mock"
assert settings.demo_clock is True
Generated
+1909
View File
File diff suppressed because it is too large Load Diff