Skip to content

Commit

Permalink
feat(MachineTranslation): Add optional IDetectLanguageProvider implem…
Browse files Browse the repository at this point in the history
…entation (#232)

Signed-off-by: Andrey Borysenko <[email protected]>
  • Loading branch information
andrey18106 authored Feb 17, 2024
1 parent a91fdf3 commit 2327360
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 76 deletions.
5 changes: 4 additions & 1 deletion docs/tech_details/api/machinetranslation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ Request data
{
"name": "unique_provider_name",
"display_name": "Provider Display Name",
"action_handler": "/handler_route_on_ex_app",
"from_languages": {
"en": "English",
"fr": "French",
Expand All @@ -30,11 +29,15 @@ Request data
"en": "English",
"fr": "French",
},
"action_handler": "/handler_route_on_ex_app",
"action_detect_lang": "/detect_lang_fromt_text_handler",
}
.. note::

``from_languages`` and ``to_languages`` are JSON object with language code as key and language name as value.
``action_detect_lang`` is optional. If provided, server's translation manager will call this handler to detect language from text if no source lang provided,
for reference see `Providing Language detection <https://docs.nextcloud.com/server/latest/developer_manual/digging_deeper/translation.html#providing-language-detection>`_.


Response
Expand Down
3 changes: 2 additions & 1 deletion lib/Controller/TranslationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public function registerProvider(
array $fromLanguages,
array $toLanguages,
string $actionHandler,
string $actionDetectLang = '',
): DataResponse {
$ncVersion = $this->config->getSystemValueString('version', '0.0.0');
if (version_compare($ncVersion, '29.0', '<')) {
Expand All @@ -51,7 +52,7 @@ public function registerProvider(

$provider = $this->translationService->registerTranslationProvider(
$this->request->getHeader('EX-APP-ID'), $name, $displayName,
$fromLanguages, $toLanguages, $actionHandler
$fromLanguages, $toLanguages, $actionHandler, $actionDetectLang,
);

if ($provider === null) {
Expand Down
8 changes: 8 additions & 0 deletions lib/Db/Translation/TranslationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
* @method array getFromLanguages()
* @method array getToLanguages()
* @method string getActionHandler()
* @method string getActionDetectLang()
* @method void setAppid(string $appid)
* @method void setName(string $name)
* @method void setDisplayName(string $displayName)
* @method void setFromLanguages(array $fromLanguages)
* @method void setToLanguages(array $toLanguages)
* @method void setActionHandler(string $actionHandler)
* @method void setActionDetectLang(string $actionDetectLang)
*/
class TranslationProvider extends Entity implements \JsonSerializable {
protected $appid;
Expand All @@ -31,6 +33,7 @@ class TranslationProvider extends Entity implements \JsonSerializable {
protected $fromLanguages;
protected $toLanguages;
protected $actionHandler;
protected $actionDetectLang;

public function __construct(array $params = []) {
$this->addType('appid', 'string');
Expand All @@ -39,6 +42,7 @@ public function __construct(array $params = []) {
$this->addType('fromLanguages', 'json');
$this->addType('toLanguages', 'json');
$this->addType('actionHandler', 'string');
$this->addType('actionDetectLang', 'string');

if (isset($params['id'])) {
$this->setId($params['id']);
Expand All @@ -61,6 +65,9 @@ public function __construct(array $params = []) {
if (isset($params['action_handler'])) {
$this->setActionHandler($params['action_handler']);
}
if (isset($params['action_detect_lang'])) {
$this->setActionDetectLang($params['action_detect_lang']);
}
}

public function jsonSerialize(): array {
Expand All @@ -72,6 +79,7 @@ public function jsonSerialize(): array {
'from_languages' => $this->getFromLanguages(),
'to_languages' => $this->getToLanguages(),
'action_handler' => $this->getActionHandler(),
'action_detect_lang' => $this->getActionDetectLang(),
];
}
}
37 changes: 37 additions & 0 deletions lib/Migration/Version2200Date20240216164351.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace OCA\AppAPI\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version2200Date20240216164351 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('ex_translation')) {
$table = $schema->getTable('ex_translation');

$table->addColumn('action_detect_lang', Types::STRING, [
'notnull' => false,
'length' => 410,
'default' => '',
]);
}

return $schema;
}
}
214 changes: 140 additions & 74 deletions lib/Service/ProvidersAI/TranslationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IServerContainer;
use OCP\Translation\IDetectLanguageProvider;
use OCP\Translation\ITranslationProviderWithId;
use OCP\Translation\ITranslationProviderWithUserId;
use OCP\Translation\LanguageTuple;
Expand All @@ -39,7 +40,8 @@ public function registerTranslationProvider(
string $displayName,
array $fromLanguages,
array $toLanguages,
string $actionHandler
string $actionHandler,
string $actionDetectLang,
): ?TranslationProvider {
try {
$translationProvider = $this->mapper->findByAppidName($appId, $name);
Expand All @@ -54,6 +56,7 @@ public function registerTranslationProvider(
'from_languages' => $fromLanguages,
'to_languages' => $toLanguages,
'action_handler' => ltrim($actionHandler, '/'),
'action_detect_lang' => ltrim($actionDetectLang, '/'),
]);
if ($translationProvider !== null) {
$newTranslationProvider->setId($translationProvider->getId());
Expand Down Expand Up @@ -139,7 +142,12 @@ public function registerExAppTranslationProviders(IRegistrationContext &$context
$exAppsProviders = $this->getRegisteredTranslationProviders();
foreach ($exAppsProviders as $exAppProvider) {
$class = '\\OCA\\AppAPI\\' . $exAppProvider->getAppid() . '\\' . $exAppProvider->getName();
$provider = $this->getAnonymousExAppProvider($exAppProvider, $serverContainer, $class);
// IDetectLanguageProvider implementation is optional if ExApp has action_detect_lang
if ($exAppProvider->getActionDetectLang() !== '') {
$provider = $this->getAnonymousExAppIDetectLanguageProvider($exAppProvider, $serverContainer, $class);
} else {
$provider = $this->getAnonymousExAppProvider($exAppProvider, $serverContainer, $class);
}
$context->registerService($class, function () use ($provider) {
return $provider;
});
Expand All @@ -152,101 +160,159 @@ public function registerExAppTranslationProviders(IRegistrationContext &$context
*/
private function getAnonymousExAppProvider(TranslationProvider $provider, IServerContainer $serverContainer, string $class): ?ITranslationProviderWithId {
return new class($provider, $serverContainer, $class) implements ITranslationProviderWithId, ITranslationProviderWithUserId {
private ?string $userId;

public function __construct(
private TranslationProvider $provider,
private IServerContainer $serverContainer,
private readonly string $class,
TranslationProvider $provider,
IServerContainer $serverContainer,
string $class,
) {
$this->provider = $provider;
$this->serverContainer = $serverContainer;
$this->class = $class;
}

public function getId(): string {
return $this->class;
}
use TranslationProviderWithIdAndUserId;
};
}

public function getName(): string {
return $this->provider->getDisplayName();
/**
* @psalm-suppress UndefinedClass, MissingDependency, InvalidReturnStatement, InvalidReturnType
*/
private function getAnonymousExAppIDetectLanguageProvider(TranslationProvider $provider, IServerContainer $serverContainer, string $class): ?IDetectLanguageProvider {
return new class($provider, $serverContainer, $class) implements ITranslationProviderWithId, ITranslationProviderWithUserId, IDetectLanguageProvider {
public function __construct(
TranslationProvider $provider,
IServerContainer $serverContainer,
string $class,
) {
$this->provider = $provider;
$this->serverContainer = $serverContainer;
$this->class = $class;
}

public function getAvailableLanguages(): array {
// $fromLanguages and $toLanguages are JSON objects with lang_code => lang_label paris { "language_code": "language_label" }
$fromLanguages = $this->provider->getFromLanguages();
$toLanguages = $this->provider->getToLanguages();
// Convert JSON objects to array of all possible LanguageTuple pairs
$availableLanguages = [];
foreach ($fromLanguages as $fromLanguageCode => $fromLanguageLabel) {
foreach ($toLanguages as $toLanguageCode => $toLanguageLabel) {
if ($fromLanguageCode === $toLanguageCode) {
continue;
}
$availableLanguages[] = LanguageTuple::fromArray([
'from' => $fromLanguageCode,
'fromLabel' => $fromLanguageLabel,
'to' => $toLanguageCode,
'toLabel' => $toLanguageLabel,
]);
}
}
return $availableLanguages;
}
use TranslationProviderWithIdAndUserId;

public function translate(?string $fromLanguage, string $toLanguage, string $text, float $maxExecutionTime = 0): string {
public function detectLanguage(string $text): ?string {
/** @var PublicFunctions $service */
$service = $this->serverContainer->get(PublicFunctions::class);
/** @var TranslationQueueMapper $mapper */
$mapper = $this->serverContainer->get(TranslationQueueMapper::class);
$route = $this->provider->getActionHandler();
$queueRecord = $mapper->insert(new TranslationQueue(['created_time' => time()]));
$taskId = $queueRecord->getId();
$logger = $this->serverContainer->get(LoggerInterface::class);
$route = $this->provider->getActionDetectLang();

if ($route === '') {
return null; // ExApp does not support language detection
}

$response = $service->exAppRequestWithUserInit($this->provider->getAppid(),
$route,
$this->userId,
params: [
'from_language' => $fromLanguage,
'to_language' => $toLanguage,
'text' => $text,
'task_id' => $taskId,
'max_execution_time' => $maxExecutionTime,
],
options: [
'timeout' => $maxExecutionTime,
],
);
$response = json_decode($response->getBody(), true);

if (is_array($response)) {
$mapper->delete($mapper->getById($taskId));
throw new \Exception(sprintf('Failed to process translation task: %s:%s:%s-%s. Error: %s',
$this->provider->getAppid(),
$this->provider->getName(),
$fromLanguage,
$toLanguage,
$response['error']
));
}
$logger->debug('Detect language response ' . json_encode($response));

do {
$taskResults = $mapper->getById($taskId);
usleep(300000); // 0.3s
} while ($taskResults->getFinished() === 0);

$mapper->delete($taskResults);
if (!empty($taskResults->getError())) {
throw new \Exception(sprintf('Translation task returned error: %s:%s:%s-%s. Error: %s',
$this->provider->getAppid(),
$this->provider->getName(),
$fromLanguage,
$toLanguage,
$taskResults->getError(),
));
if (isset($response['error'])) {
throw new \Exception(sprintf('Failed to detect language for text: %s. Error: %s', $text, $response['error']));
}
return $taskResults->getResult();
}

public function setUserId(?string $userId): void {
$this->userId = $userId;
return $response['detected_lang'] ?? null;
}
};
}
}


trait TranslationProviderWithIdAndUserId {
private ?string $userId;
private IServerContainer $serverContainer;
private TranslationProvider $provider;
private string $class;

public function getId(): string {
return $this->class;
}

public function getName(): string {
return $this->provider->getDisplayName();
}

public function getAvailableLanguages(): array {
// $fromLanguages and $toLanguages are JSON objects with lang_code => lang_label paris { "language_code": "language_label" }
$fromLanguages = $this->provider->getFromLanguages();
$toLanguages = $this->provider->getToLanguages();
// Convert JSON objects to array of all possible LanguageTuple pairs
$availableLanguages = [];
foreach ($fromLanguages as $fromLanguageCode => $fromLanguageLabel) {
foreach ($toLanguages as $toLanguageCode => $toLanguageLabel) {
if ($fromLanguageCode === $toLanguageCode) {
continue;
}
$availableLanguages[] = LanguageTuple::fromArray([
'from' => $fromLanguageCode,
'fromLabel' => $fromLanguageLabel,
'to' => $toLanguageCode,
'toLabel' => $toLanguageLabel,
]);
}
}
return $availableLanguages;
}

public function translate(?string $fromLanguage, string $toLanguage, string $text, float $maxExecutionTime = 0): string {
/** @var PublicFunctions $service */
$service = $this->serverContainer->get(PublicFunctions::class);
/** @var TranslationQueueMapper $mapper */
$mapper = $this->serverContainer->get(TranslationQueueMapper::class);
$route = $this->provider->getActionHandler();
$queueRecord = $mapper->insert(new TranslationQueue(['created_time' => time()]));
$taskId = $queueRecord->getId();

$response = $service->exAppRequestWithUserInit($this->provider->getAppid(),
$route,
$this->userId,
params: [
'from_language' => $fromLanguage,
'to_language' => $toLanguage,
'text' => $text,
'task_id' => $taskId,
'max_execution_time' => $maxExecutionTime,
],
options: [
'timeout' => $maxExecutionTime,
],
);

if (is_array($response)) {
$mapper->delete($mapper->getById($taskId));
throw new \Exception(sprintf('Failed to process translation task: %s:%s:%s-%s. Error: %s',
$this->provider->getAppid(),
$this->provider->getName(),
$fromLanguage,
$toLanguage,
$response['error']
));
}

do {
$taskResults = $mapper->getById($taskId);
usleep(300000); // 0.3s
} while ($taskResults->getFinished() === 0);

$mapper->delete($taskResults);
if (!empty($taskResults->getError())) {
throw new \Exception(sprintf('Translation task returned error: %s:%s:%s-%s. Error: %s',
$this->provider->getAppid(),
$this->provider->getName(),
$fromLanguage,
$toLanguage,
$taskResults->getError(),
));
}
return $taskResults->getResult();
}

public function setUserId(?string $userId): void {
$this->userId = $userId;
}
}

0 comments on commit 2327360

Please sign in to comment.