Автаматызацыя введення транзакцій у FastAPI за дапамою асистента на базе LangGraph.
Выучыце, як стварыць асистента на базе LangGraph, які будзе парсаваць нейтральную мову ў структурованыя транзакціі і зберагаць іх у базе дадзеных PostgreSQL за дапамою FastAPI.
Кожны, хто прымался стежыць за ўсёдневнымі вытратамі через веб-форму, знае, насколькі гэта клопатна. Запіс купавання кавы за 5 долераў не павінен вымагаць заполнення многах полей, але самэ гэта і відбываецца ў багатых дапамогах для стежэння за фінансамі, створанных з FastAPI і PostgreSQL, дзе кожная транзакцыя — незалежна ад ўжоўсколькі малая — павінна быть введзенае ручна.
Уявіце стварэнне такой дапамоги, дзе кожная транзакцыя, простая чы ўскладненая, павінна праходзіць через форму. Гэта добра працюе для эпізодычных запісаў, але становіцца выматваючым, калі трэба запісаць калькі транзакцый за раз.
У такі момент падымаецца прыродны вопыт: што, як увесь гэты процес можна было б автаматызаваць? Што, як замест заполнення формы можна было б проста сказаць асистэнту «Я сегодня заплатіў 5 долераў за каву ў рэстаране» і парадзіць яму займацца рэштой?
Самэ тут і стае корыстным LangGraph.
За дапамою LangGraph вы можете стварыць асистента AI, які прыме звычны опис таго, што адбылася, і ператворыць яго на правільна запісаная транзакцыя заместа вас.
Введэнне
У этай статыце рассказваецца пра основныя концэпцыі LangGraph і яго аб’ёмнай экосферы, а пасля дэтальна раскрываецца, як можна дадаць асистента AI у прыемлень FastAPI з вядзімам LangGraph для автаматызацыі запісу транзакцый.
LangGraph
LangGraph створаны камандай, якая працавала над LangChain, і ён — це инструментарый на адкрытым кодзе для складання та керавання рабочымі процесамі AI-агентаў за дапамою графаў. З яго аднойчы можна описаць процес як сукупнасць „вузлаў“ і „рэшацоў“, што дапамагае падтрымваць складную працу агента ў аранжаванам, масштабаваным і лёгкада керованым стане.
Перш чым праглыбацца глэбэй у ЛангГраф, корыстна спачатку разумець ЛангЧейн, адколькі ЛангГраф будуецца на яму.
ЛангЧейн
ЛангЧейн — это таксікіт, таксама адкрытыя кансорсу, для стварэння прыкладоў практычнае інтэлекту, якія выкарыстоўваюць велікія мовныя модэлі. Яго галоўная задача — стварыць для разработчыкаў мост межу мовным модэлем і званычнымі рэсурсамі — джереламі дадзеных, інструментамі та крокамі рабочага прайсепту — так, каб система могла выкананыць багатоэтапныя расчункі та автаматызаваныя задачы, а не проста адбывалася адна ізольаваная замена запыт-адказ.
Мэта: Ён прызначаны для стварэння прыкладоў практычнае інтэлекту, якім трэба складзіць канец-канец калькі крокаў — напрыклад, обработка вводу ад корыстніка, запошук актуальных дадзеных та стварэнне адказу.
Структура: LangChain выкарыстоўвае "ланцюгі", якія ўскладненыя последовасці операцый, дзе выход кожнага крока становіць вхід для наступнага. Гэта дазволяе расклаць складную логіку на меншыя, працоўнае часткі.
Застосоўванне: Тыповыя прыклады выкарыстоўвання включаюць чат-боты, задачі багатакрокавага аналізу, выкарыстоўванне та структуруванне дакументаў, а таксама падключэнне LLM-аў да званачных інструментоў чы API.
LangGraph (працяг)
Проста кажучы, LangGraph аранжуе вызовы LLM у рабочыя процесы у форме графа, які падтрымляе гнучкі, а часам і паралельны багатакрокавы аналіз, у працоўнае з мертвай лінейной последовасцю.
Мэта: Ён дазваляе ствараць AI-застосоўванні, дзе логіка можа розгалужвацца, ціклізавацца чы выконваліцца паралельна, перакрываючы меркі таго, што можа выразіць просты лінейны ланцюг.
Структура: LangGraph выклікае операцыі як „вузлы“, а праймап дадзейна межа ўсіма імі як „рэшткі“. Расход выходу аднасобнага вузла можа падтрымваць калькі наступных вузлаў, што дазволяе ствараць дынамічныя шляхі прыняття рашэнняў.
Застосоўанне: LangGraph ідзеальна падходзіць для коордынавання калькі агентаў, стварэння складных пайплайнаў прыняття рашэнняў, автоматызаціі задач, якія выкарыстоўваюць умовную логіку, а таксама для супраўляння калькі LLM-аў чы роўнасця інструментаў адразу.
Што такое граф у LangGraph?
Граф, ў загальным сэнсе, — это нелінейная структура дадзейна, якая складаецца з „вершын“ (вузлаў) і „рэштак“ (з’яносаў межа ўсіма імі), якія фіксуюць адносы межа об’ектамі.
У LangGraph гэта структура графа выкарыстоўвается для стварэння робочых практык зі станамі та цыкламі — такіх, дзе ШІ можа прымкнуць рашэнні, вернуцца да паканальных крокаў або перейсці па разныя шляхі залежна ад проміжных рэзультатаў.
LangChain проты LangGraph
Архітэктура проекта
(Канцэнтры FastAPI + LangGraph + стварэнне та зберагчыце транзакцый)
Проблема
Перш чым LangGraph быў уведзен у фінансовыя дапрацоўкі, стварэнне транзакцыі значыла безпасяродна вызваць канцэнтры /transactions/add з пакетам дадзеных, які выглядаў так:
{
title: "Coffee for Rosy",
type: "expense",
amount: 5,
note: "Paid $5 to Rosy for coffee",
category_id: "5bc22126-5982-4500-9e74-71c9c089f0c8",
payment_option_id: "07c5d180-fa4d-4435-aa04-b54ef436eca1"
}
Ёнкаменне дацься было толькі пасля двух пярэдніх вызоў API — аднаго для запрашэння спісу categories і другога для запрашэння payment_options — толькі каб атрымаць ID, якія былі неабходны для пакета дадзеных. Іншымі словамі, стварэнне адной транзакцыі было трывалым процэсам, які складаўся з трох крокаў.
Рашэнне
Рашэннем было дазволіць AI-асистэнту адкарыцца за всіма трымі крокамі, тады як корыстніку проста трэба было описаць, наўмовістны мовай, што ён зрабіў з сваімі грошамі. З уважнэйшым прыглядам да гэтай меты, хтоць так і структуруецца адбудова.
Архітэктура з трох крокаў
- FastAPI Endpoint
- LangGraph Orchestrator
- PostgreSQL Database
1. FastAPI Endpoint
Пользоватар адресуе прыказку до канцэнтра FastAPI /assistance/transaction-entry, прыкладваючы пакет дадзейна, які містіць запіс, який апісвае транзакцыю.
{
message: "Sent $5 to Rosy for Coffee through cash."
}
2. LangGraph Orchestrator
Оркестрайзер ствараецца у вачыні графа, дзе кожны вузол выступае як операцыя, а кожная грань — як працэўка дадзейна между операцыямі.
Першы вузел, Analyzer LLM, прыме паведамленне ад пользователя і пераканаецца, чы не містіць яно всіх неабходных дакументаў для запісу транзакцыі — чы то ўхідныя, чы выходныя сумы, мета транзакцыі, спосаб адплатаў і так далей.
Якщо паведамленне вже містіць усі неабходныя дакументы, LLM ператварае яго на структураваныя дадзеныя, напрыклад:
{
title: "Coffee",
transaction_type: "expense",
amount: 5.0,
note: "Sent $5 to Rosy for coffee through cash",
category: "coffee",
payment_option: "cash",
payment_type: "Cash",
is_complete: True, // Flag
missing_info_message: None // Flag
}
Гэты структураваны дадзеныя паслягае да вузла Database Writer, пасля таго як два поля-флажкі (is_complete і missing_info_message) выкарыстоўваюцца. Вузел Database Writer вызывае метод create_transaction(), які фіксуе транзакцыю прыналежную корыстніку ў базе дадзенаў.
Але што будзе, як у прыемніку не хапяе якой-небудзь дазначэння? Рассмотрім прыемнік наступнага типу:
{
message: "Sent $5 to Rosy for Coffee." // payment mode is not specified
}
У гэтым случыку спосаб адплата не заданы. У такім разе дадзеныя, якія выкарыстоўвае LLM, будуць уключаць заполненыя значэнняя флажкаў, напрыклад:
{
title: "Coffee",
transaction_type: "expense",
amount: 5.0,
note: "Sent $5 to Rosy for coffee",
category: "coffee",
payment_option: None,
payment_type: None,
is_complete: False, // Flag
missing_info_message: "Please enter the payment mode used for this expense." // Flag
}
Паколькі флажок is_complete тут равнае False, прыемнік missing_info_message паслягае да іншага вузла, які з’яўляецца ў сэтках Analyzer LLM – вузла Clarification. Гэты маршрут актываецца толькі тады, калі is_complete равнае False.
Узел Clarification прымае missing_info_message і вызывае метод ask_again(), які вяртае гэты месаж як адказ на первычны запит FastAPI. Гэта пазначае заканчэнне выконання графа для гэтаго запуску — адказ, які отрымае корыстнік, ўсьмо толькі запит пра нехватную інфармацыю, у гэтым случае спосаб адплатаў.
Падазроўцам, корыстнік пасля таго адпавядае з нехватной інфармацыяй, напрыклад:
{
message: "UPI"
}
Гэты адказ выклікае паўтарную ініцыявацыю графа оркестрабіста, і ён праходзіць па той самай последовасці крокаў, як і раней.
Ключовая адзінака ў гэтым другім пракцэсе — тое, што жадных ранейшых дазведамацтва не трапляецца; історыя размовы захаваецца ў кожны раз, калі LLM выкарыстоўвае данні (гэты механізм захавання падробней расказаны пазней у стацыі). Паколькі payment_option тепер ў наявнасці і яго можна сумаваць з ранейшымі значэннямі, is_complete пераходзіць у стан True, а готавыя, фільтраваныя данні перадаюцца да вузла Database Writer, і ў выглядзе яны выглядаюць так:
{
title: "Coffee",
transaction_type: "expense",
amount: 5.0,
note: "Sent $5 to Rosy for coffee through cash",
category: "coffee",
payment_option: "UPI",
payment_type: "Digital"
}
// Flags removed.
Узел Database Writer пасуюць наступны, маючы ў руках гэтыя фільтраваныя данны. Памятайце, калі запис транзакцыі ствараўся вручную, было патрэбна два дадатковыя запыті API — адны для categories і адны для payment_options — проста ўпынуць адпаведныя ID перш чым можна было стварыць саму транзакцыю. Тая ж проблема выступае і тут: фільтраваныя данны маюць справжніе текстовыя значэння категорый і вароў оплаты, а не ўпынуцыя іх у базе дадзеных, і база дадзеных не будзе прымаліць сырыя значэння для гэтых поль.
Ёжы рашыць гэту проблему, узел Database Writer павінен запытаць базу дадзеных, каб знайсці адпаведныя записы категорый і вароў оплаты на адной заснове значэнняў, якія є у фільтраваных данных.
Паколькі проект выкарыстоўвае FastAPI разам з SQLAlchemy, гэтыя запыты рэалізуюцца як запыты SQLAlchemy.
Для categories:
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
# Run a select query to check if the category in data.category exists or not.
stmt = select(CategoriesModel).where(
CategoriesModel.user_id == user_id,
func.lower(getattr(CategoriesModel, name)) == data.category.lower()
)
# Execute the query.
result = await session.execute(stmt)
# If category exists, assign its ID to data.category.
existing_category = resule.scalar_one_or_none()
if existing_category:
data.category = existing_category.id
# If category doens't exits, create a new category and save it to database.
new_category = CategoriesModel(**{name: data.category, "user_id": user_id})
session.add(new_row)
try:
await session.flush()
except IntegrityError:
# In case another concurrent request created it first,
# we need to roll back and fetch it again.
await session.rollback()
result = await session.execute(stmt)
existing_category = result.scalar_one_or_none()
if existing_category:
data.category = existing_category.id
raise
Корачэй кажучы, гэта логіка:
Выконваецца запит select, каб пераканацца, чы рэгіструемая ў data.category категорыя вже існуе.
Якщо ёй існуе, ID категорыі заменяецца на значэнне ў data.category.
Якщо яе няма, ствараецца новы запис категорыі, і замест яго викорыстоўваецца нова, сгенераваная ID.
Той жа прынцып застосоўваецца да payment_options:
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
# Run a select query to check if the peyment_option in data.payment_option exists or not.
stmt = select(PaymentOptionsModel).where(
PaymentOptionsModel.user_id == user_id,
func.lower(getattr(PaymentOptionsModel, name)) == data.payment_option.lower()
)
# Execute the query.
result = await session.execute(stmt)
# If payment_option exists, assign its ID to data.payment_option.
existing_option = resule.scalar_one_or_none()
if existing_option:
data.payment_option = existing_option.id
# If payment_option doesn't exits, create a new payment_option and save it to database.
new_option = PaymentOptionsModel(**{name: data.payment_option, "user_id": user_id})
session.add(new_row)
try:
await session.flush()
except IntegrityError:
# In case another concurrent request created it first,
# we need to roll back and fetch it again.
await session.rollback()
result = await session.execute(stmt)
existing_option = result.scalar_one_or_none()
if existing_option:
data.payment_option = existing_option.id
raise
Калі ID категорыі і ID варуховага варыянта ўжо выкананы, об’ект дадзеных абновляецца цэлкам і стае гатовым да вставкі, выглядаючы так:
{
title: "Coffee",
type: "expense",
amount: 5.0,
note: "Sent $5 to Rosy for coffee through cash",
category_id: "5bc22126-5982-4500-9e74-71c9c089f0c8",
payment_option_id: "07c5d180-fa4d-4435-aa04-b54ef436eca1"
}
З гэтымі завершанымі дадзеннямі вузол Database Writer вызывае метод create_transaction(), які фактычна зафіксавае запис транзакцыі ў базе дадзеных.
3. База дадзеных PostgreSQL
Это паказваець апошні ўрадзей архітэктуры, дзе завершаныя даны, якія перадаюцца з вузла Database Writer, запісваюцца ў табліцу transactions.
Рэзультатныя структуры табліцы transactions выглядаюць так:
Адкліканне
Калі архітэктура вучоранена, настае час рассказаць пра конкрэтныя деталі адклікання гэтага оркестрайзера за дапамою LangGraph. Заўважыце, што порядак, які тут выказаны, не абавязкова падражнае вышэйшыя архітэктурныя паследаванні. У замен адкліканне організавана так:
- LangGraph Orchestrator
- База дадзеных PostgreSQL
- Канец пункту FastAPI
1. LangGraph Orchestrator
Сам аранжарац знаходзіцца ў файле src/assistance/graph.py. Шырокі файл адпавядае за наладку LLM, визначэнне вузлаў графа, стварэнне з’ёднанняў межы гэтымі вузламі і, нарэшце, скомпіляванне всього ў рабочы граф.
Як уже згадвалася, гэты аранжарац складаецца з трох вузлаў: Analyzer LLM, Database Writer і вузла Clarification.
Вузел Analyzer LLM (Groq)
Гэты вузел па суты ёсць мовны модель, задачай якой є выявленне намеру пользователя і перакананне, чы рэчь содержыць усі неабходныя, правильныя дакладнасці. У замест на стварэнне спецыяльной моделі з нуля, гэты проект выкарыстоўвае Groq для выпання складных задач.
Што такое Groq?
Groq — это фрэймворк на Python з адзвянутым кодарам, ствараны для работы з дадзейнамі з графавай структурой. Ён дае разработчыкам можлівасць выразна запрашваць, фільтруваць і агрэгаваць інфармацыю, якая зберагаецца у вачэнні графаў, і ён чырплёва падходзіць для большых набораў дадзейнаў у вачэнні графаў, укладаючы ў сябе такія рэшткі, як соцыяльныя сеті, графы знанняў або механізмы рэкамендацый, як описваеца ў стацыі GeekForGeeks пра API Groq.
Чэрез хоставаны API Groq вы можете адправляць запиты да шырока вжываных адзвянутых модэляў — openai/gpt-oss-120b ёсць тым, які вжываецца ў гэтым проекте — і атрымляць адпаведзі, якія зазвычай прыходзяць значна быстрэй, чым тыя, якія вы моглі б атрымаць ад іншых прадаўцаў, якія служыць адпоўнайна падобныя модэлі.
Чаму Groq?
Гэтак Groq быў выбран замест альтернатываў, такіх як ChatOpenAI чы ChatAnthropic, з кальколька прычын:
- Скорасць: Groq выкарыстоўвае спецыяльна створаную апаратную частку пад назвай LPU (Language Processing Units), а не GPU, на якіх паслугуюць большасць іншых прадаўцоў, што дазволяе працаваць быстрэй.
- Доступны бесплатны тариф: Бесплатны тариф Groq настаўна дастатні, каб падтрымаць індывідуальны чы навчальны проект, не ствараючы значных витакаў за API пад час експерыментаў.
- Сумеснае працаванне через LangChain: клас
langchain_groq.ChatGroqінтегруецца з LangChain і LangGraph так сама, якChatOpenAIчыChatAnthropic. Это значыць, што пазнейшая замена прадаўца не будзе вымагаць переработы логікі графа — проста патрэбна будзе замена кліента.
Як отрыць ключ Groq API
Groq дазваляе ствараць безкоштовныя ключы API для разработкі. Ці крокі паказуюць, як іх отрыць:
- Зайдзіце на https://console.groq.com і або заўяжыцеся, або зарэгіструйцеся.
- У выборчай стрэлці выберыце опцыю API Keys.
- Выберыце пункт «Стварыць ключ API».
- З’явіцца форма, у якой будзе запитана назва (для гэтага проекту была выбрана
transaction-assistant) і термін дзейнасці ключа. Падайце форму пасля таго, як заполніце ёю. - Ключ паказуецца толькі адной раз, ведаўж чым толькі ён ствараны — таму обавязкова яго адразу скопіюйце.
Пасля стварэння всі вашы ключы будуць паказаны ў галоўным списку на той сторонцы.
Іспользованне ключа Groq API у коде FastAPI
Дадзіце ключ Groq API у ваш файл .env, які знаходзіцца ў корні проекта, разам з іншымі зменнымі сераўіса:
GROQ_API_KEY = "gsk_***************************************DyxM"
Існуе кальколь спосабоў завантажэння зменных сераўіса ў модулі, якія іх патрабуюць. У этам проекте выкарыстоўваецца спецыяльны клас налашчэнняў:
Задаць клас Settings у файле src/utils/settings.py:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Configure connection with the .env file
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# ... Other Variables ...
GROQ_API_KEY: str
settings = Settings()
Потым імпортуваць гэты об’ект налашчэнняў там, дзе ён патрабуецца:
from src.utils.settings import settings
# After importing, the object settings can be used as
# "settings.GROQ_API_KEY" to access the environment variable for Groq API Key.
Настройка LLM
Перад налашчэнням LLM неабходна запісаць LangGraph і LangChain, а таксама інтеграцыю з Groq:
pip install -U langgraph langchain langchain-groq
Потым ствараецца экземпляр кліента Groq і налаштовуецца з адным конкрэтым моделлю:
from langchain_groq import ChatGroq
from src.assitance.schema import ExtractedTransactionSchema
from src.utils.settings import settings
assistance_llm = ChatGroq(model="openai/gpt-oss-120b", temperature=0.2,
api_key=settings.GROQ_API_KEY)
structured_llm = assistance_llm.with_structured_output(
ExtractedTransactionSchema)
У гэтым случае ChatGroq выступае як абгортка LangChain наваколо чат-модэляў Groq, якая дазволяе вам взаімадзейваць з імі через стандартны інтерфейс LangChain узамен таго, каб ручна ствараць запиты HTTP.
assistance_llm = ChatGroq(model="openai/gpt-oss-120b", temperature=0.2,
api_key=settings.GROQ_API_KEY)
Этый фрагмент стварае адзінак экзампляра кліента Groq, працавога вышэй, настроеныя з выбраным моделем і значэннем тэмпературы, низкім за звычай, і аутентыфікуемага за дапамою кантролл-клуча, які берэцца з налашоўкаў сяродовысі.
Тэмпература — это параметр, які зазвычай крыўцяеся ад 0 да 1, і які вплывае на тое, насколькі аднойчыныя чы ўваходжаючыя элементы будуць у адпаведзеннях моделі. Вышэйшае значэнне, напрыклад 0.8, спрабоўвае даць болей разнаобразныя і крэатыўныя рэзультаты, тады калі нижэйшае значэнне, напрыклад 0.2, робіць адпаведзенні болей стабільнымі і прыемлемымі. У этам проекте задаецца temperature = 0.2.
structured_llm = assistance_llm.with_structured_output(
ExtractedTransactionSchema)
Эты код абрабатоўвае LLM так, што замест таго, каб вяртаць просты текст, ён стварае об’ект на Python, які абсалютна супакоўваецца з ExtractedTransactionSchema. Внутршняя частка гэтага адбываецца праз тое, што модэлю даўацца заданне стварыць выход, які паспаўляе шаблону, а пасля гэтаго выход автоматычна парсуецца і пераканальваецца — чым усунутая патрэба вручную адгукавацца да неапранутага тексту модэля.
Сам ExtractedTransactionSchema вызначаны ў файле src/assistance/schema.py:
from typing import Optional
from pydantic import BaseModel, Field
class ExtractedTransactionSchema(BaseModel):
is_complete: bool = Field(
description="True only if title, type, amount, category, and payment method were all found.")
missing_info_message: Optional[str] = Field(
default=None, description="A polite clarifying question listing listing exactly what's missing. Must be null if is_complete is True")
title: str
transaction_type: str = Field(description="'income' or 'expense'")
amount: float
category: str
payment_option: str = Field(
description="e.g. 'UPI', 'Cash', 'HDFC Credit Card'")
payment_type: str = Field(
description="Broad classification of the payment_option, one of: 'Cash', 'Card', 'Digital', 'Bank Transfer', 'Other'"
)
note: str
Заўважыце, што на гэты момент LLM яшчо не быў вызваны — гэты крок толькі вызначае формат, які павінен маты выход пасля його запуску.
Стан графа
Стан графа выступаець як структура дадзейна, яка працуе ў межах графа і апдэюўваецца пад час його функцыянавання. Можна супарабяляць яго з рабочай памятчукаі аранжыроўщыка: ён зберагае всю інфармацыю, яку граф стежыць за ёю і мяніць пад час выканання кожнага крока. Для гэтага асистента для транзакцый стан графа визначаецца так:
from pydantic import BaseModel, Field
from typing import Annotated, List, Optional
import operator
from src.assitance.schema import ExtractedTransactionSchema
class GraphState(BaseModel):
user_input: str = Field(description="The user input to the graph.")
conversation_history: Annotated[List[str], operator.add] = []
extracted: Optional[ExtractedTransactionSchema] = None
final_response: Optional[str] = None
Давайте розберам, што на самае працоўвае гэты код:
from pydantic import BaseModel, Field
Pydantic — это бібліятэка для верыфікацыі дадзейна, якая викорыстоўваецца тут. BaseModel — это родны клас, які вы расширяеце пад час стварэння структураванага, перакантроліванага за типам об’екта, такога як GraphState. Field дазволяе прыкрепіць метаданы — апісанні, стандартныя значэння і т. п. — да кожнага атрыбута.
from typing import Annotated, List, Optional
import operator
Это з’явы Пайтону для працы з типамі. Optional паведамляе, што поле можа быць порожнім і магчымае зберагачванне значэння None. List прызначае атрыбут як список элементаў. Annotated, выкарыстоўваны разам з operator.add, паведамляе LangGraph: «калі вузол вяртае новае значэнне для гэтага поля, дадзіце яго да таго, што вялікі ўжо, а не заменіце яго». Гэты механізм дазволяе conversation_history растаць з кожным новым крокам размовы, а не стварацца занова пасля кожнага новага поведамлення.
class GraphState(BaseModel):
user_input: str = Field(description="The user input to the graph.")
conversation_history: Annotated[List[str], operator.add] = []
extracted: Optional[ExtractedTransactionSchema] = None
final_response: Optional[str] = None
user_input: найняўэжэйшае поведамленне, якое выкарыстоўваў корыстнік у цяму конкрэтным вызыве.conversation_history: весь спіс паказваных ранейшае поведамленняў, які накапліваецца з кожным крокам размовы, а не перазапісваецца.
extracted: заполняецца пасля таго, як LLM выявіў структураваныя даны пракрытці з канварзации. Спачатку ён мае значэнне None, таму што на пачатку адработкі нічога ўжо не было выявлена.final_response: паведамленне, якое ў канцэ настаўляецца паказаць корыстніку — гэта можа быць падтверджэнне пра занесення пракрытці або дадатковы запит пра большую дзеянку.Запрос на выявленне
from langchain_core.prompts import PromptTemplate
EXTRACTION_PROMPT = PromptTemplate(
template="""
You are a financial assistant extracting transaction details.
Below is the conversation so far (it may span multiple messages, where later
messages answer questions raised by earlier ones). Treat it as one combined input.
Required fields: title, transaction_type (income/expense), amount, category, payment_option.
If title is missing, add one based on the context of the message.
If anything required is missing, except title, set is_complete to False and write a short, polite
clarifying question in missing_info_message asking only for what's missing.
If everything is present, set is_complete to True, leave missing_info_message null,
and fill in all fields. Always copy the user's original message into `note`.
Conversation so far:
{user_input}
""",
input_variables=["user_input"]
)
Это буквальная інструкцыя, яка передаецца LLM на адзінаковай мове — у яй выявлены поля, якія трэба шукать, што робіць, калі чагось не хапяць, і як павінна быць структуравана адпаведзь. Паколькі structured_llm вже прыменяе схему на рэвалюцыйнам узле, задача запрошэння заключаецца пераважна ў кераванні логікай моделі: выборы таго, што значыць „полны“, як сформулюваць уточняючы запит і т. п., тады як схема адпаведзяў за форматаванне.
Экстрактар
def extractor(state: GraphState):
full_conversation = "\n".join(
state.conversation_history + [state.user_input])
prompt = EXTRACTION_PROMPT.format(user_input=full_conversation)
result: ExtractedTransactionSchema = structured_llm.invoke(prompt)
return {"extracted": result, "conversation_history": [state.user_input]}
Функцыя extractor выконвае наступнае:
- Спаўнюе ўсі паказваныя ранейя сообщэння з нынешнім, каб LLM бачыў полны контэкст.
- Перадае гэты спаўнены текст LLM.
- Прымеяць у адпаведзь структураваны об’ект
ExtractedTransactionSchema.
operator.add, настаўленаму для гэтага поля.Рашэнне
route_after_extraction
def route_after_extraction(state: GraphState):
return "create_transaction" if state.extracted.is_complete else "ask_again"
Гэтая функцыя не выканае жадных практычных пераробкаў — яе ўжоўткаваная задача — выдаты рашэнне. Залежна ад таго, чы ЛЛМ пазначыў выкаранутыя даны як завершаныя, яна вернюець строку, якая паведамляе граф, який вузол трэба запусціць далей. Можна супрацаваць яе як логіку розгалужэнняў у схеме прайску: граф аналізуе значэнне, якое вяртае гэтая функцыя, і следуе па адпаведнаму шляху — або да create_transaction, каб зазначыць транзакцыю, або да ask_again, каб запрашаць больш мяркаванняў.
Вузел для запісу ў базе дадаўнаў
create_transaction_node
Этый вузел адмініструець запіс падтверджаных данных транзакцыі ў базу дадзеных для вялікага корыстніка. Функцыя create_transaction_node, якая рэалізуе вузел DB Writer, выглядае так:
from langchain_core.runnables import RunnableConfig
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from src.transaction import controller
from src.transaction.schema import TransactionCreateSchema
from src.utils.db_helper import get_or_create
from src.categories.models import CategoriesModel
from src.categories.controller import get_deterministic_color
from src.payment_options.models import PaymentOptionsModel
from src.utils.db_helper import get_or_create
async def create_transaction_node(state: GraphState, config: RunnableConfig):
session: AsyncSession = config["configurable"]["session"]
user = config["configurable"]["user"]
data = state.extracted
try:
category_id = await get_or_create(
session, CategoriesModel, user.id, data.category,
extra_defaults={"color": get_deterministic_color(data.category)}
)
payment_option_id = await get_or_create(
session, PaymentOptionsModel, user.id, data.payment_option,
extra_defaults={"payment_type": data.payment_type}
)
payload = TransactionCreateSchema(
amount=data.amount,
category_id=category_id,
payment_option_id=payment_option_id,
note=data.note,
title=data.title,
type=data.transaction_type,
)
await controller.create_transaction(payload, session, user)
await session.commit()
except SQLAlchemyError as err:
await session.rollback()
print(
f"Error while creating transaction through AI assistance :: {err}")
return {
"final_response": "Something went wrong while saving your transaction. Please try again."
}
message = f"Added {data.transaction_type} of {data.amount} under '{data.category}' ({data.payment_option})"
return {"final_response": message}
Цяжка ўсё гэта спрыяць за адну момент, таму рассмотрзім па шагах.
from langchain_core.runnables import RunnableConfig
Тып, які выступае адносама з об’ектам config, які пасылаецца кожнаму вузлу. Ён існуе толькі як падказка пра тип, таму кожны, хто чытае сігнатуру create_transaction_node, адразу разумее, які формат мае config.
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
Это стандартныя імпорты з SQLAlchemy, якія неабходны для фіксацыі памылак у базе дадзеных і для задання типу асінхроннай сесыі базы дадзеных, якая викорыстоўваецца для абмену з PostgreSQL.
from src.transaction import controller
from src.transaction.schema import TransactionCreateSchema
Этот кастомны код выкачвае логіку стварэння транзакцый, якая вжо выкорыстоўвалася ў іншых частках прыкладнага програма, а таксама ўзор вхідных дадзеных. Яе павторны выкарыстоўванне значыць, што асистэнт стварае транзакцыі праз абоўсюды той самы кодавы шлях, як і звычны CRUD API, узамен таго, каб дублюваць гэтую логіку тут.
from src.utils.db_helper import get_or_create
from src.categories.models import CategoriesModel
from src.categories.controller import get_deterministic_color
from src.payment_options.models import PaymentOptionsModel
Этыя элементы ўжоўляюцца для ператварэння назв категорый і вароў оплаты, якія выдобыліся за дапамогою LLM, у рэальныя рядкі і ID у базе дадзеных, ствараючы новыя записі, калі іх ўжо нэма.
Прыметка: логіка пошуку/стварэння для categories і payment_options была з’едынена ў адны загальны хелпер get_or_create, адтолькі ўбачліва, што оба моделі патрабавалі практычна той самыя функцыі.
async def create_transaction_node(state: GraphState, config: RunnableConfig):
Функцыя create_transaction_node выконваецца толькі пасля таго, як даныя, які былі выкараны, паўнастаюць. Яна адзначаецца як async, таму што выпраўляе рэальную работу з базай дадзенаў, і яй неабходныя config і state, каб яна могла аб’ектавацца да актыўнай сесіі базы дадзенаў і залогаванага пользователя. Гэтыя два значэння прадаюцца з маршруту API, а не з LLM чы стану размовы, адтуды што яны належаць конкрэтнаму запиту, а не да трываючай дыялогу.
session: AsyncSession = config["configurable"]["session"]
user = config["configurable"]["user"]
data = state.extracted
Гэта выкаранае кода выкаранае сесію, пользователя і даныя транзакцыі.
try:
category_id = await get_or_create(...)
payment_option_id = await get_or_create(...)
Пакалькі LLM выявіў толькі назвы катэгорыі і спосабу адплата, такія як „Продукты“ чыста „UPI“, а не іх ID у базе дадзеных, таму ў гэтым кроцы пераканальваецца, чы не існуе ўжо відпаведной строкі для ныякога пользователя. Якщо не існуе, яна ствараецца. У будзь-якім случае вяртаецца відпаведны ID.
payload = TransactionCreateSchema(...)
await controller.create_transaction(payload, session, user)
await session.commit()
Пакет дадзеных складаецца у тым жа формате, які выкарыстоўваецца існуючай логікай стварэння транзакцый, пасля чаго перадаецца ў тую ж функцію-контролер, што дазволяе пераўтарыць існуючую логіку прыкладнага програму, а не перапісваць яе. Пасля чаго транзакцыя ў базе дадзеных фіксуецца, каб зафіксаваць змяны.
except SQLAlchemyError as err:
await session.rollback()
print(...)
return {"final_response": "Something went wrong..."}
Якщо ў рэўерсе базы дадзеных выйдзе якая-небудзь проблема, всі частковыя змены будуць анульваны, і будзе вярнута зручная паведамленне пра адказку, ўместо таго каб запыт зламаўся. Чыгун гарантуе, што, напрыклад, не стануць нова створаная катэгорыя без адпаведную транзакцыю.
message = f"Added {data.transaction_type} of {data.amount} under '{data.category}' ({data.payment_option})"
return {"final_response": message}
Пры успеху ствараецца паведамленне, якое можна прачытаць чалавеком, і ёно вярнуецца як апдэйт стану.
Уточненне
ask_again
def ask_again_node(state: GraphState):
return {"final_response": state.extracted.missing_info_message}
Это просты запасны варіант. Як было описана ранейш, гэты вузол запускаецца толькі тады, калі для выкараных дадзеных значэнне is_complete стоіць False, а таксама є кориснае паведамленне у missing_info_message.
У функцыі ask_again_node прыймаеся параметр state, які дае ёй доступ да элементаў state.extracted.is_complete і state.extracted.missing_info_message.
Корача кажучы, калі не хватает информацыі, гэты вузол проста перадае запит на дапамогу, які LLM вже створыў падчас выкарыстоўвання інформацыі, тады корыстувач точна ведае, што трэба запрацаваць далей.
Складанне графа
from langgraph.graph import StateGraph, START, END
graph_builder = StateGraph(GraphState)
У гэтым кроку ствараецца новы будоўнік графа, і яму паведамляецца, што кожны вузел графа будзе чытаць і запісваць данні ў об’ект, які мае форму GraphState.
graph_builder.add_node("extractor", extractor)
graph_builder.add_node("create_transaction", create_transaction_node)
graph_builder.add_node("ask_again", ask_again_node)
Кожная функцыя рэўідуецца тут як вузел з іменем, практычна як пазначаны крок у графе.
graph_builder.add_edge(START, "extractor")
У гэтым кроку задаецца точка запуску: кожны выконанні графа пачынаецца з вузла extractor.
graph_builder.add_conditional_edges(
"extractor",
route_after_extraction,
{
"create_transaction": "create_transaction",
"ask_again": "ask_again",
},
)
Хтоць тут адбываецца розгалужэнне. Калі extractor завершыць сваю роботу, LangGraph вызывае route_after_extraction, ўбачыць наступны крок. Які бы строчак не вернуўся — create_transaction чы ask_again — ён шукаецца ў гэтым справакаванні, якое паў’язуе кожны строчак-рашэнне з фактычным вузлом, да якога трэба перейсці.
graph_builder.add_edge("create_transaction", END)
graph_builder.add_edge("ask_again", END)
Оба можлівыя розгалужэння завершаюць выконанне графа пасля таго, як ўбачыць END на будзь-яму з шляхоў.
Кампіляванне з викорыстаннем памяці
from langgraph.checkpoint.memory import MemorySaver
memory = MemorySaver()
assistance_graph = graph_builder.compile(checkpointer=memory)
Выкліканне compile() ператварае вялічыню графа ў што-то, што можна запусціць. Перадача параметра checkpointer=memory актывае механізм зберагчыць стан, описанный ранейш, таму ўскладненне графа знову з тым самым thread_id продовжвае роботу з таго моменту, дзе яна была перыявлена, а не пачынае ёю занова.
Фінальны код
Таким чынам шар оркестрацыі стае завершаным. Ось готовы файл (src/assistance/graph.py):
from langgraph.graph import StateGraph, START, END
from langchain_groq import ChatGroq
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel, Field
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, List, Optional
import operator
from src.utils.settings import settings
from src.assitance.schema import ExtractedTransactionSchema
from src.transaction import controller
from src.transaction.schema import TransactionCreateSchema
from src.utils.db_helper import get_or_create
from src.categories.models import CategoriesModel
from src.categories.controller import get_deterministic_color
from src.payment_options.models import PaymentOptionsModel
assistance_llm = ChatGroq(model="openai/gpt-oss-120b", temperature=0.2,
api_key=settings.GROQ_API_KEY)
structured_llm = assistance_llm.with_structured_output(
ExtractedTransactionSchema)
class GraphState(BaseModel):
user_input: str = Field(description="The user input to the graph.")
conversation_history: Annotated[List[str], operator.add] = []
extracted: Optional[ExtractedTransactionSchema] = None
final_response: Optional[str] = None
EXTRACTION_PROMPT = PromptTemplate(
template="""
You are a financial assistant extracting transaction details.
Below is the conversation so far (it may span multiple messages, where later
messages answer questions raised by earlier ones). Treat it as one combined input.
Required fields: title, transaction_type (income/expense), amount, category, payment_option.
If title is missing, add one based on the context of the message.
If anything required is missing, except title, set is_complete to False and write a short, polite
clarifying question in missing_info_message asking only for what's missing.
If everything is present, set is_complete to True, leave missing_info_message null,
and fill in all fields. Always copy the user's original message into `note`.
Conversation so far:
{user_input}
""",
input_variables=["user_input"]
)
def extractor(state: GraphState):
full_conversation = "\n".join(
state.conversation_history + [state.user_input])
prompt = EXTRACTION_PROMPT.format(user_input=full_conversation)
result: ExtractedTransactionSchema = structured_llm.invoke(prompt)
return {"extracted": result, "conversation_history": [state.user_input]}
def route_after_extraction(state: GraphState):
return "create_transaction" if state.extracted.is_complete else "ask_again"
async def create_transaction_node(state: GraphState, config: RunnableConfig):
session: AsyncSession = config["configurable"]["session"]
user = config["configurable"]["user"]
data = state.extracted
try:
category_id = await get_or_create(
session, CategoriesModel, user.id, data.category,
extra_defaults={"color": get_deterministic_color(data.category)}
)
payment_option_id = await get_or_create(
session, PaymentOptionsModel, user.id, data.payment_option,
extra_defaults={"payment_type": data.payment_type}
)
payload = TransactionCreateSchema(
amount=data.amount,
category_id=category_id,
payment_option_id=payment_option_id,
note=data.note,
title=data.title,
type=data.transaction_type,
)
await controller.create_transaction(payload, session, user)
await session.commit()
except SQLAlchemyError as err:
await session.rollback()
return {
"final_response": "Something went wrong while saving your transaction. Please try again."
}
message = f"Added {data.transaction_type} of {data.amount} under '{data.category}' ({data.payment_option})"
return {"final_response": message}
def ask_again_node(state: GraphState):
return {"final_response": state.extracted.missing_info_message}
graph_builder = StateGraph(GraphState)
graph_builder.add_node("extractor", extractor)
graph_builder.add_node("create_transaction", create_transaction_node)
graph_builder.add_node("ask_again", ask_again_node)
graph_builder.add_edge(START, "extractor")
graph_builder.add_conditional_edges(
"extractor",
route_after_extraction,
{
"create_transaction": "create_transaction",
"ask_again": "ask_again",
},
)
graph_builder.add_edge("create_transaction", END)
graph_builder.add_edge("ask_again", END)
memory = MemorySaver()
assistance_graph = graph_builder.compile(checkpointer=memory)
2. База дадзеных PostgreSQL
Взаімадзея з базай дадзеных на гэтым этапе вже обрабоўваецца ўнутрь create_transaction_node, пра якій гаворылася ранейш, дзе фінальныя даныя транзакцыі запісваюцца ў таблицу транзакцый.
3. Канец FastAPI
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.assitance.schema import UserMessageSchema
from src.assitance.graph import assistance_graph
from src.auth.models import UsersModel
from src.utils.db import get_db
from src.utils.auth.authentication import allow_all
assistance_routes = APIRouter(prefix="/assistance")
@assistance_routes.post("/transaction-entry", status_code=status.HTTP_201_CREATED)
async def run_transaction_assistance(payload: UserMessageSchema, session: AsyncSession = Depends(get_db), user: UsersModel = Depends(allow_all)):
config = {"configurable": {
"thread_id": str(user.id),
"session": session,
"user": user
}
}
result = await assistance_graph.ainvoke(
{"user_input": payload.message}, config=config)
return {"response": result["final_response"]}
Кліянты вызывают этот эндпоинт (/assistance/transaction-entry) и в теле запроса указываюць прыемку, якая описвае, пра шта была зданая транзакцыя.
Давайте розберамо, што робіць кожны элемент.
@assistance_routes.post("/transaction-entry", status_code=status.HTTP_201_CREATED)
Этам установліваецца маршрут POST пад адресам /transaction-entry. Значэнне status_code=status.HTTP_201_CREATED паведамляе FastAPI, які код статусу трэба вярнуць за замовчанням у разе успеху. 201 — это стандартны код для „была створана новая рэсурс“, што падходзіць тут, адколі успешны вызов прыводзіць да стварэння новай строчкі транзакцыі.
Складанне настройкаў графа:
config = {
"configurable": {
"thread_id": str(user.id),
"session": session,
"user": user
}
}
Этам ствараецца об’ект config, які передаецца пад вызов графа. Об’ект config містіць значэнні, спецыфічныя для запиту, якія не павінны знаходзіцца ў самым стане перазберагаючайся канверсаціі.
"thread_id": str(user.id): гэта значэнне выкорыстоўваецца чекпойнтэрам LangGraph, каб з’ясаваць, чыёі історыі размов трэба запрашаць та апдэйтаваць. Завяршваючы йго на ID аутентыфікованага пользователя, кожны пользователь автаматычна атрымле ізольаваную, стойкую нітку, таму пачатковы запис транзакцыі аднаго пользователя ніколі не можа паўтарыцца ў іншага. Ён ператвараецца у строку, таму што чекпойнтэр спакоюеthread_idяк строку, тады каліuser.idзазвычай являе сабою UUID."session"і"user": гэтыя параметры перадаюцца, каб функцыяcreate_transaction_node, якая выконваецца ўнутры графа, мела доступ да актуальнай сесіі базы дадзеных і да дакладнасцей пра таго, хто выконвае запит.
Выклік графа:
result = await assistance_graph.ainvoke(
{"user_input": payload.message}, config=config)
Эта лінія фактычна запускае выконанне. ainvoke — это асінхронны аналаг запуску графа; викорыстанне сінхроннага invoke заблокавало бы цыкл запуску падзеяў, што ў даным случае мае значэнне, таму што create_transaction_node усередзінна выканае асінхронныя операцыі з базай дадзенаў.
- Першы ўзлёг,
{"user_input": payload.message}, прадставляе пачатковы стан для гэтага запуску. Толькіuser_inputнеабходна падаць явна; застальныя поляGraphState(conversation_history,extracted,final_response) або маюць стандартныя значэння, або заполняюцца па мере выканання задачі ў рамках графа. Калі існуючыthread_idвже мае зберажаную історію, LangGraph аднаёць гэты новы вхід з тым зберажаным станом, а не пачынае з нуля. config=configпадае ўсё, што было падготавлена на паканальным кроцы:thread_idдля вычыслення правильнага стану, а таксамаsessionіuserдля вузла, які адпавядае за запіс у базе дадзеных.
await паўзяе выконанне гэтага корутына, пакуль граф не завершыць сваё выконанне, адтолькі ainvoke вяртае корутыну, якую неабходна чакаць, прычаму толькі пасля чаго рэзультат стане доступным.Тое, што вяртаецца як result, — это заканчальны стан GraphState, які представляецца у вачынку і які паказвае ход выканання графа, незалежна ад таго, чы робіцца це ў момент create_transaction чы ў момент ask_again.
Вярнэнне адпаведзі:
return {"response": result["final_response"]}
Маршрут завершаецца вярненням простага вачынка, які містіць толькі текст заканчальнай адпаведзі. FastAPI сама ператварае яго на JSON-пакет для кліента, ствараючы ўмовна такі формат:
{ "response": "Added expense of 450 under 'Groceries' (UPI)" }
Эты тэкст ідентычны таму, што было сформавана ранейце ўнутрь або create_transaction_node, або ask_again_node. Сам маршрут не ведае, якая самэйчына насправды была выканана; ён проста перадае тое, што апынулася ў final_response.
Вывык
Работа з этым асистэнтам практыкаванняў выявляе тое, чаго нарады зазвычай не адзначаюць: складнай часткай реалізацыі функцый AI ня є запрошэнне моделі да адпаведзення, а перакананне, што гэта адпаведзення будзе безбедна працаваць пасля взаімадзея з рэальным системам. Складзенне запрошэнняў — гэта простая частка. Настоямы інжынерны зусіллі направлены на схемы, якія вымагаюць структураванага выходу, умовныя графы, якія вяршаюць выбор между зберагчыцем дадзеныя і запрошэнням да уточнення, а таксама на стан, які правильна перадаецца праз калькі раундоў размовы.
LangGraph быў падходяны для гэтага проекту саме таму, што працы ў яму выклікалі неабходнасць прыемлень рашэнняў, а не простае перадача даных з вхіду на выход. Якщо ваша функцыя патрабуе простага, лінейнага апарату, то звычны вызов LLM чы праця з LangChain, верагатна, будуць простэйшымі і болей падходяжымі інструментамі. Але калі логіка вашага ШІ трэбуе розгалужэння, зберагчыць інформацію чы настаўкі, каб збраць больш даных пры продажу, структура на адной графі перестае выглядаць як непатрэбная складнасць і стае самым разумным спосабам моделювання такога апарату.
Справы
Аднаковыя матэрыялы
- LangChain протык LangGraph: Выбір между ланцюгамі та графамі з станамі — Дазвольце дазнацца, як лінейныя елементы LangChain адрозніцца ад графічных, з роздзяламі, працэйных падходаў LangGraph, і як выбраць тое, што падходзіць вашай АІ-практыцы.
- Стварэнне АІ-агента з нуля: шаблоны, ReAct і LangGraph — Дазвольце дазнацца пра основныя концэпцыі АІ-агентаў — планаванне, викорыстоўванне інструментаў, рэфлексія та шаблон ReAct — і як LangChain і LangGraph падходзяць для ручнага стварэння такога агента.