МСУИИ AmigaVirtual — универсальный ИИ для каждого

от автора

Логотип Привет всем любителям и исследователям искусственного интеллекта! В данной статье я хотел бы рассказать об интересном проекте: модульной системе универсального искусственного интеллекта (МСУИИ) «Amiga Virtual» (AV, «Виртуальная Подружка»). Я расскажу об основных принципах её работы и опишу некоторые детали реализации, а самые любопытные смогут исследовать все исходные коды. Разработка ведётся на Delphi, но модули теоретически могут быть написаны на любом ЯП. Данная система будет интересна как конечным пользователям чат-ботов и связанных с ними систем, так и разработчикам ИИ — ведь на её основе можно разработать практически любой тип ИИ.

Для начала немного истории. Проект был начат в 2012-м году, когда я решил заняться разработкой чат-бота. А решил я так после знакомства с «Sayme 2» — простой флеш-игрой про общение с виртуальной девушкой (к сожалению, амбициозное продолжение этой игры — 3-я версия — по всей видимости умерло в зародыше. R.I.P., «Sayme 3»). Тогда AV представляла собой монолит, с вынесенными в одну DLL картинками с частями аватара — чтобы не качать лишние мегабайты, если аватар не нужен.

скриншот 1

скриншот 2

Так выглядела первая версия Amiga Virtual.

С тех пор концепция изменилась — теперь собственно AV — только платформа для организации работы модулей. Все «органы» ИИ вынесены в модули.

Несколько слов о языке программирования. В 2012 я выбрал Delphi, так как не знал практически ничего, кроме него (не считая Turbo Pascal). Позже я перепробовал множество языков… Больше всего мне понравилась Ada, но обучающих материалов по ней мало, да и программы получаются медленными. Ещё я пробовал Oxygen (Delphi Prism), но по нему материалов ещё меньше. C++ и другие си-подобные языки мне не нравятся вообще. Поэтому в итоге я вернулся к Delphi, который я хорошо знаю и по которому просто море обучающего материала в Интернете, да и готовых решений тоже много.

Теперь собственно о проекте. Amiga Virtual — это многоцелевая многоагентная система. В общих чертах она устроена так: есть основная программа (ОП) и набор модулей (представлены в виде DLL и являются программными агентами), у которых реализованы те или иные интерфейсы из определённого списка; во время старта ОП загружает и идентифицирует (определяет, что модуль может делать; назначение модуля программе не сообщается — это не имеет смысла) все модули, затем пользователь выбирает модули, с которыми он хочет работать, и запускает многопоточную обработку интерфейсов ввода-вывода выбранных модулей. Вот так происходит загрузка и идентифицирование модулей:

procedure LoadModules; var   i: Integer; begin   for i := 0 to Length(Module) - 1 do   begin     if Module[i].Handle > 0 then       FreeModule(Module[i]);     if LoadModule(Module[i]) then       DetermineModuleType(Module[i]);   end; end;  function LoadModule(var M: TModule): boolean; var   B: String;   C: Integer; begin   with M do   begin     Handle := LoadLibrary(PWideChar(FileName));     if Handle > 0 then     begin       @SetLanguage := GetProcAddress(Handle, 'SetLanguage');       IncAndUpdateProgress; // Для прогресс-бара       @SendLanguageData := GetProcAddress(Handle, 'SendLanguageData');       IncAndUpdateProgress;       @GetName := GetProcAddress(Handle, 'GetName');       IncAndUpdateProgress;        // Тут много строк, подключающих интерфейсы        // Вынимаем из модуля немного информации       if @GetName <> nil then       begin         B := GetName;         C := Pos(ControlCodeMarker, B);         if C > 0 then         begin           Name := Copy(B, C + 1, Length(B));           ControlCode := Copy(B, 1, C - 1);           ControlCodeFormated := FormatControlCode(ControlCode);         end         else         begin           Name := B;           ControlCode := MainForm.LanguageData[133];         end;       end       else       begin         Name := MainForm.LanguageData[114];         ControlCode := MainForm.LanguageData[133];       end;        if (@OpenWindow <> nil) and (@CloseWindow <> nil) then         WindowState := closed       else         WindowState := window_does_not_exist;        if (@Sleep <> nil) and (@WakeUp <> nil) then         ModuleState := working       else         ModuleState := module_cant_sleep;        LoadStatistics; // Это пока не реализовано       Result := true;     end     else       Result := false;   end; end;  function DetermineModuleType(var M: TModule): TModuleType; begin   with M do   begin     MType := undetermined;      if (@SetLanguage = nil) and ... (* много сравнений интерфейсов с nil *) then       MType := erroneous_or_empty     else     begin       if (@SetSource <> nil) or (@NextData <> nil) or (@Progress <> nil) or         (@RestartParsing <> nil) then         MType := parser       else       begin         if (@GetData <> nil) and (@SendData <> nil) then           MType := input_and_output         else if (@GetData <> nil) and (@SendData = nil) then           MType := only_input         else if (@GetData = nil) and (@SendData <> nil) then           MType := only_output         else if (@GetData = nil) and (@SendData = nil) then           MType := no_input_and_no_output;       end;     end;      Result := MType;   end; end;

ОП реализует только текстовый интерфейс взаимодействия пользователя с модулями, или, проще говоря, окно чата. Все модули общаются через этот чат, но обычно технические сообщения модулей не показываются пользователю, т.к. не представляют для него интереса (в целях отладки эти сообщения можно отобразить). Технические сообщения помечаются как несколько непечатных символов в начале строки. Этот код («контроль-код») задаётся разработчиком модуля. Загрузка двух модулей с одним контроль-кодом может привести к зацикливанию системы — эти два модуля будут бесконечно обмениваться сообщениями, как два поставленных друг против друга зеркала. Короче, контроль-код нужен для того, чтобы модули игнорировали сообщения, которые адресованы не им. Однако некоторые модули могут обрабатывать все сообщения — например, модули типа «интеллект-ядро».

Каждый модуль, имеющий интерфейсы ввода, вывода или ввода-вывода обрабатывается в отдельном потоке ОП. Это позволяет сохранить работоспособность ОП, если какой-то модуль зависнет или просто «задумается». Остальные модули используются пользователем в ручном режиме через их собственные окна. Следующий класс реализует поток ввода-вывода:

  TIOThread = class(TThread)   public   const     SleepTime = 1000;   var     Module: TModule;     SelfID: Integer;     constructor Create(M: TModule; ID: Integer);   protected     procedure Execute; override;   end;

А вот как происходит обработка ввода-вывода:

procedure TIOThread.Execute;   procedure GetData;   var     M: String;   begin     M := String(Module.GetData);     if M <> SCM_No_Message then       Synchronize(         procedure         begin           Pool.AddRecord(M, SelfID);         end);   end;    procedure SendData;   var     i: Integer;   begin     with Pool do       if not Empty then       begin         for i := 0 to Length(Records) - 1 do           with Records[i] do             if not ModuleGot[SelfID] and (AuthorID <> SelfID) then             begin               Module.SendData(PChar(Text));               ModuleGot[SelfID] := true;             end;         Synchronize(           procedure           begin             CheckAndDeleteOddRecords;           end);       end;   end;  begin   inherited;   while not Terminated do   begin     case Module.MType of       only_input:         GetData;       only_output:         SendData;       input_and_output:         begin           SendData;           GetData;         end;     end;     Sleep(SleepTime);   end; end;

Для обмена сообщениями ОП реализует пул сообщений. В него сохраняются все сообщения, пришедшие из окна чата и из модулей, и хранятся до тех пор, пока все модули не получат эти сообщения, затем они удаляются. Следующий класс реализует пул сообщений:

  TPoolRecord = record     Text: String;     AuthorID: Integer;     ModuleGot: array of Boolean;   end;    TPool = class     Records: array of TPoolRecord;     Empty: Boolean;     procedure AddRecord(RecordText: String; RecordAuthor: Integer);     procedure CheckAndDeleteOddRecords;     constructor Create;     procedure Show;   end;  procedure TPool.AddRecord(RecordText: String; RecordAuthor: Integer); var   i, RL: Integer; begin   RL := Length(Records);   SetLength(Records, RL + 1);   with Records[RL] do   begin     Text := RecordText;     AuthorID := RecordAuthor;     SetLength(ModuleGot, OutputModulesCount);     for i := 0 to OutputModulesCount - 1 do       if i = AuthorID then         ModuleGot[i] := true       else         ModuleGot[i] := false;   end;   with MainForm, MainForm.ChatBox.Lines do     case RecordAuthor of       - 1:         Add(User.Name + ': ' + RecordText);     else       if RecordText = SCM_Dont_Know_Answer then       begin         if DontKnowCheckBtn.Checked then           Add(LanguageData[156]);       end       else         Add(AVirtual.Name + ': ' + RecordText);     end;   Empty := false; end;  procedure TPool.CheckAndDeleteOddRecords;   function ItsOdd(ID: Integer): Boolean;   var     i: Integer;   begin     ItsOdd := true;     with Records[ID] do       for i := 0 to Length(ModuleGot) - 1 do         if not ModuleGot[i] then         begin           ItsOdd := false;           exit;         end;   end;    procedure DeleteRecord(ID: Integer);   var     i: Integer;   begin     for i := ID to Length(Records) - 2 do       Records[i] := Records[i + 1];     SetLength(Records, Length(Records) - 1);   end;  var   i: Integer; begin   if not Empty then   begin     for i := Length(Records) - 1 downto 0 do       if ItsOdd(i) then         DeleteRecord(i);     if Length(Records) = 0 then       Empty := true;   end;   if MainForm.PoolShowBtn.Checked then     Show; end;

Теперь поговорим о возможных классах модулей и их возможностях.

Самый важный, ключевой класс модулей, ради которых была задумана и реализована Подружка — это «интеллект-ядра» (ИЯ, или просто ядра). Ядро обрабатывает все сообщения, поступающие в чат, в соответствии с заложенным в него алгоритмом. То есть он выполняет основную интеллектуальную работу. Конкретный алгоритм и его реализация зависит от разработчика ядра. Как сделать хорошее ядро — интересный вопрос, и я рассмотрю его в другой статье на примере моей серии ядер. Другой интересный вопрос — как разделить ядро на отдельные модули. У меня не получается подразделить ядра, над которыми я сейчас работаю. Но, в принципе, ничто не мешает вам сделать составное ядро.

Все остальные модули разделяются на несколько категорий: рецепторы (генерируют сообщения в чат — модуль зрения, модуль слуха и т.п.), эффекторы (совершают действия в ответ на сообщения из чата), инструменты (не относятся к ИИ напрямую, используются пользователем вручную), парсеры (используются обучающим модулем «Dream Fusion») и другие модули, которые могут использоваться какими-то особыми модулями.

Некоторые рецепторы и эффекторы соответственно кодируют и декодируют сообщения, записанные в стиле «контроль-код + команда». Такие сообщения обычно не показываются пользователю. Суть этих сообщений в следующем: рецептор распознаёт некое действие, совершённое пользователем, кодирует в сообщение и отправляет его ядру на хранение. Когда ядро выдаст это сообщение, соответствующий эффектор раскодирует сообщение, получив команду, соответствующую действию пользователя, и выполнит это действие. Поскольку ядру нет разницы, какие сообщения запоминать и выдавать, а подключить к системе мы можем любые рецепторы и эффекторы — интеллект получается универсальным, то есть его можно научить чему угодно. Здорово, правда?

Следует заменить, что для удобства родственные рецепторы и эффекторы могут быть реализованы в одном модуле. Пример — класс модулей под общим названием «эмоциональный аватар» (я называю это «эмотар»): пользователь собирает из частей лицо, выражающее определённую эмоцию, и модуль создаёт сообщение, кодирующее выбранную эмоцию, т.е. действует как рецептор; когда ядро выдает это сообщение в чат, эмотар декодирует его и выстраивает изображение лица с соответствующей эмоцией, т.е. действует как эффектор. Разделять эмотар на два отдельных модуля я не вижу смысла.

Вот пример шаблона для создания модуля на Delphi:

library ИмяМодуля;  uses   System.SysUtils,   System.Classes,   SystemControlMessagesUnit     in '..\..\AmigaVirtual\SystemControlMessagesUnit.pas',   MainFormUnit in 'MainFormUnit.pas',  const   ControlCode = три+непечатных+символа;   Name = ControlCode + '>Имя Модуля';   Help = 'Многострочная' + #13 + 'справка';  var   FormState: (closed, opened);   Buffer, VirtualName: String;   NewMessageGot: Boolean;  function GetName: PChar; stdcall; begin   Result := PChar(Name); end;  function GetHelp: PChar; stdcall; begin   Result := PChar(Help); end;  procedure OpenWindow; stdcall; begin   if MainForm = nil then     MainForm := TMainForm.Create(nil);   MainForm.Show;   FormState := opened; end;  procedure CloseWindow; stdcall; begin   if FormState = opened then   begin     MainForm.Close;     FormState := closed;     MainForm.Release;     MainForm := nil;   end; end;  procedure SendData(Data: PChar); stdcall; begin   Buffer := Data; end;  function GetData: PChar; stdcall; begin   if NewMessageGot then   begin     Result := PChar(Buffer);     NewMessageGot := false;   end   else     Result := PChar(SCM_No_Message); end;  procedure Start; stdcall; begin   NewMessageGot := false;   if MainForm = nil then     MainForm := TMainForm.Create(nil); end;  procedure SetVirtual(Name: PChar); stdcall; begin   VirtualName := Name; end;  procedure LoadData; stdcall; begin   // Загрузка базы данных end;  procedure SaveData; stdcall; begin   // Сохранение базы данных end;  exports GetName, GetHelp, OpenWindow, CloseWindow, SendData, GetData, Start,   SetVirtual, LoadData, SaveData;  begin end.

На данный момент есть 21 интерфейс:

    SetLanguage: function(Language: PChar): boolean; stdcall;     SendLanguageData: function(Data: array of PChar): boolean; stdcall;     GetName: function: PChar; stdcall;     GetHelp: function: PChar; stdcall;     Start: procedure; stdcall;     Sleep: function: boolean; stdcall;     WakeUp: procedure; stdcall;     OpenWindow: procedure; stdcall;     CloseWindow: procedure; stdcall;     SetVirtual: procedure(Name: PChar); stdcall;     SaveData: procedure; stdcall;     LoadData: procedure; stdcall;     Reset: procedure; stdcall;     SetNewMainWindow: procedure(Position, Size: TPoint); stdcall;     GetTimerInterval: function: Integer; stdcall;     SendData: procedure(Data: PChar); stdcall;     GetData: function: PChar; stdcall;     SetSource: procedure(SourcePath: PChar); stdcall;     NextData: function: PChar; stdcall;     Progress: function: Real; stdcall;     RestartParsing: procedure; stdcall;

Модуль может реализовывать любой набор из вышеперечисленных интерфейсов.

Является ли типичный модуль интеллектуальным агентом? Зависит от реализации. Я вижу два варианта реализации. Рассмотрим их на примере модуля зрения. Первый вариант — простой программный агент: он кодирует картинки как есть, то есть конвертирует битмапы в текстовую строку. Второй вариант — сложный интеллектуальный агент: например, искусственная нейронная сеть, которая распознаёт на картинках объекты и описывает их словами в сообщении. Если используется несколько модулей второго типа, то можно сказать, что универсальный ИИ построен из набора слабых ИИ. Сильный это интеллект или нет — зависит от реализации ядра.

А теперь о деталях основной программы. Их три: организация баз данных ИИ по именам, регистрация пользователей со сбором статистики и «Центр Обмена» для обмена контентом.

Знаете платформу чат-ботов iii? По сути, Amiga Virtual — это во много раз более продвинутая версия iii. Только я называю экземпляры ИИ (поименованные базы данных) не «Инфами», а «Виртуалами». Каждый Виртуал обучается пользователем с нуля и может быть легко передан на другой компьютер. А с помощью модулей-аватаров (или эмотаров) может быть создан уникальный визуальный образ Виртуала. Вкладка менеджера Виртуалов выглядит так:

скриншот

Выбор используемых модулей производится с проверкой на коллизии по контроль-коду:

type   CResult = (no_collision, collision);    function CheckModulesCollision: CResult;   var     i, j: Integer;     CC: String;   begin     Result := no_collision;     with ModulesList do       for i := 0 to Items.Count - 1 do         if Checked[i] then         begin           CC := FindModuleByFileName(ExtractDLLName(Items[i])).ControlCode;           for j := 0 to Items.Count - 1 do             if Checked[j] and (i <> j) and               (CC = FindModuleByFileName(ExtractDLLName(Items[j])).ControlCode)             then               Result := collision;         end;   end;

Для того, чтобы знать, как используется AV большинством пользователей и в каком направлении развивать проект, я собираю анонимную статистику.
Пока сбор стастистики не реализован.

Центр Обмена (ЦО) — это сервис, доступный из основной программы, предназначенный для обмена модулями, Виртуалами и прочим контентом между пользователями:

скриншот

Вот так реализовано скачивание контента:

procedure TMainForm.DownloadFilesButtonClick(Sender: TObject); var   Dir, FileName: String; begin   SetCurrentDir(ProgramPath);   Dir := Category[ContentCategoryBox.ItemIndex];   Dir := UpCase(Dir[1]) + Copy(Dir, 2, Length(Dir));   if not DirectoryExists(Dir) then     CreateDir(Dir);   SetCurrentDir(Dir);   FileName := ContentList.Items[ContentList.ItemIndex];   if FileExists(FileName) then     MessageDlg(LanguageData[172], mtInformation, [mbOk], 0)   else   begin     DownloadFile(SiteProtocol + OfficialWebsite + ExchangeCenterPage + '?c=' +       Category[ContentCategoryBox.ItemIndex] + '&f=' + FileName + '&l=' +       LanguageData[0], FileName);     // В LanguageData[0] хранится название языка системы     if Copy(FileName, Length(FileName) - 2, 3) = 'zip' then       UnzipFiles(FileName, GetCurrentDir);     case ContentCategoryBox.ItemIndex of       0:         UpdateModulesList;       1:         UpdateVirtualsList;     end;     SetStatusMessage(LanguageData[173] + ' ' + ProgramPath + Dir + '\');   end; end;  procedure TMainForm.DownloadFile(From, SaveTo: String); var   LoadStream: TMemoryStream; begin   Downloading := true;   LoadStream := TMemoryStream.Create;   IdHTTP.Get(TIdURI.URLEncode(From), LoadStream);   LoadStream.SaveToFile(SaveTo);   LoadStream.Free;   Downloading := false;   SplashScreen.Close; end;

Для загрузки контента в ЦО реализована система аккаунтов (пока не работает):

скриншот

Планирую соединить её с системой аккаунтов официального форума, но пока не знаю, как.

Базовый доступ (только скачивание) обеспечивается в гостевом аккаунте, а для загрузки своего контента требуется зарегистрироваться (пока алгоритм загрузки контента не реализован, загружаю вручную по FTP).

Кроме ЦО также есть форум, который обеспечивает техподдержку пользователей — он открывается во встроенном браузере (стандартный для Delphi — вроде бы используется ядро Trident, но мне многого и не нужно, только отрисовать простую страницу; сам форум на phpBB):

скриншот

Как видно из скриншота, можно ввести свой адрес и добавить страницу в закладки (закладки пока не реализованы) — чтобы не потерять обсуждение на форуме.

Ещё один момент. Заметили в коде выше LanguageData? Это массив, в котором хранятся текстовые строки для компонентов GUI, соответствующие выбранному языку. Русский и английский упакованы в .exe как ресурсы, и при старте распаковываются в папку Languages. Файлы с другими языками можно будет скачать через Центр Обмена. Т.к. Delphi поддерживает Юникод, язык можно установить какой угодно — японский или арабский, к примеру.

Вот так языковые файлы выгружаются из .exe:

procedure TMainForm.DeployDefaultLanguages;   procedure DeployLanguage(LanguageName: String);   var     ResHandle, MemHandle: THandle;     MemStream: TMemoryStream;     ResPtr: PByte;     ResSize: Longint;     ResName: String;     i: Integer;   begin     ResName := '';     for i := 1 to Length(LanguageName) do       ResName := ResName + UpCase(LanguageName[i]);     ResName := ResName + '_LP';     ResHandle := FindResource(HInstance, PWideChar(ResName), RT_RCDATA);     if ResHandle = 0 then     begin       ShowMessage('Default language "' + LanguageName + '" not found. (' +         ResName + ')');       exit;     end;     MemHandle := LoadResource(HInstance, ResHandle);     ResPtr := LockResource(MemHandle);     MemStream := TMemoryStream.Create;     ResSize := SizeOfResource(HInstance, ResHandle);     MemStream.SetSize(ResSize);     MemStream.Write(ResPtr^, ResSize);     MemStream.Seek(0, 0);     MemStream.SaveToFile(LangFilesDir + '/' + LanguageName +       LanguageFileExtension);     FreeResource(MemHandle);     MemStream.Destroy;   end;  begin   if not DirectoryExists(LangFilesDir) then     CreateDir(LangFilesDir);   DeployLanguage('Russian');   DeployLanguage('English'); end;

Как видно, чтобы встроить новый предустановленный язык, достаточно вставить файл языка в файл ресурсов, добавить строчку вида DeployLanguage(‘НазваниеЯзыка’) и перекомпилировать проект — удобно.

Вот так загружается язык:

procedure TMainForm.ChangeLanguageTo;   function LanguageDataLoaded: Boolean;   var     T: TextFile;     B: RawByteString;   begin     SetCurrentDir(ProgramPath + LangFilesDir);     if FileExists(Language + LanguageFileExtension) then     begin       AssignFile(T, Language + LanguageFileExtension);       Reset(T);       SetLength(LanguageData, 1);       LanguageData[0] := Language;       while not eof(T) do       begin         SetLength(LanguageData, Length(LanguageData) + 1);         ReadLn(T, B);         LanguageData[Length(LanguageData) - 1] := UTF8ToWideString(B);       end;       CloseFile(T);       if Length(LanguageData) - 1 >= LangFileMinSize then         Result := true       else         Result := false;     end     else       Result := false;   end;    procedure SetCaptions;   begin     with HelpForm do     begin       LoadHelpTexts;       if CurrentTopic < HelpLast then         OpenTopic(CurrentTopic)       else         OpenTopic(0);     end;     AddModulesHelpToMainProgramHelp;     ChangeModulesLanguageToProgramLanguage;      AuthorizationTab.Caption := LanguageData[18];     // много-много строк...     CloudSaveNow.Caption := LanguageData[181];     CloudLoadNow.Caption := LanguageData[182];   end;    procedure CheckMenuItem;   var     Item: TMenuItem;   begin     for Item in LanguageMenu.Items do       if Item.Name = LanguageData[0] + 'Lang' then         Item.Checked := true;   end;  begin   if LanguageDataLoaded then   begin     if not Silent then       SetStatusMessage(LanguageData[2] + ' ' + LanguageData[0])     else       FormCaption.Caption := LanguageData[1];     SetCaptions;   end   else   begin     if Language <> SavedLanguage then       ChangeLanguageTo(SavedLanguage)     else       ChangeLanguageTo(DefaultLanguage);     MessageDlg(LanguageData[3] + #13 + LanguageData[2] + ' ' + LanguageData[0] +       '.', mtError, [mbOk], 0);   end;   CheckMenuItem; end;

А ещё есть окно со справкой:

скриншот

В него же через главное меню можно вывести информацию о модуле:

скриншот

Ещё один интересный трюк — самообновление программы, сделано с помощью .bat-скрипта:

procedure TMainForm.UpdateProgram;   procedure DeployBAT;   var     bat: TextFile;   begin     if not FileExists(ProgramPath + 'update.bat') then     begin       AssignFile(bat, ProgramPath + 'update.bat');       Rewrite(bat);       WriteLn(bat, 'taskkill /im av.exe');       WriteLn(bat, 'sleep 1'); // Windows XP       WriteLn(bat, 'timeout /t 1 /nobreak'); // Windows 7+       WriteLn(bat, 'del av.exe');       WriteLn(bat, 'move ' + ZipsDir + '\av.exe %1');       WriteLn(bat, 'del /S /Q ' + ZipsDir);       WriteLn(bat, 'start av.exe');       // WriteLn(bat, 'pause');       CloseFile(bat);     end;   end;  begin   DownloadFile(SiteProtocol + OfficialWebsite + '/av.zip',     ProgramPath + 'av.zip');   UnzipFiles(ProgramPath + 'av.zip', ProgramPath + ZipsDir);   DeployBAT;   SetCurrentDir(ProgramPath);   ShellExecute(Handle, nil, 'update.bat', PChar(ProgramPath), nil, SW_SHOW); end;

Напоследок хочу заметить, что проект Amiga Virtual включает в себя не только Windows-программу. Кроме неё планируются варианты системы для Android (AV Mobile), роботизированных платформ (AV OS) и суперкомпьютеров (AV Super). А алгоритмы интеллект-ядер могут быть использованы для создания интеллектуальной поисковой системы (реорганизующей результаты поиска Яндекса и Google). Когда будет готова альфа версия по какому-то из этих направлений, я напишу статью с описанием её работы.

Исходные коды проекта и дизайн-документ можно скачать здесь: github.com/TimKruz/AV

Спасибо за внимание.
ссылка на оригинал статьи https://habrahabr.ru/post/316830/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *