Merge pull request 'chore(devops): каркас — uv, docker compose, Makefile, CI' (#26) from chore/1-skeleton into dev
CI / ruff · pytest (push) Successful in 21s
CI / ruff · pytest (push) Successful in 21s
This commit was merged in pull request #26.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.claude
|
||||
.venv
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.env
|
||||
docs/source/*.pdf
|
||||
*.pdf
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+11
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+29
@@ -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"}
|
||||
@@ -0,0 +1,9 @@
|
||||
# config/
|
||||
|
||||
Конфигурация клинической логики (ADR-008). Схемы задаёт зона `core`, значения утверждает психиатр (`clinical`).
|
||||
|
||||
- `scales/` — шкалы: пункты, варианты, градации, флаги
|
||||
- `rules.yaml` — правила алертов и действий
|
||||
- `modules.yaml` — модули и расписания шкал
|
||||
- `lexicon/risk_ru.yaml` — лексикон риска
|
||||
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
|
||||
@@ -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}**")
|
||||
@@ -0,0 +1,7 @@
|
||||
# data/
|
||||
|
||||
Только синтетические данные.
|
||||
|
||||
- `synthetic/` — сценарии персон (jsonl)
|
||||
- `meds/` — выдержки инструкций ГРЛС, мок назначений МИС
|
||||
- `eval/` — разметка психиатра для оценки извлечения
|
||||
@@ -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:
|
||||
Executable
+14
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user