Помощник читателя: визуализируем сюжет

от автора

В текущих кодогенеративных реалиях создать что-то новое с нуля до уровня худо-бедной демонстрации стало предательски просто. Только успевай доходчиво формулировать свои хотелки, да вовремя давать по рукам бездушной LLM. Посему делюсь результатами воскресного вайбкодинга — концепцией ai-помощника для анализа текста. В первую очередь художественного.

Откуда растут ноги.

Думаю, многие, кто окунается в любое, хоть сколько-нибудь сложное произведение, порою теряется в хитросплетениях взаимоотношений героев, причин их поступков и развитии общего настроения произведения. Особенно если вещают несколько рассказчиков, события подаются не в хронологическом порядке, имеет место реверсивная композиция, или линии развиваются параллельно. У хитрого-то писателя все расписано и всегда перед глазами. Кто есть кто, что у кого на уме, где случится встреча и когда выстрелит ружье. Для примера окопал фотографию своей шпаргалки, нарисованной в процессе первого прочтения «Бесов» Достоевского:

Хитросплетения взаимоотношений героев

Хитросплетения взаимоотношений героев

А еще более полезно окинуть взглядом общий контекст уже после прочтения, чтобы с прискорбием осознать, сколько слоев и смыслов ускользнуло от внимания.

Читаем текст и генерируем эмбеддинги

В этом подходе я волей обстоятельств положился на YandexGPT, поэтому эмбеддинги и дальнейшие примеры приведены на основе LLM Яндекса.

Доступный в langchain_community класс YandexGPTEmbeddings дополнительно оборачиваем в лимитер, позволяющий не заспамить API, ограниченный десятью запросами в секунду (опускаю детали, полный код здесь):

from tenacity import (     retry,     stop_after_attempt,     wait_exponential,     retry_if_exception_type ) from pydantic import Field, ConfigDict, BaseModel from langchain_community.embeddings.yandex import YandexGPTEmbeddings # ...  class RateLimitedEmbeddings(YandexGPTEmbeddings):     # ...     @retry(         retry=retry_if_exception_type(Exception),         stop=stop_after_attempt(3),         wait=wait_exponential(multiplier=1, min=2, max=10)     )     def _embed_batch(self, batch: List[str]) -> List[List[float]]:         time.sleep(0.1)         return super().embed_documents(batch)      def embed_documents(self, texts: List[str]) -> List[List[float]]:         # ...         result = []                      for i in range(0, len(texts), self.batch_size):             batch = texts[i:i + self.batch_size]             # ...             batch_result = self._embed_batch(batch)             result.extend(batch_result)                          if i + self.batch_size < len(texts):             time.sleep(self.delay_between_batches)                      return result

Далее делаем следующее:

  1. Посредством TextLoader читаем файлик с текстом

  2. При помощи RecursiveCharacterTextSplitter разделяем текст на чанки, заданные параметрами chunk_size и chunk_overlap (здесь 1000 и 100 соответственно).

  3. Генерируем эмбеддинги с помощью объявленного выше RateLimitedEmbeddings, и складываем их в векторное хранилище FAISS.

  4. Инициализируем языковую модель YandexGPT (здесь yandexgpt-32k).

  5. Создаём экземпляр RetrievalQA для ответа на вопросы по данным из векторного хранилища.

from langchain.document_loaders import TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.vectorstores import FAISS from langchain.chains import RetrievalQA from langchain_community.llms import YandexGPT # ...  loader = TextLoader(file_path, encoding="utf-8") documents = loader.load()          text_splitter = RecursiveCharacterTextSplitter(     chunk_size=config.chunk_size,     chunk_overlap=config.chunk_overlap ) texts = text_splitter.split_documents(documents)  embeddings = RateLimitedEmbeddings()  vectorstore = FAISS.from_documents(texts, embeddings)          llm = YandexGPT(     api_key=config.api_key,     folder_id=config.folder_id,     model_uri=config.model_uri )  qa = RetrievalQA.from_chain_type(     llm=llm,     chain_type="map_reduce",     retriever=vectorstore.as_retriever(search_kwargs={"k": config.search_k}),     return_source_documents=False )

В целом все готово, чтобы спросить что-то по тексту. Но для последующей визуализации важен не только ответ модели. Поэтому будем настоятельно требовать на выходе информацию в структурированной форме.

Граф связей между персонажами

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

qa(""" Представь связь всех главных героев книги в виде списка в формате JSON, где каждый элемент имеет формат:  {  "name": "имя одного героя",  "links": {  "имя другого героя": "тип отношений между ними",  ...  }  }  Тип отношений должен быть лаконичным, например: отец, сестра, друг, знакомый, cупруг и т.п. """)

На выходе получаем желаемый JSON, пример для Чеховского Ионыча:

[    {       "name":"Дмитрий Старцев (Ионыч)",       "links":{          "Иван Петрович Туркин":"знакомый",          "Вера Иосифовна Туркина":"пациентка",          "Екатерина Ивановна Туркина (Котик)":"объект любви"       }    },    {       "name":"Иван Петрович Туркин",       "links":{          "Дмитрий Старцев":"знакомый",          "Вера Иосифовна Туркина":"жена",          "Екатерина Ивановна Туркина":"дочь"       }    },    {       "name":"Вера Иосифовна Туркина",       "links":{          "Дмитрий Старцев":"пациент",          "Иван Петрович Туркин":"муж",          "Екатерина Ивановна Туркина":"дочь"       }    },    {       "name":"Екатерина Ивановна Туркина (Котик)",       "links":{          "Дмитрий Старцев":"объект симпатии",          "Иван Петрович Туркин":"отец",          "Вера Иосифовна Туркина":"мать"       }    } ]

Визуализируем полученную структуру дешево и сердито, с помощью matplotlib и networkx (весь код, опять же, здесь):

import networkx as nx import matplotlib.pyplot as plt # ...  G = nx.Graph()              for character in data:     name = character["name"]     G.add_node(name)     for linked_character, relation in character["links"].items():         G.add_edge(name, linked_character, relation=relation)  pos = nx.spring_layout(G) nx.draw(     G, pos,     with_labels=True,     node_color=self.config.graph.node_color,     node_size=self.config.graph.node_size,     font_size=self.config.graph.font_size )  edge_labels = nx.get_edge_attributes(G, "relation") nx.draw_networkx_edge_labels(     G, pos,     edge_labels=edge_labels,     font_size=self.config.graph.edge_font_size )              plt.title(self.config.graph.title) plt.axis("off") plt.show()

Для упомянутых вначале «Бесов» визуализатор выдает вот такой граф для главных героев произведения:

Граф связей между персонажами

Граф связей между персонажами

Изменение формулировки запроса к модели позволяет, например, описать характер отношений между героями, а не только формальный тип родственных связей. Для главы Бэла из «Героя нашего времени» получилась вот такая пентаграмма:

Граф отношений между персонажами

Граф отношений между персонажами

Очень важен размер контекста модели. Те же «Бесы» преобразуются в порядка 700k токенов. Такой размер способны объять лишь недавно появившиеся в публичном доступе модели.

Хронология событий

Движемся дальше — попросим модель представить хронологию событий в книге. Запрос выглядит следующим образом:

qa(""" Составь список событий в книге в формате JSON: [     {         "date": "Дата события по английски в английской локали",         "event": "Краткое описание события по русски"     } ] Ограничься только 10 событиями """)

Визуализируем по традиции максимально просто:

for event in data:     event['date'] = datetime.datetime.strptime(event['date'], '%d %B %y')  data.sort(key=lambda x: x['date'])  dates = [event['date'] for event in data] events = [event['event'] for event in data]  fig, ax = plt.subplots(figsize=self.config.timeline.figsize) ax.plot(     [1] * len(dates), dates,     marker='o',     color=self.config.timeline.marker_color,     linestyle=self.config.timeline.linestyle )  for i, event in enumerate(events):     ax.annotate(         event,         (1, dates[i]),         xytext=(10, 0),         textcoords='offset points',         ha='left',         va='center',         fontsize=self.config.timeline.fontsize     )  ax.yaxis.set_major_formatter(DateFormatter('%d %b %Y')) #...  plt.show()

Попросим модель нарисовать хронологию событий из дневника доктора Борменталя из Булгаковского «Собачьего сердца»:

Хронология событий из дневника

Хронология событий из дневника

Легко переваривать дневниковые записи. Тяжелее обстоит дело с хаотично разбросанными по тексту датами, особенно когда перемежаются собственно действие и какие-нибудь исторические справки. Хороший пример — Чеховский «Остров Сахалин». Если попросить модель составить список событий, произошедших с рассказчиком, выходит такая картина:

Хронология событий, произошедших с Чеховым

Хронология событий, произошедших с Чеховым

А если потребовать на стол список исторических событий, упомянутых в книге, то иная:

Хронология исторических событий, упомянутых в книге

Хронология исторических событий, упомянутых в книге

Кто поспорит, что визит Антона Палыча на Сахалин нельзя отнести к достойным упоминания историческим событиям.

Карта мест действия

И напоследок менее тривиальная задача — нарисовать карту действий книги. Здесь между непосредственно инференсом модели и отрисовкой полученных данных добавляется этап геокодинга. Требуется получить географические координаты по полученным от модели топонимам.

Вначале просим модель составить список мест:

qa("""   Выведи список географических объектов из текста. Только названия через запятую. """)

Затем получаем их координаты:

from yandex_geocoder import Client # ...  locator = Client(YANDEX_GEOCODER_API_KEY)  locations = text.split(', ') result = []  for loc in set(locations):     # ...     coords = locator.coordinates(loc)     if coords:         result.append({"name": loc, "coordinates": [str(c) for c in coords]})

И уже теперь кладем их на карту с помощью cartopy,

import cartopy.crs as ccrs import cartopy.feature as cfeature # ...  longitudes = [float(coord[0]) for coord in [d['coordinates'] for d in data]] latitudes = [float(coord[1]) for coord in [d['coordinates'] for d in data]] names = [d['name'] for d in data] # ...  fig, ax = plt.subplots(     figsize=self.config.map.figsize,     subplot_kw={'projection': ccrs.PlateCarree()} )  ax.add_feature(cfeature.LAND) ax.add_feature(cfeature.OCEAN) ax.add_feature(cfeature.COASTLINE, linewidth=0.3) ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.3) ax.add_feature(cfeature.LAKES, alpha=0.5) ax.add_feature(cfeature.RIVERS)  for lon, lat, name in zip(longitudes, latitudes, names):     ax.plot(           lon, lat,           marker='o',           color=self.config.map.marker_color,           markersize=self.config.map.marker_size,           transform=ccrs.PlateCarree()     ) # ...      plt.show()

Пробуем что-то простенькое, например «Вокруг света за 80 дней»:

Карта путешествия героев книги

Карта путешествия героев книги

И что-то менее прямолинейное «На Западном фронте без перемен»:

Карта географических мест, умоминаемых в книге

Карта географических мест, умоминаемых в книге

В конечном счете все очень сильно упирается в размер модели. С локальными решениями добиться хорошего результата порою сложно. Не говоря уже о том, что в их контекстное окно с трудом влезает даже малая проза. А вот крупные SOTA модели, доступные по API, выдают отличный результат уже сейчас. Без труда генерируют стройный JSON по заданной простым промптом структуре, и не ошибаются в смысловой нагрузке.


ссылка на оригинал статьи https://habr.com/ru/articles/900870/


Комментарии

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

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