Hi. It's Lev
I love building in the web
I love building in the web
EmploymentWork
Full-stack VPN product work across backend, Telegram automation, server operations, analytics, and revenue systems
Full-Stack Developer | October 2025-August 2026
At Get Robo VPN, my work covered the connection itself, the account around it, and the tools needed to run the service. I worked across the frontend, backend, billing, API, and Telegram bot from October 2025 to August 2026.
That scope mattered because a connection problem rarely stays in one part of the product. Users experience it as a failed service, whether the cause is routing, account access, or a confusing interface.
I implemented DPI bypass using Xray protocols, cascade VPN routing, and a VK TURN calls proxy. I also managed a fleet of 20 servers. This was operational work as well as feature development: keeping the service available and reducing the effort required to maintain it.
The product also needed visibility into what was happening. I built dashboards and automated Telegram reports so that operational and revenue information did not depend on someone checking several systems by hand.
I built gifts, referrals, and promotional campaigns, alongside whitelist-censorship bypass features. The reported 60% MRR increase came from the combined product, campaign, and reporting work; I would not attribute it to a single feature.
I also built a React web counterpart to the Telegram Mini-App. It supported email and Telegram authentication, with SMTP transactional emails for account communication. Bringing the experience to the web meant looking beyond the assumptions of a Telegram-only interface.
Alongside new features, I refactored code across the frontend, backend, billing, API, and bot. The useful part of that work was making changes easier to follow across those components, rather than treating each one as a separate product.
The public project link opens the Telegram bot page. I have not published internal service code or infrastructure credentials here.
Flask commerce, queued AI classification, and a TypeScript catalog-review workspace.
Full-Stack Developer / Web Designer | 2024-2025; updates in 2026
Garments Parliament is a commerce site for archive and secondhand designer clothing. I built the Flask application around PostgreSQL and SQLAlchemy, using Blueprints to separate routes. My work covered the storefront, sourcing tools, and the admin workflow behind the catalog.
The original work dates to 2024–2025. In 2026, I added queued processing and a TypeScript review workspace. Those additions address a practical problem: importing an item should not depend on keeping a browser request open while scraping and AI calls finish.
An import request first creates a job record in PostgreSQL. A periodic dispatcher publishes pending jobs to RabbitMQ, and Celery workers perform the scraping or image classification. The database records what is queued, running, ready for review, or failed.
There is a deliberate ordering decision in the dispatcher: publish the message before marking it dispatched. A crash between those steps can produce another delivery. The workers therefore check job state and use PostgreSQL advisory locks rather than assuming each message arrives only once. Retries are bounded, and a failed job can be retried from the workspace.
Gemini classifies a product image into fields such as category, name, and characteristic. The worker checks the shape and length of the response, then stores a draft on the job. It does not silently overwrite the catalog item.
The React/TypeScript workspace lets an editor compare that draft with an existing record, correct fields, and approve several items together. Version checks reject stale edits. This matters when a draft has changed since someone opened it: saving should not quietly discard the newer work.
Zod validates API responses in the frontend, and Redis limits import requests. Neither proves that an AI-generated description is correct. A plausible category can still be wrong, so human review remains part of the workflow.
The source-site scraper uses Chrome TLS fingerprint impersonation and a Proof-of-Work solver. Scraping is a dependency to maintain, not a one-time integration: source pages and protections can change. Keeping that work in a queue makes failures visible without tying up the storefront.
Import requests persist jobs in PostgreSQL. A periodic dispatcher publishes pending work to RabbitMQ, keeping scraping and AI processing out of the request lifecycle.
Publish before marking a job dispatched. A crash between those steps may repeat delivery, so workers also use advisory locks and terminal-state checks.
At-least-once delivery, not exactly-once execution. This excerpt depends on the job model, worker task, and Celery configuration in the full module.
services/catalog_queue.py : 35-52
def enqueue(kind, source_key, payload):
stmt = insert(CatalogJob).values(kind=kind, source_key=source_key, payload=payload)
db.session.execute(stmt.on_conflict_do_nothing(constraint='uq_catalog_job_source'))
return CatalogJob.query.filter_by(kind=kind, source_key=source_key).one()
@celery.task(name='parliament.dispatch')
def dispatch_pending():
from app import app
with app.app_context():
cutoff = datetime.utcnow() - timedelta(minutes=5)
jobs = (CatalogJob.query.filter(CatalogJob.status == 'queued', CatalogJob.available_at <= datetime.utcnow(),
or_(CatalogJob.dispatched_at.is_(None), CatalogJob.dispatched_at < cutoff)).limit(100).all())
for job in jobs:
# Publish before recording delivery. A crash can duplicate delivery but cannot lose the job.
process_job.apply_async(args=[job.id], task_id=job.id)
job.dispatched_at = datetime.utcnow()
db.session.commit()Import requests use a Redis-backed limit of ten per identity per minute. A Lua script increments the counter and sets expiry atomically.
The counter lives outside the web process, so separate application workers share the same budget. The API returns a retry delay when the budget is exhausted.
A fixed-window limiter can allow bursts around a window boundary. Redis is also used separately in NewEd to cache audio, not database query results.
services/rate_limits.py : 6-25
SCRIPT = '''
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return {n, redis.call('TTL', KEYS[1])}
'''
@lru_cache(maxsize=1)
def client():
url = os.getenv('REDIS_URL')
return redis.Redis.from_url(url, socket_connect_timeout=1, socket_timeout=1) if url else None
def allow_import(user_id):
cache = client()
if cache is None:
raise redis.ConnectionError('Redis is not configured')
key = hashlib.sha256(str(user_id).encode()).hexdigest()
count, ttl = cache.eval(SCRIPT, 1, 'parliament:imports:' + key, 60)
return count <= 10, max(ttl, 1)A worker classifies a product image, validates the returned fields, and stores a proposed draft. Published or sold products are skipped.
Suggestions stay in the job result rather than overwriting an item an editor may be changing. Publication happens through a separate authenticated review endpoint.
Validation checks shape and field lengths, not semantic correctness. Human review is still required; the Gemini client and pricing helpers are outside this excerpt.
services/catalog_queue.py : 69-96
def process_product(job):
from services.ai_service import ai_service
from config import get_config
from utils.helpers import calculate_price_from_2nd
existing = Item.query.filter_by(id_2nd=job.source_key).order_by(Item.id).first()
if existing and existing.status != 0:
job.item_id, job.status, job.result = existing.id, 'duplicate', existing.to_dict()
return
source_id = job.source_key
image = f'https://cdn2.2ndstreet.jp/img/pc/goods/{source_id[:6]}/{source_id[6:8]}/{source_id[8:13]}/1.jpg'
classification = ai_service.categorize_item_from_image(image)
if not classification:
raise RuntimeError('Image categorization failed')
category, name, characteristic = classification
if any(not isinstance(value, str) or not value.strip() or len(value) > 255 for value in classification):
raise ValueError('Invalid classification')
config = get_config()
payload = job.payload
draft = {
'brand': str(payload.get('brand') or (existing.brand if existing else '') or '')[:255],
'size': str(payload.get('size') or (existing.size if existing else '') or '')[:50],
'price': existing.price if existing else calculate_price_from_2nd(payload.get('price_2nd', ''), config.PRICE_MULTIPLIER,
config.PRICE_DIVISOR, config.PRICE_ROUND_TO, config.PRICE_ADDITION),
'sex': payload.get('sex', 'U'), 'name': name, 'category1': category,
'category2': name, 'characteristic': characteristic, 'img': image,
}
# Store suggestions separately so an in-flight worker cannot overwrite an editor's changes.
job.result, job.item_id, job.status = draft, existing.id if existing else None, 'review'FastAPI learning platform with typed language lessons, 3D simulations, and reviewed content generation.
Full-Stack Developer / Content Creator | 2022-2025; updates in 2026
NewEd combines subject courses with language lessons and interactive 3D material. I built the platform with FastAPI, PostgreSQL, and SQLAlchemy. The original work dates to 2022–2025, with further lesson-player and background-processing work in 2026.
A subject article and a Chinese exercise do not need the same interface. Articles need room for explanation. A language lesson needs a clear sequence of small actions: learn a word, hear it, match it, use it in a sentence, and practise writing it. I kept those flows separate rather than moving the whole CMS into TypeScript.
The language player uses React and TypeScript, with Zod checking the exercise data before rendering. It checks more than field types. A choice exercise must contain its correct answer; sentence tokens must be able to form the answer; a matching exercise must have distinct valid pairs.
Answer handling lives in a separate reducer. One small detail is important for sentence exercises: selected tokens are tracked by index, not by their text. A sentence can contain the same word twice, and those two tokens still need to be selectable independently.
Learning cards can advance without an answer. Handwriting uses Hanzi Writer, and the final step saves progress before showing completion. If that save fails, the learner gets a retry rather than a false success screen. Progress is associated with the signed-in user through the backend.
The pronunciation endpoint fetches audio from an external TTS service and caches it in Redis for 24 hours. A repeated request can return the cached audio instead of calling the provider again. An atomic Lua counter also limits requests per client.
This cache is specifically for audio; it is not a PostgreSQL query cache. Keeping the claim narrow is useful when reasoning about a failure: cached audio, provider availability, and database performance are different concerns.
Content generation runs through Celery and RabbitMQ, backed by durable job records in PostgreSQL. Jobs have retry limits and duplicate protection. The output is held for review rather than published directly to a course.
The 3D work uses Three.js and Blender GLTF models, including raycaster-based dragging and proximity checks. These interactions let a learner manipulate a scene instead of only reading about it. They also have to work within a browser's rendering budget, especially on a phone.
The language player separates answer rules from React rendering. Each action updates a typed state; completion depends on the exercise type.
Sentence tokens are tracked by position, not text. Two identical words can be used independently without selecting the same token twice.
Excerpt from the production module; imports and the state definition are omitted. This is lesson UI logic, not a TypeScript subject CMS.
static/react/language/state.ts : 14-50
export type Action =
| { type: 'choice'; value: string }
| { type: 'given'; value: string }
| { type: 'match'; value: string }
| { type: 'token'; index: number }
| { type: 'remove'; index: number }
| { type: 'drawn' };
export function answerReducer(q: Exercise, state: AnswerState, action: Action): AnswerState {
switch (action.type) {
case 'choice':
if (!('answers' in q.content) || typeof q.content.answers !== 'string' || !q.content.options.includes(action.value)) return state;
return { ...state, choice: action.value, mistake: action.value !== q.content.answers };
case 'given':
if (!isMatch(q) || !q.content.given.includes(action.value) || state.pairs[action.value]) return state;
return { ...state, selectedGiven: action.value, mistake: false };
case 'match': {
if (!isMatch(q) || !state.selectedGiven || Object.values(state.pairs).includes(action.value)) return state;
const correct = q.content.answers[state.selectedGiven] === action.value;
return { ...state, selectedGiven: null, mistake: !correct,
pairs: correct ? { ...state.pairs, [state.selectedGiven]: action.value } : state.pairs };
}
case 'token':
if (!isOrder(q) || !Number.isInteger(action.index) || !q.content.options[action.index] || state.tokens.includes(action.index)) return state;
return { ...state, tokens: [...state.tokens, action.index] };
case 'remove': return { ...state, tokens: state.tokens.filter(index => index !== action.index) };
case 'drawn': return q.type === 'draw_char' ? { ...state, drawn: true } : state;
}
}
export function canAdvance(q: Exercise, state: AnswerState): boolean {
if (q.type === 'learn_card' || q.type === 'text') return true;
if (q.type === 'draw_char') return state.drawn;
if (isMatch(q)) return q.content.given.every(given => state.pairs[given] === q.content.answers[given]);
if (isOrder(q)) return state.tokens.length === q.content.answer.length &&
state.tokens.every((index, position) => q.content.options[index] === q.content.answer[position]);
return state.choice === q.content.answers;
}At Telega.io, I worked on the international side of a Telegram advertising marketplace. The work involved finding publishers, understanding the market outside the existing Russian-language audience, and helping advertisers run campaigns.
International marketplace development
At Telega.io, I worked on the international side of a Telegram advertising marketplace. The work involved finding publishers, understanding the market outside the existing Russian-language audience, and helping advertisers run campaigns.
I researched channels, contacted their owners, and worked on the process of bringing both publishers and advertisers onto the platform. I also led a remote team of seven people.
Running campaigns exposed questions that research alone could not settle: what information a buyer needs, how publishers quote prices, and where an order becomes difficult to coordinate. Those details fed back into outreach, onboarding, and reporting.
This was a commercial and team role, not a software-development position. It helped shape how I later approached product work: the interface is only part of the service someone is trying to use.
HowToxic is a project for examining toxicity in online discussions. The interface accepts text or a supported conversation source, including YouTube and Reddit, and presents analysis of the comments.
Projects and background
HowToxic is a project for examining toxicity in online discussions. The interface accepts text or a supported conversation source, including YouTube and Reddit, and presents analysis of the comments.
The useful question is not just whether a comment gets a high score. It is where the result appears in a larger discussion and what the reader can inspect alongside it.
Toxicity classification can miss context, quotation, humour, or language specific to a community. I treat the output as a way to explore comments, not as a substitute for moderation judgment.
This sits alongside my earlier experiments with toxic-comment classification, where results on a labeled dataset did not carry over cleanly to comments collected from a different source.
This is the version of my portfolio built around a floating notebook, computer, and other 3D objects. Three.js renders the scene, while JavaScript moves between the introduction, employment, projects, and contact screens. Flask serves the page.
Projects and background
This is the version of my portfolio built around a floating notebook, computer, and other 3D objects. Three.js renders the scene, while JavaScript moves between the introduction, employment, projects, and contact screens. Flask serves the page.
I kept this version available at /v2 because its character is different from the main site. The content now uses the same case studies and resume as the main portfolio, so maintaining two designs does not require writing two different accounts of the work.
Project details open above the scene. The current version adds explicit navigation, keyboard-accessible controls, and Escape to close a project. Mobile uses fewer 3D objects and a different composition.
The code examples are extracts from NewEd and Garments Parliament. They include enough surrounding explanation to show the decision involved, without presenting a short extract as a complete standalone application.
I started learning 3D because I wanted a character for NewEd. I bought a base model and commissioned a rig, then learned to animate it and build scenes around it. That distinction matters: the starting model and rig were not entirely my own work.
Projects and background
I started learning 3D because I wanted a character for NewEd. I bought a base model and commissioned a rig, then learned to animate it and build scenes around it. That distinction matters: the starting model and rig were not entirely my own work.
Most of the learning came through tutorials and trying things in a scene: adjusting motion, fixing lighting, and getting an exported model to behave in the browser.
For NewEd, I used 3D in small learning activities rather than only in video. Three.js let me connect rendered objects to input, dragging, and proximity checks. That added another set of concerns beyond how a scene looked in Blender.
I also made commercial and personal video work, including modelling, animation, lighting, and editing. Browser scenes pushed me to pay closer attention to mesh size, textures, and export formats; rendered video gave me more room to work on composition and timing.
A screen-parsing pipeline that captures table states and maps game variables against GTO strategy matrices for decision output.
Projects and background
This personal project uses Python and OpenCV to parse a poker table from the screen. The parsed variables are mapped against GTO strategy matrices to produce a decision output.
There are two different problems in that pipeline: reading the table correctly and interpreting the resulting state. A strategy lookup is only useful if the input represents what is actually on screen.
The project described here is the screen-parsing and decision pipeline. It should not be read as a claim that a strategy lookup solves every possible poker situation, or that the perception stage is error-free.
An event-driven assistant integrating live camera feeds with Gemini AI, ESP32 environmental sensors, and agentic PC access.
Projects and background
This personal assistant brings together live camera feeds, Gemini, ESP32 environmental sensors, and access to PC actions. Python coordinates the software, with Docker used in the setup.
The interesting part is connecting inputs with different meanings: an image gives visual context, a sensor supplies a measurement, and a computer action changes something. They should not be treated as interchangeable pieces of text.
This is an experimental home-automation project rather than a general-purpose commercial product. Camera feeds and device access are private, so they are not exposed as a public portfolio demo.
Open-source Plasma widgets for productivity and desktop customization.
Projects and background
I build QML widgets for KDE Plasma, focused on productivity and desktop customization. They are published through the KDE Store, where the actual widgets can be inspected rather than represented only by a resume bullet.
Desktop widgets have a different rhythm from a website: they remain in the user's workspace and need to fit alongside other tools. The aim is a small, useful addition rather than another full application to manage.
I created All the Good Things as an English-language Telegram channel for positive news. I handled the name, visual identity, story selection, and writing. Each post needed to give enough context to be useful while staying short enough for a messaging feed.
Projects and background
I created All the Good Things as an English-language Telegram channel for positive news. I handled the name, visual identity, story selection, and writing. Each post needed to give enough context to be useful while staying short enough for a messaging feed.
The channel grew to 12,000 followers without paid promotion. Growth came through consistent publishing, cross-promotion, and partnerships with other channels.
I later sold advertising placements and eventually sold the channel. Choosing relevant ads was part of the editorial work: a short-term sale could make the feed less useful to the people who had subscribed.
The project gave me practical experience in writing, audience development, and running a small media product, separate from my software projects.
One of my early machine-learning projects classified toxic Russian-language comments using BERT representations and CatBoost. It reached 85% accuracy on the labeled test dataset I used. When I tried it on comments collected through the VK API, the results were worse.
Projects and background
One of my early machine-learning projects classified toxic Russian-language comments using BERT representations and CatBoost. It reached 85% accuracy on the labeled test dataset I used. When I tried it on comments collected through the VK API, the results were worse.
The gap was more instructive than the headline accuracy. The source and wording of the new comments differed from the training data, so a test-set score was not enough to judge how the model would work in use.
I worked with Python, Pandas, SQL, scikit-learn, and TensorFlow, alongside formal study through Codecademy's data-science and deep-learning courses. I also spent a month in a data-science role at a financial firm in Asia.
That experience carried into later projects involving AI: inspect the input, keep the output reviewable, and do not confuse a model response with a verified fact.
My interests moved from mathematics and physics toward art, business, programming, and education. I studied independently as well as through school, university, and online courses. I left university and went into work rather than completing the degree.
Projects and background
My interests moved from mathematics and physics toward art, business, programming, and education. I studied independently as well as through school, university, and online courses. I left university and went into work rather than completing the degree.
Data science gave me a reason to learn Python. NewEd gave me a reason to build a web application. Garments Parliament brought visual design, commerce, and sourcing into the same project. Each exposed gaps I had to go back and study.
I read across subjects including psychology, linguistics, art, and economics, but interest in a subject is different from a professional qualification in it. The concrete evidence here is the work: applications, course material, experiments, and the changes made after using them.
I'm always open to new opportunities and collaborations
Lev Voinov / 2026