From 67c1bf2a8adbad29a316acfb9977bdee5c7d3584 Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Wed, 15 Apr 2026 23:07:51 +0300 Subject: [PATCH 01/12] Refactor LLM module and add feature specification functionality - Removed the OllamaClient and related LLM router, schemas, and service files. - Introduced a new feature specification module with models, orchestrator, and router. - Added prompt template management with bootstrap functionality. - Implemented database migrations for prompt templates and feature spec runs. - Enhanced error handling and logging in the new feature spec generation process. - Updated Docker Compose to include Ollama service and health checks. - Added unit tests for new feature spec functionality and authentication schemas. --- .env.example | 73 ++++----- README.md | 26 +++- .../a1f9c7de42b3_add_prompt_templates.py | 35 +++++ .../b3d1f4a90e2c_add_feature_spec_runs.py | 66 ++++++++ ...f6b1_enforce_single_prompt_template_row.py | 42 +++++ ...ompt_template_column_to_feature_summary.py | 29 ++++ app/core/bootstrap.py | 76 --------- app/core/settings.py | 5 + app/main.py | 4 +- .../infrastructure/fastapi_users_adapter.py | 8 + app/modules/auth/schemas.py | 22 ++- app/modules/feature_spec/__init__.py | 3 + app/modules/feature_spec/models.py | 61 ++++++++ app/modules/feature_spec/orchestrator.py | 104 +++++++++++++ app/modules/feature_spec/parser.py | 18 +++ app/modules/feature_spec/prompts/__init__.py | 13 ++ .../feature_spec/prompts/feature_summary.py | 47 ++++++ .../feature_spec/providers/__init__.py | 3 + app/modules/feature_spec/providers/ollama.py | 131 ++++++++++++++++ app/modules/feature_spec/router.py | 47 ++++++ app/modules/feature_spec/schemas.py | 56 +++++++ app/modules/llm/parser.py | 49 ------ app/modules/llm/providers/ollama.py | 70 --------- app/modules/llm/router.py | 23 --- app/modules/llm/schemas.py | 15 -- app/modules/llm/service.py | 26 ---- app/scripts/bootstrap_admin.py | 68 ++++++++- app/scripts/bootstrap_prompt_template.py | 144 ++++++++++++++++++ docker-compose.yml | 26 ++++ entrypoint.sh | 3 + tests/modules/api/test_llm_api.py | 73 --------- tests/modules/auth/test_auth_schemas.py | 25 +++ tests/modules/llm/test_ollama_provider.py | 100 ------------ 33 files changed, 1016 insertions(+), 475 deletions(-) create mode 100644 alembic/versions/a1f9c7de42b3_add_prompt_templates.py create mode 100644 alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py create mode 100644 alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py create mode 100644 alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py delete mode 100644 app/core/bootstrap.py create mode 100644 app/modules/feature_spec/__init__.py create mode 100644 app/modules/feature_spec/models.py create mode 100644 app/modules/feature_spec/orchestrator.py create mode 100644 app/modules/feature_spec/parser.py create mode 100644 app/modules/feature_spec/prompts/__init__.py create mode 100644 app/modules/feature_spec/prompts/feature_summary.py create mode 100644 app/modules/feature_spec/providers/__init__.py create mode 100644 app/modules/feature_spec/providers/ollama.py create mode 100644 app/modules/feature_spec/router.py create mode 100644 app/modules/feature_spec/schemas.py delete mode 100644 app/modules/llm/parser.py delete mode 100644 app/modules/llm/providers/ollama.py delete mode 100644 app/modules/llm/router.py delete mode 100644 app/modules/llm/schemas.py delete mode 100644 app/modules/llm/service.py create mode 100644 app/scripts/bootstrap_prompt_template.py delete mode 100644 tests/modules/api/test_llm_api.py create mode 100644 tests/modules/auth/test_auth_schemas.py delete mode 100644 tests/modules/llm/test_ollama_provider.py 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..527a2f3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -97,21 +100,35 @@ pip install -r requirements.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 +docker exec -it specification-generator-ollama ollama pull mistral ``` 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`) - uvicorn app startup +- 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 @@ -208,3 +225,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/a1f9c7de42b3_add_prompt_templates.py b/alembic/versions/a1f9c7de42b3_add_prompt_templates.py new file mode 100644 index 0000000..9c5b47c --- /dev/null +++ b/alembic/versions/a1f9c7de42b3_add_prompt_templates.py @@ -0,0 +1,35 @@ +"""add prompt templates + +Revision ID: a1f9c7de42b3 +Revises: 6f4e8b8f4b11 +Create Date: 2026-04-15 12:15:00.000000 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "a1f9c7de42b3" +down_revision = "6f4e8b8f4b11" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "prompt_templates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("feature_to_user_stories", sa.Text(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("prompt_templates") \ No newline at end of file diff --git a/alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py b/alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py new file mode 100644 index 0000000..a9fc2e8 --- /dev/null +++ b/alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py @@ -0,0 +1,66 @@ +"""add feature spec runs + +Revision ID: b3d1f4a90e2c +Revises: a1f9c7de42b3 +Create Date: 2026-04-15 13:05:00.000000 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "b3d1f4a90e2c" +down_revision = "a1f9c7de42b3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "feature_spec_runs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("feature_idea", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("response_json", sa.JSON(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_feature_spec_runs_user_id"), + "feature_spec_runs", + ["user_id"], + unique=False, + ) + op.create_index( + op.f("ix_feature_spec_runs_status"), + "feature_spec_runs", + ["status"], + unique=False, + ) + op.create_index( + op.f("ix_feature_spec_runs_created_at"), + "feature_spec_runs", + ["created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_feature_spec_runs_created_at"), table_name="feature_spec_runs") + op.drop_index(op.f("ix_feature_spec_runs_status"), table_name="feature_spec_runs") + op.drop_index(op.f("ix_feature_spec_runs_user_id"), table_name="feature_spec_runs") + op.drop_table("feature_spec_runs") diff --git a/alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py b/alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py new file mode 100644 index 0000000..f3efada --- /dev/null +++ b/alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py @@ -0,0 +1,42 @@ +"""enforce single prompt template row + +Revision ID: c8a4e9d2f6b1 +Revises: b3d1f4a90e2c +Create Date: 2026-04-15 13:45:00.000000 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "c8a4e9d2f6b1" +down_revision = "b3d1f4a90e2c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + row_count = conn.execute(sa.text("SELECT COUNT(*) FROM prompt_templates")).scalar_one() + if row_count > 1: + raise RuntimeError( + "Cannot enforce single-row constraint: prompt_templates has more than one row" + ) + + if row_count == 1: + conn.execute(sa.text("UPDATE prompt_templates SET id = 1 WHERE id <> 1")) + + op.create_check_constraint( + "ck_prompt_templates_single_row", + "prompt_templates", + "id = 1", + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_prompt_templates_single_row", + "prompt_templates", + type_="check", + ) diff --git a/alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py b/alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py new file mode 100644 index 0000000..e6531e8 --- /dev/null +++ b/alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py @@ -0,0 +1,29 @@ +"""rename prompt template column to feature summary + +Revision ID: f1d2c3b4a5e6 +Revises: c8a4e9d2f6b1 +Create Date: 2026-04-15 16:30:00.000000 +""" + +from alembic import op + +revision = "f1d2c3b4a5e6" +down_revision = "c8a4e9d2f6b1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "prompt_templates", + "feature_to_user_stories", + new_column_name="feature_to_feature_summary", + ) + + +def downgrade() -> None: + op.alter_column( + "prompt_templates", + "feature_to_feature_summary", + new_column_name="feature_to_user_stories", + ) 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..4f52844 --- /dev/null +++ b/app/modules/feature_spec/parser.py @@ -0,0 +1,18 @@ +import json +import re + + +def extract_json(text: str) -> dict | list: + 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: + 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..d674804 --- /dev/null +++ b/app/modules/feature_spec/prompts/feature_summary.py @@ -0,0 +1,47 @@ +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) + try: + return safe_template.format( + feature_idea=escaped_feature_idea, + input=escaped_feature_idea, + ) + except KeyError as exc: + missing_key = exc.args[0] if exc.args else "unknown" + raise ValueError( + f"Prompt template error: missing placeholder '{missing_key}'" + ) from exc + + +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..8caa8ca --- /dev/null +++ b/app/modules/feature_spec/providers/ollama.py @@ -0,0 +1,131 @@ +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, "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..3072af1 --- /dev/null +++ b/app/modules/feature_spec/schemas.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import 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 FeatureSummaryResult(BaseModel): + feature_summary: str + feature_summary_items: list[FeatureSummaryItem] + + @model_validator(mode="before") + @classmethod + def normalize_legacy_user_stories(cls, data): + if isinstance(data, dict) and "feature_summary_items" not in data: + if "user_stories" in data: + data = {**data, "feature_summary_items": data["user_stories"]} + return data + + +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..d7d7358 --- /dev/null +++ b/app/scripts/bootstrap_prompt_template.py @@ -0,0 +1,144 @@ +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 software systems. + +Your job is to convert a high-level feature idea into a precise, +implementation-ready technical specification. + +You must think in terms of real software delivery: backend systems, APIs, +databases, edge cases, and scalability. + +--- + +## Core rules: +- Be extremely structured and deterministic +- Do NOT invent product requirements that were not implied or provided +- If information is missing, explicitly list assumptions in a separate section +- Prefer simplicity over over-engineering +- Think like you are writing a specification for a real engineering team +- All outputs must be actionable and implementation-oriented + +--- + +## Output format (strict structure): + +1. FEATURE OVERVIEW +- Short description of the feature +- Problem it solves +- Target users + +--- + +2. USER STORIES +- List of user stories in format: + As a [user type], I want [goal], so that [benefit] + +--- + +3. ACCEPTANCE CRITERIA +- Each user story must have clear, testable conditions +- Use bullet points +- Must be unambiguous and QA-ready + +--- + +4. API DESIGN (REST) +- List endpoints with: + - method (GET, POST, etc.) + - path + - request body (if applicable) + - response schema (high-level) + - purpose + +--- + +5. DATABASE DESIGN +- Define tables with: + - table name + - fields with types + - relationships +- Keep it normalized and production-oriented + +--- + +6. EDGE CASES & RISKS +- Identify failure scenarios +- Data consistency risks +- Security concerns +- Performance concerns + +--- + +7. FUTURE IMPROVEMENTS +- Optional enhancements +- Scalability improvements +- UX improvements + +--- + +## Important constraints: +- Do not add irrelevant features or expand scope +- Keep all assumptions explicitly labeled as "Assumption" +- Prefer backend clarity over marketing language +- Avoid vague phrases like "user-friendly", "robust", "fast" +- Be precise, technical, and engineering-focused + +Feature idea: +{feature_idea} +""" + + +def bootstrap_prompt_template(db) -> None: + existing_template = db.get(PromptTemplate, 1) + + if existing_template is None: + try: + db.add( + PromptTemplate( + id=1, + feature_to_feature_summary=DEFAULT_FEATURE_SUMMARY_PROMPT_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 + + if existing_template.feature_to_feature_summary.strip(): + return + + try: + existing_template.feature_to_feature_summary = ( + DEFAULT_FEATURE_SUMMARY_PROMPT_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/docker-compose.yml b/docker-compose.yml index 066f6d5..b72b652 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - .env ports: - "8001:8001" + depends_on: + - ollama healthcheck: test: [ @@ -23,3 +25,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..23bda5d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,6 +7,9 @@ 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 + 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") From 0ddf4b5bc0501ce949a13eac10d527b41d4e152e Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Wed, 15 Apr 2026 23:23:25 +0300 Subject: [PATCH 02/12] feat: add Ollama model bootstrap script and update Docker Compose health check --- README.md | 3 +- app/scripts/ensure_ollama_model.py | 84 ++++++++++++++++++++++++++++++ docker-compose.yml | 3 +- entrypoint.sh | 3 ++ 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 app/scripts/ensure_ollama_model.py diff --git a/README.md b/README.md index 527a2f3..fd6b720 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,6 @@ python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 ```bash docker compose up -d --build -docker exec -it specification-generator-ollama ollama pull mistral ``` Notes: @@ -117,7 +116,9 @@ Notes: - migration + DB head check (`python -m app.scripts.migrate_and_check`) - 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: 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/docker-compose.yml b/docker-compose.yml index b72b652..906423e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: ports: - "8001:8001" depends_on: - - ollama + ollama: + condition: service_healthy healthcheck: test: [ diff --git a/entrypoint.sh b/entrypoint.sh index 23bda5d..b6729c1 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,6 +10,9 @@ 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 From 7bfcd45eb2ced3aebea7f338c5634999c930daf9 Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Wed, 15 Apr 2026 23:40:44 +0300 Subject: [PATCH 03/12] feat: enhance JSON extraction and update feature summary schema with new fields --- app/modules/feature_spec/parser.py | 15 ++++++--- app/modules/feature_spec/schemas.py | 39 ++++++++++++++++++++---- app/scripts/bootstrap_prompt_template.py | 33 +++++++++++++++++--- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/app/modules/feature_spec/parser.py b/app/modules/feature_spec/parser.py index 4f52844..de7a156 100644 --- a/app/modules/feature_spec/parser.py +++ b/app/modules/feature_spec/parser.py @@ -3,10 +3,17 @@ def extract_json(text: str) -> dict | list: - match = re.search(r"(\{.*\}|\[.*\])", text, re.DOTALL) - if not match: - raise ValueError("No JSON found in LLM response") - return json.loads(match.group(1)) + 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: diff --git a/app/modules/feature_spec/schemas.py b/app/modules/feature_spec/schemas.py index 3072af1..6021b0f 100644 --- a/app/modules/feature_spec/schemas.py +++ b/app/modules/feature_spec/schemas.py @@ -25,17 +25,44 @@ class FeatureSummaryItem(BaseModel): so_that: str +class DbModelsAndApiEndpoints(BaseModel): + db_models: list[str] + api_endpoints: list[str] + + class FeatureSummaryResult(BaseModel): - feature_summary: str - feature_summary_items: list[FeatureSummaryItem] + 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 isinstance(data, dict) and "feature_summary_items" not in data: - if "user_stories" in data: - data = {**data, "feature_summary_items": data["user_stories"]} - return 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): diff --git a/app/scripts/bootstrap_prompt_template.py b/app/scripts/bootstrap_prompt_template.py index d7d7358..defca5e 100644 --- a/app/scripts/bootstrap_prompt_template.py +++ b/app/scripts/bootstrap_prompt_template.py @@ -92,18 +92,42 @@ Feature idea: {feature_idea} + +--- + +Return format requirements (strict): +- Return ONLY valid JSON. +- Do not include markdown, code fences, or any text before/after JSON. +- JSON object must match this structure exactly: +{ + "user_stories": [ + { + "title": "string", + "as_a": "string", + "i_want": "string", + "so_that": "string" + } + ], + "acceptance_criteria": ["string"], + "db_models_and_api_endpoints": { + "db_models": ["string"], + "api_endpoints": ["string"] + }, + "risk_assessment": ["string"] +} """ 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_FEATURE_SUMMARY_PROMPT_TEMPLATE, + feature_to_feature_summary=default_template, is_active=True, ) ) @@ -115,13 +139,12 @@ def bootstrap_prompt_template(db) -> None: raise return - if existing_template.feature_to_feature_summary.strip(): + existing_value = existing_template.feature_to_feature_summary.strip() + if existing_value: return try: - existing_template.feature_to_feature_summary = ( - DEFAULT_FEATURE_SUMMARY_PROMPT_TEMPLATE - ) + 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") From c3793f807114e6c8207124c0c01792e02c25883b Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Wed, 15 Apr 2026 23:50:18 +0300 Subject: [PATCH 04/12] feat: include system prompt in payload for OllamaClient --- app/modules/feature_spec/providers/ollama.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/modules/feature_spec/providers/ollama.py b/app/modules/feature_spec/providers/ollama.py index 8caa8ca..08ff0d3 100644 --- a/app/modules/feature_spec/providers/ollama.py +++ b/app/modules/feature_spec/providers/ollama.py @@ -25,7 +25,12 @@ def _build_payload(self, user_prompt: str, stream: bool = False) -> dict: 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, "messages": messages} + 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)) From cb3b317a7af3f36a9c1362c96f18e3d76d0fc6c1 Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Wed, 15 Apr 2026 23:56:05 +0300 Subject: [PATCH 05/12] feat: remove obsolete migration scripts for database schema updates --- alembic/versions/2e5bd3a1eb88_initial.py | 45 ------------- .../6f4e8b8f4b11_add_refresh_tokens.py | 54 --------------- .../a1f9c7de42b3_add_prompt_templates.py | 35 ---------- .../b3d1f4a90e2c_add_feature_spec_runs.py | 66 ------------------- ...f6b1_enforce_single_prompt_template_row.py | 42 ------------ ...ompt_template_column_to_feature_summary.py | 29 -------- 6 files changed, 271 deletions(-) delete mode 100644 alembic/versions/2e5bd3a1eb88_initial.py delete mode 100644 alembic/versions/6f4e8b8f4b11_add_refresh_tokens.py delete mode 100644 alembic/versions/a1f9c7de42b3_add_prompt_templates.py delete mode 100644 alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py delete mode 100644 alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py delete mode 100644 alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py 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/alembic/versions/a1f9c7de42b3_add_prompt_templates.py b/alembic/versions/a1f9c7de42b3_add_prompt_templates.py deleted file mode 100644 index 9c5b47c..0000000 --- a/alembic/versions/a1f9c7de42b3_add_prompt_templates.py +++ /dev/null @@ -1,35 +0,0 @@ -"""add prompt templates - -Revision ID: a1f9c7de42b3 -Revises: 6f4e8b8f4b11 -Create Date: 2026-04-15 12:15:00.000000 -""" - -import sqlalchemy as sa - -from alembic import op - -revision = "a1f9c7de42b3" -down_revision = "6f4e8b8f4b11" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "prompt_templates", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("feature_to_user_stories", sa.Text(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("prompt_templates") \ No newline at end of file diff --git a/alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py b/alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py deleted file mode 100644 index a9fc2e8..0000000 --- a/alembic/versions/b3d1f4a90e2c_add_feature_spec_runs.py +++ /dev/null @@ -1,66 +0,0 @@ -"""add feature spec runs - -Revision ID: b3d1f4a90e2c -Revises: a1f9c7de42b3 -Create Date: 2026-04-15 13:05:00.000000 -""" - -import sqlalchemy as sa - -from alembic import op - -revision = "b3d1f4a90e2c" -down_revision = "a1f9c7de42b3" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "feature_spec_runs", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("feature_idea", sa.Text(), nullable=False), - sa.Column("status", sa.String(length=32), nullable=False), - sa.Column("response_json", sa.JSON(), nullable=True), - sa.Column("error_message", sa.Text(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_feature_spec_runs_user_id"), - "feature_spec_runs", - ["user_id"], - unique=False, - ) - op.create_index( - op.f("ix_feature_spec_runs_status"), - "feature_spec_runs", - ["status"], - unique=False, - ) - op.create_index( - op.f("ix_feature_spec_runs_created_at"), - "feature_spec_runs", - ["created_at"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_feature_spec_runs_created_at"), table_name="feature_spec_runs") - op.drop_index(op.f("ix_feature_spec_runs_status"), table_name="feature_spec_runs") - op.drop_index(op.f("ix_feature_spec_runs_user_id"), table_name="feature_spec_runs") - op.drop_table("feature_spec_runs") diff --git a/alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py b/alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py deleted file mode 100644 index f3efada..0000000 --- a/alembic/versions/c8a4e9d2f6b1_enforce_single_prompt_template_row.py +++ /dev/null @@ -1,42 +0,0 @@ -"""enforce single prompt template row - -Revision ID: c8a4e9d2f6b1 -Revises: b3d1f4a90e2c -Create Date: 2026-04-15 13:45:00.000000 -""" - -import sqlalchemy as sa - -from alembic import op - -revision = "c8a4e9d2f6b1" -down_revision = "b3d1f4a90e2c" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - conn = op.get_bind() - - row_count = conn.execute(sa.text("SELECT COUNT(*) FROM prompt_templates")).scalar_one() - if row_count > 1: - raise RuntimeError( - "Cannot enforce single-row constraint: prompt_templates has more than one row" - ) - - if row_count == 1: - conn.execute(sa.text("UPDATE prompt_templates SET id = 1 WHERE id <> 1")) - - op.create_check_constraint( - "ck_prompt_templates_single_row", - "prompt_templates", - "id = 1", - ) - - -def downgrade() -> None: - op.drop_constraint( - "ck_prompt_templates_single_row", - "prompt_templates", - type_="check", - ) diff --git a/alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py b/alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py deleted file mode 100644 index e6531e8..0000000 --- a/alembic/versions/f1d2c3b4a5e6_rename_prompt_template_column_to_feature_summary.py +++ /dev/null @@ -1,29 +0,0 @@ -"""rename prompt template column to feature summary - -Revision ID: f1d2c3b4a5e6 -Revises: c8a4e9d2f6b1 -Create Date: 2026-04-15 16:30:00.000000 -""" - -from alembic import op - -revision = "f1d2c3b4a5e6" -down_revision = "c8a4e9d2f6b1" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.alter_column( - "prompt_templates", - "feature_to_user_stories", - new_column_name="feature_to_feature_summary", - ) - - -def downgrade() -> None: - op.alter_column( - "prompt_templates", - "feature_to_feature_summary", - new_column_name="feature_to_user_stories", - ) From 0cd0e2037117a7006c2bcb3f3ab49c8c5236671d Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Thu, 16 Apr 2026 00:00:07 +0300 Subject: [PATCH 06/12] feat: add schema bootstrap functionality when no migration files are found --- app/scripts/migrate_and_check.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/scripts/migrate_and_check.py b/app/scripts/migrate_and_check.py index a32a596..833fec6 100644 --- a/app/scripts/migrate_and_check.py +++ b/app/scripts/migrate_and_check.py @@ -31,7 +31,26 @@ 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: + from app.core.database import Base, engine + + logger.warning( + "No Alembic migration files found; creating schema via SQLAlchemy metadata" + ) + Base.metadata.create_all(bind=engine) + logger.info("Schema created via SQLAlchemy metadata") + + 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") From 96119d9d458d77789d24bc2f294c506986522e8f Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Thu, 16 Apr 2026 00:03:26 +0300 Subject: [PATCH 07/12] feat: enhance schema bootstrapping to raise error if no SQLAlchemy models are registered --- app/scripts/migrate_and_check.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/scripts/migrate_and_check.py b/app/scripts/migrate_and_check.py index 833fec6..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 @@ -37,13 +38,22 @@ def _has_migration_files() -> bool: 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") + logger.info( + "Schema created via SQLAlchemy metadata (tables=%s)", + sorted(Base.metadata.tables.keys()), + ) def migrate_and_check() -> None: From 5302657c90cb4b2117ba044ecb99cd6bbc30d55c Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Thu, 16 Apr 2026 00:08:39 +0300 Subject: [PATCH 08/12] feat: simplify feature summary prompt rendering by removing error handling for missing placeholders --- app/modules/feature_spec/prompts/feature_summary.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/modules/feature_spec/prompts/feature_summary.py b/app/modules/feature_spec/prompts/feature_summary.py index d674804..895889e 100644 --- a/app/modules/feature_spec/prompts/feature_summary.py +++ b/app/modules/feature_spec/prompts/feature_summary.py @@ -10,16 +10,9 @@ 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) - try: - return safe_template.format( - feature_idea=escaped_feature_idea, - input=escaped_feature_idea, - ) - except KeyError as exc: - missing_key = exc.args[0] if exc.args else "unknown" - raise ValueError( - f"Prompt template error: missing placeholder '{missing_key}'" - ) from exc + 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: From fe58b5a1cb0e72da739cf46bfdda80c58d245694 Mon Sep 17 00:00:00 2001 From: opsmanager1 Date: Thu, 16 Apr 2026 00:20:07 +0300 Subject: [PATCH 09/12] feat: update DbModelsAndApiEndpoints to allow mixed types for db_models and api_endpoints --- app/modules/feature_spec/schemas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/modules/feature_spec/schemas.py b/app/modules/feature_spec/schemas.py index 6021b0f..79af927 100644 --- a/app/modules/feature_spec/schemas.py +++ b/app/modules/feature_spec/schemas.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator @@ -26,8 +26,8 @@ class FeatureSummaryItem(BaseModel): class DbModelsAndApiEndpoints(BaseModel): - db_models: list[str] - api_endpoints: list[str] + db_models: list[str | dict[str, Any]] + api_endpoints: list[str | dict[str, Any]] class FeatureSummaryResult(BaseModel): From 656a3e3fa77948595a269cb216d7946bd35a276f Mon Sep 17 00:00:00 2001 From: serhii lemeshko Date: Thu, 16 Apr 2026 10:36:40 +0300 Subject: [PATCH 10/12] feat: refine default feature summary prompt template for clarity and structure --- app/scripts/bootstrap_prompt_template.py | 160 +++++++++++------------ 1 file changed, 76 insertions(+), 84 deletions(-) diff --git a/app/scripts/bootstrap_prompt_template.py b/app/scripts/bootstrap_prompt_template.py index defca5e..092b367 100644 --- a/app/scripts/bootstrap_prompt_template.py +++ b/app/scripts/bootstrap_prompt_template.py @@ -6,115 +6,107 @@ logger = logging.getLogger(__name__) -DEFAULT_FEATURE_SUMMARY_PROMPT_TEMPLATE = """You are a senior staff-level product engineer -and system architect working on production-grade software systems. +DEFAULT_FEATURE_SUMMARY_PROMPT_TEMPLATE = """You are a senior staff-level product engineer and system architect working on production-grade backend systems. -Your job is to convert a high-level feature idea into a precise, -implementation-ready technical specification. +Your task is to convert a high-level feature idea into a precise, implementation-ready technical specification. -You must think in terms of real software delivery: backend systems, APIs, -databases, edge cases, and scalability. +You must think in terms of real backend development: APIs, database schema, data consistency, edge cases, and scalability. --- ## Core rules: -- Be extremely structured and deterministic -- Do NOT invent product requirements that were not implied or provided -- If information is missing, explicitly list assumptions in a separate section -- Prefer simplicity over over-engineering -- Think like you are writing a specification for a real engineering team -- All outputs must be actionable and implementation-oriented ---- - -## Output format (strict structure): - -1. FEATURE OVERVIEW -- Short description of the feature -- Problem it solves -- Target users +- 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 --- -2. USER STORIES -- List of user stories in format: - As a [user type], I want [goal], so that [benefit] - ---- +## Hard requirements: -3. ACCEPTANCE CRITERIA -- Each user story must have clear, testable conditions -- Use bullet points -- Must be unambiguous and QA-ready +- 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 -4. API DESIGN (REST) -- List endpoints with: - - method (GET, POST, etc.) - - path - - request body (if applicable) - - response schema (high-level) - - purpose +- 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 -5. DATABASE DESIGN -- Define tables with: - - table name - - fields with types - - relationships -- Keep it normalized and production-oriented +- If the feature involves payments: + - include payment provider interaction (e.g. Stripe-like flow) + - include idempotency handling + - include transaction status tracking --- -6. EDGE CASES & RISKS -- Identify failure scenarios -- Data consistency risks -- Security concerns -- Performance concerns +## Output format (STRICT JSON ONLY) ---- +Return ONLY valid JSON. No explanations, no markdown, no extra text. -7. FUTURE IMPROVEMENTS -- Optional enhancements -- Scalability improvements -- UX improvements +{ + "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"] +} --- -## Important constraints: -- Do not add irrelevant features or expand scope -- Keep all assumptions explicitly labeled as "Assumption" -- Prefer backend clarity over marketing language -- Avoid vague phrases like "user-friendly", "robust", "fast" -- Be precise, technical, and engineering-focused - Feature idea: {feature_idea} - ---- - -Return format requirements (strict): -- Return ONLY valid JSON. -- Do not include markdown, code fences, or any text before/after JSON. -- JSON object must match this structure exactly: -{ - "user_stories": [ - { - "title": "string", - "as_a": "string", - "i_want": "string", - "so_that": "string" - } - ], - "acceptance_criteria": ["string"], - "db_models_and_api_endpoints": { - "db_models": ["string"], - "api_endpoints": ["string"] - }, - "risk_assessment": ["string"] -} """ From 28a1c74af5910ae307ba8d0b74af730a4a78ea33 Mon Sep 17 00:00:00 2001 From: serhii lemeshko Date: Thu, 16 Apr 2026 10:57:06 +0300 Subject: [PATCH 11/12] feat: improve readability of the default feature summary prompt template --- app/scripts/bootstrap_prompt_template.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/scripts/bootstrap_prompt_template.py b/app/scripts/bootstrap_prompt_template.py index 092b367..68626b0 100644 --- a/app/scripts/bootstrap_prompt_template.py +++ b/app/scripts/bootstrap_prompt_template.py @@ -6,11 +6,14 @@ 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. +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. +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. +You must think in terms of real backend development: APIs, database schema, +data consistency, edge cases, and scalability. --- From 51c0217c12671d4798cf1c143fcfbf21dcdb2995 Mon Sep 17 00:00:00 2001 From: serhii lemeshko Date: Thu, 16 Apr 2026 11:00:53 +0300 Subject: [PATCH 12/12] feat: update README to reflect changes in feature specification endpoints and project structure --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fd6b720..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 @@ -97,6 +97,7 @@ 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 @@ -133,12 +134,14 @@ curl http://localhost:11434/api/generate -d '{ ## 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 @@ -169,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" } ``` @@ -187,7 +190,7 @@ Request body example: Run linter: ```bash -python -m flake8 +python -m flake8 . ``` Run auth unit tests: