diff --git a/.env.example b/.env.example index 5b52a4d..57ec2b3 100644 --- a/.env.example +++ b/.env.example @@ -1,51 +1,56 @@ -APP_NAME=Specification Generator -ENV=development -DEBUG=False -VERSION=1.0.0 +APP_NAME= +ENV= +DEBUG= +VERSION= -HOST=0.0.0.0 -PORT=8000 +HOST= +PORT= DATABASE_URL=postgresql://:@:5432/?sslmode=disable -SECRET_KEY= -ACCESS_TOKEN_EXPIRE_MINUTES=60 -ACCESS_TOKEN_MINUTE_IN_SECONDS=60 +SECRET_KEY= +ACCESS_TOKEN_EXPIRE_MINUTES= +ACCESS_TOKEN_MINUTE_IN_SECONDS= -AUTH_PREFIX=/auth -AUTH_ME_PATH=/me -AUTH_TAG=auth -AUTH_BOOTSTRAP_ENABLED=False -AUTH_BOOTSTRAP_SUPERUSER=True +AUTH_PREFIX= +AUTH_ME_PATH= +AUTH_TAG= +AUTH_BOOTSTRAP_ENABLED= +AUTH_BOOTSTRAP_SUPERUSER= AUTH_USERNAME= AUTH_EMAIL= AUTH_PASSWORD= -AUTH_PASSWORD_HASH= +AUTH_PASSWORD_HASH= -ALLOWED_ORIGINS=["*"] -SECURITY_TRUSTED_HOSTS=["*"] -SECURITY_ENABLE_HTTPS_REDIRECT=False -SECURITY_CSP=default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' -SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin -SECURITY_CORS_ALLOW_METHODS=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] -SECURITY_CORS_ALLOW_HEADERS=["Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-Request-ID"] -SECURITY_REQUEST_ID_HEADER=X-Request-ID -SECURITY_LOG_SUSPICIOUS=True -SECURITY_RATE_LIMIT_ENABLED=True -SECURITY_RATE_LIMIT_REQUESTS=120 -SECURITY_RATE_LIMIT_WINDOW_SECONDS=60 -SECURITY_RATE_LIMIT_PATHS=["/api/v1/auth"] +ALLOWED_ORIGINS= +SECURITY_TRUSTED_HOSTS= +SECURITY_ENABLE_HTTPS_REDIRECT= +SECURITY_CSP= +SECURITY_REFERRER_POLICY= +SECURITY_CORS_ALLOW_METHODS= +SECURITY_CORS_ALLOW_HEADERS= +SECURITY_REQUEST_ID_HEADER= +SECURITY_LOG_SUSPICIOUS= +SECURITY_RATE_LIMIT_ENABLED= +SECURITY_RATE_LIMIT_REQUESTS= +SECURITY_RATE_LIMIT_WINDOW_SECONDS= +SECURITY_RATE_LIMIT_PATHS= -OLLAMA_BASE_URL=http://localhost:11434 -OLLAMA_MODEL=mistral -OLLAMA_TIMEOUT=120 -OLLAMA_SYSTEM_PROMPT=You are a helpful assistant that generates software specifications. +OLLAMA_BASE_URL= +OLLAMA_MODEL= +OLLAMA_TIMEOUT= +OLLAMA_CONNECT_TIMEOUT= +OLLAMA_MAX_RETRIES= +OLLAMA_RETRY_BACKOFF_SECONDS= +OLLAMA_SYSTEM_PROMPT= +LLM_PROMPT_MAX_LENGTH= +FEATURE_SPEC_HISTORY_DEFAULT_LIMIT= TEST_DB_HOST= -TEST_DB_PORT=5432 +TEST_DB_PORT= TEST_DB_NAME= TEST_DB_USER= TEST_DB_PASSWORD= -TEST_DB_SSLMODE=disable +TEST_DB_SSLMODE= TEST_DEFAULT_USERNAME= TEST_DEFAULT_EMAIL= TEST_DEFAULT_HASHED_PASSWORD= diff --git a/README.md b/README.md index aae4c45..309d479 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Production-ready FastAPI backend for authentication, LLM-powered specification g - JWT authentication based on fastapi-users - User registration and user management endpoints -- LLM generation endpoint with multiple response formats (text, sections, json) +- Feature specification generation endpoints powered by Ollama - Readiness and health probes for runtime checks - Alembic database migrations - Security middleware baseline: @@ -49,8 +49,8 @@ Production-ready FastAPI backend for authentication, LLM-powered specification g - app/api/: health, readiness, OpenAPI customization - app/middlewares/: security middleware composition and implementations - app/modules/auth/: auth domain (models, schemas, dependencies, router) -- app/modules/llm/: LLM API, schemas, providers -- app/scripts/: utility scripts (admin bootstrap) +- app/modules/feature_spec/: feature spec API, schemas, prompts, providers +- app/scripts/: utility scripts (admin and prompt/model bootstrap) - alembic/: migration config and versions - docker-compose.yml: containerized app run @@ -60,8 +60,7 @@ Production-ready FastAPI backend for authentication, LLM-powered specification g - Python 3.10+ - PostgreSQL database -- Optional: Docker + Docker Compose -- Optional: local Ollama instance for LLM generation +- Optional: Docker + Docker Compose (recommended for VPS) ### 1) Configure environment @@ -83,6 +82,10 @@ LLM values: - OLLAMA_BASE_URL - OLLAMA_MODEL +For Docker Compose in this project use: + +- OLLAMA_BASE_URL=http://ollama:11434 + ### 2A) Run locally ```bash @@ -94,33 +97,51 @@ source venv/bin/activate pip install --upgrade pip pip install -r requirements.txt +pip install -r requirements-dev.txt python -m alembic upgrade head python -m app.scripts.bootstrap_admin +python -m app.scripts.bootstrap_prompt_template python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` ### 2B) Run with Docker ```bash -docker compose up --build +docker compose up -d --build ``` Notes: - Container entrypoint automatically runs: - migration + DB head check (`python -m app.scripts.migrate_and_check`) - - admin bootstrap script + - admin bootstrap script (`python -m app.scripts.bootstrap_admin`) + - prompt template bootstrap script (`python -m app.scripts.bootstrap_prompt_template`) + - Ollama model bootstrap (`python -m app.scripts.ensure_ollama_model`) - uvicorn app startup +- On first deploy, startup may take longer while the configured `OLLAMA_MODEL` is downloaded. +- FastAPI container reaches Ollama via internal Docker network URL: http://ollama:11434 + +Verify Ollama API: + +```bash +curl http://localhost:11434/api/generate -d '{ + "model": "mistral", + "prompt": "hello", + "stream": false +}' +``` ## API Docs -When server is running: +When server is running locally: - Swagger UI: http://localhost:8000/docs - ReDoc: http://localhost:8000/redoc (pinned ReDoc 2.x script) - OpenAPI JSON: http://localhost:8000/openapi.json +For Docker Compose deployment, use port 8001 instead of 8000. + ## API Endpoints Base API prefix: /api/v1 @@ -151,16 +172,16 @@ Login note: - `/api/v1/auth/jwt/logout` clears refresh cookie on client side. - Swagger `Authorize` value must contain only raw JWT token (without `Bearer ` prefix). -### LLM +### Feature Spec -- POST /api/v1/llm/generate +- POST /api/v1/feature-spec/generate +- GET /api/v1/feature-spec/history?limit=10 -Request body example: +Request body example for generation: ```json { - "prompt": "Generate API specification for user profile module", - "response_format": "sections" + "feature_idea": "payment for premium posts" } ``` @@ -169,7 +190,7 @@ Request body example: Run linter: ```bash -python -m flake8 +python -m flake8 . ``` Run auth unit tests: @@ -208,3 +229,4 @@ If LLM requests fail: - Verify OLLAMA_BASE_URL - Ensure Ollama is running and model is available +- For Docker deployment, ensure OLLAMA_BASE_URL is http://ollama:11434 diff --git a/alembic/versions/2e5bd3a1eb88_initial.py b/alembic/versions/2e5bd3a1eb88_initial.py deleted file mode 100644 index 9b120c8..0000000 --- a/alembic/versions/2e5bd3a1eb88_initial.py +++ /dev/null @@ -1,45 +0,0 @@ -"""initial - -Revision ID: 2e5bd3a1eb88 -Revises: -Create Date: 2026-04-11 10:30:05.432174 -""" - -import sqlalchemy as sa - -from alembic import op - -revision = "2e5bd3a1eb88" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("username", sa.String(length=100), nullable=False), - sa.Column("hashed_password", sa.String(length=1024), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("is_verified", sa.Boolean(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("email", sa.String(length=320), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) - op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) - op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) - - -def downgrade() -> None: - op.drop_index(op.f("ix_users_username"), table_name="users") - op.drop_index(op.f("ix_users_id"), table_name="users") - op.drop_index(op.f("ix_users_email"), table_name="users") - op.drop_table("users") diff --git a/alembic/versions/6f4e8b8f4b11_add_refresh_tokens.py b/alembic/versions/6f4e8b8f4b11_add_refresh_tokens.py deleted file mode 100644 index d0079cc..0000000 --- a/alembic/versions/6f4e8b8f4b11_add_refresh_tokens.py +++ /dev/null @@ -1,54 +0,0 @@ -"""add refresh tokens - -Revision ID: 6f4e8b8f4b11 -Revises: 2e5bd3a1eb88 -Create Date: 2026-04-11 22:30:00.000000 -""" - -import sqlalchemy as sa - -from alembic import op - -revision = "6f4e8b8f4b11" -down_revision = "2e5bd3a1eb88" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "refresh_tokens", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("token_hash", sa.String(length=128), nullable=False), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_refresh_tokens_id"), "refresh_tokens", ["id"], unique=False) - op.create_index( - op.f("ix_refresh_tokens_token_hash"), - "refresh_tokens", - ["token_hash"], - unique=True, - ) - op.create_index( - op.f("ix_refresh_tokens_user_id"), - "refresh_tokens", - ["user_id"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens") - op.drop_index(op.f("ix_refresh_tokens_token_hash"), table_name="refresh_tokens") - op.drop_index(op.f("ix_refresh_tokens_id"), table_name="refresh_tokens") - op.drop_table("refresh_tokens") diff --git a/app/core/bootstrap.py b/app/core/bootstrap.py deleted file mode 100644 index 5f00ba4..0000000 --- a/app/core/bootstrap.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging - -from fastapi_users.password import PasswordHelper -from sqlalchemy.orm import Session - -from app.core.settings import settings -from app.modules.auth.models import User - -logger = logging.getLogger(__name__) - - -def _resolve_bootstrap_hashed_password() -> str: - helper = PasswordHelper() - if not settings.AUTH_PASSWORD: - raise RuntimeError( - "AUTH_PASSWORD must be set for admin bootstrap user creation" - ) - return helper.hash(settings.AUTH_PASSWORD) - - -def _is_bootstrap_enabled() -> bool: - if settings.ENV.lower() == "production": - return settings.AUTH_BOOTSTRAP_ENABLED - return settings.AUTH_BOOTSTRAP_ENABLED or bool(settings.AUTH_EMAIL) - - -def bootstrap_auth(db: Session) -> None: - if not _is_bootstrap_enabled(): - logger.info("Auth bootstrap is disabled") - return - - if not settings.AUTH_EMAIL or not settings.AUTH_USERNAME: - raise RuntimeError( - "AUTH_EMAIL and AUTH_USERNAME must be set when bootstrap is enabled" - ) - - existing_user = ( - db.query(User).filter(User.email == settings.AUTH_EMAIL).one_or_none() - ) - if existing_user is not None: - try: - if not existing_user.hashed_password: - existing_user.hashed_password = _resolve_bootstrap_hashed_password() - db.add(existing_user) - db.commit() - except Exception: - logger.exception( - "Failed to ensure bootstrap state for existing user: email=%s", - settings.AUTH_EMAIL, - ) - db.rollback() - raise - return - - try: - user = User( - username=settings.AUTH_USERNAME, - email=settings.AUTH_EMAIL, - hashed_password=_resolve_bootstrap_hashed_password(), - is_active=True, - is_superuser=settings.AUTH_BOOTSTRAP_SUPERUSER, - is_verified=True, - ) - db.add(user) - db.commit() - except Exception: - logger.exception( - "Failed to create bootstrap user: email=%s", - settings.AUTH_EMAIL, - ) - db.rollback() - raise - - -def ensure_default_user(db: Session) -> None: - bootstrap_auth(db) diff --git a/app/core/settings.py b/app/core/settings.py index 98d4676..56e29d6 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -69,12 +69,17 @@ class Settings(BaseSettings): OLLAMA_BASE_URL: str = "http://localhost:11434" OLLAMA_MODEL: str = "mistral" OLLAMA_TIMEOUT: int = 120 + OLLAMA_CONNECT_TIMEOUT: int = 10 + OLLAMA_MAX_RETRIES: int = 2 + OLLAMA_RETRY_BACKOFF_SECONDS: float = 1.0 OLLAMA_SYSTEM_PROMPT: str = ( "You are a helpful assistant that generates software specifications." ) LLM_PREFIX: str = "/llm" LLM_GENERATE_PATH: str = "/generate" LLM_TAG: str = "llm" + LLM_PROMPT_MAX_LENGTH: int = 8000 + FEATURE_SPEC_HISTORY_DEFAULT_LIMIT: int = 10 model_config = SettingsConfigDict( env_file=".env", diff --git a/app/main.py b/app/main.py index 9d2df52..dd4a95f 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ from app.core.startup import lifespan from app.middlewares import configure_security_middlewares from app.modules.auth.router import router as auth_router -from app.modules.llm.router import router as llm_router +from app.modules.feature_spec.router import router as feature_spec_router app = FastAPI( title=settings.APP_NAME, @@ -19,7 +19,7 @@ configure_security_middlewares(app) app.include_router(auth_router, prefix=settings.API_V1_PREFIX) -app.include_router(llm_router, prefix=settings.API_V1_PREFIX) +app.include_router(feature_spec_router, prefix=settings.API_V1_PREFIX) app.include_router(health_router) configure_openapi_bearer_auth(app) diff --git a/app/modules/auth/infrastructure/fastapi_users_adapter.py b/app/modules/auth/infrastructure/fastapi_users_adapter.py index daa8e27..17102f6 100644 --- a/app/modules/auth/infrastructure/fastapi_users_adapter.py +++ b/app/modules/auth/infrastructure/fastapi_users_adapter.py @@ -1,4 +1,5 @@ from collections.abc import AsyncGenerator +from typing import Any from fastapi import Depends from fastapi.security import OAuth2PasswordRequestForm @@ -41,6 +42,13 @@ async def authenticate( return user + async def create(self, user_create: Any, safe: bool = False, request: Any = None): + if safe: + user_create = user_create.model_copy( + update={"is_superuser": False, "is_verified": False} + ) + return await super().create(user_create, safe=safe, request=request) + async def get_user_db( session: AsyncSession = Depends(get_async_db), diff --git a/app/modules/auth/schemas.py b/app/modules/auth/schemas.py index 8c03e69..96a4f36 100644 --- a/app/modules/auth/schemas.py +++ b/app/modules/auth/schemas.py @@ -1,6 +1,6 @@ from fastapi_users import schemas -from pydantic import Field +from pydantic import ConfigDict, Field, model_validator from app.core.settings import settings @@ -14,6 +14,26 @@ class UserCreate(schemas.BaseUserCreate): min_length=settings.AUTH_USERNAME_MIN_LENGTH, max_length=settings.AUTH_USERNAME_MAX_LENGTH, ) + is_superuser: bool = Field(default=False, exclude=True) + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_superuser_registration(self): + if self.is_superuser: + raise ValueError("Setting is_superuser via public registration is forbidden") + return self + + @classmethod + def model_json_schema(cls, *args, **kwargs): + schema = super().model_json_schema(*args, **kwargs) + properties = schema.get("properties", {}) + properties.pop("is_superuser", None) + + required = schema.get("required") + if isinstance(required, list) and "is_superuser" in required: + required.remove("is_superuser") + return schema class UserUpdate(schemas.BaseUserUpdate): diff --git a/app/modules/feature_spec/__init__.py b/app/modules/feature_spec/__init__.py new file mode 100644 index 0000000..c57be5d --- /dev/null +++ b/app/modules/feature_spec/__init__.py @@ -0,0 +1,3 @@ +from app.modules.feature_spec.router import router + +__all__ = ["router"] diff --git a/app/modules/feature_spec/models.py b/app/modules/feature_spec/models.py new file mode 100644 index 0000000..b8eeca5 --- /dev/null +++ b/app/modules/feature_spec/models.py @@ -0,0 +1,61 @@ +from datetime import datetime + +from sqlalchemy import ( + JSON, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class PromptTemplate(Base): + __tablename__ = "prompt_templates" + __table_args__ = ( + CheckConstraint("id = 1", name="ck_prompt_templates_single_row"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + feature_to_feature_summary: Mapped[str] = mapped_column(Text, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class FeatureSpecRun(Base): + __tablename__ = "feature_spec_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + feature_idea: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + response_json: Mapped[dict | list | None] = mapped_column(JSON, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + index=True, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/app/modules/feature_spec/orchestrator.py b/app/modules/feature_spec/orchestrator.py new file mode 100644 index 0000000..79f7f8b --- /dev/null +++ b/app/modules/feature_spec/orchestrator.py @@ -0,0 +1,104 @@ +from sqlalchemy.orm import Session +from sqlalchemy import select + +from app.core.settings import settings +from app.modules.feature_spec.models import FeatureSpecRun +from app.modules.feature_spec.prompts.feature_summary import ( + build_feature_summary_prompt_from_db, + parse_feature_summary_response, +) +from app.modules.feature_spec.providers.ollama import ollama_client +from app.modules.feature_spec.schemas import ( + FeatureSummaryResult, + FeatureSpecGenerateResponse, + FeatureSpecHistoryItem, + FeatureSpecHistoryResponse, +) + + +class FeatureSpecOrchestrator: + def __init__(self, llm_client) -> None: + self.llm = llm_client + + async def generate( + self, + idea: str, + db: Session, + user_id: int, + ) -> FeatureSpecGenerateResponse: + run = FeatureSpecRun( + user_id=user_id, + feature_idea=idea, + status="pending", + ) + db.add(run) + db.commit() + db.refresh(run) + + feature_summary_prompt = build_feature_summary_prompt_from_db(idea, db) + try: + feature_summary_raw = await self.llm.generate(feature_summary_prompt) + feature_summary_json = parse_feature_summary_response(feature_summary_raw) + feature_summary_typed = FeatureSummaryResult.model_validate( + feature_summary_json + ) + + run.status = "success" + run.response_json = feature_summary_typed.model_dump(mode="json") + run.error_message = None + db.add(run) + db.commit() + except Exception as exc: + run.status = "error" + run.error_message = str(exc) + db.add(run) + db.commit() + raise + + return FeatureSpecGenerateResponse( + feature_idea=idea, + feature_summary=feature_summary_typed, + ) + + +orchestrator = FeatureSpecOrchestrator(ollama_client) + + +async def generate_feature_spec( + feature_idea: str, + db: Session, + user_id: int, +) -> FeatureSpecGenerateResponse: + return await orchestrator.generate(feature_idea, db, user_id) + + +def get_feature_spec_history( + db: Session, + user_id: int, + limit: int = settings.FEATURE_SPEC_HISTORY_DEFAULT_LIMIT, +) -> FeatureSpecHistoryResponse: + safe_limit = max(1, min(limit, 100)) + statement = ( + select(FeatureSpecRun) + .where(FeatureSpecRun.user_id == user_id) + .order_by(FeatureSpecRun.created_at.desc()) + .limit(safe_limit) + ) + rows = db.execute(statement).scalars().all() + + items = [ + FeatureSpecHistoryItem( + id=row.id, + feature_idea=row.feature_idea, + status=row.status, + response_json=( + FeatureSummaryResult.model_validate(row.response_json) + if row.response_json is not None + else None + ), + error_message=row.error_message, + created_at=row.created_at, + ) + for row in rows + ] + return FeatureSpecHistoryResponse(items=items) diff --git a/app/modules/feature_spec/parser.py b/app/modules/feature_spec/parser.py new file mode 100644 index 0000000..de7a156 --- /dev/null +++ b/app/modules/feature_spec/parser.py @@ -0,0 +1,25 @@ +import json +import re + + +def extract_json(text: str) -> dict | list: + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char not in "[{": + continue + try: + parsed, _ = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + if isinstance(parsed, (dict, list)): + return parsed + raise ValueError("No JSON found in LLM response") + + +def strip_markdown(text: str) -> str: + text = re.sub(r"```[\w]*\n?", "", text) + return text.strip() + + +def normalize_whitespace(text: str) -> str: + return re.sub(r"\n{3,}", "\n\n", text).strip() diff --git a/app/modules/feature_spec/prompts/__init__.py b/app/modules/feature_spec/prompts/__init__.py new file mode 100644 index 0000000..891cfab --- /dev/null +++ b/app/modules/feature_spec/prompts/__init__.py @@ -0,0 +1,13 @@ +from app.modules.feature_spec.prompts.feature_summary import ( + build_feature_summary_prompt_from_db, + build_feature_summary_prompt_from_template, + load_feature_summary_template, + parse_feature_summary_response, +) + +__all__ = [ + "load_feature_summary_template", + "build_feature_summary_prompt_from_template", + "build_feature_summary_prompt_from_db", + "parse_feature_summary_response", +] diff --git a/app/modules/feature_spec/prompts/feature_summary.py b/app/modules/feature_spec/prompts/feature_summary.py new file mode 100644 index 0000000..895889e --- /dev/null +++ b/app/modules/feature_spec/prompts/feature_summary.py @@ -0,0 +1,40 @@ +import html + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.feature_spec.models import PromptTemplate +from app.modules.feature_spec.parser import extract_json, normalize_whitespace, strip_markdown + + +def build_feature_summary_prompt_from_template(template: str, feature_idea: str) -> str: + safe_template = template.strip() + escaped_feature_idea = html.escape(feature_idea.strip(), quote=True) + rendered = safe_template.replace("{feature_idea}", escaped_feature_idea) + rendered = rendered.replace("{input}", escaped_feature_idea) + return rendered + + +def load_feature_summary_template(db: Session) -> str: + statement = ( + select(PromptTemplate.feature_to_feature_summary) + .where(PromptTemplate.is_active.is_(True)) + .order_by(PromptTemplate.updated_at.desc()) + .limit(1) + ) + template = db.execute(statement).scalar_one_or_none() + if template and template.strip(): + return template + raise ValueError( + "Active feature summary prompt template is not configured in database" + ) + + +def build_feature_summary_prompt_from_db(feature_idea: str, db: Session) -> str: + template = load_feature_summary_template(db) + return build_feature_summary_prompt_from_template(template, feature_idea) + + +def parse_feature_summary_response(raw_response: str) -> dict | list: + normalized_content = normalize_whitespace(strip_markdown(raw_response)) + return extract_json(normalized_content) diff --git a/app/modules/feature_spec/providers/__init__.py b/app/modules/feature_spec/providers/__init__.py new file mode 100644 index 0000000..3176b21 --- /dev/null +++ b/app/modules/feature_spec/providers/__init__.py @@ -0,0 +1,3 @@ +from app.modules.feature_spec.providers.ollama import OllamaClient, ollama_client + +__all__ = ["OllamaClient", "ollama_client"] diff --git a/app/modules/feature_spec/providers/ollama.py b/app/modules/feature_spec/providers/ollama.py new file mode 100644 index 0000000..08ff0d3 --- /dev/null +++ b/app/modules/feature_spec/providers/ollama.py @@ -0,0 +1,136 @@ +import asyncio +import json +import logging +from typing import Any, AsyncGenerator + +import httpx + +from app.core.settings import settings + +logger = logging.getLogger(__name__) + + +class OllamaClient: + def __init__(self) -> None: + self._base_url = settings.OLLAMA_BASE_URL.rstrip("/") + self._model = settings.OLLAMA_MODEL + self._timeout = settings.OLLAMA_TIMEOUT + self._connect_timeout = settings.OLLAMA_CONNECT_TIMEOUT + self._max_retries = settings.OLLAMA_MAX_RETRIES + self._retry_backoff_seconds = settings.OLLAMA_RETRY_BACKOFF_SECONDS + self._system_prompt = settings.OLLAMA_SYSTEM_PROMPT + + def _build_payload(self, user_prompt: str, stream: bool = False) -> dict: + messages = [] + if self._system_prompt.strip(): + messages.append({"role": "system", "content": self._system_prompt}) + messages.append({"role": "user", "content": user_prompt}) + return { + "model": self._model, + "stream": stream, + "format": "json", + "messages": messages, + } + + def _http_timeout(self) -> httpx.Timeout: + return httpx.Timeout(float(self._timeout), connect=float(self._connect_timeout)) + + def _should_retry_status(self, status_code: int) -> bool: + return 500 <= status_code <= 599 + + async def _backoff(self, attempt: int) -> None: + await asyncio.sleep(self._retry_backoff_seconds * (attempt + 1)) + + async def generate(self, user_prompt: str) -> str: + url = f"{self._base_url}/api/chat" + payload = self._build_payload(user_prompt, stream=False) + + async with httpx.AsyncClient(timeout=self._http_timeout()) as client: + for attempt in range(self._max_retries + 1): + try: + response = await client.post(url, json=payload) + response.raise_for_status() + data = response.json() + message = data.get("message", {}) + content = message.get("content", "") + return content if isinstance(content, str) else str(content) + except httpx.TimeoutException as exc: + if attempt < self._max_retries: + await self._backoff(attempt) + continue + raise RuntimeError( + f"Ollama request timed out after {self._timeout}s" + ) from exc + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code + if ( + self._should_retry_status(status_code) + and attempt < self._max_retries + ): + await self._backoff(attempt) + continue + raise RuntimeError( + f"Ollama returned HTTP {status_code}: {exc.response.text}" + ) from exc + except httpx.RequestError as exc: + if attempt < self._max_retries: + await self._backoff(attempt) + continue + raise RuntimeError("Ollama request failed") from exc + + raise RuntimeError("Ollama request failed after retries") + + async def generate_stream(self, user_prompt: str) -> AsyncGenerator[str, None]: + url = f"{self._base_url}/api/chat" + payload = self._build_payload(user_prompt, stream=True) + + yielded_any = False + async with httpx.AsyncClient(timeout=self._http_timeout()) as client: + for attempt in range(self._max_retries + 1): + try: + async with client.stream("POST", url, json=payload) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line: + continue + try: + chunk: Any = json.loads(line) + except Exception: + logger.warning("Skipping invalid Ollama stream chunk") + continue + token = chunk.get("message", {}).get("content", "") + if token: + yielded_any = True + yield token + if chunk.get("done"): + return + return + except httpx.TimeoutException as exc: + if not yielded_any and attempt < self._max_retries: + await self._backoff(attempt) + continue + raise RuntimeError( + f"Ollama stream timed out after {self._timeout}s" + ) from exc + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code + if ( + not yielded_any + and self._should_retry_status(status_code) + and attempt < self._max_retries + ): + await self._backoff(attempt) + continue + raise RuntimeError( + f"Ollama stream returned HTTP {status_code}: {exc.response.text}" + ) from exc + except httpx.RequestError as exc: + if not yielded_any and attempt < self._max_retries: + await self._backoff(attempt) + continue + raise RuntimeError("Ollama stream request failed") from exc + + raise RuntimeError("Ollama stream failed after retries") + + +ollama_client = OllamaClient() diff --git a/app/modules/feature_spec/router.py b/app/modules/feature_spec/router.py new file mode 100644 index 0000000..de4d668 --- /dev/null +++ b/app/modules/feature_spec/router.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.settings import settings +from app.modules.auth.dependencies import get_current_user +from app.modules.auth.models import User +from app.modules.feature_spec.orchestrator import ( + generate_feature_spec, + get_feature_spec_history, +) +from app.modules.feature_spec.schemas import ( + FeatureSpecGenerateRequest, + FeatureSpecGenerateResponse, + FeatureSpecHistoryResponse, +) + +router = APIRouter(prefix="/feature-spec", tags=["feature-spec"]) + + +@router.post("/generate", response_model=FeatureSpecGenerateResponse) +async def generate_feature_spec_endpoint( + payload: FeatureSpecGenerateRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> FeatureSpecGenerateResponse: + try: + return await generate_feature_spec(payload.feature_idea, db, current_user.id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + except RuntimeError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + +@router.get("/history", response_model=FeatureSpecHistoryResponse) +async def get_feature_spec_history_endpoint( + limit: int = settings.FEATURE_SPEC_HISTORY_DEFAULT_LIMIT, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> FeatureSpecHistoryResponse: + return get_feature_spec_history(db, current_user.id, limit) diff --git a/app/modules/feature_spec/schemas.py b/app/modules/feature_spec/schemas.py new file mode 100644 index 0000000..79af927 --- /dev/null +++ b/app/modules/feature_spec/schemas.py @@ -0,0 +1,83 @@ +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +from app.core.settings import settings + + +class FeatureSpecGenerateRequest(BaseModel): + feature_idea: str = Field(min_length=1, max_length=settings.LLM_PROMPT_MAX_LENGTH) + + @field_validator("feature_idea") + @classmethod + def validate_feature_idea(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("Feature idea must not be empty") + return normalized + + +class FeatureSummaryItem(BaseModel): + title: str + as_a: str + i_want: str + so_that: str + + +class DbModelsAndApiEndpoints(BaseModel): + db_models: list[str | dict[str, Any]] + api_endpoints: list[str | dict[str, Any]] + + +class FeatureSummaryResult(BaseModel): + user_stories: list[FeatureSummaryItem] + acceptance_criteria: list[str] + db_models_and_api_endpoints: DbModelsAndApiEndpoints + risk_assessment: list[str] + + @model_validator(mode="before") + @classmethod + def normalize_legacy_user_stories(cls, data): + if not isinstance(data, dict): + return data + + normalized = dict(data) + + if "user_stories" not in normalized and "feature_summary_items" in normalized: + normalized["user_stories"] = normalized["feature_summary_items"] + + if "acceptance_criteria" not in normalized: + if "acceptance" in normalized: + normalized["acceptance_criteria"] = normalized["acceptance"] + + if "db_models_and_api_endpoints" not in normalized: + db_models = normalized.get("db_models", []) + api_endpoints = normalized.get("api_endpoints", []) + normalized["db_models_and_api_endpoints"] = { + "db_models": db_models, + "api_endpoints": api_endpoints, + } + + if "risk_assessment" not in normalized and "risks" in normalized: + normalized["risk_assessment"] = normalized["risks"] + + return normalized + + +class FeatureSpecGenerateResponse(BaseModel): + feature_idea: str + feature_summary: FeatureSummaryResult + + +class FeatureSpecHistoryItem(BaseModel): + id: int + feature_idea: str + status: Literal["pending", "success", "error"] + response_json: FeatureSummaryResult | None = None + error_message: str | None = None + created_at: datetime + + +class FeatureSpecHistoryResponse(BaseModel): + items: list[FeatureSpecHistoryItem] diff --git a/app/modules/llm/parser.py b/app/modules/llm/parser.py deleted file mode 100644 index 4d71793..0000000 --- a/app/modules/llm/parser.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -import re - - -def extract_json(text: str) -> dict | list: - """Extract the first JSON object or array from a raw LLM response.""" - match = re.search(r"(\{.*\}|\[.*\])", text, re.DOTALL) - if not match: - raise ValueError("No JSON found in LLM response") - return json.loads(match.group(1)) - - -def strip_markdown(text: str) -> str: - """Remove Markdown code fences and trim whitespace.""" - text = re.sub(r"```[\w]*\n?", "", text) - return text.strip() - - -def extract_sections(text: str) -> dict[str, str]: - """ - Split a Markdown-formatted LLM response into {heading: content} sections. - - Example input: - ## Overview - This is a spec... - ## Requirements - 1. ... - """ - sections: dict[str, str] = {} - current_heading = "_preamble" - current_lines: list[str] = [] - - for line in text.splitlines(): - heading_match = re.match(r"^#{1,3}\s+(.+)", line) - if heading_match: - sections[current_heading] = "\n".join(current_lines).strip() - current_heading = heading_match.group(1).strip() - current_lines = [] - else: - current_lines.append(line) - - sections[current_heading] = "\n".join(current_lines).strip() - sections.pop("_preamble", None) - return sections - - -def normalize_whitespace(text: str) -> str: - """Collapse multiple blank lines to a single blank line.""" - return re.sub(r"\n{3,}", "\n\n", text).strip() diff --git a/app/modules/llm/providers/ollama.py b/app/modules/llm/providers/ollama.py deleted file mode 100644 index 743c6d4..0000000 --- a/app/modules/llm/providers/ollama.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging -from typing import AsyncGenerator - -import httpx - -from app.core.settings import settings - -logger = logging.getLogger(__name__) - - -class OllamaClient: - def __init__(self) -> None: - self._base_url = settings.OLLAMA_BASE_URL.rstrip("/") - self._model = settings.OLLAMA_MODEL - self._timeout = settings.OLLAMA_TIMEOUT - self._system_prompt = settings.OLLAMA_SYSTEM_PROMPT - - def _build_payload(self, user_prompt: str, stream: bool = False) -> dict: - return { - "model": self._model, - "stream": stream, - "messages": [ - {"role": "system", "content": self._system_prompt}, - {"role": "user", "content": user_prompt}, - ], - } - - async def generate(self, user_prompt: str) -> str: - url = f"{self._base_url}/api/chat" - payload = self._build_payload(user_prompt, stream=False) - - async with httpx.AsyncClient(timeout=self._timeout) as client: - try: - response = await client.post(url, json=payload) - response.raise_for_status() - except httpx.TimeoutException as exc: - logger.exception("Ollama request timed out") - raise RuntimeError( - f"Ollama request timed out after {self._timeout}s" - ) from exc - except httpx.HTTPStatusError as exc: - logger.exception("Ollama returned error response") - raise RuntimeError( - f"Ollama returned HTTP {exc.response.status_code}: {exc.response.text}" - ) from exc - - data = response.json() - return data["message"]["content"] - - async def generate_stream(self, user_prompt: str) -> AsyncGenerator[str, None]: - url = f"{self._base_url}/api/chat" - payload = self._build_payload(user_prompt, stream=True) - - async with httpx.AsyncClient(timeout=self._timeout) as client: - async with client.stream("POST", url, json=payload) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if not line: - continue - import json as _json - - chunk = _json.loads(line) - token = chunk.get("message", {}).get("content", "") - if token: - yield token - if chunk.get("done"): - break - - -ollama_client = OllamaClient() diff --git a/app/modules/llm/router.py b/app/modules/llm/router.py deleted file mode 100644 index e868011..0000000 --- a/app/modules/llm/router.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter, HTTPException, status - -from app.core.settings import settings -from app.modules.llm.schemas import LlmGenerateRequest, LlmGenerateResponse -from app.modules.llm.service import generate_completion - -router = APIRouter(prefix=settings.LLM_PREFIX, tags=[settings.LLM_TAG]) - - -@router.post(settings.LLM_GENERATE_PATH, response_model=LlmGenerateResponse) -async def generate(payload: LlmGenerateRequest) -> LlmGenerateResponse: - try: - return await generate_completion(payload) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - except RuntimeError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc diff --git a/app/modules/llm/schemas.py b/app/modules/llm/schemas.py deleted file mode 100644 index 0d31e9f..0000000 --- a/app/modules/llm/schemas.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, Field - - -class LlmGenerateRequest(BaseModel): - prompt: str = Field(min_length=1) - response_format: Literal["text", "sections", "json"] = "text" - - -class LlmGenerateResponse(BaseModel): - raw_content: str - content: str | None = None - sections: dict[str, str] | None = None - data: dict | list | None = None diff --git a/app/modules/llm/service.py b/app/modules/llm/service.py deleted file mode 100644 index e8c347b..0000000 --- a/app/modules/llm/service.py +++ /dev/null @@ -1,26 +0,0 @@ -from app.modules.llm.parser import (extract_json, extract_sections, - normalize_whitespace, strip_markdown) -from app.modules.llm.providers.ollama import ollama_client -from app.modules.llm.schemas import LlmGenerateRequest, LlmGenerateResponse - - -async def generate_completion(payload: LlmGenerateRequest) -> LlmGenerateResponse: - raw_content = await ollama_client.generate(payload.prompt) - normalized_content = normalize_whitespace(strip_markdown(raw_content)) - - if payload.response_format == "json": - return LlmGenerateResponse( - raw_content=raw_content, - data=extract_json(normalized_content), - ) - - if payload.response_format == "sections": - return LlmGenerateResponse( - raw_content=raw_content, - sections=extract_sections(normalized_content), - ) - - return LlmGenerateResponse( - raw_content=raw_content, - content=normalized_content, - ) diff --git a/app/scripts/bootstrap_admin.py b/app/scripts/bootstrap_admin.py index 9c36452..785782d 100644 --- a/app/scripts/bootstrap_admin.py +++ b/app/scripts/bootstrap_admin.py @@ -1,15 +1,79 @@ import logging -from app.core.bootstrap import bootstrap_auth +from fastapi_users.password import PasswordHelper + from app.core.database import SessionLocal +from app.core.settings import settings +from app.modules.auth.models import User logger = logging.getLogger(__name__) +def _resolve_bootstrap_hashed_password() -> str: + helper = PasswordHelper() + if not settings.AUTH_PASSWORD: + raise RuntimeError( + "AUTH_PASSWORD must be set for admin bootstrap user creation" + ) + return helper.hash(settings.AUTH_PASSWORD) + + +def _is_bootstrap_enabled() -> bool: + if settings.ENV.lower() == "production": + return settings.AUTH_BOOTSTRAP_ENABLED + return settings.AUTH_BOOTSTRAP_ENABLED or bool(settings.AUTH_EMAIL) + + +def bootstrap_admin_user(db) -> None: + if not _is_bootstrap_enabled(): + logger.info("Auth bootstrap is disabled") + return + + if not settings.AUTH_EMAIL or not settings.AUTH_USERNAME: + raise RuntimeError( + "AUTH_EMAIL and AUTH_USERNAME must be set when bootstrap is enabled" + ) + + existing_user = db.query(User).filter(User.email == settings.AUTH_EMAIL).one_or_none() + if existing_user is not None: + try: + if not existing_user.hashed_password: + existing_user.hashed_password = _resolve_bootstrap_hashed_password() + db.add(existing_user) + db.commit() + except Exception: + logger.exception( + "Failed to ensure bootstrap state for existing user: email=%s", + settings.AUTH_EMAIL, + ) + db.rollback() + raise + return + + try: + user = User( + username=settings.AUTH_USERNAME, + email=settings.AUTH_EMAIL, + hashed_password=_resolve_bootstrap_hashed_password(), + is_active=True, + is_superuser=settings.AUTH_BOOTSTRAP_SUPERUSER, + is_verified=True, + ) + db.add(user) + db.commit() + except Exception: + logger.exception( + "Failed to create bootstrap user: email=%s", + settings.AUTH_EMAIL, + ) + db.rollback() + raise + + def main() -> None: db = SessionLocal() try: - bootstrap_auth(db) + bootstrap_admin_user(db) logger.info("Admin bootstrap script completed") finally: db.close() diff --git a/app/scripts/bootstrap_prompt_template.py b/app/scripts/bootstrap_prompt_template.py new file mode 100644 index 0000000..68626b0 --- /dev/null +++ b/app/scripts/bootstrap_prompt_template.py @@ -0,0 +1,162 @@ +import logging + +from app.core.database import SessionLocal +from app.modules.feature_spec.models import PromptTemplate + +logger = logging.getLogger(__name__) + + +DEFAULT_FEATURE_SUMMARY_PROMPT_TEMPLATE = """You are a senior staff-level product engineer +and system architect working on production-grade backend systems. + +Your task is to convert a high-level feature idea into a precise, +implementation-ready technical specification. + +You must think in terms of real backend development: APIs, database schema, +data consistency, edge cases, and scalability. + +--- + +## Core rules: + +- Be strictly structured and deterministic +- Do NOT invent product requirements beyond the given feature idea +- If information is missing, explicitly list it under "assumptions" +- Prefer simple and realistic solutions over over-engineering +- Every part of the output must be implementation-ready +- Avoid vague phrases like "etc", "additional fields", "and so on" +- If something is required — define it explicitly + +--- + +## Hard requirements: + +- Database design MUST include full schema: + - each table must have 4–8 fields + - include types and constraints (PK, FK, unique, nullable) + - include relationships + +- API design MUST: + - include at least 4 endpoints (not just 1–2) + - include request validation + - include access control logic + - include error responses + +- Acceptance criteria MUST: + - include positive and negative cases + - include authorization behavior + - be testable (QA-ready, no vague language) + +- Edge cases & risks MUST include: + - concurrency issues + - data consistency problems + - security concerns + - failure scenarios + +- If the feature involves payments: + - include payment provider interaction (e.g. Stripe-like flow) + - include idempotency handling + - include transaction status tracking + +--- + +## Output format (STRICT JSON ONLY) + +Return ONLY valid JSON. No explanations, no markdown, no extra text. + +{ + "assumptions": ["string"], + + "user_stories": [ + { + "title": "string", + "as_a": "string", + "i_want": "string", + "so_that": "string" + } + ], + + "acceptance_criteria": ["string"], + + "db_models": [ + { + "table": "string", + "fields": [ + { + "name": "string", + "type": "string", + "constraints": "string" + } + ], + "relationships": ["string"] + } + ], + + "api_endpoints": [ + { + "method": "string", + "path": "string", + "request_body": "string", + "response": "string", + "errors": ["string"], + "purpose": "string" + } + ], + + "risk_assessment": ["string"] +} + +--- + +Feature idea: +{feature_idea} +""" + + +def bootstrap_prompt_template(db) -> None: + existing_template = db.get(PromptTemplate, 1) + default_template = DEFAULT_FEATURE_SUMMARY_PROMPT_TEMPLATE + + if existing_template is None: + try: + db.add( + PromptTemplate( + id=1, + feature_to_feature_summary=default_template, + is_active=True, + ) + ) + db.commit() + logger.info("Created default feature summary prompt template") + except Exception: + logger.exception("Failed to create default feature summary prompt template") + db.rollback() + raise + return + + existing_value = existing_template.feature_to_feature_summary.strip() + if existing_value: + return + + try: + existing_template.feature_to_feature_summary = default_template + db.add(existing_template) + db.commit() + logger.info("Filled empty feature summary prompt template with default value") + except Exception: + logger.exception("Failed to fill default feature summary prompt template") + db.rollback() + raise + + +def main() -> None: + db = SessionLocal() + try: + bootstrap_prompt_template(db) + logger.info("Prompt template bootstrap script completed") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/app/scripts/ensure_ollama_model.py b/app/scripts/ensure_ollama_model.py new file mode 100644 index 0000000..42b3651 --- /dev/null +++ b/app/scripts/ensure_ollama_model.py @@ -0,0 +1,84 @@ +import logging +import os +import time + +import httpx + +logger = logging.getLogger(__name__) + + +def _env_int(name: str, default: int) -> int: + raw_value = os.getenv(name) + if raw_value is None: + return default + try: + return int(raw_value) + except ValueError as exc: + raise RuntimeError(f"{name} must be an integer") from exc + + +def _is_model_available(client: httpx.Client, model: str) -> bool: + response = client.get("/api/tags") + response.raise_for_status() + models = response.json().get("models", []) + return any(item.get("name") == model for item in models if isinstance(item, dict)) + + +def wait_for_ollama(client: httpx.Client, wait_seconds: int) -> None: + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + try: + client.get("/api/tags").raise_for_status() + logger.info("Ollama is ready") + return + except httpx.HTTPError: + logger.info("Waiting for Ollama to become ready...") + time.sleep(2) + + raise RuntimeError(f"Ollama did not become ready within {wait_seconds}s") + + +def ensure_model(client: httpx.Client, model: str, pull_timeout: int) -> None: + if _is_model_available(client, model): + logger.info("Ollama model '%s' already available", model) + return + + logger.info("Pulling missing Ollama model '%s'", model) + response = client.post( + "/api/pull", + json={"model": model, "stream": False}, + timeout=pull_timeout, + ) + response.raise_for_status() + + if not _is_model_available(client, model): + raise RuntimeError(f"Ollama model '{model}' is still unavailable after pull") + + logger.info("Ollama model '%s' is ready", model) + + +def ensure_ollama_model() -> None: + base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/") + model = os.getenv("OLLAMA_MODEL", "mistral").strip() + connect_timeout = _env_int("OLLAMA_CONNECT_TIMEOUT", 10) + request_timeout = _env_int("OLLAMA_TIMEOUT", 120) + startup_wait = _env_int("OLLAMA_STARTUP_WAIT_SECONDS", 120) + + if not model: + raise RuntimeError("OLLAMA_MODEL must not be empty") + + logger.info("Checking Ollama availability at %s", base_url) + + timeout = httpx.Timeout(timeout=request_timeout, connect=connect_timeout) + with httpx.Client(base_url=base_url, timeout=timeout) as client: + wait_for_ollama(client, startup_wait) + ensure_model(client, model, pull_timeout=max(request_timeout, 300)) + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="[ollama-bootstrap] %(message)s") + ensure_ollama_model() + + +if __name__ == "__main__": + main() diff --git a/app/scripts/migrate_and_check.py b/app/scripts/migrate_and_check.py index a32a596..25e8da0 100644 --- a/app/scripts/migrate_and_check.py +++ b/app/scripts/migrate_and_check.py @@ -1,4 +1,5 @@ import logging +import importlib from pathlib import Path import re import subprocess @@ -31,7 +32,35 @@ def _extract_revisions(raw_output: str) -> set[str]: return revisions +def _has_migration_files() -> bool: + versions_dir = _project_root() / "alembic" / "versions" + return any(versions_dir.glob("*.py")) + + +def _bootstrap_schema_without_migrations() -> None: + importlib.import_module("app.modules.auth.models") + importlib.import_module("app.modules.feature_spec.models") + from app.core.database import Base, engine + + logger.warning( + "No Alembic migration files found; creating schema via SQLAlchemy metadata" + ) + if not Base.metadata.tables: + raise RuntimeError( + "No SQLAlchemy models are registered; cannot bootstrap schema" + ) + Base.metadata.create_all(bind=engine) + logger.info( + "Schema created via SQLAlchemy metadata (tables=%s)", + sorted(Base.metadata.tables.keys()), + ) + + def migrate_and_check() -> None: + if not _has_migration_files(): + _bootstrap_schema_without_migrations() + return + logger.info("Running Alembic upgrade to head") _run_alembic("upgrade", "head") diff --git a/docker-compose.yml b/docker-compose.yml index 066f6d5..906423e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: - .env ports: - "8001:8001" + depends_on: + ollama: + condition: service_healthy healthcheck: test: [ @@ -23,3 +26,27 @@ services: start_period: 20s stop_grace_period: 20s restart: unless-stopped + + ollama: + image: ollama/ollama:latest + container_name: specification-generator-ollama + init: true + ports: + - "11434:11434" + volumes: + - ollama:/root/.ollama + healthcheck: + test: + [ + "CMD", + "ollama", + "list", + ] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + restart: unless-stopped + +volumes: + ollama: diff --git a/entrypoint.sh b/entrypoint.sh index 2116919..b6729c1 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,6 +7,12 @@ python -m app.scripts.migrate_and_check echo "[start] Running admin bootstrap script..." python -m app.scripts.bootstrap_admin +echo "[start] Running prompt template bootstrap script..." +python -m app.scripts.bootstrap_prompt_template + +echo "[start] Ensuring Ollama model is available..." +python -m app.scripts.ensure_ollama_model + if [ "$#" -eq 0 ]; then set -- python -m uvicorn app.main:app --host 0.0.0.0 --port 8001 fi diff --git a/tests/modules/api/test_llm_api.py b/tests/modules/api/test_llm_api.py deleted file mode 100644 index 25dbf58..0000000 --- a/tests/modules/api/test_llm_api.py +++ /dev/null @@ -1,73 +0,0 @@ -from unittest.mock import AsyncMock - -import pytest -from fastapi.testclient import TestClient - - -@pytest.mark.unit -def test_generate_returns_service_result( - monkeypatch: pytest.MonkeyPatch, api_client: TestClient -) -> None: - mocked_generate = AsyncMock( - return_value={ - "raw_content": "# Spec", - "content": "Spec", - "sections": None, - "data": None, - } - ) - monkeypatch.setattr("app.modules.llm.router.generate_completion", mocked_generate) - - response = api_client.post( - "/api/v1/llm/generate", - json={"prompt": "Generate auth spec", "response_format": "text"}, - ) - - assert response.status_code == 200 - assert response.json()["content"] == "Spec" - - -@pytest.mark.unit -def test_generate_maps_value_error_to_502( - monkeypatch: pytest.MonkeyPatch, api_client: TestClient -) -> None: - monkeypatch.setattr( - "app.modules.llm.router.generate_completion", - AsyncMock(side_effect=ValueError("invalid provider response")), - ) - - response = api_client.post( - "/api/v1/llm/generate", - json={"prompt": "Generate auth spec", "response_format": "text"}, - ) - - assert response.status_code == 502 - assert response.json()["detail"] == "invalid provider response" - - -@pytest.mark.unit -def test_generate_maps_runtime_error_to_502( - monkeypatch: pytest.MonkeyPatch, api_client: TestClient -) -> None: - monkeypatch.setattr( - "app.modules.llm.router.generate_completion", - AsyncMock(side_effect=RuntimeError("provider timeout")), - ) - - response = api_client.post( - "/api/v1/llm/generate", - json={"prompt": "Generate auth spec", "response_format": "sections"}, - ) - - assert response.status_code == 502 - assert response.json()["detail"] == "provider timeout" - - -@pytest.mark.unit -def test_generate_validates_request_payload(api_client: TestClient) -> None: - response = api_client.post( - "/api/v1/llm/generate", - json={"prompt": "", "response_format": "text"}, - ) - - assert response.status_code == 422 diff --git a/tests/modules/auth/test_auth_schemas.py b/tests/modules/auth/test_auth_schemas.py new file mode 100644 index 0000000..3207c9b --- /dev/null +++ b/tests/modules/auth/test_auth_schemas.py @@ -0,0 +1,25 @@ +import pytest + +from app.modules.auth.schemas import UserCreate + + +@pytest.mark.unit +def test_user_create_rejects_superuser_flag() -> None: + with pytest.raises(ValueError): + UserCreate( + email="user@example.com", + password="StrongPass123!", + username="user", + is_superuser=True, + ) + + +@pytest.mark.unit +def test_user_create_defaults_to_non_superuser() -> None: + user = UserCreate( + email="user@example.com", + password="StrongPass123!", + username="user", + ) + + assert user.is_superuser is False diff --git a/tests/modules/llm/test_ollama_provider.py b/tests/modules/llm/test_ollama_provider.py deleted file mode 100644 index 98b7904..0000000 --- a/tests/modules/llm/test_ollama_provider.py +++ /dev/null @@ -1,100 +0,0 @@ -import httpx -import pytest - -from app.modules.llm.providers.ollama import OllamaClient - - -class FakeResponse: - def __init__(self, *, json_data=None, status_code: int = 200, text: str = ""): - self._json_data = json_data or {} - self.status_code = status_code - self.text = text - - def raise_for_status(self) -> None: - if self.status_code >= 400: - request = httpx.Request("POST", "http://mock/api/chat") - response = httpx.Response(self.status_code, request=request, text=self.text) - raise httpx.HTTPStatusError("error", request=request, response=response) - - def json(self): - return self._json_data - - -class FakeAsyncClient: - def __init__( - self, response: FakeResponse | None = None, error: Exception | None = None - ): - self._response = response - self._error = error - self.last_url = None - self.last_json = None - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def post(self, url, json): - self.last_url = url - self.last_json = json - if self._error: - raise self._error - return self._response - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_generate_returns_content(monkeypatch: pytest.MonkeyPatch) -> None: - fake_client = FakeAsyncClient( - response=FakeResponse( - json_data={"message": {"content": "Generated specification"}} - ) - ) - monkeypatch.setattr( - "app.modules.llm.providers.ollama.httpx.AsyncClient", - lambda *args, **kwargs: fake_client, - ) - - client = OllamaClient() - result = await client.generate("Build API spec") - - assert result == "Generated specification" - assert fake_client.last_url.endswith("/api/chat") - assert fake_client.last_json["messages"][1]["content"] == "Build API spec" - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_generate_maps_timeout_to_runtime_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_client = FakeAsyncClient(error=httpx.TimeoutException("timeout")) - monkeypatch.setattr( - "app.modules.llm.providers.ollama.httpx.AsyncClient", - lambda *args, **kwargs: fake_client, - ) - - client = OllamaClient() - - with pytest.raises(RuntimeError, match="timed out"): - await client.generate("Build API spec") - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_generate_maps_http_status_error_to_runtime_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_client = FakeAsyncClient( - response=FakeResponse(status_code=500, text="internal error") - ) - monkeypatch.setattr( - "app.modules.llm.providers.ollama.httpx.AsyncClient", - lambda *args, **kwargs: fake_client, - ) - - client = OllamaClient() - - with pytest.raises(RuntimeError, match="Ollama returned HTTP 500"): - await client.generate("Build API spec")