Repository: Solvro/backend-translator
Pull request #1: feat: openAI translations with caching
Author: login: czaja307
Labels: name: size/XL
Created: 2025-03-05T10:24:52Z | Merged: 2025-03-25T09:59:52Z

<!-- ELLIPSIS_HIDDEN -->

----

> [!IMPORTANT]
> This PR adds language and translation management with OpenAI integration, including new models, controllers, routes, and environment configurations.
> 
>   - **Environment Configuration**:
>     - Added `OPENAI_API_KEY` to `.env.example` and `start/env.ts` for OpenAI integration.
>   - **Docker Setup**:
>     - Introduced a multi-stage `Dockerfile` for build and deployment.
>   - **Documentation**:
>     - Expanded `README.md` with API documentation for language and translation endpoints.
>   - **Controllers**:
>     - `languages_controller.ts`: Added CRUD operations for languages.
>     - `translations_controller.ts`: Added CRUD operations for translations, including OpenAI translation requests.
>   - **Models**:
>     - `language.ts`: Defined `Language` model with relationships and timestamps.
>     - `translation.ts`: Defined `Translation` model with relationships, timestamps, and approval status.
>   - **Validators**:
>     - `language.ts`: Added validators for creating and updating languages.
>     - `translation.ts`: Added validators for creating, updating, and requesting translations via OpenAI.
>   - **Database Migrations**:
>     - Created `languages` and `translations` tables with necessary constraints.
>   - **Seeders**:
>     - Added a seeder for initial language data.
>   - **Dependencies**:
>     - Added `openai` package to `package.json` for OpenAI integration.
>   - **Routes**:
>     - Updated `start/routes.ts` to define routes for language and translation management, including OpenAI translation requests.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=Solvro%2Fbackend-translator&utm_source=github&utm_medium=referral)<sup> for 40ae2accbdf9a187319aa3a0c8124f0027b8f985. It will automatically update as commits are pushed.</sup>

<!-- ELLIPSIS_HIDDEN -->


Diff:
diff --git a/.env.example b/.env.example
index b0f1ec6..dcf9ff8 100644
--- a/.env.example
+++ b/.env.example
@@ -8,4 +8,5 @@ DB_HOST=127.0.0.1
 DB_PORT=5432
 DB_USER=root
 DB_PASSWORD=root
-DB_DATABASE=app
\ No newline at end of file
+DB_DATABASE=app
+OPENAI_API_KEY=elo_zelo
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..463b3cf
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+FROM node:22.14.0-alpine3.21 AS base
+
+# All deps stage
+FROM base AS deps
+WORKDIR /app
+ADD package.json package-lock.json ./
+RUN npm ci
+
+# Production only deps stage
+FROM base AS production-deps
+WORKDIR /app
+ADD package.json package-lock.json ./
+RUN npm ci --omit=dev
+
+# Build stage
+FROM base AS build
+WORKDIR /app
+COPY --from=deps /app/node_modules /app/node_modules
+ADD . .
+RUN node ace build
+
+# Production stage
+FROM base
+ENV NODE_ENV=production
+WORKDIR /app
+COPY --from=production-deps /app/node_modules /app/node_modules
+COPY --from=build /app/build /app
+EXPOSE 8080
+CMD ["node", "./bin/server.js"]
diff --git a/README.md b/README.md
index ddcc971..d4d2cd6 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,192 @@
 # backend-translator
+
+![Solvro banner](https://github.com/Solvro/backend-topwr-sks/blob/main/assets/solvro_dark.png#gh-dark-mode-only)
+![Solvro banner](https://github.com/Solvro/backend-topwr-sks/blob/main/assets/solvro_dark.png#gh-light-mode-only)
+
+# Translation API Documentation
+
+## Base URL
+
+```
+/api/v1
+```
+
+## Endpoints
+
+### Languages
+
+#### Get All Languages
+
+```
+GET /languages
+```
+
+**Response:** List of all available languages.
+
+#### Create a Language
+
+```
+POST /languages
+```
+
+**Body:**
+
+```json
+{
+  "isoCode": "en"
+}
+```
+
+**Response:** Created language object.
+
+#### Get Language by ISO Code
+
+```
+GET /languages/:isoCode
+```
+
+**Response:** Language object.
+
+#### Update Language
+
+```
+PUT /languages/:isoCode
+```
+
+**Body:**
+
+```json
+{
+  "name": "Updated Name"
+}
+```
+
+**Response:** Updated language object.
+
+#### Delete Language
+
+```
+DELETE /languages/:isoCode
+```
+
+**Response:** No content.
+
+---
+
+### Translations
+
+#### Get All Translations
+
+```
+GET /translations
+```
+
+**Response:** List of all translations.
+
+#### Create a Translation
+
+```
+POST /translations
+```
+
+**Body:**
+
+```json
+{
+  "originalText": "Hello",
+  "originalLanguageCode": "en",
+  "translatedLanguageCode": "fr",
+  "translatedText": "Bonjour"
+}
+```
+
+**Response:** Created translation object.
+
+#### Get Translations for a Specific Text
+
+```
+GET /translations/:hash
+```
+
+**Response:** List of translations for the given text hash.
+
+#### Get Translations for a Specific Language
+
+```
+GET /translations/:isoCode
+```
+
+**Response:** List of translations in the given language.
+
+#### Get Specific Translation
+
+```
+GET /translations/:hash/:isoCode
+```
+
+**Response:** Translation object.
+
+#### Update Translation
+
+```
+PUT /translations/:hash/:isoCode
+```
+
+**Body:**
+
+```json
+{
+  "originalText": "Hello",
+  "translatedText": "Salut"
+}
+```
+
+**Response:** Updated translation object.
+
+#### Delete Translation
+
+```
+DELETE /translations/:hash/:isoCode
+```
+
+**Response:** No content.
+
+#### Approve Translation
+
+```
+POST /translations/:hash/:isoCode/approve
+```
+
+**Response:** Approved translation object.
+
+#### Request Translation via OpenAI
+
+```
+POST /translations/openAI
+```
+
+**Body:**
+
+```json
+{
+  "originalText": "Hello",
+  "originalLanguageCode": "en",
+  "translatedLanguageCode": "es"
+}
+```
+
+**Response:** Translation object created using OpenAI.
+
+---
+
+## Notes
+
+- The `hash` parameter is generated using SHA-256 from `originalText`.
+- The OpenAI translation request utilizes `gpt-4o-mini`.
+- All responses follow JSON format.
+- Errors return appropriate HTTP status codes with messages.
+- **The API is generated by ChatGPT - watch out for inconsistencies**
+
+## Links
+
+[![docs.solvro.pl](https://i.imgur.com/fuV0gra.png)](https://docs.solvro.pl)
diff --git a/adonisrc.ts b/adonisrc.ts
index f6bd141..0291c9c 100644
--- a/adonisrc.ts
+++ b/adonisrc.ts
@@ -34,6 +34,7 @@ export default defineConfig({
     () => import("@adonisjs/core/providers/vinejs_provider"),
     () => import("@adonisjs/cors/cors_provider"),
     () => import("@adonisjs/lucid/database_provider"),
+    () => import("@adonisjs/auth/auth_provider"),
   ],
 
   /*
diff --git a/app/controllers/languages_controller.ts b/app/controllers/languages_controller.ts
new file mode 100644
index 0000000..7891ac7
--- /dev/null
+++ b/app/controllers/languages_controller.ts
@@ -0,0 +1,51 @@
+import type { HttpContext } from "@adonisjs/core/http";
+
+import Language from "#models/language";
+import {
+  createLanguageValidator,
+  updateLanguageValidator,
+} from "#validators/language";
+
+export default class LanguagesController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Language.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createLanguageValidator);
+    const language = await Language.create(data);
+    return response.created(language);
+  }
+
+  /**
+   * Show individual record
+   */
+  async show({ params }: HttpContext) {
+    return await Language.findOrFail(params.isoCode);
+  }
+
+  /**
+   * Handle form submission for the edit action
+   */
+  async update({ params, request }: HttpContext) {
+    const data = await request.validateUsing(updateLanguageValidator);
+    const language = await Language.findOrFail(params.isoCode);
+    language.merge(data);
+    await language.save();
+    return language;
+  }
+
+  /**
+   * Delete record
+   */
+  async destroy({ params }: HttpContext) {
+    const language = await Language.findOrFail(params.isoCode);
+    await language.delete();
+  }
+}
diff --git a/app/controllers/translations_controller.ts b/app/controllers/translations_controller.ts
new file mode 100644
index 0000000..9bf5f8d
--- /dev/null
+++ b/app/controllers/translations_controller.ts
@@ -0,0 +1,195 @@
+import { createHash } from "node:crypto";
+import OpenAI from "openai";
+
+import type { HttpContext } from "@adonisjs/core/http";
+import logger from "@adonisjs/core/services/logger";
+
+import Translation from "#models/translation";
+import env from "#start/env";
+import {
+  createTranslationValidator,
+  openaiTranslationValidator,
+  updateTranslationValidator,
+} from "#validators/translation";
+
+const openai = new OpenAI({
+  apiKey: env.get("OPENAI_API_KEY"),
+});
+
+export default class TranslationsController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Translation.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createTranslationValidator);
+
+    const hash = createHash("sha256").update(data.originalText).digest("hex");
+    const existingTranslation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", data.translatedLanguageCode)
+      .first();
+    if (existingTranslation !== null) {
+      return response.conflict({ message: "Translation already exists." });
+    }
+
+    const translation = await Translation.create({
+      hash,
+      ...data,
+      isApproved: false,
+    });
+    return response.created(translation);
+  }
+
+  /**
+   * Show individual record
+   */
+  async show({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    return await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+  }
+
+  /**
+   * Handle form submission for the edit action
+   */
+  async update({ params, request }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const data = await request.validateUsing(updateTranslationValidator);
+
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+
+    const newHash = createHash("sha256")
+      .update(data.originalText)
+      .digest("hex");
+    const existingTranslation = await Translation.find(newHash);
+    if (existingTranslation !== null) {
+      //TODO: check if that's the way we want to handle this
+      return existingTranslation;
+    }
+
+    translation.merge({
+      hash: newHash,
+      ...data,
+      isApproved: false,
+    });
+    await translation.save();
+    return translation;
+  }
+
+  /**
+   * Delete record
+   */
+  async destroy({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+    await translation.delete();
+  }
+
+  /**
+   * Approve translation
+   */
+  async approve({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+    translation.isApproved = true;
+    await translation.save();
+    return translation;
+  }
+
+  /**
+   * Request translation by OpenAI
+   */
+  async requestTranslationOpenAI({ request, response }: HttpContext) {
+    const { originalText, originalLanguageCode, translatedLanguageCode } =
+      await openaiTranslationValidator.validate(request.all());
+
+    const hash = createHash("sha256").update(originalText).digest("hex");
+    const existingTranslation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", translatedLanguageCode)
+      .first();
+    if (existingTranslation !== null) {
+      return existingTranslation;
+    }
+
+    //TODO: get someone from ML team to review this
+    const systemPrompt = `You are a translation tool. You receive a string written in
+    ${originalLanguageCode} language, and solely return the same string in ${translatedLanguageCode} language
+    without losing the original formatting. Your translations are accurate, aiming not to deviate from the original
+    structure, content, writing style and tone. The language were defined as ISO 639-1 codes. Do not add any additional information.`;
+
+    const aiParams: OpenAI.Chat.ChatCompletionCreateParams = {
+      messages: [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: originalText },
+      ],
+      model: "gpt-4",
+      store: true,
+    };
+
+    let aiResponse;
+    try {
+      aiResponse = await openai.chat.completions.create(aiParams);
+    } catch (error) {
+      logger.error("OpenAI request failed", error);
+      return response.internalServerError({
+        message: "Failed to fetch translation.",
+      });
+    }
+
+    const translatedText = aiResponse.choices[0].message.content?.trim();
+    if (translatedText === undefined) {
+      return response.internalServerError({
+        message: "Translation failed.",
+      });
+    }
+
+    const tokensConsumed = aiResponse.usage?.total_tokens;
+    if (tokensConsumed !== undefined) {
+      logger.info(`OpenAI tokens consumed: ${tokensConsumed}`);
+    }
+
+    const translation = await Translation.create({
+      hash,
+      translatedText,
+      originalLanguageCode,
+      translatedLanguageCode,
+      isApproved: false,
+    });
+    return response.created(translation);
+  }
+
+  /**
+   * All translations for a specific language
+   */
+  async translationsForLanguage({ params }: HttpContext) {
+    const { isoCode } = params as { isoCode: string };
+    return Translation.query().where("translatedLanguageCode", isoCode);
+  }
+
+  /**
+   * All translations for a specific text
+   */
+  async translationsForText({ params }: HttpContext) {
+    const { hash } = params as { hash: string };
+    return Translation.query().where("hash", hash);
+  }
+}
diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts
new file mode 100644
index 0000000..a086b14
--- /dev/null
+++ b/app/middleware/auth_middleware.ts
@@ -0,0 +1,43 @@
+import { DateTime } from "luxon";
+import crypto from "node:crypto";
+
+import type { HttpContext } from "@adonisjs/core/http";
+import logger from "@adonisjs/core/services/logger";
+
+import ApiToken from "#models/api_token";
+
+/**
+ * Auth middleware is used authenticate HTTP requests and deny
+ * access to unauthenticated requests.
+ */
+export default class AuthMiddleware {
+  public async handle(
+    { request, response }: HttpContext,
+    next: () => Promise<void>,
+  ) {
+    if (request.method() === "GET") {
+      await next();
+      return;
+    }
+
+    const token = request.header("x-api-token");
+
+    if (token === undefined) {
+      logger.error("missing token");
+      return response.unauthorized({ error: "Missing API token" });
+    }
+
+    const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
+    const authToken = await ApiToken.query().where("hash", hashedToken).first();
+
+    if (authToken === null) {
+      logger.error("invalid token");
+      return response.unauthorized({ error: "Invalid API token" });
+    }
+
+    authToken.lastUsedAt = DateTime.now();
+    await authToken.save();
+
+    await next();
+  }
+}
diff --git a/app/models/api_token.ts b/app/models/api_token.ts
new file mode 100644
index 0000000..b72912a
--- /dev/null
+++ b/app/models/api_token.ts
@@ -0,0 +1,23 @@
+import { DateTime } from "luxon";
+
+import { BaseModel, column } from "@adonisjs/lucid/orm";
+
+export default class ApiToken extends BaseModel {
+  @column({ isPrimary: true })
+  declare id: number;
+
+  @column()
+  declare name: string;
+
+  @column()
+  declare hash: string;
+
+  @column.dateTime({ autoCreate: true })
+  declare createdAt: DateTime;
+
+  @column.dateTime({ autoCreate: true, autoUpdate: true })
+  declare updatedAt: DateTime;
+
+  @column.dateTime()
+  declare lastUsedAt: DateTime | null;
+}
diff --git a/app/models/language.ts b/app/models/language.ts
new file mode 100644
index 0000000..c13258f
--- /dev/null
+++ b/app/models/language.ts
@@ -0,0 +1,20 @@
+import { DateTime } from "luxon";
+
+import { BaseModel, column, hasMany } from "@adonisjs/lucid/orm";
+import type { HasMany } from "@adonisjs/lucid/types/relations";
+
+import Translation from "#models/translation";
+
+export default class Language extends BaseModel {
+  @column({ isPrimary: true })
+  declare isoCode: string; // ISO 639-1
+
+  @column.dateTime({ autoCreate: true })
+  declare createdAt: DateTime;
+
+  @column.dateTime({ autoCreate: true, autoUpdate: true })
+  declare updatedAt: DateTime;
+
+  @hasMany(() => Translation)
+  declare translations: HasMany<typeof Translation>;
+}
diff --git a/app/models/translation.ts b/app/models/translation.ts
new file mode 100644
index 0000000..2b13f43
--- /dev/null
+++ b/app/models/translation.ts
@@ -0,0 +1,38 @@
+import { DateTime } from "luxon";
+
+import { BaseModel, belongsTo, column } from "@adonisjs/lucid/orm";
+import type { BelongsTo } from "@adonisjs/lucid/types/relations";
+
+import Language from "#models/language";
+
+export default class Translation extends BaseModel {
+  @column({ isPrimary: true })
+  declare hash: string;
+
+  @column()
+  declare originalText: string;
+
+  @column()
+  declare translatedText: string;
+
+  @column()
+  declare originalLanguageCode: string;
+
+  @belongsTo(() => Language)
+  declare originalLanguage: BelongsTo<typeof Language>;
+
+  @column()
+  declare translatedLanguageCode: string;
+
+  @belongsTo(() => Language)
+  declare translatedLanguage: BelongsTo<typeof Language>;
+
+  @column()
+  declare isApproved: boolean;
+
+  @column.dateTime({ autoCreate: true })
+  declare createdAt: DateTime;
+
+  @column.dateTime({ autoCreate: true, autoUpdate: true })
+  declare updatedAt: DateTime;
+}
diff --git a/app/validators/language.ts b/app/validators/language.ts
new file mode 100644
index 0000000..b3c7f35
--- /dev/null
+++ b/app/validators/language.ts
@@ -0,0 +1,19 @@
+import vine from "@vinejs/vine";
+
+/*
+ * Validates the language's create action
+ */
+export const createLanguageValidator = vine.compile(
+  vine.object({
+    isoCode: vine.string().fixedLength(2).trim().toLowerCase(),
+  }),
+);
+
+/*
+ * Validates the language's update action
+ */
+export const updateLanguageValidator = vine.compile(
+  vine.object({
+    isoCode: vine.string().fixedLength(2).trim().toLowerCase(),
+  }),
+);
diff --git a/app/validators/translation.ts b/app/validators/translation.ts
new file mode 100644
index 0000000..a4a2fc4
--- /dev/null
+++ b/app/validators/translation.ts
@@ -0,0 +1,36 @@
+import vine from "@vinejs/vine";
+
+/*
+ * Validates the translation's create action
+ */
+export const createTranslationValidator = vine.compile(
+  vine.object({
+    originalText: vine.string().minLength(1).trim(),
+    translatedText: vine.string().minLength(1).trim(),
+    originalLanguageCode: vine.string().fixedLength(2).trim().toLowerCase(),
+    translatedLanguageCode: vine.string().fixedLength(2).trim().toLowerCase(),
+  }),
+);
+
+/*
+ * Validates the translation's update action
+ */
+export const updateTranslationValidator = vine.compile(
+  vine.object({
+    originalText: vine.string().minLength(1).trim(),
+    translatedText: vine.string().minLength(1).trim(),
+    originalLanguageCode: vine.string().fixedLength(2).trim().toLowerCase(),
+    translatedLanguageCode: vine.string().fixedLength(2).trim().toLowerCase(),
+  }),
+);
+
+/*
+ * Validates the translation's openai action
+ */
+export const openaiTranslationValidator = vine.compile(
+  vine.object({
+    originalText: vine.string().minLength(1).trim(),
+    originalLanguageCode: vine.string().fixedLength(2).trim().toLowerCase(),
+    translatedLanguageCode: vine.string().fixedLength(2).trim().toLowerCase(),
+  }),
+);
diff --git a/commands/generate_token.ts b/commands/generate_token.ts
new file mode 100644
index 0000000..c6c2314
--- /dev/null
+++ b/commands/generate_token.ts
@@ -0,0 +1,35 @@
+import crypto from "node:crypto";
+
+import { BaseCommand, args } from "@adonisjs/core/ace";
+import type { CommandOptions } from "@adonisjs/core/types/ace";
+
+import ApiToken from "#models/api_token";
+
+export default class GenerateToken extends BaseCommand {
+  static commandName = "generate:token";
+  static description = "Generate a new API token";
+
+  static options: CommandOptions = {
+    startApp: true,
+  };
+
+  @args.string({
+    argumentName: "name",
+    description: "Name of the API token",
+  })
+  declare name: string;
+
+  async run() {
+    const plainToken = crypto.randomBytes(32).toString("hex");
+    const hashedToken = crypto
+      .createHash("sha256")
+      .update(plainToken)
+      .digest("hex");
+
+    await ApiToken.create({ name: this.name, hash: hashedToken });
+    this.logger.info(`Generated Token: ${plainToken}`);
+    this.logger.info(
+      "Store this token safely. You won't be able to see it again.",
+    );
+  }
+}
diff --git a/database/migrations/1740234509674_create_languages_table.ts b/database/migrations/1740234509674_create_languages_table.ts
new file mode 100644
index 0000000..dde9dd6
--- /dev/null
+++ b/database/migrations/1740234509674_create_languages_table.ts
@@ -0,0 +1,18 @@
+import { BaseSchema } from "@adonisjs/lucid/schema";
+
+export default class extends BaseSchema {
+  protected tableName = "languages";
+
+  async up() {
+    this.schema.createTable(this.tableName, (table) => {
+      table.string("iso_code", 2).primary(); // ISO 639-1
+
+      table.timestamp("created_at");
+      table.timestamp("updated_at");
+    });
+  }
+
+  async down() {
+    this.schema.dropTable(this.tableName);
+  }
+}
diff --git a/database/migrations/1740234553902_create_translations_table.ts b/database/migrations/1740234553902_create_translations_table.ts
new file mode 100644
index 0000000..37b4500
--- /dev/null
+++ b/database/migrations/1740234553902_create_translations_table.ts
@@ -0,0 +1,38 @@
+import { BaseSchema } from "@adonisjs/lucid/schema";
+
+export default class extends BaseSchema {
+  protected tableName = "translations";
+
+  async up() {
+    this.schema.createTable(this.tableName, (table) => {
+      table.string("hash"); //hashed original text
+
+      table.text("original_text").notNullable();
+      table.text("translated_text").notNullable();
+
+      table
+        .string("original_language_code")
+        .references("languages.iso_code")
+        .onDelete("RESTRICT")
+        .onUpdate("CASCADE")
+        .notNullable();
+      table
+        .string("translated_language_code")
+        .references("languages.iso_code")
+        .onDelete("RESTRICT")
+        .onUpdate("CASCADE")
+        .notNullable();
+
+      table.boolean("is_approved").defaultTo(false);
+
+      table.timestamp("created_at");
+      table.timestamp("updated_at");
+
+      table.primary(["hash", "translated_language_code"]);
+    });
+  }
+
+  async down() {
+    this.schema.dropTable(this.tableName);
+  }
+}
diff --git a/database/migrations/1742819635340_create_access_tokens_table.ts b/database/migrations/1742819635340_create_access_tokens_table.ts
new file mode 100644
index 0000000..4cf08a0
--- /dev/null
+++ b/database/migrations/1742819635340_create_access_tokens_table.ts
@@ -0,0 +1,20 @@
+import { BaseSchema } from "@adonisjs/lucid/schema";
+
+export default class extends BaseSchema {
+  protected tableName = "api_tokens";
+
+  async up() {
+    this.schema.createTable(this.tableName, (table) => {
+      table.increments("id");
+      table.string("name").notNullable();
+      table.string("hash").notNullable();
+      table.timestamp("created_at");
+      table.timestamp("updated_at");
+      table.timestamp("last_used_at").nullable();
+    });
+  }
+
+  async down() {
+    this.schema.dropTable(this.tableName);
+  }
+}
diff --git a/database/seeders/language_seeder.ts b/database/seeders/language_seeder.ts
new file mode 100644
index 0000000..aac2a8d
--- /dev/null
+++ b/database/seeders/language_seeder.ts
@@ -0,0 +1,14 @@
+import { BaseSeeder } from "@adonisjs/lucid/seeders";
+
+import Language from "#models/language";
+
+export default class LanguageSeeder extends BaseSeeder {
+  static environment = ["production", "development", "testing"];
+
+  static isoCodes = ["pl", "en"];
+  async run() {
+    for (const code of LanguageSeeder.isoCodes) {
+      await Language.updateOrCreate({ isoCode: code }, { isoCode: code });
+    }
+  }
+}
diff --git a/package-lock.json b/package-lock.json
index c3dd6ee..a7bc338 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,13 @@
       "version": "0.0.0",
       "license": "UNLICENSED",
       "dependencies": {
-        "@adonisjs/auth": "^9.3.1",
+        "@adonisjs/auth": "^9.3.2",
         "@adonisjs/core": "^6.17.2",
         "@adonisjs/cors": "^2.2.1",
         "@adonisjs/lucid": "^21.6.0",
         "@vinejs/vine": "^3.0.0",
         "luxon": "^3.5.0",
+        "openai": "^4.86.1",
         "pg": "^8.13.3",
         "reflect-metadata": "^0.2.2"
       },
@@ -116,9 +117,9 @@
       }
     },
     "node_modules/@adonisjs/auth": {
-      "version": "9.3.1",
-      "resolved": "https://registry.npmjs.org/@adonisjs/auth/-/auth-9.3.1.tgz",
-      "integrity": "sha512-FQ1ylsH/PRjqMsnjtplKgcWM7CEfmCGuIRbj0lFzTb9xajRTjjgBLWNIhUW/NocmkZP2T2rjV0TnDT1tQUe44Q==",
+      "version": "9.3.2",
+      "resolved": "https://registry.npmjs.org/@adonisjs/auth/-/auth-9.3.2.tgz",
+      "integrity": "sha512-ZaOeSEsCMZkXlfS3YYp2fXh6Eri/uq87mHmhW/uN8/Iww7VlpJbaacGokwHk1gBvU3hScYNVYwiazn3jkcNygw==",
       "license": "MIT",
       "dependencies": {
         "@adonisjs/presets": "^2.6.4",
@@ -2695,12 +2696,21 @@
       "version": "22.13.5",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
       "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
-      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "undici-types": "~6.20.0"
       }
     },
+    "node_modules/@types/node-fetch": {
+      "version": "2.6.12",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
+      "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "form-data": "^4.0.0"
+      }
+    },
     "node_modules/@types/normalize-package-data": {
       "version": "2.4.4",
       "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
@@ -2947,6 +2957,18 @@
         "node": ">=18.16.0"
       }
     },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/abstract-logging": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -3002,6 +3024,18 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/agentkeepalive": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+      "license": "MIT",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
     "node_modules/ajv": {
       "version": "6.12.6",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3323,7 +3357,6 @@
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/atob": {
@@ -3915,7 +3948,6 @@
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "delayed-stream": "~1.0.0"
@@ -4416,7 +4448,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "devOptional": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.4.0"
@@ -4815,7 +4846,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
       "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
-      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -5607,6 +5637,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/eventemitter3": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -5935,7 +5974,6 @@
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
       "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
-      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "asynckit": "^0.4.0",
@@ -5947,6 +5985,25 @@
         "node": ">= 6"
       }
     },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+      "license": "MIT"
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
     "node_modules/formidable": {
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz",
@@ -6402,7 +6459,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
       "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
-      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "has-symbols": "^1.0.3"
@@ -6505,6 +6561,15 @@
         "node": ">=18.18.0"
       }
     },
+    "node_modules/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.0.0"
+      }
+    },
     "node_modules/husky": {
       "version": "9.1.7",
       "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -8257,6 +8322,45 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/node-releases": {
       "version": "2.0.19",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -8488,6 +8592,51 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/openai": {
+      "version": "4.86.1",
+      "resolved": "https://registry.npmjs.org/openai/-/openai-4.86.1.tgz",
+      "integrity": "sha512-x3iCLyaC3yegFVZaxOmrYJjitKxZ9hpVbLi+ZlT5UHuHTMlEQEbKXkGOM78z9qm2T5GF+XRUZCP2/aV4UPFPJQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7"
+      },
+      "bin": {
+        "openai": "bin/cli"
+      },
+      "peerDependencies": {
+        "ws": "^8.18.0",
+        "zod": "^3.23.8"
+      },
+      "peerDependenciesMeta": {
+        "ws": {
+          "optional": true
+        },
+        "zod": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/openai/node_modules/@types/node": {
+      "version": "18.19.78",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.78.tgz",
+      "integrity": "sha512-m1ilZCTwKLkk9rruBJXFeYN0Bc5SbjirwYX/Td3MqPfioYbgun3IvK/m8dQxMCnrPGZPg1kvXjp3SIekCN/ynw==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/openai/node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "license": "MIT"
+    },
     "node_modules/optionator": {
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10911,6 +11060,12 @@
         "url": "https://github.com/sponsors/Borewit"
       }
     },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
     "node_modules/truncatise": {
       "version": "0.0.8",
       "resolved": "https://registry.npmjs.org/truncatise/-/truncatise-0.0.8.tgz",
@@ -11235,7 +11390,6 @@
       "version": "6.20.0",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
       "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
-      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/unicorn-magic": {
@@ -11337,6 +11491,31 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index 428ffff..de9220b 100644
--- a/package.json
+++ b/package.json
@@ -38,12 +38,13 @@
   },
   "prettier": "@solvro/config/prettier",
   "dependencies": {
-    "@adonisjs/auth": "^9.3.1",
+    "@adonisjs/auth": "^9.3.2",
     "@adonisjs/core": "^6.17.2",
     "@adonisjs/cors": "^2.2.1",
     "@adonisjs/lucid": "^21.6.0",
     "@vinejs/vine": "^3.0.0",
     "luxon": "^3.5.0",
+    "openai": "^4.86.1",
     "pg": "^8.13.3",
     "reflect-metadata": "^0.2.2"
   },
diff --git a/start/env.ts b/start/env.ts
index c821f7f..3f11436 100644
--- a/start/env.ts
+++ b/start/env.ts
@@ -34,4 +34,11 @@ export default await Env.create(new URL("../", import.meta.url), {
   DB_USER: Env.schema.string(),
   DB_PASSWORD: Env.schema.string.optional(),
   DB_DATABASE: Env.schema.string(),
+
+  /*
+  |----------------------------------------------------------
+  | Custom environment variables
+  |----------------------------------------------------------
+  */
+  OPENAI_API_KEY: Env.schema.string(),
 });
diff --git a/start/kernel.ts b/start/kernel.ts
index 5cd610b..890ccdb 100644
--- a/start/kernel.ts
+++ b/start/kernel.ts
@@ -31,7 +31,10 @@ server.use([
  * The router middleware stack runs middleware on all the HTTP
  * requests with a registered route.
  */
-router.use([() => import("@adonisjs/core/bodyparser_middleware")]);
+router.use([
+  () => import("@adonisjs/core/bodyparser_middleware"),
+  () => import("#middleware/auth_middleware"),
+]);
 
 /**
  * Named middleware collection must be explicitly assigned to
diff --git a/start/routes.ts b/start/routes.ts
index 22532cf..faa6637 100644
--- a/start/routes.ts
+++ b/start/routes.ts
@@ -1,15 +1,48 @@
-/*
-|--------------------------------------------------------------------------
-| Routes file
-|--------------------------------------------------------------------------
-|
-| The routes file is used for defining the HTTP routes.
-|
-*/
 import router from "@adonisjs/core/services/router";
 
+const TranslationsController = () =>
+  import("#controllers/translations_controller");
+const LanguagesController = () => import("#controllers/languages_controller");
+
 router.get("/", async () => {
   return {
-    hello: "world",
+    elo: "zelo",
   };
 });
+
+router
+  .group(() => {
+    router
+      .group(() => {
+        router.get("", [LanguagesController, "index"]);
+        router.post("", [LanguagesController, "store"]);
+        router.get(":isoCode", [LanguagesController, "show"]);
+        router.put("/:isoCode", [LanguagesController, "update"]);
+        router.delete("/:isoCode", [LanguagesController, "destroy"]);
+      })
+      .prefix("/languages");
+
+    router
+      .group(() => {
+        router.get("", [TranslationsController, "index"]);
+        router.post("", [TranslationsController, "store"]);
+        router.get("/:hash", [TranslationsController, "translationsForText"]);
+        router.get("/language/:isoCode", [
+          TranslationsController,
+          "translationsForLanguage",
+        ]);
+        router.get("/:hash/:isoCode", [TranslationsController, "show"]);
+        router.put("/:hash/:isoCode", [TranslationsController, "update"]);
+        router.delete("/:hash/:isoCode", [TranslationsController, "destroy"]);
+        router.post("/:hash/:isoCode/approve", [
+          TranslationsController,
+          "approve",
+        ]);
+        router.post("/openAI", [
+          TranslationsController,
+          "requestTranslationOpenAI",
+        ]);
+      })
+      .prefix("/translations");
+  })
+  .prefix("/api/v1");


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## PR Overview

This PR introduces language and translation management features with OpenAI-based translation integration, including new models, controllers, routes, validations, database migrations, and seeders.
- Added Translation and Language models with their corresponding database migrations.
- Introduced CRUD operations and OpenAI translation request handling in dedicated controllers.
- Updated routes and documentation to support new language and translation endpoints.

### Reviewed Changes

| File                                       | Description                                                            |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| app/models/translation.ts                  | Added Translation model; note potential primary key mismatch with DB.   |
| database/migrations/1740234553902_create_translations_table.ts | Creates translations table with composite primary key.                |
| app/models/language.ts                      | Created Language model with ISO code as primary key.                   |
| database/seeders/language_seeder.ts         | Seeder for initial language data.                                      |
| database/migrations/1740234509674_create_languages_table.ts | Creates languages table with ISO code primary key.                     |
| start/routes.ts                             | Updated routes to include language and translation endpoints.          |
| app/controllers/translations_controller.ts  | Added full CRUD and OpenAI translation handling with a problematic role. |
| README.md                                   | Expanded API documentation.                                              |
| app/validators/translation.ts               | Created validators for translation actions.                            |
| app/validators/language.ts                  | Created validators for language actions.                               |
| start/env.ts                                | Added OPENAI_API_KEY environment variable.                             |
| app/controllers/languages_controller.ts      | Added full CRUD for language management with an inconsistency in param.  |

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.



state: COMMENTED
author_association: NONE
submitted_at: 2025-03-05T10:37:41Z
user: login: ellipsis-dev[bot]
body: :x: Changes requested. Reviewed everything up to 40ae2accbdf9a187319aa3a0c8124f0027b8f985 in <ellipsistime>3 minutes and 25 seconds</ellipsistime>

<details>
<summary>More details</summary>

 - Looked at `815` lines of code in `15` files
 - Skipped `1` files when reviewing.
 - Skipped posting `13` drafted comments based on config settings.


<details>
<summary>1. <code>app/controllers/translations_controller.ts:73</code></summary>

- **Draft comment:** 
Existing translation lookup in update() does not filter by translatedLanguageCode; consider scoping the query.
- **Reason this comment was not posted:** 
Comment looked like it was already resolved.

</details>

<details>
<summary>2. <code>app/controllers/languages_controller.ts:29</code></summary>

- **Draft comment:** 
Inconsistent param name: use 'params.isoCode' (not 'iso_code') to match route.
- **Reason this comment was not posted:** 
Marked as duplicate.

</details>

<details>
<summary>3. <code>app/controllers/translations_controller.ts:73</code></summary>

- **Draft comment:** 
Duplicate check in update also ignores composite key; check using both 'newHash' and language code.
- **Reason this comment was not posted:** 
Marked as duplicate.

</details>

<details>
<summary>4. <code>app/controllers/translations_controller.ts:138</code></summary>

- **Draft comment:** 
Use 'system' role instead of 'developer' for system prompt per OpenAI Chat API conventions.
- **Reason this comment was not posted:** 
Marked as duplicate.

</details>

<details>
<summary>5. <code>app/validators/language.ts:7</code></summary>

- **Draft comment:** 
Validator only checks 'isoCode' but README and API docs mention a 'name' field. Update model/validator or docs.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 20% vs. threshold = 50%
The comment references documentation I can't see, making it impossible to verify the claim. While it could be a valid concern if true, I don't have enough context to be confident. The comment is speculative without the full context. Following our rules, we should err on the side of removing comments when we lack strong evidence.
The comment might be pointing out a real inconsistency that could cause issues. Missing required fields in validators can lead to runtime errors.
Without seeing the referenced docs, we can't verify if this is actually an issue. The PR author likely has a reason for only including isoCode, and they would have better context about the requirements.
Delete the comment because we lack the necessary context to verify its validity, and speculative comments should be removed.

</details>

<details>
<summary>6. <code>start/routes.ts:29</code></summary>

- **Draft comment:** 
Ambiguous GET routes: '/translations/:hash' and '/translations/:isoCode' may conflict. Consider making them more explicit.
- **Reason this comment was not posted:** 
Marked as duplicate.

</details>

<details>
<summary>7. <code>README.md:3</code></summary>

- **Draft comment:** 
The image URLs in lines 3 and 4 refer to the repository 'backend-topwr-sks', which may be a typo given the current repository name ('backend-translator'). Please verify if this is intentional or correct the URL to match the repository.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50%
While the comment identifies a potential issue with the image URLs, this seems like a UI/presentation concern rather than a logic issue. The rules specifically state not to comment on UI changes and to assume the author made UI changes correctly. Even though it might be a mistake, we should trust that the author knows which repository to reference for their images.
The repository mismatch could cause broken images, which might affect documentation usability. This isn't purely aesthetic.
However, the rules are clear about not commenting on UI and presentation issues. The author may have valid reasons for referencing that repository, such as sharing common assets across projects.
Following the rules strictly, we should delete this comment as it relates to UI/presentation changes, which we're instructed to assume the author handled correctly.

</details>

<details>
<summary>8. <code>README.md:165</code></summary>

- **Draft comment:** 
The endpoint 'POST /translations/openAI' uses 'openAI' with a lowercase 'o'. Considering the correct styling of the brand is 'OpenAI', please review the casing to ensure consistency across the documentation.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50%
While the comment is technically correct about brand name consistency, API endpoints commonly use lowercase for technical reasons. URL paths are often case-sensitive, and it's a common convention to use lowercase for API endpoints. The inconsistency between API paths and prose text is actually normal and expected.
The comment might be helping maintain brand consistency. Some APIs do use camelCase in their endpoints.
API endpoints typically follow their own conventions separate from brand name styling. This is a technical choice, not a documentation error.
Delete the comment as it suggests a change that goes against common API design practices and doesn't improve code quality.

</details>

<details>
<summary>9. <code>app/controllers/languages_controller.ts:11</code></summary>

- **Draft comment:** 
Minor typographical issue: the comment on line 11 says 'Display a list of resource'. Consider using the plural form 'resources' for clarity.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50%
While technically correct, this is an extremely minor documentation issue. Our rules state we should only make comments that require clear code changes and avoid obvious or unimportant issues. Documentation typos, especially minor ones like singular vs plural, don't impact code functionality or maintainability.
The grammatical error could make the documentation slightly less clear to future readers. Documentation quality is part of code quality.
While documentation is important, this change is too minor to warrant a PR comment. It doesn't meaningfully impact understanding of the code.
Delete this comment as it focuses on an extremely minor documentation issue that doesn't warrant blocking or modifying the PR.

</details>

<details>
<summary>10. <code>app/controllers/languages_controller.ts:34</code></summary>

- **Draft comment:** 
The comment on line 34 reads 'Handle form submission for the edit action', yet the method is named 'update'. Consider aligning the comment with the method name to avoid confusion.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50%
While the suggestion is technically correct about the inconsistency, it's an extremely minor documentation issue. The meaning is clear either way - both "edit" and "update" convey the same concept. This feels like the kind of nitpicky comment that doesn't add value and could be distracting from more important review feedback.
The consistency in documentation could help maintain a cleaner codebase and make it slightly easier to read. Some style guides do emphasize consistent terminology.
While consistency is good, this change is too minor to warrant a comment. The current wording is perfectly clear and doesn't impact code quality or maintainability.
Delete this comment as it's too minor and doesn't require a code change. The existing documentation is clear enough.

</details>

<details>
<summary>11. <code>app/controllers/translations_controller.ts:133</code></summary>

- **Draft comment:** 
Typo in the systemPrompt text: the phrase 'The language were defined as ISO 639-1 codes.' should be revised. Consider changing it to 'Languages are defined as ISO 639-1 codes.' for correct grammar.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 20% vs. threshold = 50%
Since this is a prompt that will be sent to OpenAI's API, grammar is actually somewhat important as it could affect the model's understanding. However, this particular grammar error is minor and unlikely to cause any real issues with the translation functionality. The prompt's main instructions are clear despite this small error.
The grammar error might not actually impact the model's performance at all - GPT models are quite robust to minor grammar issues. Also, this could be seen as a purely stylistic change.
While the model might work fine either way, professional code should still maintain proper grammar, especially in customer-facing or API-facing text.
The comment should be deleted as it points out a minor grammatical issue that doesn't materially impact the functionality of the code.

</details>

<details>
<summary>12. <code>app/validators/translation.ts:28</code></summary>

- **Draft comment:** 
Consider updating 'openai' to 'OpenAI' in both the comment (line 28) and the validator name (line 30) for consistent styling and to match the proper noun's capitalization.
- **Reason this comment was not posted:** 
Decided after close inspection that this draft comment was likely wrong and/or not actionable: usefulness confidence = 10% vs. threshold = 50%
While OpenAI is technically a proper noun that should be capitalized, this seems like a minor stylistic issue. The code will function exactly the same either way. The comment doesn't point out any functional issues or necessary code changes. Looking at the other validator names, they use camelCase consistently, so the current style actually matches the codebase better.
OpenAI's brand guidelines might require proper capitalization. Also, inconsistent capitalization across a codebase could be confusing.
The validator name follows JavaScript camelCase conventions correctly, and the comment is just documentation. This isn't a significant enough issue to warrant a comment.
Delete this comment as it's purely stylistic, doesn't affect functionality, and the current style actually follows camelCase conventions properly.

</details>

<details>
<summary>13. <code>start/env.ts:7</code></summary>

- **Draft comment:** 
Minor grammatical fix: Consider changing "and also cast values" to "and also casts values" in the comment on line 7 for better clarity.
- **Reason this comment was not posted:** 
Comment was not on a location in the diff, so it can't be submitted as a review comment.

</details>


Workflow ID: <workflowid>`wflow_CGgxLMYH5ASRE5Gj`</workflowid>

</details>


----
**Want Ellipsis to fix these issues?** Tag `@ellipsis-dev` in a comment. You can customize Ellipsis with :+1: / :-1: [feedback](https://docs.ellipsis.dev/review), review rules, user-specific overrides, `quiet` mode, and [more](https://docs.ellipsis.dev/config).



:hourglass: **7 hours** left in your free trial, [upgrade](https://app.ellipsis.dev) for $20/seat/month or [contact us](https://docs.ellipsis.dev/contact).
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-05T10:38:22Z
user: login: kguzek
body: Generalnie API śmiga, ale mam parę uwag.
1. Każde wywołanie zwraca też oryginalny tekst, co przy masowym tłumaczeniu długich tekstów zuzywa zbędnie więcej zasobów i czasu 
2. Przez to że korzysta z OpenAI to te tłumaczenia potrafią być czasem mocno średnie, np:

| oryginalny | angielski |
| --------------- | -------------- |
|![Image](https://github.com/user-attachments/assets/02740e11-bb48-4ee2-9652-19092d0fca14) | ![Image](https://github.com/user-attachments/assets/0a219bfd-774c-41a7-be98-7e9582ac4c81) |
|![Image](https://github.com/user-attachments/assets/7f5b9ae7-4e4f-4c0c-a600-a37b648cb8ad)|![Image](https://github.com/user-attachments/assets/74129e9d-fb73-45bc-b43a-41d573c52ef0)|

3. Jakie są planowane zabezpieczenia tego API? będziesz rozdawał jakieś klucze do API, service accounts, coś takiego? Pytam bo obecnie dostęp jest publiczny i to chyba nie jest planowane żeby każdy kto znajdzie tego endpointa mógł bez limitów wywoływać z naszym kluczem OpenAI

Poza tym szybko działa, bezproblemowo tłumaczy HTML i ogólnie mega fajne :+1: 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:45:05Z
user: login: dawidlinek
body: do dodania autoryzacja żądania tłumaczeń i zwiększenie modelu
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-05T17:00:35Z

Review comments:
diff_hunk: @@ -0,0 +1,38 @@
+import { DateTime } from "luxon";
+
+import { BaseModel, belongsTo, column } from "@adonisjs/lucid/orm";
+import type { BelongsTo } from "@adonisjs/lucid/types/relations";
+
+import Language from "#models/language";
+
+export default class Translation extends BaseModel {
+  @column({ isPrimary: true })
path: app/models/translation.ts
user: login: Copilot
body: The Translation model declares 'hash' as the sole primary key, but the migration defines a composite primary key (['hash', 'translated_language_code']). This mismatch may lead to unexpected ORM behavior; please align the model's primary key definition with the database schema.
```suggestion
  @column({ isPrimary: true, columnName: 'hash' })
```
created_at: 2025-03-05T10:37:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,193 @@
+import { createHash } from "node:crypto";
+import OpenAI from "openai";
+
+import type { HttpContext } from "@adonisjs/core/http";
+import logger from "@adonisjs/core/services/logger";
+
+import Translation from "#models/translation";
+import env from "#start/env";
+import {
+  createTranslationValidator,
+  openaiTranslationValidator,
+  updateTranslationValidator,
+} from "#validators/translation";
+
+const openai = new OpenAI({
+  apiKey: env.get("OPENAI_API_KEY"),
+});
+
+export default class TranslationsController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Translation.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createTranslationValidator);
+
+    const hash = createHash("sha256").update(data.originalText).digest("hex");
+    const existingTranslation = await Translation.find(hash);
+    if (existingTranslation !== null) {
+      return response.conflict({ message: "Translation already exists." });
+    }
+
+    const translation = await Translation.create({
+      hash,
+      ...data,
+      isApproved: false,
+    });
+    return response.created(translation);
+  }
+
+  /**
+   * Show individual record
+   */
+  async show({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    return await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+  }
+
+  /**
+   * Handle form submission for the edit action
+   */
+  async update({ params, request }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const data = await request.validateUsing(updateTranslationValidator);
+
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+
+    const newHash = createHash("sha256")
+      .update(data.originalText)
+      .digest("hex");
+    const existingTranslation = await Translation.find(newHash);
+    if (existingTranslation !== null) {
+      //TODO: check if that's the way we want to handle this
+      return existingTranslation;
+    }
+
+    translation.merge({
+      hash: newHash,
+      ...data,
+      isApproved: false,
+    });
+    await translation.save();
+    return translation;
+  }
+
+  /**
+   * Delete record
+   */
+  async destroy({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+    await translation.delete();
+  }
+
+  /**
+   * Approve translation
+   */
+  async approve({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+    translation.isApproved = true;
+    await translation.save();
+    return translation;
+  }
+
+  /**
+   * Request translation by OpenAI
+   */
+  async requestTranslationOpenAI({ request, response }: HttpContext) {
+    const { originalText, originalLanguageCode, translatedLanguageCode } =
+      await openaiTranslationValidator.validate(request.all());
+
+    const hash = createHash("sha256").update(originalText).digest("hex");
+    const existingTranslation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", translatedLanguageCode)
+      .first();
+    if (existingTranslation !== null) {
+      return existingTranslation;
+    }
+
+    //TODO: get someone from ML team to review this
+    const systemPrompt = `You are a translation tool. You receive a string written in
+    ${originalLanguageCode} language, and solely return the same string in ${translatedLanguageCode} language
+    without losing the original formatting. Your translations are accurate, aiming not to deviate from the original
+    structure, content, writing style and tone. The language were defined as ISO 639-1 codes. Do not add any additional information.`;
+
+    const aiParams: OpenAI.Chat.ChatCompletionCreateParams = {
+      messages: [
+        { role: "developer", content: systemPrompt },
path: app/controllers/translations_controller.ts
user: login: Copilot
body: The OpenAI API expects roles like 'system', 'user', or 'assistant'. Using 'developer' is likely incorrect and should be replaced with 'system' for the system prompt.
```suggestion
        { role: "system", content: systemPrompt },
```
created_at: 2025-03-05T10:37:41Z
author_association: NONE
diff_hunk: @@ -0,0 +1,51 @@
+import type { HttpContext } from "@adonisjs/core/http";
+
+import Language from "#models/language";
+import {
+  createLanguageValidator,
+  updateLanguageValidator,
+} from "#validators/language";
+
+export default class LanguagesController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Language.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createLanguageValidator);
+    const language = await Language.create(data);
+    return response.created(language);
+  }
+
+  /**
+   * Show individual record
+   */
+  async show({ params }: HttpContext) {
+    return await Language.findOrFail(params.iso_code);
path: app/controllers/languages_controller.ts
user: login: Copilot
body: There is an inconsistency in parameter naming; the route defines the parameter as 'isoCode' while the code uses 'iso_code'. Please update the parameter name to match the route definition.
```suggestion
    return await Language.findOrFail(params.isoCode);
```
created_at: 2025-03-05T10:37:41Z
author_association: NONE
diff_hunk: @@ -0,0 +1,51 @@
+import type { HttpContext } from "@adonisjs/core/http";
+
+import Language from "#models/language";
+import {
+  createLanguageValidator,
+  updateLanguageValidator,
+} from "#validators/language";
+
+export default class LanguagesController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Language.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createLanguageValidator);
+    const language = await Language.create(data);
+    return response.created(language);
+  }
+
+  /**
+   * Show individual record
+   */
+  async show({ params }: HttpContext) {
+    return await Language.findOrFail(params.iso_code);
path: app/controllers/languages_controller.ts
user: login: ellipsis-dev[bot]
body: Inconsistent parameter naming: use the same key (`isoCode`) instead of switching to `iso_code`.
```suggestion
    return await Language.findOrFail(params.isoCode);
```
created_at: 2025-03-05T10:38:21Z
author_association: NONE
diff_hunk: @@ -1 +1,193 @@
 # backend-translator
+
+![Solvro banner](https://github.com/Solvro/backend-topwr-sks/blob/main/assets/solvro_dark.png#gh-dark-mode-only)
+![Solvro banner](https://github.com/Solvro/backend-topwr-sks/blob/main/assets/solvro_dark.png#gh-light-mode-only)
+
+# Translation API Documentation
+
+## Base URL
+
+```
+/api/v1
+```
+
+## Endpoints
+
+### Languages
+
+#### Get All Languages
+
+```
+GET /languages
+```
+
+**Response:** List of all available languages.
+
+#### Create a Language
+
+```
+POST /languages
+```
+
+**Body:**
+
+```json
+{
+  "isoCode": "en",
+  "name": "English"
path: README.md
user: login: ellipsis-dev[bot]
body: Docs for creating a language include a `name`, but validator/model only expect `isoCode`.
created_at: 2025-03-05T10:38:21Z
author_association: NONE
diff_hunk: @@ -0,0 +1,193 @@
+import { createHash } from "node:crypto";
+import OpenAI from "openai";
+
+import type { HttpContext } from "@adonisjs/core/http";
+import logger from "@adonisjs/core/services/logger";
+
+import Translation from "#models/translation";
+import env from "#start/env";
+import {
+  createTranslationValidator,
+  openaiTranslationValidator,
+  updateTranslationValidator,
+} from "#validators/translation";
+
+const openai = new OpenAI({
+  apiKey: env.get("OPENAI_API_KEY"),
+});
+
+export default class TranslationsController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Translation.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createTranslationValidator);
+
+    const hash = createHash("sha256").update(data.originalText).digest("hex");
+    const existingTranslation = await Translation.find(hash);
+    if (existingTranslation !== null) {
+      return response.conflict({ message: "Translation already exists." });
+    }
+
+    const translation = await Translation.create({
+      hash,
+      ...data,
+      isApproved: false,
+    });
+    return response.created(translation);
+  }
+
+  /**
+   * Show individual record
+   */
+  async show({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    return await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+  }
+
+  /**
+   * Handle form submission for the edit action
+   */
+  async update({ params, request }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const data = await request.validateUsing(updateTranslationValidator);
+
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+
+    const newHash = createHash("sha256")
+      .update(data.originalText)
+      .digest("hex");
+    const existingTranslation = await Translation.find(newHash);
+    if (existingTranslation !== null) {
+      //TODO: check if that's the way we want to handle this
+      return existingTranslation;
+    }
+
+    translation.merge({
+      hash: newHash,
+      ...data,
+      isApproved: false,
+    });
+    await translation.save();
+    return translation;
+  }
+
+  /**
+   * Delete record
+   */
+  async destroy({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+    await translation.delete();
+  }
+
+  /**
+   * Approve translation
+   */
+  async approve({ params }: HttpContext) {
+    const { hash, isoCode } = params as { hash: string; isoCode: string };
+    const translation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", isoCode)
+      .firstOrFail();
+    translation.isApproved = true;
+    await translation.save();
+    return translation;
+  }
+
+  /**
+   * Request translation by OpenAI
+   */
+  async requestTranslationOpenAI({ request, response }: HttpContext) {
+    const { originalText, originalLanguageCode, translatedLanguageCode } =
+      await openaiTranslationValidator.validate(request.all());
+
+    const hash = createHash("sha256").update(originalText).digest("hex");
+    const existingTranslation = await Translation.query()
+      .where("hash", hash)
+      .where("translatedLanguageCode", translatedLanguageCode)
+      .first();
+    if (existingTranslation !== null) {
+      return existingTranslation;
+    }
+
+    //TODO: get someone from ML team to review this
+    const systemPrompt = `You are a translation tool. You receive a string written in
+    ${originalLanguageCode} language, and solely return the same string in ${translatedLanguageCode} language
+    without losing the original formatting. Your translations are accurate, aiming not to deviate from the original
+    structure, content, writing style and tone. The language were defined as ISO 639-1 codes. Do not add any additional information.`;
+
+    const aiParams: OpenAI.Chat.ChatCompletionCreateParams = {
+      messages: [
+        { role: "developer", content: systemPrompt },
path: app/controllers/translations_controller.ts
user: login: ellipsis-dev[bot]
body: The message role 'developer' is nonstandard; consider using 'system' for initial instructions.
```suggestion
        { role: "system", content: systemPrompt },
```
created_at: 2025-03-05T10:38:21Z
author_association: NONE
diff_hunk: @@ -1,15 +1,48 @@
-/*
-|--------------------------------------------------------------------------
-| Routes file
-|--------------------------------------------------------------------------
-|
-| The routes file is used for defining the HTTP routes.
-|
-*/
 import router from "@adonisjs/core/services/router";
 
+const TranslationsController = () =>
+  import("#controllers/translations_controller");
+const LanguagesController = () => import("#controllers/languages_controller");
+
 router.get("/", async () => {
   return {
     hello: "world",
   };
 });
+
+router
+  .group(() => {
+    router
+      .group(() => {
+        router.get("", [LanguagesController, "index"]);
+        router.post("", [LanguagesController, "store"]);
+        router.get(":isoCode", [LanguagesController, "show"]);
+        router.put("/:isoCode", [LanguagesController, "update"]);
+        router.delete("/:isoCode", [LanguagesController, "destroy"]);
+      })
+      .prefix("/languages");
+
+    router
+      .group(() => {
+        router.get("", [TranslationsController, "index"]);
+        router.post("", [TranslationsController, "store"]);
+        router.get("/:hash", [TranslationsController, "translationsForText"]);
path: start/routes.ts
user: login: ellipsis-dev[bot]
body: Ambiguous route parameters: GET /translations/:hash and GET /translations/:isoCode may conflict.
```suggestion
        router.get("/hash/:hash", [TranslationsController, "translationsForText"]);
```
created_at: 2025-03-05T10:38:21Z
author_association: NONE
diff_hunk: @@ -0,0 +1,193 @@
+import { createHash } from "node:crypto";
+import OpenAI from "openai";
+
+import type { HttpContext } from "@adonisjs/core/http";
+import logger from "@adonisjs/core/services/logger";
+
+import Translation from "#models/translation";
+import env from "#start/env";
+import {
+  createTranslationValidator,
+  openaiTranslationValidator,
+  updateTranslationValidator,
+} from "#validators/translation";
+
+const openai = new OpenAI({
+  apiKey: env.get("OPENAI_API_KEY"),
+});
+
+export default class TranslationsController {
+  /**
+   * Display a list of resource
+   */
+  async index() {
+    return await Translation.all();
+  }
+
+  /**
+   * Handle form submission for the create action
+   */
+  async store({ request, response }: HttpContext) {
+    const data = await request.validateUsing(createTranslationValidator);
+
+    const hash = createHash("sha256").update(data.originalText).digest("hex");
+    const existingTranslation = await Translation.find(hash);
path: app/controllers/translations_controller.ts
user: login: ellipsis-dev[bot]
body: Duplicate check ignores composite key. Use both `hash` and `translatedLanguageCode` to find existing record.
```suggestion
    const existingTranslation = await Translation.query().where("hash", hash).where("translatedLanguageCode", data.translatedLanguageCode).first();
```
created_at: 2025-03-05T10:38:22Z
author_association: NONE
diff_hunk: @@ -0,0 +1,29 @@
+FROM node:22.14.0-alpine3.21 AS base
+
+# All deps stage
+FROM base AS deps
+WORKDIR /app
+ADD package.json package-lock.json ./
path: Dockerfile
user: login: ellipsis-dev[bot]
body: Prefer using `COPY` instead of `ADD` when no extraction or URL retrieval is needed.
```suggestion
COPY package.json package-lock.json ./
```
created_at: 2025-03-05T10:38:22Z
author_association: NONE

PR comments:
user: login: czaja307
created_at: 2025-03-05T14:55:40Z
body: > Generalnie API śmiga, ale mam parę uwag.
> 
> 1. Każde wywołanie zwraca też oryginalny tekst, co przy masowym tłumaczeniu długich tekstów zuzywa zbędnie więcej zasobów i czasu
> 2. Przez to że korzysta z OpenAI to te tłumaczenia potrafią być czasem mocno średnie, np:
> 
> oryginalny	angielski
> ![Image](https://private-user-images.githubusercontent.com/52281528/419497656-02740e11-bb48-4ee2-9652-19092d0fca14.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTc2NTYtMDI3NDBlMTEtYmI0OC00ZWUyLTk2NTItMTkwOTJkMGZjYTE0LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUxMjM0ODJmZjA1NTJiMzdhZWIyNWJhOTg4OTQyY2JlMzg1MTUwYTgwZjJmMzc3MmQyNDIxYmIzNDMxZDRkZmImWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.uNHMLB7nBde_vLjAunAIhbHy4vYgHpkVaV14t_tzNcY)	![Image](https://private-user-images.githubusercontent.com/52281528/419498395-0a219bfd-774c-41a7-be98-7e9582ac4c81.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTgzOTUtMGEyMTliZmQtNzc0Yy00MWE3LWJlOTgtN2U5NTgyYWM0YzgxLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWIxMzJkYjJiNDA4YTYyNjgzZTdmMzY0YjE4MTk0ZThkMzI3ZTQ5MWRmMjE5YWU0NTZjMDAxYTU4MmRhZDU2NjQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.S9wQRRNbcH14MUQlZjfSWt_yyZdS7jh5VD6tmeimTAs)
> ![Image](https://private-user-images.githubusercontent.com/52281528/419498883-7f5b9ae7-4e4f-4c0c-a600-a37b648cb8ad.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTg4ODMtN2Y1YjlhZTctNGU0Zi00YzBjLWE2MDAtYTM3YjY0OGNiOGFkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBjYmJjOTlkYjM5ZmEyZjJlOTMyNDkyNWEzYzJkYjZiZGFhZmM2MWYwNjQ3Y2U2OTg4NWMzYTQyMjU3NzUyZTYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.kLAW43MEA3-aELuZ9mIQrCL7-Gzm6kI9Y8xEK8zseNo)	![Image](https://private-user-images.githubusercontent.com/52281528/419498820-74129e9d-fb73-45bc-b43a-41d573c52ef0.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTg4MjAtNzQxMjllOWQtZmI3My00NWJjLWI0M2EtNDFkNTczYzUyZWYwLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWMxOTc4M2QyZTZkYmJlYzcxMDIxNjdkMThjMDA3N2E4NzM2MmZlYWYwYmNmYWIxODljYjNlMWI3MTc0MjZiN2UmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.f64_0_yv4cquMUYSgXe-YmoLBf6vqCbNq7_cSlab13I)
> 3. Jakie są planowane zabezpieczenia tego API? będziesz rozdawał jakieś klucze do API, service accounts, coś takiego? Pytam bo obecnie dostęp jest publiczny i to chyba nie jest planowane żeby każdy kto znajdzie tego endpointa mógł bez limitów wywoływać z naszym kluczem OpenAI
> 
> Poza tym szybko działa, bezproblemowo tłumaczy HTML i ogólnie mega fajne 👍

ad 1. dobry pomysł, faktycznie oryginalny tekst do wywalenia
ad 2. mogę spróbować podmienić model i zobaczymy czy się poprawi - na razie korzystam z 4o-mini, który jest najtańszym z modeli
ad 3. dorzucę na pewno jeszcze admin panel do dostępu moderatorskiego
jeżeli chodzi o zabezpieczanie to pytanie do @simon-the-shark @dawidlinek co robimy, bo słyszałem zarówno o opcji publicznego serwisu, jak i internal use 
author_association: MEMBER
user: login: dawidlinek
created_at: 2025-03-05T16:59:14Z
body: > > Generalnie API śmiga, ale mam parę uwag.
> > 
> > 1. Każde wywołanie zwraca też oryginalny tekst, co przy masowym tłumaczeniu długich tekstów zuzywa zbędnie więcej zasobów i czasu
> > 2. Przez to że korzysta z OpenAI to te tłumaczenia potrafią być czasem mocno średnie, np:
> > 
> > oryginalny	angielski
> > ![Image](https://private-user-images.githubusercontent.com/52281528/419497656-02740e11-bb48-4ee2-9652-19092d0fca14.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTc2NTYtMDI3NDBlMTEtYmI0OC00ZWUyLTk2NTItMTkwOTJkMGZjYTE0LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUxMjM0ODJmZjA1NTJiMzdhZWIyNWJhOTg4OTQyY2JlMzg1MTUwYTgwZjJmMzc3MmQyNDIxYmIzNDMxZDRkZmImWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.uNHMLB7nBde_vLjAunAIhbHy4vYgHpkVaV14t_tzNcY)	![Image](https://private-user-images.githubusercontent.com/52281528/419498395-0a219bfd-774c-41a7-be98-7e9582ac4c81.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTgzOTUtMGEyMTliZmQtNzc0Yy00MWE3LWJlOTgtN2U5NTgyYWM0YzgxLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWIxMzJkYjJiNDA4YTYyNjgzZTdmMzY0YjE4MTk0ZThkMzI3ZTQ5MWRmMjE5YWU0NTZjMDAxYTU4MmRhZDU2NjQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.S9wQRRNbcH14MUQlZjfSWt_yyZdS7jh5VD6tmeimTAs)
> > ![Image](https://private-user-images.githubusercontent.com/52281528/419498883-7f5b9ae7-4e4f-4c0c-a600-a37b648cb8ad.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTg4ODMtN2Y1YjlhZTctNGU0Zi00YzBjLWE2MDAtYTM3YjY0OGNiOGFkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBjYmJjOTlkYjM5ZmEyZjJlOTMyNDkyNWEzYzJkYjZiZGFhZmM2MWYwNjQ3Y2U2OTg4NWMzYTQyMjU3NzUyZTYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.kLAW43MEA3-aELuZ9mIQrCL7-Gzm6kI9Y8xEK8zseNo)	![Image](https://private-user-images.githubusercontent.com/52281528/419498820-74129e9d-fb73-45bc-b43a-41d573c52ef0.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDExODYzMjMsIm5iZiI6MTc0MTE4NjAyMywicGF0aCI6Ii81MjI4MTUyOC80MTk0OTg4MjAtNzQxMjllOWQtZmI3My00NWJjLWI0M2EtNDFkNTczYzUyZWYwLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA1VDE0NDcwM1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWMxOTc4M2QyZTZkYmJlYzcxMDIxNjdkMThjMDA3N2E4NzM2MmZlYWYwYmNmYWIxODljYjNlMWI3MTc0MjZiN2UmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.f64_0_yv4cquMUYSgXe-YmoLBf6vqCbNq7_cSlab13I)
> > 3. Jakie są planowane zabezpieczenia tego API? będziesz rozdawał jakieś klucze do API, service accounts, coś takiego? Pytam bo obecnie dostęp jest publiczny i to chyba nie jest planowane żeby każdy kto znajdzie tego endpointa mógł bez limitów wywoływać z naszym kluczem OpenAI
> > Poza tym szybko działa, bezproblemowo tłumaczy HTML i ogólnie mega fajne 👍
> 
> ad 1. dobry pomysł, faktycznie oryginalny tekst do wywalenia ad 2. mogę spróbować podmienić model i zobaczymy czy się poprawi - na razie korzystam z 4o-mini, który jest najtańszym z modeli ad 3. dorzucę na pewno jeszcze admin panel do dostępu moderatorskiego jeżeli chodzi o zabezpieczanie to pytanie do @simon-the-shark @dawidlinek co robimy, bo słyszałem zarówno o opcji publicznego serwisu, jak i internal use

Wygląda spoko, można spróbować z większym modelem, albo dać możliwość wyboru. Ogólnie dostęp do samych tłumaczeń może być publiczny, ale żądanie tłumaczenia zabezpieczony jakimś tokenem czy coś. Na razie będziemy używali do strony i topwr
author_association: MEMBER
user: login: dawidlinek
created_at: 2025-03-11T16:19:10Z
body: @czaja307 coś tu się ruszyło?
author_association: MEMBER