Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
Alexander Mattick
2022-12-25 11:55:09 +01:00
186 changed files with 10957 additions and 1583 deletions
+3 -7
View File
@@ -1,4 +1,4 @@
# Open-Chat-GPT REST Backend
# Open-Assistant REST Backend
## REST Server Configuration
@@ -8,14 +8,10 @@ Example contents of a `.env` file for the backend:
```
DATABASE_URI="postgresql://<username>:<password>@<host>/<database_name>"
BACKEND_CORS_ORIGINS=["http://localhost", "http://localhost:4200", "http://localhost:3000", "http://localhost:8080", "https://localhost", "https://localhost:4200", "https://localhost:3000", "https://localhost:8080", "http://dev.ocgpt.laion.ai", "https://stag.ocgpt.laion.ai", "https://ocgpt.laion.ai"]
BACKEND_CORS_ORIGINS=["http://localhost", "http://localhost:4200", "http://localhost:3000", "http://localhost:8080", "https://localhost", "https://localhost:4200", "https://localhost:3000", "https://localhost:8080", "http://dev.oasst.laion.ai", "https://stag.oasst.laion.ai", "https://oasst.laion.ai"]
```
## Running the REST Server locally for development
First, install the requirements in `requirements.txt`.
Then, run two terminals (note the working directory for each):
- Terminal 1, to go `backend/scripts` and run `docker-compose up`. This will start postgres.
- Terminal 2, to go `backend` and run `scripts/run-local.sh`. This will start the REST server.
Have a look into the main `README.md` file for more information on how to set up the backend for development.
+2 -2
View File
@@ -8,7 +8,7 @@ script_location = %(here)s/alembic
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
@@ -56,7 +56,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# output_encoding = utf-8
# sqlalchemy.url = postgresql://<username>:<password>@<host>/<database_name>
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/postgres
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
+3 -1
View File
@@ -3,7 +3,7 @@ from logging.config import fileConfig
import sqlmodel
from alembic import context
from app import models # noqa: F401
from oasst_backend import models # noqa: F401
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
@@ -68,6 +68,8 @@ def run_migrations_online() -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.get_context()._ensure_version_table()
connection.execute("LOCK TABLE alembic_version IN ACCESS EXCLUSIVE MODE")
context.run_migrations()
@@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""v1 db structure
Revision ID: cd7de470586e
Revises: 23e5fea252dd
Create Date: 2022-12-15 11:15:32.830225
"""
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
# revision identifiers, used by Alembic.
revision = "cd7de470586e"
down_revision = "23e5fea252dd"
branch_labels = None
depends_on = None
def upgrade() -> None:
# remove database objects
op.drop_index(op.f("prompt_labeler_id"), table_name="prompt")
op.drop_table("prompt")
op.drop_table("labeler")
op.drop_index(op.f("ix_service_client_api_key"), table_name="service_client")
op.drop_table("service_client")
# wreate new database structure
op.create_table(
"api_client",
sa.Column("id", UUID(as_uuid=True), default=uuid.uuid4, server_default=sa.text("gen_random_uuid()")),
sa.Column("api_key", sa.String(512), nullable=False),
sa.Column("description", sa.String(256), nullable=False),
sa.Column("admin_email", sa.String(256), nullable=True),
sa.Column("enabled", sa.Boolean, default=True, nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_api_client_api_key"), "api_client", ["api_key"], unique=True)
op.create_table(
"person",
sa.Column("id", UUID(as_uuid=True), default=uuid.uuid4, server_default=sa.text("gen_random_uuid()")),
sa.Column("username", sa.String(128), nullable=False), # unique in combination with api_client_id
sa.Column("display_name", sa.String(256), nullable=False), # cached last seen display_name
sa.Column("created_date", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("api_client_id", UUID(as_uuid=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["api_client_id"], ["api_client.id"]),
)
op.create_index(op.f("ix_person_username"), "person", ["api_client_id", "username"], unique=True)
op.create_table(
"person_stats",
sa.Column("person_id", UUID(as_uuid=True)),
sa.Column("leader_score", sa.Integer, default=0, nullable=False), # determines position on leader board
sa.Column("modified_date", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("reactions", sa.Integer, default=0, nullable=False), # reactions sent by user
sa.Column("posts", sa.Integer, default=0, nullable=False), # posts sent by user
sa.Column("upvotes", sa.Integer, default=0, nullable=False), # received upvotes (form other users)
sa.Column("downvotes", sa.Integer, default=0, nullable=False), # received downvotes (from other users)
sa.Column("work_reward", sa.Integer, default=0, nullable=False), # reward for workpackage completions
sa.Column("compare_wins", sa.Integer, default=0, nullable=False), # num times user's post won compare tasks
sa.Column("compare_losses", sa.Integer, default=0, nullable=False), # num times users's post lost compare tasks
sa.PrimaryKeyConstraint("person_id"),
sa.ForeignKeyConstraint(["person_id"], ["person.id"]),
)
op.create_table(
"work_package",
sa.Column("id", UUID(as_uuid=True), default=uuid.uuid4, server_default=sa.text("gen_random_uuid()")),
sa.Column("created_date", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("expiry_date", sa.DateTime(), nullable=True),
sa.Column("person_id", UUID(as_uuid=True), nullable=True),
sa.Column("payload_type", sa.String(200), nullable=False), # deserialization hint & dbg aid
sa.Column("payload", JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("api_client_id", UUID(as_uuid=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["person_id"], ["person.id"]),
sa.ForeignKeyConstraint(["api_client_id"], ["api_client.id"]),
)
op.create_index(op.f("ix_work_package_person_id"), "work_package", ["person_id"], unique=False)
op.create_table(
"post",
sa.Column("id", UUID(as_uuid=True), default=uuid.uuid4, server_default=sa.text("gen_random_uuid()")),
sa.Column("parent_id", UUID(as_uuid=True), nullable=True), # root posts have NULL parent
sa.Column("thread_id", UUID(as_uuid=True), nullable=False), # id of thread root
sa.Column("workpackage_id", UUID(as_uuid=True), nullable=True), # workpackage id to pass to handler on reply
sa.Column("person_id", UUID(as_uuid=True), nullable=True), # sender (recipients are part of payload)
sa.Column("api_client_id", UUID(as_uuid=True), nullable=False),
sa.Column("role", sa.String(128), nullable=False), # 'assistant', 'user' or something else
sa.Column("frontend_post_id", sa.String(200), nullable=False), # unique together with api_client_id
sa.Column("created_date", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("payload_type", sa.String(200), nullable=False), # deserialization hint & dbg aid
sa.Column("payload", JSONB(astext_type=sa.Text()), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["person_id"], ["person.id"]),
sa.ForeignKeyConstraint(["api_client_id"], ["api_client.id"]),
)
op.create_index(op.f("ix_post_frontend_post_id"), "post", ["api_client_id", "frontend_post_id"], unique=True)
op.create_index(op.f("ix_post_thread_id"), "post", ["thread_id"], unique=False)
op.create_index(op.f("ix_post_workpackage_id"), "post", ["workpackage_id"], unique=False)
op.create_index(op.f("ix_post_person_id"), "post", ["person_id"], unique=False)
op.create_table(
"post_reaction",
sa.Column("post_id", UUID(as_uuid=True), nullable=False),
sa.Column("person_id", UUID(as_uuid=True), nullable=False), # sender (recipients are part of payload)
sa.Column("created_date", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("payload_type", sa.String(200), nullable=False), # deserialization hint & dbg aid
sa.Column("payload", JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("api_client_id", UUID(as_uuid=True), nullable=False),
sa.PrimaryKeyConstraint("post_id", "person_id"),
sa.ForeignKeyConstraint(["post_id"], ["post.id"]),
sa.ForeignKeyConstraint(["person_id"], ["person.id"]),
sa.ForeignKeyConstraint(["api_client_id"], ["api_client.id"]),
)
def downgrade() -> None:
op.drop_table("post_reaction")
op.drop_index("ix_post_person_id")
op.drop_index("ix_post_workpackage_id")
op.drop_index("ix_post_thread_id")
op.drop_index("ix_post_frontend_post_id")
op.drop_table("post")
op.drop_index("ix_work_package_person_id")
op.drop_table("work_package")
op.drop_table("person_stats")
op.drop_index("ix_person_username")
op.drop_table("person")
op.drop_index("ix_api_client_api_key")
op.drop_table("api_client")
op.create_table(
"service_client",
sa.Column("id", sa.Integer, sa.Identity()),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("service_admin_email", sa.String(128), nullable=True),
sa.Column("api_key", sa.String(300), nullable=False),
sa.Column("can_append", sa.Boolean, nullable=False, server_default="true"),
sa.Column("can_write", sa.Boolean, nullable=False, server_default="false"),
sa.Column("can_delete", sa.Boolean, nullable=False, server_default="false"),
sa.Column("can_read", sa.Boolean, nullable=False, server_default="true"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_service_client_api_key"), "service_client", ["api_key"], unique=True)
op.create_table(
"labeler",
sa.Column("id", sa.Integer, sa.Identity()),
sa.Column("display_name", sa.String(96), nullable=False),
sa.Column("discord_username", sa.String(96), nullable=True),
sa.Column(
"created_date",
sa.DateTime,
nullable=False,
server_default=sa.func.current_timestamp(),
),
sa.Column("is_enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("notes", sa.String(10 * 1024), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("discord_username"),
)
op.create_table(
"prompt",
sa.Column("id", sa.Integer, sa.Identity()),
sa.Column("labeler_id", sa.Integer, nullable=False),
sa.Column("prompt", sa.Text, nullable=False),
sa.Column("response", sa.Text, nullable=True),
sa.Column("lang", sa.String(32), nullable=True),
sa.Column(
"created_date",
sa.DateTime(),
nullable=False,
server_default=sa.func.current_timestamp(),
),
sa.ForeignKeyConstraint(
["labeler_id"],
["labeler.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("prompt_labeler_id"), "prompt", ["labeler_id"], unique=False)
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""add auth_method to person
Revision ID: 6368515778c5
Revises: cd7de470586e
Create Date: 2022-12-17 17:57:33.022549
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "6368515778c5"
down_revision = "cd7de470586e"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("person", sa.Column("auth_method", sa.String(length=128), nullable=True))
op.execute("UPDATE person SET auth_method = 'local'")
op.alter_column("person", "auth_method", nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("person", "auth_method")
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""add_auth_method_to_ix_person_username
Revision ID: 0daec5f8135f
Revises: 6368515778c5
Create Date: 2022-12-22 18:35:59.609013
"""
import sqlalchemy as sa # noqa: F401
from alembic import op
# revision identifiers, used by Alembic.
revision = "0daec5f8135f"
down_revision = "6368515778c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_person_username", table_name="person")
op.create_index("ix_person_username", "person", ["api_client_id", "username", "auth_method"], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_person_username", table_name="person")
op.create_index("ix_person_username", "person", ["api_client_id", "username"], unique=False)
# ### end Alembic commands ###
-50
View File
@@ -1,50 +0,0 @@
# -*- coding: utf-8 -*-
from typing import Generator
from app.database import engine
from app.models import ServiceClient
from fastapi import HTTPException, Security
from fastapi.security.api_key import APIKey, APIKeyHeader, APIKeyQuery
from sqlmodel import Session
from starlette.status import HTTP_403_FORBIDDEN
def get_db() -> Generator:
with Session(engine) as db:
yield db
api_key_query = APIKeyQuery(name="api_key", auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_api_key(
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header),
):
if api_key_query:
return api_key_query
else:
return api_key_header
def api_auth(
api_key: APIKey,
db: Session,
create: bool = False,
read: bool = True,
update: bool = False,
delete: bool = False,
) -> ServiceClient:
if api_key is not None:
api_client = db.query(ServiceClient).filter(ServiceClient.api_key == api_key).first()
if api_client is not None:
if (
(create is False or api_client.can_append)
and (read is False or api_client.can_read)
and (update is False or api_client.can_write)
and (delete is False or api_client.can_delete)
):
return api_client
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials")
-7
View File
@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
from app.api.v1 import labelers, prompts
from fastapi import APIRouter
api_router = APIRouter()
api_router.include_router(labelers.router, prefix="/labelers", tags=["labelers"])
api_router.include_router(prompts.router, prefix="/prompts", tags=["prompts"])
-114
View File
@@ -1,114 +0,0 @@
# -*- coding: utf-8 -*-
from typing import Any, List
from app import crud, schemas
from app.api import deps
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security.api_key import APIKey
from sqlmodel import Session
from starlette.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
router = APIRouter()
@router.get("/", response_model=List[schemas.Labeler])
def read_labelers(
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
begin_id: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve labelers.
"""
deps.api_auth(api_key, db, read=True)
if limit > 10000:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Bad request")
labelers = crud.labeler.get_multi(db, begin_id=begin_id, limit=limit)
return labelers
@router.post("/", response_model=schemas.Labeler)
def create_labeler(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
item_in: schemas.LabelerCreate,
) -> Any:
"""
Create new labeler.
"""
deps.api_auth(api_key, db, create=True)
item = crud.labeler.create(db=db, obj_in=item_in)
return item
@router.put("/{id}", response_model=schemas.Labeler)
def update_labeler(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
id: int,
item_in: schemas.LabelerUpdate,
) -> Any:
"""
Update a labeler.
"""
deps.api_auth(api_key, db, update=True, read=True)
item = crud.labeler.get(db=db, id=id)
if not item:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Item not found")
item = crud.labeler.update(db=db, db_obj=item, obj_in=item_in)
return item
@router.get("/by-username", response_model=schemas.Labeler)
def read_labeler_by_username(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
discord_username: str,
) -> Any:
"""
Get labeler by ID.
"""
deps.api_auth(api_key, db, read=True)
item = crud.labeler.get_by_discord_username(db=db, discord_username=discord_username)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
@router.get("/{id}", response_model=schemas.Labeler)
def read_labeler(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
id: int,
) -> Any:
"""
Get labeler by ID.
"""
deps.api_auth(api_key, db, read=True)
item = crud.labeler.get(db=db, id=id)
if not item:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Item not found")
return item
@router.delete("/{id}", response_model=schemas.Labeler)
def delete_labeler(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
id: int,
) -> Any:
"""
Delete a labeler.
"""
deps.api_auth(api_key, db, delete=True)
labeler = crud.labeler.get(db=db, id=id)
if not labeler:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Item not found")
labeler = crud.labeler.remove(db=db, id=id)
return labeler
-91
View File
@@ -1,91 +0,0 @@
# -*- coding: utf-8 -*-
from typing import Any, List
from app import crud, schemas
from app.api import deps
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security.api_key import APIKey
from sqlmodel import Session
from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
router = APIRouter()
@router.get("/", response_model=List[schemas.Prompt])
def read_prompts(
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
begin_id: int = 0,
limit: int = 1000,
) -> Any:
"""
Retrieve prompts.
"""
deps.api_auth(api_key, db, read=True)
if limit > 10000:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Bad request")
return crud.prompt.get_multi(db, begin_id=begin_id, limit=limit)
@router.post("/", response_model=schemas.Prompt)
def create_prompt(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
item_in: schemas.PromptCreate,
) -> Any:
"""
Create new prompt.
"""
deps.api_auth(api_key, db, create=True)
if item_in.labeler_id is None:
if item_in.discord_username is None:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Bad request")
labeler = crud.labeler.get_by_discord_username(db=db, discord_username=item_in.discord_username)
else:
labeler = crud.labeler.get(db=db, id=item_in.labeler_id)
if labeler is None:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Invalid labeler user name")
if not labeler.is_enabled:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Labeler disabled")
item_in.labeler_id = labeler.id
item_in.discord_username = None
item = crud.prompt.create(db=db, obj_in=item_in)
return item
@router.get("/{id}", response_model=schemas.Prompt)
def read_prompt(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
id: int,
) -> Any:
"""
Get prompt by ID.
"""
deps.api_auth(api_key, db, read=True)
item = crud.prompt.get(db=db, id=id)
if not item:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Item not found")
return item
@router.delete("/{id}", response_model=schemas.Prompt)
def delete_prompt(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
id: int,
) -> Any:
"""
Delete a prompt.
"""
deps.api_auth(api_key, db, delete=True)
item = crud.prompt.get(db=db, id=id)
if not item:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Item not found")
item = crud.prompt.remove(db=db, id=id)
return item
-25
View File
@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# touch
from typing import List, Optional, Union
from pydantic import AnyHttpUrl, BaseSettings, PostgresDsn, validator
class Settings(BaseSettings):
PROJECT_NAME: str = "open-chatGPT backend"
API_V1_STR: str = "/api/v1"
DATABASE_URI: Optional[PostgresDsn] = None
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
UPDATE_ALEMBIC: bool = True
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
settings = Settings(_env_file=".env")
-5
View File
@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
from .crud_labeler import labeler
from .crud_prompt import prompt
__all__ = ["labeler", "prompt"]
-15
View File
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
from typing import Optional
from app.crud.base import CRUDBase
from app.models.labeler import Labeler
from app.schemas.labeler import LabelerCreate, LabelerUpdate
from sqlmodel import Session
class CRUDLabeler(CRUDBase[Labeler, LabelerCreate, LabelerUpdate]):
def get_by_discord_username(self, db: Session, discord_username: str) -> Optional[Labeler]:
return db.query(Labeler).filter(Labeler.discord_username == discord_username).first()
labeler = CRUDLabeler(Labeler)
-11
View File
@@ -1,11 +0,0 @@
# -*- coding: utf-8 -*-
from app.crud.base import CRUDBase
from app.models.prompt import Prompt
from app.schemas.prompt import PromptCreate
class CRUDPrompt(CRUDBase[Prompt, PromptCreate, None]):
pass
prompt = CRUDPrompt(Prompt)
-6
View File
@@ -1,6 +0,0 @@
# -*- coding: utf-8 -*-
from .labeler import Labeler
from .prompt import Prompt
from .service_client import ServiceClient
__all__ = ["Labeler", "Prompt", "ServiceClient"]
-19
View File
@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from sqlmodel import Field, SQLModel
class Labeler(SQLModel, table=True):
__tablename__ = "labeler"
id: Optional[int] = Field(default=None, primary_key=True)
display_name: str
discord_username: str
created_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
nullable=False,
)
is_enabled: bool
notes: str
-19
View File
@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from sqlmodel import Field, SQLModel
class Prompt(SQLModel, table=True):
__tablename__ = "prompt"
id: Optional[int] = Field(default=None, primary_key=True)
labeler_id: Optional[int] = Field(default=None, foreign_key="labeler.id")
prompt: str
response: Optional[str]
lang: Optional[str]
created_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
nullable=False,
)
-17
View File
@@ -1,17 +0,0 @@
# -*- coding: utf-8 -*-
from typing import Optional
from sqlmodel import Field, SQLModel
class ServiceClient(SQLModel, table=True):
__tablename__ = "service_client"
id: Optional[int] = Field(default=None, primary_key=True)
name: str
api_key: str
service_admin_email: Optional[str] = None
api_key: str
can_append: bool = True
can_write: bool = False
can_delete: bool = False
can_read: bool = True
-5
View File
@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
from .labeler import Labeler, LabelerCreate, LabelerUpdate
from .prompt import Prompt, PromptCreate
__all__ = ["Labeler", "LabelerCreate", "LabelerUpdate", "Prompt", "PromptCreate"]
-28
View File
@@ -1,28 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class Labeler(BaseModel):
id: int
discord_username: str
display_name: str
created_date: datetime
is_enabled: str
notes: Optional[str]
class LabelerCreate(BaseModel):
discord_username: str
display_name: Optional[str]
is_enabled: Optional[bool] = True
notes: Optional[str] = None
class LabelerUpdate(BaseModel):
discord_username: Optional[str] = None
display_name: Optional[str] = None
enabled: Optional[bool] = None
notes: Optional[str] = None
-22
View File
@@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class Prompt(BaseModel):
id: int
labeler_id: int
prompt: str
response: Optional[str]
lang: Optional[str]
created_date: datetime
class PromptCreate(BaseModel):
labeler_id: Optional[int] = None
discord_username: Optional[str] = None
prompt: str
response: Optional[str] = None
lang: Optional[str] = None
View File
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.9
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app
COPY ./app /app
ENV PYTHONPATH=/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
-3
View File
@@ -1,3 +0,0 @@
FROM postgres:15
COPY ./scripts/create-db.sh /docker-entrypoint-initdb.d/
+3 -3
View File
@@ -4,9 +4,9 @@ from pathlib import Path
import alembic.command
import alembic.config
import fastapi
from app.api.v1.api import api_router
from app.config import settings
from loguru import logger
from oasst_backend.api.v1.api import api_router
from oasst_backend.config import settings
from starlette.middleware.cors import CORSMiddleware
app = fastapi.FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json")
@@ -27,7 +27,7 @@ if settings.UPDATE_ALEMBIC:
def alembic_upgrade():
logger.info("Attempting to upgrade alembic on startup")
try:
alembic_ini_path = Path(__file__).parent.parent / "alembic.ini"
alembic_ini_path = Path(__file__).parent / "alembic.ini"
alembic_cfg = alembic.config.Config(str(alembic_ini_path))
alembic_cfg.set_main_option("sqlalchemy.url", settings.DATABASE_URI)
alembic.command.upgrade(alembic_cfg, "head")
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
from secrets import token_hex
from typing import Generator
from uuid import UUID
from fastapi import HTTPException, Security
from fastapi.security.api_key import APIKey, APIKeyHeader, APIKeyQuery
from loguru import logger
from oasst_backend.config import settings
from oasst_backend.database import engine
from oasst_backend.models import ApiClient
from sqlmodel import Session
from starlette.status import HTTP_403_FORBIDDEN
def get_db() -> Generator:
with Session(engine) as db:
yield db
api_key_query = APIKeyQuery(name="api_key", auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_api_key(
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header),
):
if api_key_query:
return api_key_query
else:
return api_key_header
def api_auth(
api_key: APIKey,
db: Session,
) -> ApiClient:
if api_key is not None:
if settings.ALLOW_ANY_API_KEY:
# make sure that a dummy api key exits in db (foreign key references)
ANY_API_KEY_ID = UUID("00000000-1111-2222-3333-444444444444")
api_client: ApiClient = db.query(ApiClient).filter(ApiClient.id == ANY_API_KEY_ID).first()
if api_client is None:
token = token_hex(32)
logger.info(f"ANY_API_KEY missing, inserting api_key: {token}")
api_client = ApiClient(id=ANY_API_KEY_ID, api_key=token, description="ANY_API_KEY, random token")
db.add(api_client)
db.commit()
return api_client
api_client = db.query(ApiClient).filter(ApiClient.api_key == api_key).first()
if api_client is not None and api_client.enabled:
return api_client
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials")
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from oasst_backend.api.v1 import tasks
api_router = APIRouter()
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
+259
View File
@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
import random
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security.api_key import APIKey
from loguru import logger
from oasst_backend.api import deps
from oasst_backend.models.db_payload import TaskPayload
from oasst_backend.prompt_repository import PromptRepository
from oasst_shared.schemas import protocol as protocol_schema
from sqlmodel import Session
from starlette.status import HTTP_400_BAD_REQUEST
router = APIRouter()
def generate_task(request: protocol_schema.TaskRequest) -> protocol_schema.Task:
match request.type:
case protocol_schema.TaskRequestType.random:
logger.info("Frontend requested a random task.")
while request.type == protocol_schema.TaskRequestType.random:
request.type = random.choice(list(protocol_schema.TaskRequestType)).value
return generate_task(request)
case protocol_schema.TaskRequestType.summarize_story:
logger.info("Generating a SummarizeStoryTask.")
task = protocol_schema.SummarizeStoryTask(
story="This is a story. A very long story. So long, it needs to be summarized.",
)
case protocol_schema.TaskRequestType.rate_summary:
logger.info("Generating a RateSummaryTask.")
task = protocol_schema.RateSummaryTask(
full_text="This is a story. A very long story. So long, it needs to be summarized.",
summary="This is a summary.",
scale=protocol_schema.RatingScale(min=1, max=5),
)
case protocol_schema.TaskRequestType.initial_prompt:
logger.info("Generating an InitialPromptTask.")
task = protocol_schema.InitialPromptTask(
hint="Ask the assistant about a current event." # this is optional
)
case protocol_schema.TaskRequestType.user_reply:
logger.info("Generating a UserReplyTask.")
task = protocol_schema.UserReplyTask(
conversation=protocol_schema.Conversation(
messages=[
protocol_schema.ConversationMessage(
text="Hey, assistant, what's going on in the world?",
is_assistant=False,
),
protocol_schema.ConversationMessage(
text="I'm not sure I understood correctly, could you rephrase that?",
is_assistant=True,
),
],
)
)
case protocol_schema.TaskRequestType.assistant_reply:
logger.info("Generating a AssistantReplyTask.")
task = protocol_schema.AssistantReplyTask(
conversation=protocol_schema.Conversation(
messages=[
protocol_schema.ConversationMessage(
text="Hey, assistant, write me an English essay about water.",
is_assistant=False,
),
],
)
)
case protocol_schema.TaskRequestType.rank_initial_prompts:
logger.info("Generating a RankInitialPromptsTask.")
task = protocol_schema.RankInitialPromptsTask(
prompts=[
"Please write a story about a time you were happy.",
"Please write a story about a time you were sad.",
]
)
case protocol_schema.TaskRequestType.rank_user_replies:
logger.info("Generating a RankUserRepliesTask.")
task = protocol_schema.RankUserRepliesTask(
conversation=protocol_schema.Conversation(
messages=[
protocol_schema.ConversationMessage(
text="Hey, assistant, what's going on in the world?",
is_assistant=False,
),
protocol_schema.ConversationMessage(
text="I'm not sure I understood correctly, could you rephrase that?",
is_assistant=True,
),
],
),
replies=[
"Oh come oooooon!",
"What are the news?",
],
)
case protocol_schema.TaskRequestType.rank_assistant_replies:
logger.info("Generating a RankAssistantRepliesTask.")
task = protocol_schema.RankAssistantRepliesTask(
conversation=protocol_schema.Conversation(
messages=[
protocol_schema.ConversationMessage(
text="Hey, assistant, what's going on in the world?",
is_assistant=False,
),
],
),
replies=[
"I'm not sure I understood correctly, could you rephrase that?",
"The world is fine. All good.",
"Crap is hitting the fan. Start farming.",
],
)
case _:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="Invalid request type.",
)
logger.info(f"Generated {task=}.")
return task
@router.post("/", response_model=protocol_schema.AnyTask) # work with Union once more types are added
def request_task(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
request: protocol_schema.TaskRequest,
) -> Any:
"""
Create new task.
"""
api_client = deps.api_auth(api_key, db)
try:
task = generate_task(request)
pr = PromptRepository(db, api_client, request.user)
pr.store_task(task)
except Exception:
logger.exception("Failed to generate task.")
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
)
return task
@router.post("/{task_id}/ack")
def acknowledge_task(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
task_id: UUID,
ack_request: protocol_schema.TaskAck,
) -> Any:
"""
The frontend acknowledges a task.
"""
api_client = deps.api_auth(api_key, db)
try:
pr = PromptRepository(db, api_client, user=None)
# here we store the post id in the database for the task
logger.info(f"Frontend acknowledges task {task_id=}, {ack_request=}.")
pr.bind_frontend_post_id(task_id=task_id, post_id=ack_request.post_id)
except Exception:
logger.exception("Failed to acknowledge task.")
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
)
return {}
@router.post("/{task_id}/nack")
def acknowledge_task_failure(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
task_id: UUID,
nack_request: protocol_schema.TaskNAck,
) -> Any:
"""
The frontend reports failure to implement a task.
"""
deps.api_auth(api_key, db)
logger.info(f"Frontend reports failure to implement task {task_id=}, {nack_request=}.")
# here we would store the post id in the database for the task
return {}
@router.post("/interaction")
def post_interaction(
*,
db: Session = Depends(deps.get_db),
api_key: APIKey = Depends(deps.get_api_key),
interaction: protocol_schema.AnyInteraction,
) -> Any:
"""
The frontend reports an interaction.
"""
api_client = deps.api_auth(api_key, db)
try:
pr = PromptRepository(db, api_client, user=interaction.user)
match type(interaction):
case protocol_schema.TextReplyToPost:
logger.info(
f"Frontend reports text reply to {interaction.post_id=} with {interaction.text=} by {interaction.user=}."
)
work_package = pr.fetch_workpackage_by_postid(interaction.post_id)
work_payload: TaskPayload = work_package.payload.payload
logger.info(f"found task work package in db: {work_payload}")
# here we store the text reply in the database
# ToDo: role user or agent?
pr.store_text_reply(interaction, role="unknown")
return protocol_schema.TaskDone()
case protocol_schema.PostRating:
logger.info(
f"Frontend reports rating of {interaction.post_id=} with {interaction.rating=} by {interaction.user=}."
)
# here we store the rating in the database
pr.store_rating(interaction)
return protocol_schema.TaskDone()
case protocol_schema.PostRanking:
logger.info(
f"Frontend reports ranking of {interaction.post_id=} with {interaction.ranking=} by {interaction.user=}."
)
# TODO: check if the ranking is valid
pr.store_ranking(interaction)
# here we would store the ranking in the database
return protocol_schema.TaskDone()
case _:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="Invalid response type.",
)
except Exception:
logger.exception("Interaction request failed.")
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
)
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
from typing import Any, Dict, List, Optional, Union
from pydantic import AnyHttpUrl, BaseSettings, PostgresDsn, validator
class Settings(BaseSettings):
PROJECT_NAME: str = "open-assistant backend"
API_V1_STR: str = "/api/v1"
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: str = "5432"
POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str = "postgres"
DATABASE_URI: Optional[PostgresDsn] = None
ALLOW_ANY_API_KEY: bool = False
@validator("DATABASE_URI", pre=True)
def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
if isinstance(v, str):
return v
return PostgresDsn.build(
scheme="postgresql",
user=values.get("POSTGRES_USER"),
password=values.get("POSTGRES_PASSWORD"),
host=values.get("POSTGRES_HOST"),
port=values.get("POSTGRES_PORT"),
path=f"/{values.get('POSTGRES_DB') or ''}",
)
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
UPDATE_ALEMBIC: bool = True
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
settings = Settings(_env_file=".env")
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
__all__ = []
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from app.config import settings
from oasst_backend.config import settings
from sqlmodel import create_engine
if settings.DATABASE_URI is None:
+16
View File
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from .api_client import ApiClient
from .person import Person
from .person_stats import PersonStats
from .post import Post
from .post_reaction import PostReaction
from .work_package import WorkPackage
__all__ = [
"ApiClient",
"Person",
"PersonStats",
"Post",
"PostReaction",
"WorkPackage",
]
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from typing import Optional
from uuid import UUID, uuid4
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from sqlmodel import Field, SQLModel
class ApiClient(SQLModel, table=True):
__tablename__ = "api_client"
id: Optional[UUID] = Field(
sa_column=sa.Column(
pg.UUID(as_uuid=True), primary_key=True, default=uuid4, server_default=sa.text("gen_random_uuid()")
),
)
api_key: str = Field(max_length=512, index=True, unique=True)
description: str = Field(max_length=256)
admin_email: Optional[str] = Field(max_length=256, nullable=True)
enabled: bool = Field(default=True)
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
from typing import Literal
from oasst_backend.models.payload_column_type import payload_type
from oasst_shared.schemas import protocol as protocol_schema
from pydantic import BaseModel
@payload_type
class TaskPayload(BaseModel):
type: str
@payload_type
class SummarizationStoryPayload(TaskPayload):
type: Literal["summarize_story"] = "summarize_story"
story: str
@payload_type
class RateSummaryPayload(TaskPayload):
type: Literal["rate_summary"] = "rate_summary"
full_text: str
summary: str
scale: protocol_schema.RatingScale
@payload_type
class InitialPromptPayload(TaskPayload):
type: Literal["initial_prompt"] = "initial_prompt"
hint: str
@payload_type
class UserReplyPayload(TaskPayload):
type: Literal["user_reply"] = "user_reply"
conversation: protocol_schema.Conversation
hint: str | None
@payload_type
class AssistantReplyPayload(TaskPayload):
type: Literal["assistant_reply"] = "assistant_reply"
conversation: protocol_schema.Conversation
@payload_type
class PostPayload(BaseModel):
text: str
@payload_type
class ReactionPayload(BaseModel):
type: str
@payload_type
class RatingReactionPayload(ReactionPayload):
type: Literal["post_rating"] = "post_rating"
rating: str
@payload_type
class RankingReactionPayload(ReactionPayload):
type: Literal["post_ranking"] = "post_ranking"
ranking: list[int]
@payload_type
class RankConversationRepliesPayload(TaskPayload):
conversation: protocol_schema.Conversation # the conversation so far
replies: list[str]
@payload_type
class RankInitialPromptsPayload(TaskPayload):
"""A task to rank a set of initial prompts."""
type: Literal["rank_initial_prompts"] = "rank_initial_prompts"
prompts: list[str]
@payload_type
class RankUserRepliesPayload(RankConversationRepliesPayload):
"""A task to rank a set of user replies to a conversation."""
type: Literal["rank_user_replies"] = "rank_user_replies"
@payload_type
class RankAssistantRepliesPayload(RankConversationRepliesPayload):
"""A task to rank a set of assistant replies to a conversation."""
type: Literal["rank_assistant_replies"] = "rank_assistant_replies"
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
import json
from typing import Any, Generic, Type, TypeVar
import sqlalchemy.dialects.postgresql as pg
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, parse_obj_as, validator
from pydantic.main import ModelMetaclass
from sqlalchemy.types import TypeDecorator
payload_type_registry = {}
P = TypeVar("P", bound=BaseModel)
def payload_type(cls: Type[P]) -> Type[P]:
payload_type_registry[cls.__name__] = cls
return cls
class PayloadContainer(BaseModel):
payload_type: str = ""
payload: BaseModel = None
def __init__(self, **v):
p = v["payload"]
if isinstance(p, dict):
t = v["payload_type"]
if t not in payload_type_registry:
raise RuntimeError(f"Payload type '{t}' not registered")
cls = payload_type_registry[t]
v["payload"] = cls(**p)
super().__init__(**v)
@validator("payload", pre=True)
def check_payload(cls, v: BaseModel, values: dict[str, Any]) -> BaseModel:
values["payload_type"] = type(v).__name__
return v
class Config:
orm_mode = True
T = TypeVar("T")
def payload_column_type(pydantic_type):
class PayloadJSONBType(TypeDecorator, Generic[T]):
impl = pg.JSONB()
def __init__(
self,
json_encoder=json,
):
self.json_encoder = json_encoder
super(PayloadJSONBType, self).__init__()
# serialize
def bind_processor(self, dialect):
impl_processor = self.impl.bind_processor(dialect)
dumps = self.json_encoder.dumps
def process(value: T):
if value is not None:
if isinstance(pydantic_type, ModelMetaclass):
# This allows to assign non-InDB models and if they're
# compatible, they're directly parsed into the InDB
# representation, thus hiding the implementation in the
# background. However, the InDB model will still be returned
value_to_dump = pydantic_type.from_orm(value)
else:
value_to_dump = value
value = jsonable_encoder(value_to_dump)
if impl_processor:
return impl_processor(value)
else:
return dumps(jsonable_encoder(value_to_dump))
return process
# deserialize
def result_processor(self, dialect, coltype) -> T:
impl_processor = self.impl.result_processor(dialect, coltype)
def process(value):
if impl_processor:
value = impl_processor(value)
if value is None:
return None
# Explicitly use the generic directly, not type(T)
full_obj = parse_obj_as(pydantic_type, value)
return full_obj
return process
def compare_values(self, x, y):
return x == y
return PayloadJSONBType
+26
View File
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from sqlmodel import Field, Index, SQLModel
class Person(SQLModel, table=True):
__tablename__ = "person"
__table_args__ = (Index("ix_person_username", "api_client_id", "username", "auth_method", unique=True),)
id: Optional[UUID] = Field(
sa_column=sa.Column(
pg.UUID(as_uuid=True), primary_key=True, default=uuid4, server_default=sa.text("gen_random_uuid()")
),
)
username: str = Field(nullable=False, max_length=128)
auth_method: str = Field(nullable=False, max_length=128, default="local")
display_name: str = Field(nullable=False, max_length=256)
created_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp())
)
api_client_id: UUID = Field(foreign_key="api_client.id")
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from uuid import UUID
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from sqlmodel import Field, SQLModel
class PersonStats(SQLModel, table=True):
__tablename__ = "person_stats"
person_id: Optional[UUID] = Field(
sa_column=sa.Column(pg.UUID(as_uuid=True), sa.ForeignKey("person.id"), primary_key=True)
)
leader_score: int = 0
modified_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp())
)
reactions: int = 0 # reactions sent by user
posts: int = 0 # posts sent by user
upvotes: int = 0 # received upvotes (form other users)
downvotes: int = 0 # received downvotes (from other users)
work_reward: int = 0 # reward for workpackage completions
compare_wins: int = 0 # num times user's post won compare tasks
compare_losses: int = 0 # num times users's post lost compare tasks
+33
View File
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from sqlmodel import Field, Index, SQLModel
from .payload_column_type import PayloadContainer, payload_column_type
class Post(SQLModel, table=True):
__tablename__ = "post"
__table_args__ = (Index("ix_post_frontend_post_id", "api_client_id", "frontend_post_id", unique=True),)
id: Optional[UUID] = Field(
sa_column=sa.Column(
pg.UUID(as_uuid=True), primary_key=True, default=uuid4, server_default=sa.text("gen_random_uuid()")
),
)
parent_id: UUID = Field(nullable=True)
thread_id: UUID = Field(nullable=False, index=True)
workpackage_id: UUID = Field(nullable=True, index=True)
person_id: UUID = Field(nullable=True, foreign_key="person.id", index=True)
role: str = Field(nullable=False, max_length=128)
api_client_id: UUID = Field(nullable=False, foreign_key="api_client.id")
frontend_post_id: str = Field(max_length=200, nullable=False)
created_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp())
)
payload_type: str = Field(nullable=False, max_length=200)
payload: PayloadContainer = Field(sa_column=sa.Column(payload_column_type(PayloadContainer), nullable=True))
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from uuid import UUID
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from sqlmodel import Field, SQLModel
from .payload_column_type import PayloadContainer, payload_column_type
class PostReaction(SQLModel, table=True):
__tablename__ = "post_reaction"
post_id: Optional[UUID] = Field(
sa_column=sa.Column(pg.UUID(as_uuid=True), sa.ForeignKey("post.id"), nullable=False, primary_key=True)
)
person_id: UUID = Field(
sa_column=sa.Column(pg.UUID(as_uuid=True), sa.ForeignKey("person.id"), nullable=False, primary_key=True)
)
created_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp())
)
payload_type: str = Field(nullable=False, max_length=200)
payload: PayloadContainer = Field(sa_column=sa.Column(payload_column_type(PayloadContainer), nullable=False))
api_client_id: UUID = Field(nullable=False, foreign_key="api_client.id")
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
import sqlalchemy as sa
import sqlalchemy.dialects.postgresql as pg
from sqlmodel import Field, SQLModel
from .payload_column_type import PayloadContainer, payload_column_type
class WorkPackage(SQLModel, table=True):
__tablename__ = "work_package"
id: Optional[UUID] = Field(
sa_column=sa.Column(
pg.UUID(as_uuid=True), primary_key=True, default=uuid4, server_default=sa.text("gen_random_uuid()")
),
)
created_date: Optional[datetime] = Field(
sa_column=sa.Column(sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
)
expiry_date: Optional[datetime] = Field(sa_column=sa.Column(sa.DateTime(), nullable=True))
person_id: UUID = Field(nullable=True, foreign_key="person.id", index=True)
payload_type: str = Field(nullable=False, max_length=200)
payload: PayloadContainer = Field(sa_column=sa.Column(payload_column_type(PayloadContainer), nullable=False))
api_client_id: UUID = Field(nullable=False, foreign_key="api_client.id")
+316
View File
@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
import oasst_backend.models.db_payload as db_payload
from loguru import logger
from oasst_backend.models import ApiClient, Person, Post, PostReaction, WorkPackage
from oasst_backend.models.payload_column_type import PayloadContainer
from oasst_shared.schemas import protocol as protocol_schema
from sqlmodel import Session
class PromptRepository:
def __init__(self, db: Session, api_client: ApiClient, user: Optional[protocol_schema.User]):
self.db = db
self.api_client = api_client
self.person = self.lookup_person(user)
self.person_id = self.person.id if self.person else None
def lookup_person(self, user: protocol_schema.User) -> Person:
if not user:
return None
person: Person = (
self.db.query(Person)
.filter(
Person.api_client_id == self.api_client.id,
Person.username == user.id,
Person.auth_method == user.auth_method,
)
.first()
)
if person is None:
# user is unknown, create new record
person = Person(
username=user.id,
display_name=user.display_name,
api_client_id=self.api_client.id,
auth_method=user.auth_method,
)
self.db.add(person)
self.db.commit()
self.db.refresh(person)
elif user.display_name and user.display_name != person.display_name:
# we found the user but the display name changed
person.display_name = user.display_name
self.db.add(person)
self.db.commit()
return person
def validate_post_id(self, post_id: str) -> None:
if not isinstance(post_id, str):
raise TypeError(f"post_id must be string, not {type(post_id)}")
if not post_id:
raise ValueError("post_id must not be empty")
def bind_frontend_post_id(self, task_id: UUID, post_id: str):
self.validate_post_id(post_id)
# find work package
work_pack: WorkPackage = (
self.db.query(WorkPackage)
.filter(WorkPackage.id == task_id, WorkPackage.api_client_id == self.api_client.id)
.first()
)
if work_pack is None:
raise KeyError(f"WorkPackage for task {task_id} not found")
if work_pack.expiry_date is not None and datetime.utcnow() > work_pack.expiry_date:
raise RuntimeError("WorkPackage already expired.")
# ToDo: check race-condition, transaction
# check if task thread exits
thread_root = (
self.db.query(Post)
.filter(
Post.workpackage_id == work_pack.id,
Post.frontend_post_id == post_id,
Post.parent_id is None,
Post.api_client_id == self.api_client.id,
)
.one_or_none()
)
if thread_root is None:
thread_id = uuid4()
thread_root = self.insert_post(
post_id=thread_id,
thread_id=thread_id,
frontend_post_id=post_id,
parent_id=None,
role="system",
workpackage_id=work_pack.id,
payload=None,
payload_type="bind",
)
return thread_root
def fetch_post_by_frontend_post_id(self, frontend_post_id: str, fail_if_missing: bool = True) -> Post:
self.validate_post_id(frontend_post_id)
post: Post = (
self.db.query(Post)
.filter(Post.api_client_id == self.api_client.id, Post.frontend_post_id == frontend_post_id)
.one_or_none()
)
if fail_if_missing and post is None:
raise KeyError(f"Post with post_id {frontend_post_id} not found.")
return post
def fetch_workpackage_by_postid(self, post_id: str) -> WorkPackage:
self.validate_post_id(post_id)
post = self.fetch_post_by_frontend_post_id(post_id, fail_if_missing=True)
work_pack = self.db.query(WorkPackage).filter(WorkPackage.id == post.workpackage_id).one()
return work_pack
def store_text_reply(self, reply: protocol_schema.TextReplyToPost, role: str) -> Post:
self.validate_post_id(reply.post_id)
self.validate_post_id(reply.user_post_id)
# find post with post-id
parent_post: Post = (
self.db.query(Post)
.filter(
Post.api_client_id == self.api_client.id,
Post.frontend_post_id == reply.post_id,
# Post.person_id == self.person_id
)
.one_or_none()
)
if parent_post is None:
raise KeyError(f"Post for post_id {reply.post_id} not found.")
# create reply post
user_post_id = uuid4()
user_post = self.insert_post(
post_id=user_post_id,
frontend_post_id=reply.user_post_id,
parent_id=parent_post.id,
thread_id=parent_post.thread_id,
workpackage_id=parent_post.workpackage_id,
role=role,
payload=db_payload.PostPayload(text=reply.text),
)
return user_post
def store_rating(self, rating: protocol_schema.PostRating) -> PostReaction:
post = self.fetch_post_by_frontend_post_id(rating.post_id, fail_if_missing=True)
work_package = self.fetch_workpackage_by_postid(rating.post_id)
work_payload: db_payload.RateSummaryPayload = work_package.payload.payload
if type(work_payload) != db_payload.RateSummaryPayload:
raise ValueError(
f"work_package payload type mismatch: {type(work_payload)=} != {db_payload.RateSummaryPayload}"
)
if rating.rating < work_payload.scale.min or rating.rating > work_payload.scale.max:
raise ValueError(f"Invalid rating value: {rating.rating=} not in {work_payload.scale=}")
# store reaction to post
reaction_payload = db_payload.RatingReactionPayload(rating=rating.rating)
reaction = self.insert_reaction(post.id, reaction_payload)
logger.info(f"Ranking {rating.rating} stored for work_package {work_package.id}.")
return reaction
def store_ranking(self, ranking: protocol_schema.PostRanking) -> PostReaction:
post = self.fetch_post_by_frontend_post_id(ranking.post_id, fail_if_missing=True)
# fetch work_package
work_package = self.fetch_workpackage_by_postid(ranking.post_id)
work_payload: db_payload.RankConversationRepliesPayload | db_payload.RankInitialPromptsPayload = (
work_package.payload.payload
)
match type(work_payload):
case db_payload.RankUserRepliesPayload | db_payload.RankAssistantRepliesPayload:
# validate ranking
num_replies = len(work_payload.replies)
if sorted(ranking.ranking) != list(range(num_replies)):
raise ValueError(
f"Invalid ranking submitted. Each reply index must appear exactly once ({num_replies=})."
)
# store reaction to post
reaction_payload = db_payload.RankingReactionPayload(ranking=ranking.ranking)
reaction = self.insert_reaction(post.id, reaction_payload)
logger.info(f"Ranking {ranking.ranking} stored for work_package {work_package.id}.")
return reaction
case db_payload.RankInitialPromptsPayload:
# validate ranking
if sorted(ranking.ranking) != list(range(num_prompts := len(work_payload.prompts))):
raise ValueError(
f"Invalid ranking submitted. Each reply index must appear exactly once ({num_prompts=})."
)
# store reaction to post
reaction_payload = db_payload.RankingReactionPayload(ranking=ranking.ranking)
reaction = self.insert_reaction(post.id, reaction_payload)
logger.info(f"Ranking {ranking.ranking} stored for work_package {work_package.id}.")
return reaction
case _:
raise ValueError(
f"work_package payload type mismatch: {type(work_payload)=} != {db_payload.RankConversationRepliesPayload}"
)
def store_task(self, task: protocol_schema.Task) -> WorkPackage:
payload: db_payload.TaskPayload
match type(task):
case protocol_schema.SummarizeStoryTask:
payload = db_payload.SummarizationStoryPayload(story=task.story)
case protocol_schema.RateSummaryTask:
payload = db_payload.RateSummaryPayload(
full_text=task.full_text, summary=task.summary, scale=task.scale
)
case protocol_schema.InitialPromptTask:
payload = db_payload.InitialPromptPayload(hint=task.hint)
case protocol_schema.UserReplyTask:
payload = db_payload.UserReplyPayload(conversation=task.conversation, hint=task.hint)
case protocol_schema.AssistantReplyTask:
payload = db_payload.AssistantReplyPayload(type=task.type, conversation=task.conversation)
case protocol_schema.RankInitialPromptsTask:
payload = db_payload.RankInitialPromptsPayload(tpye=task.type, prompts=task.prompts)
case protocol_schema.RankUserRepliesTask:
payload = db_payload.RankUserRepliesPayload(
tpye=task.type, conversation=task.conversation, replies=task.replies
)
case protocol_schema.RankAssistantRepliesTask:
payload = db_payload.RankAssistantRepliesPayload(
tpye=task.type, conversation=task.conversation, replies=task.replies
)
case _:
raise ValueError(f"Invalid task type: {type(task)=}")
wp = self.insert_work_package(payload=payload, id=task.id)
assert wp.id == task.id
return wp
def insert_work_package(self, payload: db_payload.TaskPayload, id: UUID = None) -> WorkPackage:
c = PayloadContainer(payload=payload)
wp = WorkPackage(
id=id,
person_id=self.person_id,
payload_type=type(payload).__name__,
payload=c,
api_client_id=self.api_client.id,
)
self.db.add(wp)
self.db.commit()
self.db.refresh(wp)
return wp
def insert_post(
self,
*,
post_id: UUID,
frontend_post_id: str,
parent_id: UUID,
thread_id: UUID,
workpackage_id: UUID,
role: str,
payload: db_payload.PostPayload,
payload_type: str = None,
) -> Post:
if payload_type is None:
if payload is None:
payload_type = "null"
else:
payload_type = type(payload).__name__
post = Post(
id=post_id,
parent_id=parent_id,
thread_id=thread_id,
workpackage_id=workpackage_id,
person_id=self.person_id,
role=role,
frontend_post_id=frontend_post_id,
api_client_id=self.api_client.id,
payload_type=payload_type,
payload=PayloadContainer(payload=payload),
)
self.db.add(post)
self.db.commit()
self.db.refresh(post)
return post
def insert_reaction(self, post_id: UUID, payload: db_payload.ReactionPayload) -> PostReaction:
if self.person_id is None:
raise ValueError("User required")
container = PayloadContainer(payload=payload)
reaction = PostReaction(
post_id=post_id,
person_id=self.person_id,
payload=container,
api_client_id=self.api_client.id,
payload_type=type(payload).__name__,
)
self.db.add(reaction)
self.db.commit()
self.db.refresh(reaction)
return reaction
@@ -1,98 +0,0 @@
# -*- coding: utf-8 -*-
import numpy as np
from scipy import log2
from scipy.integrate import nquad
from scipy.special import gammaln, psi
from scipy.stats import dirichlet
def make_range(*x):
"""
constructs leftover values for the simplex given the first k entries
(0,x_k) = 1-(x_1+...+x_(k-1))
"""
return (0, max(0, 1 - sum(x)))
def relative_entropy(p, q):
"""
relative entropy of the two given dirichlet distributions
"""
def tmp(*x):
"""
First adds the last always forced entry to the input (the last x_last = 1-(x_1+...+x_(N)) )
Then computes the relative entropy of posterior and prior for that datapoint
"""
x_new = np.append(x, 1 - sum(x))
return p(x_new) * log2(p(x_new) / q(x_new))
return tmp
def naive_monte_carlo_integral(fun, dim, samples=10_000_000):
s = np.random.rand(dim - 1, samples)
s = np.sort(np.concatenate((np.zeros((1, samples)), s, np.ones((1, samples)))), 0)
# print(s)
pos = np.diff(s, axis=0)
# print(pos)
res = fun(pos)
return np.mean(res)
def analytic_solution(a_post, a_prior):
"""
Analytic solution to the KL-divergence between two dirichlet distributions.
Proof is in the Notion design doc.
"""
post_sum = np.sum(a_post)
prior_sum = np.sum(a_prior)
info = (
gammaln(post_sum)
- gammaln(prior_sum)
- np.sum(gammaln(a_post))
+ np.sum(gammaln(a_prior))
- np.sum((a_post - a_prior) * (psi(a_post) - psi(post_sum)))
)
return info
def infogain(a_post, a_prior):
raise (
"""For the love of good don't use this:
it's insanely poorly conditioned, the worst numerical code I have ever written
and it's slow as molasses. Use the analytic solution instead.
Maybe remove
"""
)
args = len(a_prior)
p = dirichlet(a_post).pdf
q = dirichlet(a_prior).pdf
(info, _) = nquad(relative_entropy(p, q), [make_range for _ in range(args - 1)], opts={"epsabs": 1e-8})
# info = naive_monte_carlo_integral(relative_entropy(p,q), len(a_post))
return info
def uniform_expected_infogain(a_prior):
mean_weight = dirichlet.mean(a_prior)
print("weight", mean_weight)
results = []
for i, w in enumerate(mean_weight):
a_post = a_prior.copy()
a_post[i] = a_post[i] + 1
results.append(w * analytic_solution(a_post, a_prior))
return np.sum(results)
if __name__ == "__main__":
a_prior = np.array([1, 1, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
a_post = np.array([1, 1, 20, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
print("algebraic", analytic_solution(a_post, a_prior))
# print("raw",infogain(a_post, a_prior))
print("large infogain", uniform_expected_infogain(a_prior))
print("post infogain", uniform_expected_infogain(a_post))
# a_prior = np.array([1,1,1000])
# print("small infogain",uniform_expected_infogain(a_prior))
-141
View File
@@ -1,141 +0,0 @@
# -*- coding: utf-8 -*-
from typing import List
import numpy as np
def head_to_head_votes(ranks: List[List[int]]):
tallies = np.zeros((len(ranks[0]), len(ranks[0])))
names = sorted(ranks[0])
ranks = np.array(ranks)
# we want the sorted indices
ranks = np.argsort(ranks, axis=1)
for i in range(ranks.shape[1]):
for j in range(i + 1, ranks.shape[1]):
# now count the cases someone voted for i over j
over_j = np.sum(ranks[:, i] < ranks[:, j])
over_i = np.sum(ranks[:, j] < ranks[:, i])
tallies[i, j] = over_j
# tallies[i,j] = over_i
tallies[j, i] = over_i
# tallies[j,i] = over_j
return tallies, names
def cycle_detect(pairs):
"""Recursively detect cylces by removing condorcet losers until either only one pair is left or condorcet loosers no longer exist
This method upholds the invariant that in a ranking for all a,b either a>b or b>a for all a,b.
Returns
-------
out : False if the pairs do not contain a cycle, True if the pairs contain a cycle
"""
# get all condorcet losers (pairs that loose to all other pairs)
# idea: filter all losers that are never winners
# print("pairs", pairs)
if len(pairs) <= 1:
return False
losers = [c_lose for c_lose in np.unique(pairs[:, 1]) if c_lose not in pairs[:, 0]]
if len(losers) == 0:
# if we recursively removed pairs, and at some point we did not have
# a condorcet loser, that means everything is both a winner and loser,
# yielding at least one (winner,loser), (loser,winner) pair
return True
new = []
for p in pairs:
if p[1] not in losers:
new.append(p)
return cycle_detect(np.array(new))
def get_winner(pairs):
"""
This returns _one_ concordant winner.
It could be that there are multiple concordant winners, but in our case
since we are interested in a ranking, we have to choose one at random.
"""
losers = np.unique(pairs[:, 1]).astype(int)
winners = np.unique(pairs[:, 0]).astype(int)
for w in winners:
if w not in losers:
return w
def get_ranking(pairs):
"""
Abuses concordance property to get a (not necessarily unqiue) ranking.
The lack of uniqueness is due to the potential existance of multiple
equally ranked winners. We have to pick one, which is where
the non-uniqueness comes from
"""
if len(pairs) == 1:
return list(pairs[0])
w = get_winner(pairs)
# now remove the winner from the list of pairs
p_new = np.array([(a, b) for a, b in pairs if a != w])
return [w] + get_ranking(p_new)
def ranked_pairs(ranks: List[List[int]]):
"""
Expects a list of rankings for an item like:
[("w","x","z","y") for _ in range(3)]
+ [("w","y","x","z") for _ in range(2)]
+ [("x","y","z","w") for _ in range(4)]
+ [("x","z","w","y") for _ in range(5)]
+ [("y","w","x","z") for _ in range(1)]
This code is quite brain melting, but the idea is the following:
1. create a head-to-head matrix that tallies up all win-lose combinations of preferences
2. take all combinations that win more than they loose and sort those by how often they win
3. use that to create an (implicit) directed graph
4. recursively extract nodes from the graph that do not have incoming edges
5. said recursive list is the ranking
"""
tallies, names = head_to_head_votes(ranks)
tallies = tallies - tallies.T
# print(tallies)
# note: the resulting tally matrix should be skew-symmetric
# order by strenght of victory (using tideman's original method, don't think it would make a difference for us)
sorted_majorities = []
for i in range(len(ranks[0])):
for j in range(len(ranks[i])):
if tallies[i, j] > 0:
sorted_majorities.append((i, j, tallies[i, j]))
# we don't explicitly deal with tied majorities here
sorted_majorities = np.array(sorted(sorted_majorities, key=lambda x: x[2], reverse=True))
# now do lock ins
lock_ins = []
for (x, y, _) in sorted_majorities:
# invariant: lock_ins has no cycles here
lock_ins.append((x, y))
# print("lock ins are now",np.array(lock_ins))
if cycle_detect(np.array(lock_ins)):
# print("backup: cycle detected")
# if there's a cycle, delete the new addition and continue
lock_ins = lock_ins[:-1]
# now simply return all winners in order, and attach the losers
# to the back. This is because the overall loser might not be unique
# and (by concordance property) may never exist in any winning set to begin with.
# (otherwise he would either not be the loser, or cycles exist!)
# Since there could be multiple overall losers, we just return them in any order
# as we are unable to find a closer ranking
numerical_ranks = np.array(get_ranking(np.array(lock_ins))).astype(int)
conversion = [names[n] for n in numerical_ranks]
return conversion
if __name__ == "__main__":
ranks = (
[("w", "x", "z", "y") for _ in range(1)]
+ [("w", "y", "x", "z") for _ in range(2)]
# + [("x","y","z","w") for _ in range(4)]
+ [("x", "z", "w", "y") for _ in range(5)]
+ [("y", "w", "x", "z") for _ in range(1)]
# [("y","z","w","x") for _ in range(1000)]
)
rp = ranked_pairs(ranks)
print(rp)
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE DATABASE ocgpt_backend;
EOSQL
-17
View File
@@ -1,17 +0,0 @@
version: "3.7"
services:
db:
image: postgres
restart: always
ports:
- 5432:5432
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
adminer:
image: adminer
restart: always
ports:
- 8089:8080
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
export DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres
uvicorn app.main:app --reload