diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b31d1e8f..5684bad2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,6 +77,7 @@ repos: hooks: - id: prettier args: [--prose-wrap=always, --write] + exclude: website/tailwind.config.js|website/.storybook/main.js|website/.eslintrc.json - repo: local hooks: diff --git a/backend/alembic/versions/2023_01_24_2256-40ed93df0ed5_add_message_emoji.py b/backend/alembic/versions/2023_01_24_2256-40ed93df0ed5_add_message_emoji.py new file mode 100644 index 00000000..17368c0d --- /dev/null +++ b/backend/alembic/versions/2023_01_24_2256-40ed93df0ed5_add_message_emoji.py @@ -0,0 +1,44 @@ +"""add message_emoji + +Revision ID: 40ed93df0ed5 +Revises: 8ba17b5f467a +Create Date: 2023-01-24 22:56:28.229408 + +""" +import sqlalchemy as sa +import sqlmodel +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "40ed93df0ed5" +down_revision = "8ba17b5f467a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "message_emoji", + sa.Column("message_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_date", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False + ), + sa.Column("emoji", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.ForeignKeyConstraint(["message_id"], ["message.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("message_id", "user_id", "emoji"), + ) + op.create_index("ix_message_emoji__user_id__message_id", "message_emoji", ["user_id", "message_id"], unique=False) + op.add_column("message", sa.Column("emojis", postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("message", "emojis") + op.drop_index("ix_message_emoji__user_id__message_id", table_name="message_emoji") + op.drop_table("message_emoji") + # ### end Alembic commands ### diff --git a/backend/main.py b/backend/main.py index 147f8e30..a9dd8f72 100644 --- a/backend/main.py +++ b/backend/main.py @@ -273,6 +273,24 @@ def get_openapi_schema(): return json.dumps(app.openapi()) +def export_ready_trees(file: Optional[str] = None, use_compression: bool = False): + try: + with Session(engine) as db: + api_client = api_auth(settings.OFFICIAL_WEB_API_KEY, db=db) + dummy_user = protocol_schema.User(id="__dummy_user__", display_name="Dummy User", auth_method="local") + + ur = UserRepository(db=db, api_client=api_client) + tr = TaskRepository(db=db, api_client=api_client, client_user=dummy_user, user_repository=ur) + pr = PromptRepository( + db=db, api_client=api_client, client_user=dummy_user, user_repository=ur, task_repository=tr + ) + tm = TreeManager(db, pr) + + tm.export_all_ready_trees(file, use_compression=use_compression) + except Exception: + logger.exception("Error exporting trees.") + + def main(): # Importing here so we don't import packages unnecessarily if we're # importing main as a module. @@ -289,11 +307,21 @@ def main(): ) parser.add_argument("--host", help="The host to run the server", default="0.0.0.0") parser.add_argument("--port", help="The port to run the server", default=8080) + parser.add_argument( + "--export", help="Export all trees which are ready for exporting.", action=argparse.BooleanOptionalAction + ) + parser.add_argument( + "--export-file", + help="Name of file to export trees to. If not provided when exporting, output will be send to STDOUT", + ) args = parser.parse_args() if args.print_openapi_schema: print(get_openapi_schema()) + elif args.export: + use_compression: bool = ".gz" in args.export_file + export_ready_trees(file=args.export_file, use_compression=use_compression) else: uvicorn.run(app, host=args.host, port=args.port) diff --git a/backend/oasst_backend/api/v1/messages.py b/backend/oasst_backend/api/v1/messages.py index 06dd3fe1..af3ae42d 100644 --- a/backend/oasst_backend/api/v1/messages.py +++ b/backend/oasst_backend/api/v1/messages.py @@ -7,6 +7,7 @@ from oasst_backend.api import deps from oasst_backend.api.v1 import utils from oasst_backend.models import ApiClient from oasst_backend.prompt_repository import PromptRepository +from oasst_backend.utils.database_utils import CommitMode, managed_tx_function from oasst_shared.exceptions.oasst_api_error import OasstError, OasstErrorCode from oasst_shared.schemas import protocol from sqlmodel import Session @@ -229,3 +230,22 @@ def mark_message_deleted( ): pr = PromptRepository(db, api_client) pr.mark_messages_deleted(message_id) + + +@router.post("/{message_id}/emoji", response_model=protocol.Message) +def post_message_emoji( + *, + message_id: UUID, + request: protocol.MessageEmojiRequest, + api_client: ApiClient = Depends(deps.get_api_client), +) -> protocol.Message: + """ + Toggle, add or remove message emoji. + """ + + @managed_tx_function(CommitMode.COMMIT) + def emoji_tx(session: deps.Session): + pr = PromptRepository(session, api_client, client_user=request.user) + return pr.handle_message_emoji(message_id, request.op, request.emoji) + + return utils.prepare_message(emoji_tx()) diff --git a/backend/oasst_backend/api/v1/utils.py b/backend/oasst_backend/api/v1/utils.py index 99161e32..8b0f378f 100644 --- a/backend/oasst_backend/api/v1/utils.py +++ b/backend/oasst_backend/api/v1/utils.py @@ -14,6 +14,7 @@ def prepare_message(m: Message) -> protocol.Message: lang=m.lang, is_assistant=(m.role == "assistant"), created_date=m.created_date, + emojis=m.emojis, ) diff --git a/backend/oasst_backend/models/__init__.py b/backend/oasst_backend/models/__init__.py index 2b30b475..420c0ccd 100644 --- a/backend/oasst_backend/models/__init__.py +++ b/backend/oasst_backend/models/__init__.py @@ -2,6 +2,7 @@ from .api_client import ApiClient from .journal import Journal, JournalIntegration from .message import Message from .message_embedding import MessageEmbedding +from .message_emoji import MessageEmoji from .message_reaction import MessageReaction from .message_toxicity import MessageToxicity from .message_tree_state import MessageTreeState @@ -24,4 +25,5 @@ __all__ = [ "TextLabels", "Journal", "JournalIntegration", + "MessageEmoji", ] diff --git a/backend/oasst_backend/models/message.py b/backend/oasst_backend/models/message.py index d0b1d869..da0c06c3 100644 --- a/backend/oasst_backend/models/message.py +++ b/backend/oasst_backend/models/message.py @@ -49,6 +49,8 @@ class Message(SQLModel, table=True): rank: Optional[int] = Field(nullable=True) + emojis: dict[str, int] = Field(default={}, sa_column=sa.Column(pg.JSONB), nullable=False) + def ensure_is_message(self) -> None: if not self.payload or not isinstance(self.payload.payload, MessagePayload): raise OasstError("Invalid message", OasstErrorCode.INVALID_MESSAGE, HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/backend/oasst_backend/models/message_emoji.py b/backend/oasst_backend/models/message_emoji.py new file mode 100644 index 00000000..9e6e92fb --- /dev/null +++ b/backend/oasst_backend/models/message_emoji.py @@ -0,0 +1,27 @@ +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, Index, SQLModel + + +class MessageEmoji(SQLModel, table=True): + __tablename__ = "message_emoji" + __table_args__ = (Index("ix_message_emoji__user_id__message_id", "user_id", "message_id", unique=False),) + + message_id: Optional[UUID] = Field( + sa_column=sa.Column( + pg.UUID(as_uuid=True), sa.ForeignKey("message.id", ondelete="CASCADE"), nullable=False, primary_key=True + ) + ) + user_id: UUID = Field( + sa_column=sa.Column( + pg.UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False, primary_key=True + ) + ) + emoji: str = Field(nullable=False, max_length=128, primary_key=True) + created_date: Optional[datetime] = Field( + sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False, server_default=sa.func.current_timestamp()) + ) diff --git a/backend/oasst_backend/prompt_repository.py b/backend/oasst_backend/prompt_repository.py index 8c746320..bbc8abe2 100644 --- a/backend/oasst_backend/prompt_repository.py +++ b/backend/oasst_backend/prompt_repository.py @@ -2,7 +2,7 @@ import random from collections import defaultdict from datetime import datetime from http import HTTPStatus -from typing import List, Optional, Tuple +from typing import Optional from uuid import UUID, uuid4 import oasst_backend.models.db_payload as db_payload @@ -13,6 +13,7 @@ from oasst_backend.models import ( ApiClient, Message, MessageEmbedding, + MessageEmoji, MessageReaction, MessageToxicity, MessageTreeState, @@ -29,6 +30,7 @@ from oasst_shared.exceptions import OasstError, OasstErrorCode from oasst_shared.schemas import protocol as protocol_schema from oasst_shared.schemas.protocol import SystemStats from oasst_shared.utils import unaware_to_utc +from sqlalchemy.orm.attributes import flag_modified from sqlmodel import Session, and_, func, not_, or_, text, update from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND @@ -255,7 +257,7 @@ class PromptRepository: return reaction @managed_tx_method(CommitMode.COMMIT) - def store_ranking(self, ranking: protocol_schema.MessageRanking) -> Tuple[MessageReaction, Task]: + def store_ranking(self, ranking: protocol_schema.MessageRanking) -> tuple[MessageReaction, Task]: # fetch task task = self.task_repository.fetch_task_by_frontend_message_id(ranking.message_id) self._validate_task(task, frontend_message_id=ranking.message_id) @@ -345,13 +347,13 @@ class PromptRepository: return message_toxicity @managed_tx_method(CommitMode.FLUSH) - def insert_message_embedding(self, message_id: UUID, model: str, embedding: List[float]) -> MessageEmbedding: + def insert_message_embedding(self, message_id: UUID, model: str, embedding: list[float]) -> MessageEmbedding: """Insert the embedding of a new message in the database. Args: message_id (UUID): the identifier of the message we want to save its embedding model (str): the model used for creating the embedding - embedding (List[float]): the values obtained from the message & model + embedding (list[float]): the values obtained from the message & model Raises: OasstError: if misses some of the before params @@ -383,7 +385,7 @@ class PromptRepository: return reaction @managed_tx_method(CommitMode.FLUSH) - def store_text_labels(self, text_labels: protocol_schema.TextLabels) -> Tuple[TextLabels, Task, Message]: + def store_text_labels(self, text_labels: protocol_schema.TextLabels) -> tuple[TextLabels, Task, Message]: valid_labels: Optional[list[str]] = None mandatory_labels: Optional[list[str]] = None @@ -529,6 +531,22 @@ class PromptRepository: qry = qry.filter(not_(Message.deleted)) return qry.all() + def fetch_user_message_trees( + self, user_id: Message.user_id, reviewed: bool = True, include_deleted: bool = False + ) -> list[Message]: + qry = self.db.query(Message).filter(Message.user_id == user_id) + if reviewed: + qry = qry.filter(Message.review_result) + if not include_deleted: + qry = qry.filter(not_(Message.deleted)) + return qry.all() + + def fetch_message_trees_ready_for_export(self) -> list[MessageTreeState]: + qry = self.db.query(MessageTreeState).filter( + MessageTreeState.state == message_tree_state.State.READY_FOR_EXPORT + ) + return qry.all() + def fetch_multiple_random_replies(self, max_size: int = 5, message_role: str = None): """ Fetch a conversation with multiple possible replies to it. @@ -827,3 +845,62 @@ WHERE message.id = cc.id; deleted=result.get(True, 0), message_trees=result.get(None, 0), ) + + def handle_message_emoji(self, message_id: UUID, op: protocol_schema.EmojiOp, emoji: protocol_schema) -> Message: + self.ensure_user_is_enabled() + + message = self.fetch_message(message_id) + + # check if emoji exists + existing_emoji = ( + self.db.query(MessageEmoji) + .filter( + MessageEmoji.message_id == message_id, MessageEmoji.user_id == self.user_id, MessageEmoji.emoji == emoji + ) + .one_or_none() + ) + + if existing_emoji: + if op == protocol_schema.EmojiOp.add: + logger.info(f"Emoji record already exists {message_id=}, {emoji=}, {self.user_id=}") + return message + elif op == protocol_schema.EmojiOp.togggle: + op = protocol_schema.EmojiOp.remove + + if existing_emoji is None: + if op == protocol_schema.EmojiOp.remove: + logger.info(f"Emoji record not found {message_id=}, {emoji=}, {self.user_id=}") + return message + elif op == protocol_schema.EmojiOp.togggle: + op = protocol_schema.EmojiOp.add + + if op == protocol_schema.EmojiOp.add: + # insert emoji record & increment count + message_emoji = MessageEmoji(message_id=message.id, user_id=self.user_id, emoji=emoji) + self.db.add(message_emoji) + emoji_counts = message.emojis + if not emoji_counts: + message.emojis = {emoji.value: 1} + else: + count = emoji_counts.get(emoji.value) or 0 + emoji_counts[emoji.value] = count + 1 + elif op == protocol_schema.EmojiOp.remove: + # remove emoji record and & decrement count + message = self.fetch_message(message_id) + self.db.delete(existing_emoji) + emoji_counts = message.emojis + count = emoji_counts.get(emoji.value) + if count is not None: + if count == 1: + del emoji_counts[emoji.value] + else: + emoji_counts[emoji.value] = count - 1 + flag_modified(message, "emojis") + self.db.add(message) + else: + raise OasstError("Emoji op not supported", OasstErrorCode.EMOJI_OP_UNSUPPORTED) + + flag_modified(message, "emojis") + self.db.add(message) + self.db.flush() + return message diff --git a/backend/oasst_backend/tree_manager.py b/backend/oasst_backend/tree_manager.py index d54df08b..48dea1c9 100644 --- a/backend/oasst_backend/tree_manager.py +++ b/backend/oasst_backend/tree_manager.py @@ -1,4 +1,6 @@ +import json import random +import sys from datetime import datetime from enum import Enum from http import HTTPStatus @@ -7,11 +9,13 @@ from uuid import UUID import numpy as np import pydantic +from fastapi.encoders import jsonable_encoder from loguru import logger from oasst_backend.api.v1.utils import prepare_conversation, prepare_conversation_message_list from oasst_backend.config import TreeManagerConfiguration, settings from oasst_backend.models import Message, MessageReaction, MessageTreeState, Task, TextLabels, User, message_tree_state from oasst_backend.prompt_repository import PromptRepository +from oasst_backend.utils import tree_export from oasst_backend.utils.database_utils import CommitMode, async_managed_tx_method, managed_tx_method from oasst_backend.utils.hugging_face import HfClassificationModel, HfEmbeddingModel, HfUrl, HuggingFaceAPI from oasst_backend.utils.ranking import ranked_pairs @@ -1184,14 +1188,55 @@ DELETE FROM user_stats WHERE user_id = :user_id; if ban: self.db.execute(update(User).filter(User.id == user_id).values(deleted=True, enabled=False)) + def export_trees_to_file( + self, + message_tree_ids: list[str], + file=None, + reviewed: bool = True, + include_deleted: bool = False, + use_compression: bool = False, + ) -> None: + trees_to_export: List[tree_export.ExportMessageTree] = [] + + for message_tree_id in message_tree_ids: + messages: List[Message] = self.pr.fetch_message_tree(message_tree_id, reviewed, include_deleted) + trees_to_export.append(tree_export.build_export_tree(message_tree_id, messages)) + + if file: + tree_export.write_trees_to_file(file, trees_to_export, use_compression) + else: + sys.stdout.write(json.dumps(jsonable_encoder(trees_to_export), indent=2)) + + def export_all_ready_trees( + self, file: str, reviewed: bool = True, include_deleted: bool = False, use_compression: bool = False + ) -> None: + message_tree_states: MessageTreeState = self.pr.fetch_message_trees_ready_for_export() + message_tree_ids = [ms.message_tree_id for ms in message_tree_states] + self.export_trees_to_file(message_tree_ids, file, reviewed, include_deleted, use_compression) + + def export_all_user_trees( + self, + user_id: str, + file: str, + reviewed: bool = True, + include_deleted: bool = False, + use_compression: bool = False, + ) -> None: + messages = self.pr.fetch_user_message_trees(UUID(user_id)) + message_tree_ids = [ms.message_tree_id for ms in messages] + self.export_trees_to_file(message_tree_ids, file, reviewed, include_deleted, use_compression) + if __name__ == "__main__": from oasst_backend.api.deps import api_auth + + # from oasst_backend.api.deps import create_api_client from oasst_backend.database import engine from oasst_backend.prompt_repository import PromptRepository with Session(engine) as db: api_client = api_auth(settings.OFFICIAL_WEB_API_KEY, db=db) + # api_client = create_api_client(session=db, description="test", frontend_type="bot") dummy_user = protocol_schema.User(id="__dummy_user__", display_name="Dummy User", auth_method="local") pr = PromptRepository(db=db, api_client=api_client, client_user=dummy_user) @@ -1200,25 +1245,22 @@ if __name__ == "__main__": tm = TreeManager(db, pr, cfg) tm.ensure_tree_states() - tm.purge_user_messages(user_id=UUID("2ef9ad21-0dc5-442d-8750-6f7f1790723f"), purge_initial_prompts=False) + # tm.purge_user_messages(user_id=UUID("2ef9ad21-0dc5-442d-8750-6f7f1790723f"), purge_initial_prompts=False) # tm.purge_user(user_id=UUID("2ef9ad21-0dc5-442d-8750-6f7f1790723f")) # db.commit() # print("query_num_active_trees", tm.query_num_active_trees()) # print("query_incomplete_rankings", tm.query_incomplete_rankings()) # print("query_replies_need_review", tm.query_replies_need_review()) + # print("query_incomplete_reply_reviews", tm.query_replies_need_review()) # print("query_incomplete_initial_prompt_reviews", tm.query_prompts_need_review()) # print("query_extendible_trees", tm.query_extendible_trees()) # print("query_extendible_parents", tm.query_extendible_parents()) - # print("query_tree_size", tm.query_tree_size(message_tree_id=UUID("bdf434cf-4df5-4b74-949c-a5a157bc3292"))) - - # print( - # "query_reviews_for_message", - # tm.query_reviews_for_message(message_id=UUID("6a444493-0d48-4316-a9f1-7e263f5a2473")), - # ) # print("next_task:", tm.next_task()) # print( - # "query_tree_ranking_results", tm.query_tree_ranking_results(UUID("6036f58f-41b5-48c4-bdd9-b16f34ab1312")) + # ".query_tree_ranking_results", tm.query_tree_ranking_results(UUID("2ac20d38-6650-43aa-8bb3-f61080c0d921")) # ) + + print(tm.export_trees_to_file(message_tree_ids=["7e75fb38-e664-4e2b-817c-b9a0b01b0074"], file="lol.jsonl")) diff --git a/backend/oasst_backend/utils/database_utils.py b/backend/oasst_backend/utils/database_utils.py index 34113cfc..fb8bf6c5 100644 --- a/backend/oasst_backend/utils/database_utils.py +++ b/backend/oasst_backend/utils/database_utils.py @@ -107,6 +107,7 @@ def managed_tx_function( auto_commit: CommitMode = CommitMode.COMMIT, num_retries=settings.DATABASE_MAX_TX_RETRY_COUNT, session_factory: Callable[..., Session] = default_session_factor, + refresh_result: bool = True, ): """Passes Session object as first argument to wrapped function.""" @@ -124,6 +125,8 @@ def managed_tx_function( session.flush() elif auto_commit == CommitMode.ROLLBACK: session.rollback() + if refresh_result and isinstance(result, SQLModel): + session.refresh(result) return result except OperationalError: logger.info(f"Retry {i+1}/{num_retries} after possible DB concurrent update conflict.") diff --git a/backend/oasst_backend/utils/tree_export.py b/backend/oasst_backend/utils/tree_export.py new file mode 100644 index 00000000..ee3de9d7 --- /dev/null +++ b/backend/oasst_backend/utils/tree_export.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import gzip +import json +from collections import defaultdict +from typing import Optional, TextIO + +from fastapi.encoders import jsonable_encoder +from oasst_backend.models import Message +from pydantic import BaseModel + + +class ExportMessageNode(BaseModel): + message_id: str + parent_id: Optional[str] + text: Optional[str] + role: str + review_count: Optional[int] + rank: Optional[int] + replies: Optional[list[ExportMessageNode]] + + @classmethod + def prep_message_export(cls, message: Message) -> ExportMessageNode: + return cls( + message_id=str(message.id), + parent_id=str(message.parent_id) if message.parent_id else None, + text=str(message.payload.payload.text), + role=message.role, + review_count=message.review_count, + rank=message.rank, + ) + + +class ExportMessageTree(BaseModel): + message_tree_id: str + replies: Optional[ExportMessageNode] + + +def build_export_tree(message_tree_id: str, messages: list[Message]) -> ExportMessageTree: + export_tree = ExportMessageTree(message_tree_id=str(message_tree_id)) + export_tree_data = [ExportMessageNode.prep_message_export(m) for m in messages] + + message_parents = defaultdict(list) + for message in export_tree_data: + message_parents[message.parent_id].append(message) + + def build_tree(tree: dict, parent: Optional[str], messages: list[Message]): + children = message_parents[parent] + tree.replies = children + + for idx, child in enumerate(tree.replies): + build_tree(tree.replies[idx], child.message_id, messages) + + build_tree(export_tree, None, export_tree_data) + + return export_tree + + +def write_trees_to_file(file, trees: list[ExportMessageTree], use_compression: bool = True) -> None: + + out_buff: TextIO + if use_compression: + out_buff = gzip.open(file, "wt", encoding="UTF-8") + else: + out_buff = open(file, "wt", encoding="UTF-8") + + with out_buff as f: + for tree in trees: + file_data = jsonable_encoder(tree) + json.dump(file_data, f) + f.write("\n") diff --git a/oasst-shared/oasst_shared/exceptions/oasst_api_error.py b/oasst-shared/oasst_shared/exceptions/oasst_api_error.py index 0a548ebb..9764062e 100644 --- a/oasst-shared/oasst_shared/exceptions/oasst_api_error.py +++ b/oasst-shared/oasst_shared/exceptions/oasst_api_error.py @@ -76,6 +76,8 @@ class OasstErrorCode(IntEnum): USER_DISABLED = 4001 USER_NOT_FOUND = 4002 + EMOJI_OP_UNSUPPORTED = 5000 + class OasstError(Exception): """Base class for Open-Assistant exceptions.""" diff --git a/oasst-shared/oasst_shared/schemas/protocol.py b/oasst-shared/oasst_shared/schemas/protocol.py index 20bbdf9b..bb54b502 100644 --- a/oasst-shared/oasst_shared/schemas/protocol.py +++ b/oasst-shared/oasst_shared/schemas/protocol.py @@ -80,6 +80,7 @@ class Conversation(BaseModel): class Message(ConversationMessage): parent_id: Optional[UUID] = None created_date: Optional[datetime] = None + emojis: Optional[dict] = None class MessagePage(PageResult): @@ -432,3 +433,27 @@ class OasstErrorResponse(BaseModel): error_code: OasstErrorCode message: str + + +class EmojiCode(str, enum.Enum): + thumbs_up = "+1" # πŸ‘ + thumbs_down = "-1" # πŸ‘Ž + red_flag = "red_flag" # 🚩 + hundred = "100" # πŸ’― + rofl = "rofl" # 🀣" + heart_eyes = "heart_eyes" # 😍 + disappointed = "disappointed" # 😞 + poop = "poop" # πŸ’© + skull = "skull" # πŸ’€ + + +class EmojiOp(str, enum.Enum): + togggle = "toggle" + add = "add" + remove = "remove" + + +class MessageEmojiRequest(BaseModel): + user: User + op: EmojiOp = EmojiOp.togggle + emoji: EmojiCode diff --git a/website/public/locales/en/common.json b/website/public/locales/en/common.json index d18e5d91..8f35eaab 100644 --- a/website/public/locales/en/common.json +++ b/website/public/locales/en/common.json @@ -14,5 +14,6 @@ "sign_in": "Sign In", "sign_out": "Sign Out", "terms_of_service": "Terms of Service", - "title": "Open Assistant" + "title": "Open Assistant", + "more_information": "More Information" } diff --git a/website/public/locales/en/dashboard.json b/website/public/locales/en/dashboard.json new file mode 100644 index 00000000..0427fb0d --- /dev/null +++ b/website/public/locales/en/dashboard.json @@ -0,0 +1,8 @@ +{ + "grab_a_task": "Grab a task!", + "create": "Create", + "evaluate": "Evaluate", + "label": "Label", + "dashboard": "Dashboard", + "go": "Go" +} diff --git a/website/public/locales/en/tasks.json b/website/public/locales/en/tasks.json new file mode 100644 index 00000000..553a26d2 --- /dev/null +++ b/website/public/locales/en/tasks.json @@ -0,0 +1,79 @@ +{ + "write_initial_prompt": "Write your prompt here...", + "default": { + "unchanged_title": "No changes", + "unchanged_message": "Are you sure you would like to continue?" + }, + "random": { + "label": "I'm feeling lucky", + "desc": "Help us improve Open Assistant by starting a random task." + }, + "create_initial_prompt": { + "label": "Create Initial Prompts", + "desc": "Write initial prompts to help Open Assistant to try replying to diverse messages.", + "overview": "Create an initial message to send to the assistant", + "instruction": "Provide the initial prompts" + }, + "reply_as_user": { + "label": "Reply as User", + "desc": "Chat with Open Assistant and help improve it's responses as you interact with it.", + "overview": "Given the following conversation, provide an adequate reply", + "instruction": "Provide the user's reply" + }, + "reply_as_assistant": { + "label": "Reply as Assistant", + "desc": "Help Open Assistant improve its responses to conversations with other users.", + "overview": "Given the following conversation, provide an adequate reply" + }, + "rank_user_replies": { + "label": "Rank User Replies", + "desc": "Help Open Assistant improve its responses to conversations with other users.", + "overview": "Given the following User replies, sort them from best to worst, best being first, worst being last.", + "unchanged_title": "Order Unchanged", + "unchanged_message": "You have not changed the order of the prompts. Are you sure you would like to continue?" + }, + "rank_assistant_replies": { + "label": "Rank Assistant Replies", + "desc": "Score prompts given by Open Assistant based on their accuracy and readability.", + "overview": "Given the following Assistant replies, sort them from best to worst, best being first, worst being last.", + "unchanged_title": "Order Unchanged", + "unchanged_message": "You have not changed the order of the prompts. Are you sure you would like to continue?" + }, + "rank_initial_prompts": { + "label": "Rank Initial Prompts", + "desc": "Score prompts given by Open Assistant based on their accuracy and readability.", + "overview": "Given the following initial prompts, sort them from best to worst, best being first, worst being last.", + "unchanged_title": "Order Unchanged", + "unchanged_message": "You have not changed the order of the prompts. Are you sure you would like to continue?" + }, + "label_initial_prompt": { + "label": "Label Initial Prompt", + "desc": "Provide labels for a prompt.", + "overview": "Provide labels for the following prompt" + }, + "label_prompter_reply": { + "label": "Label Prompter Reply", + "desc": "Provide labels for a prompt.", + "overview": "Given the following discussion, provide labels for the final prompt." + }, + "label_assistant_reply": { + "label": "Label Assistant Reply", + "desc": "Provide labels for a prompt.", + "overview": "Given the following discussion, provide labels for the final prompt." + }, + "classify_initial_prompt": { + "label": "Classify Initial Prompt", + "desc": "Provide labels for a prompt.", + "overview": "Read the following prompt and then answer the question about it." + }, + "classify_prompter_reply": { + "label": "Classify Prompter Reply", + "desc": "Provide labels for a prompt.", + "overview": "Read the following conversation and then answer the question about the last reply in the discussion." + }, + "classify_assistant_reply": { + "label": "Classify Assistant Reply", + "desc": "Provide labels for a prompt.", + "overview": "Read the following conversation and then answer the question about the last reply in the discussion." + } +} diff --git a/website/src/components/Dashboard/TaskOption.tsx b/website/src/components/Dashboard/TaskOption.tsx index fd4bef2b..3497d14c 100644 --- a/website/src/components/Dashboard/TaskOption.tsx +++ b/website/src/components/Dashboard/TaskOption.tsx @@ -12,7 +12,9 @@ import { } from "@chakra-ui/react"; import { HelpCircle } from "lucide-react"; import Link from "next/link"; +import { useTranslation } from "next-i18next"; import { useMemo } from "react"; +import { getTypeSafei18nKey } from "src/lib/i18n"; import { TaskType } from "src/types/Task"; import { TaskCategory, TaskCategoryLabels, TaskInfo, TaskInfos } from "../Tasks/TaskTypes"; @@ -22,6 +24,7 @@ export interface TasksOptionProps { } export const TaskOption = ({ content }: TasksOptionProps) => { + const { t } = useTranslation(["dashboard", "tasks"]); const backgroundColor = useColorModeValue("white", "gray.700"); const taskInfoMap = useMemo( @@ -41,7 +44,7 @@ export const TaskOption = ({ content }: TasksOptionProps) => {
- {TaskCategoryLabels[category]} + {t(TaskCategoryLabels[category])} @@ -52,7 +55,7 @@ export const TaskOption = ({ content }: TasksOptionProps) => { {taskTypes .map((taskType) => taskInfoMap[taskType]) .map((item) => ( - + { className="flex flex-col justify-between h-full" > - {item.label} - {item.desc} + {t(getTypeSafei18nKey(`tasks:${item.id}.label`))} + {t(getTypeSafei18nKey(`tasks:${item.id}.desc`))} { borderBottomRadius="xl" className="px-6 py-2 transition-colors duration-300 bg-blue-500 hover:bg-blue-600" > - Go -> + {t("go")} -> diff --git a/website/src/components/Tasks/CreateTask.tsx b/website/src/components/Tasks/CreateTask.tsx index 647bed4f..36493e27 100644 --- a/website/src/components/Tasks/CreateTask.tsx +++ b/website/src/components/Tasks/CreateTask.tsx @@ -1,10 +1,12 @@ import { Box, Stack, Text, useColorModeValue } from "@chakra-ui/react"; +import { useTranslation } from "next-i18next"; import { useState } from "react"; import { MessageTable } from "src/components/Messages/MessageTable"; import { TrackedTextarea } from "src/components/Survey/TrackedTextarea"; import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards"; import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskHeader } from "src/components/Tasks/TaskHeader"; +import { getTypeSafei18nKey } from "src/lib/i18n"; export const CreateTask = ({ task, @@ -14,14 +16,15 @@ export const CreateTask = ({ onReplyChanged, onValidityChanged, }: TaskSurveyProps<{ text: string }>) => { + const { t, i18n } = useTranslation(["tasks", "common"]); const cardColor = useColorModeValue("gray.50", "gray.800"); const titleColor = useColorModeValue("gray.800", "gray.300"); - const [inputText, setInputText] = useState(""); + const textChangeHandler = (event: React.ChangeEvent) => { const text = event.target.value; - const isTextBlank = !text || /^\s*$/.test(text) ? true : false; onReplyChanged({ text }); + const isTextBlank = !text || /^\s*$/.test(text); if (!isTextBlank) { onValidityChanged("VALID"); setInputText(text); @@ -36,22 +39,24 @@ export const CreateTask = ({ <> - {task.conversation ? ( + {!!task.conversation && ( - ) : null} + )} <> - - {taskType.instruction} - + {!!i18n.exists(`task.${taskType.id}.instruction`) && ( + + {t(getTypeSafei18nKey(`${taskType.id}.instruction`))} + + )} diff --git a/website/src/components/Tasks/Task/Task.tsx b/website/src/components/Tasks/Task/Task.tsx index b16711e6..ae82ef97 100644 --- a/website/src/components/Tasks/Task/Task.tsx +++ b/website/src/components/Tasks/Task/Task.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "next-i18next"; import { useRef, useState } from "react"; import { TaskControls } from "src/components/Survey/TaskControls"; import { CreateTask } from "src/components/Tasks/CreateTask"; @@ -6,6 +7,7 @@ import { LabelTask } from "src/components/Tasks/LabelTask"; import { TaskCategory, TaskInfo, TaskInfos } from "src/components/Tasks/TaskTypes"; import { UnchangedWarning } from "src/components/Tasks/UnchangedWarning"; import { post } from "src/lib/api"; +import { getTypeSafei18nKey } from "src/lib/i18n"; import { TaskContent, TaskReplyValidity } from "src/types/Task"; import useSWRMutation from "swr/mutation"; @@ -23,6 +25,7 @@ export interface TaskSurveyProps { } export const Task = ({ frontendId, task, trigger, mutate }) => { + const { t } = useTranslation("tasks"); const [taskStatus, setTaskStatus] = useState("NOT_SUBMITTABLE"); const replyContent = useRef(null); const [showUnchangedWarning, setShowUnchangedWarning] = useState(false); @@ -111,7 +114,6 @@ export const Task = ({ frontendId, task, trigger, mutate }) => { case TaskCategory.Create: return ( { case TaskCategory.Evaluate: return ( { case TaskCategory.Label: return ( { /> setShowUnchangedWarning(false)} onContinueAnyway={() => { diff --git a/website/src/components/Tasks/TaskHeader/TaskHeader.tsx b/website/src/components/Tasks/TaskHeader/TaskHeader.tsx index b2dbd817..c4095eb8 100644 --- a/website/src/components/Tasks/TaskHeader/TaskHeader.tsx +++ b/website/src/components/Tasks/TaskHeader/TaskHeader.tsx @@ -1,6 +1,8 @@ import { HStack, IconButton, Link, Stack, Text, useColorModeValue } from "@chakra-ui/react"; import { HelpCircle } from "lucide-react"; +import { useTranslation } from "next-i18next"; import type { TaskInfo } from "src/components/Tasks/TaskTypes"; +import { getTypeSafei18nKey } from "src/lib/i18n"; interface TaskHeaderProps { /** @@ -13,20 +15,21 @@ interface TaskHeaderProps { * Presents the Task label, instructions, and help link */ const TaskHeader = ({ taskType }: TaskHeaderProps) => { + const { t } = useTranslation(["tasks", "common"]); const labelColor = useColorModeValue("gray.600", "gray.400"); const titleColor = useColorModeValue("gray.800", "gray.300"); return ( - {taskType.label} + {t(getTypeSafei18nKey(`${taskType.id}.label`))} } /> - {taskType.overview} + {t(getTypeSafei18nKey(`${taskType.id}.overview`))} ); diff --git a/website/src/components/Tasks/TaskTypes.tsx b/website/src/components/Tasks/TaskTypes.tsx index cfa5982a..e2a53d23 100644 --- a/website/src/components/Tasks/TaskTypes.tsx +++ b/website/src/components/Tasks/TaskTypes.tsx @@ -1,181 +1,150 @@ +import { TaskType } from "src/types/Task"; + export enum TaskCategory { - Random = "Random", Create = "Create", Evaluate = "Evaluate", Label = "Label", + Random = "Random", +} + +export enum TaskUpdateType { + MessageRanking = "message_ranking", + Random = "random", + TextLabels = "text_labels", + TextReplyToMessage = "text_reply_to_message", } export interface TaskInfo { - label: string; - desc: string; category: TaskCategory; + help_link: string; + id: string; + mode?: string; pathname: string; type: string; - help_link: string; - mode?: string; - overview?: string; - instruction?: string; update_type: string; - unchanged_title?: string; - unchanged_message?: string; } export const TaskCategoryLabels: { [key in TaskCategory]: string } = { - [TaskCategory.Random]: "Grab a task!", - [TaskCategory.Create]: "Create", - [TaskCategory.Evaluate]: "Evaluate", - [TaskCategory.Label]: "Label", + [TaskCategory.Random]: "grab_a_task", + [TaskCategory.Create]: "create", + [TaskCategory.Evaluate]: "evaluate", + [TaskCategory.Label]: "label", }; export const TaskInfos: TaskInfo[] = [ // general/random { - label: "I'm feeling lucky", - desc: "Help us improve Open Assistant by starting a random task.", + id: "random", category: TaskCategory.Random, pathname: "/tasks/random", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - type: "random", - update_type: "random", + type: TaskType.random, + update_type: TaskUpdateType.Random, }, // create { - label: "Create Initial Prompts", - desc: "Write initial prompts to help Open Assistant to try replying to diverse messages.", + id: "create_initial_prompt", category: TaskCategory.Create, pathname: "/create/initial_prompt", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - type: "initial_prompt", - overview: "Create an initial message to send to the assistant", - instruction: "Provide the initial prompt", - update_type: "text_reply_to_message", + type: TaskType.initial_prompt, + update_type: TaskUpdateType.TextReplyToMessage, }, { - label: "Reply as User", - desc: "Chat with Open Assistant and help improve it’s responses as you interact with it.", + id: "reply_as_user", category: TaskCategory.Create, pathname: "/create/user_reply", help_link: "https://projects.laion.ai/Open-Assistant/docs/tasks/reply_as_user", - type: "prompter_reply", - overview: "Given the following conversation, provide an adequate reply", - instruction: "Provide the user's reply", - update_type: "text_reply_to_message", + type: TaskType.prompter_reply, + update_type: TaskUpdateType.TextReplyToMessage, }, { - label: "Reply as Assistant", - desc: "Help Open Assistant improve its responses to conversations with other users.", + id: "reply_as_assistant", category: TaskCategory.Create, pathname: "/create/assistant_reply", help_link: "https://projects.laion.ai/Open-Assistant/docs/tasks/reply_as_assistant", - type: "assistant_reply", - overview: "Given the following conversation, provide an adequate reply", - instruction: "Provide the assistant's reply", - update_type: "text_reply_to_message", + type: TaskType.assistant_reply, + update_type: TaskUpdateType.TextReplyToMessage, }, // evaluate { - label: "Rank User Replies", + id: "rank_user_replies", category: TaskCategory.Evaluate, - desc: "Help Open Assistant improve its responses to conversations with other users.", pathname: "/evaluate/rank_user_replies", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: "Given the following User replies, sort them from best to worst, best being first, worst being last.", - type: "rank_prompter_replies", - update_type: "message_ranking", - unchanged_title: "Order Unchanged", - unchanged_message: "You have not changed the order of the prompts. Are you sure you would like to continue?", + type: TaskType.rank_prompter_replies, + update_type: TaskUpdateType.MessageRanking, }, { - label: "Rank Assistant Replies", - desc: "Score prompts given by Open Assistant based on their accuracy and readability.", + id: "rank_assistant_replies", category: TaskCategory.Evaluate, pathname: "/evaluate/rank_assistant_replies", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: - "Given the following Assistant replies, sort them from best to worst, best being first, worst being last.", - type: "rank_assistant_replies", - update_type: "message_ranking", - unchanged_title: "Order Unchanged", - unchanged_message: "You have not changed the order of the prompts. Are you sure you would like to continue?", + type: TaskType.rank_assistant_replies, + update_type: TaskUpdateType.MessageRanking, }, { - label: "Rank Initial Prompts", - desc: "Score prompts given by Open Assistant based on their accuracy and readability.", + id: "rank_initial_prompts", category: TaskCategory.Evaluate, pathname: "/evaluate/rank_initial_prompts", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: "Given the following initial prompts, sort them from best to worst, best being first, worst being last.", - type: "rank_initial_prompts", - update_type: "message_ranking", - unchanged_title: "Order Unchanged", - unchanged_message: "You have not changed the order of the prompts. Are you sure you would like to continue?", + type: TaskType.rank_initial_prompts, + update_type: TaskUpdateType.MessageRanking, }, // label (full) { - label: "Label Initial Prompt", - desc: "Provide labels for a prompt.", + id: "label_initial_prompt", category: TaskCategory.Label, pathname: "/label/label_initial_prompt", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: "Provide labels for the following prompt", - type: "label_initial_prompt", + type: TaskType.label_initial_prompt, mode: "full", - update_type: "text_labels", + update_type: TaskUpdateType.TextLabels, }, { - label: "Label Prompter Reply", - desc: "Provide labels for a prompt.", + id: "label_prompter_reply", category: TaskCategory.Label, pathname: "/label/label_prompter_reply", help_link: "https://projects.laion.ai/Open-Assistant/docs/tasks/label_prompter_reply", - overview: "Given the following discussion, provide labels for the final prompt.", - type: "label_prompter_reply", + type: TaskType.label_prompter_reply, mode: "full", - update_type: "text_labels", + update_type: TaskUpdateType.TextLabels, }, { - label: "Label Assistant Reply", - desc: "Provide labels for a prompt.", + id: "label_assistant_reply", category: TaskCategory.Label, pathname: "/label/label_assistant_reply", help_link: "https://projects.laion.ai/Open-Assistant/docs/tasks/label_assistant_reply", - overview: "Given the following discussion, provide labels for the final prompt.", - type: "label_assistant_reply", + type: TaskType.label_assistant_reply, mode: "full", - update_type: "text_labels", + update_type: TaskUpdateType.TextLabels, }, // label (simple) { - label: "Classify Initial Prompt", - desc: "Provide labels for a prompt.", + id: "classify_initial_prompt", category: TaskCategory.Label, pathname: "/label/label_initial_prompt", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: "Read the following prompt and then answer the question about it.", - type: "label_initial_prompt", + type: TaskType.label_initial_prompt, mode: "simple", - update_type: "text_labels", + update_type: TaskUpdateType.TextLabels, }, { - label: "Classify Prompter Reply", - desc: "Provide labels for a prompt.", + id: "classify_prompter_reply", category: TaskCategory.Label, pathname: "/label/label_prompter_reply", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: "Read the following conversation and then answer the question about the last reply in the discussion.", - type: "label_prompter_reply", + type: TaskType.label_prompter_reply, mode: "simple", - update_type: "text_labels", + update_type: TaskUpdateType.TextLabels, }, { - label: "Classify Assistant Reply", - desc: "Provide labels for a prompt.", + id: "classify_assistant_reply", category: TaskCategory.Label, pathname: "/label/label_assistant_reply", help_link: "https://projects.laion.ai/Open-Assistant/docs/guides/prompting", - overview: "Read the following conversation and then answer the question about the last reply in the discussion.", - type: "label_assistant_reply", + type: TaskType.label_assistant_reply, mode: "simple", - update_type: "text_labels", + update_type: TaskUpdateType.TextLabels, }, ]; diff --git a/website/src/lib/i18n.ts b/website/src/lib/i18n.ts new file mode 100644 index 00000000..72712d27 --- /dev/null +++ b/website/src/lib/i18n.ts @@ -0,0 +1 @@ +export const getTypeSafei18nKey = (key: string) => key as unknown as TemplateStringsArray; diff --git a/website/src/pages/dashboard.tsx b/website/src/pages/dashboard.tsx index 17e04c8a..35c254a4 100644 --- a/website/src/pages/dashboard.tsx +++ b/website/src/pages/dashboard.tsx @@ -12,8 +12,9 @@ import useSWR from "swr"; const Dashboard = () => { const { + t, i18n: { language }, - } = useTranslation(); + } = useTranslation(["dashboard", "common", "tasks"]); const [activeLang, setLang] = useState(null); const { data, mutate: fetchTasks } = useSWR("/api/available_tasks", get, { refreshInterval: 2 * 60 * 1000, //2 minutes @@ -36,7 +37,7 @@ const Dashboard = () => { return ( <> - Dashboard - Open Assistant + {`${t("dashboard")} - ${t("common:title")}`} @@ -54,6 +55,6 @@ export default Dashboard; const filterAvailableTasks = (availableTasks: Partial) => Object.entries(availableTasks) - .filter(([_, count]) => count > 0) + .filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]) .map(([taskType]) => taskType) as TaskType[]; diff --git a/website/src/pages/leaderboard.tsx b/website/src/pages/leaderboard.tsx index 18f64bac..c4d317b1 100644 --- a/website/src/pages/leaderboard.tsx +++ b/website/src/pages/leaderboard.tsx @@ -11,23 +11,22 @@ const Leaderboard = () => { return ( <> - {`${t("leaderboard:leaderboard")} - ${t("common:title")}`} + {`${t("leaderboard")} - ${t("common:title")}`} - {t("leaderboard:leaderboard")} + {t("leaderboard")} - {t("leaderboard:daily")} - {t("leaderboard:weekly")} - {t("leaderboard:monthly")} - {t("leaderboard:overall")} + {t("daily")} + {t("weekly")} + {t("monthly")} + {t("overall")} - diff --git a/website/types/i18next.d.ts b/website/types/i18next.d.ts index 9c447c2c..873101fd 100644 --- a/website/types/i18next.d.ts +++ b/website/types/i18next.d.ts @@ -1,15 +1,19 @@ import "i18next"; -import type common from "../public/locales/en/common.json"; -import type index from "../public/locales/en/index.json"; -import type leaderboard from "../public/locales/en/leaderboard.json"; +import type common from "public/locales/en/common.json"; +import type dashboard from "public/locales/en/dashboard.json"; +import type index from "public/locales/en/index.json"; +import type leaderboard from "public/locales/en/leaderboard.json"; +import type tasks from "public/locales/en/tasks.json"; declare module "i18next" { interface CustomTypeOptions { resources: { common: typeof common; + dashboard: typeof dashboard; index: typeof index; leaderboard: typeof leaderboard; + tasks: typeof tasks; }; } }