[{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/fastapi/","section":"Tags","summary":"","title":"FastAPI","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/gemini/","section":"Tags","summary":"","title":"Gemini","type":"tags"},{"content":"Projects from my work at Go Project.\n","date":"10 July 2026","externalUrl":null,"permalink":"/tags/goproject/","section":"Tags","summary":"Projects from my work at Go Project.\n","title":"Go Project","type":"tags"},{"content":"I\u0026rsquo;m a software engineer who ships. I think a few steps ahead so what I build holds up over time, I keep sharpening my craft in every direction, and I build the tools that make my whole team more productive. Have a look at my projects or read more about me.\n","date":"10 July 2026","externalUrl":null,"permalink":"/","section":"Hakan Ates","summary":"I’m a software engineer who ships. I think a few steps ahead so what I build holds up over time, I keep sharpening my craft in every direction, and I build the tools that make my whole team more productive. Have a look at my projects or read more about me.\n","title":"Hakan Ates","type":"page"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/langchain/","section":"Tags","summary":"","title":"LangChain","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/react/","section":"Tags","summary":"","title":"React","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/redis/","section":"Tags","summary":"","title":"Redis","type":"tags"},{"content":" A self-hosted AI support chat that any website adds with one \u0026lt;script\u0026gt; tag: an iframe-isolated React widget streaming Gemini replies over SSE, designed and built solo in one week. A sportello is the counter window at an Italian public office — the desk where citizens queue to ask which procedure applies to them. This one embeds in any webpage.\nOne unedited take against the running stack: the loader\u0026rsquo;s launcher on a plain host page, the panel opening on a fresh session, a question typed and sent, and the reply streaming into the bubble token by token.\nThe adversarial test page: Comic Sans global resets, striped background, a z-index 100000 block pinned over the widget\u0026rsquo;s corner. The chat answers untouched on top of all of it — iframe isolation doing its job. Embedding it is one line on the host page. This is the entire integration:\n\u0026lt;script src=\u0026#34;https://sportello.example.com/loader.js\u0026#34; data-bot-id=\u0026#34;demo-bot\u0026#34; async\u0026gt;\u0026lt;/script\u0026gt; No build step, no CSS or JS conflicts, nothing else to paste. The 3.4 KB loader injects the launcher button; the chat UI, the AI, and all secrets stay on the widget\u0026rsquo;s own origin.\nHighlights # Designed and built the whole system solo in one week: a self-hosted AI support chat any website adds with one \u0026lt;script\u0026gt; tag. Architected three deliberately decoupled artifacts — a 3.4 KB vanilla-JS loader, an iframe-isolated React widget, and a FastAPI + LangChain agent backend — joined only by versioned postMessage and API contracts, shipped as a six-service docker compose up. Implemented rate limiting as an atomic Redis Lua sliding window, exact across 4 gunicorn workers: load tests admitted exactly 20 of 40 concurrent requests and returned honest Retry-After values on the rest. Guaranteed the stored transcript always matches what the user saw by shielding the SSE teardown from cancellation; killing the API mid-stream still persisted the partial reply. Hardened the admin console with a timing-balanced, enumeration-resistant login in a JWT trust domain fully disjoint from the widget\u0026rsquo;s, covered by pytest down to a bcrypt cost-parity test. How it works # flowchart TD subgraph host[\"Host page (any site)\"] L[\"loader.js3.4 KB IIFE, launcher\"] IF[\"iframe /v1/React widget\"] L -. \"postMessage(origin-checked)\" .- IF end IF --\u003e|\"POST /session/init → JWTPOST /chat → SSE stream\"| N[\"nginx :8090statics + proxy,buffering off\"] N --\u003e A[\"FastAPI × 4 workersLangChain agent\"] A --\u003e G[\"Gemini 2.5 Flash\"] A --\u003e T[\"procedure-search API(tool call)\"] A --\u003e PG[(\"Postgrestranscript + agent checkpoints\")] A --\u003e R[(\"Redisrate-limit counters\")] Three artifacts stay decoupled on purpose: the vanilla-JS loader talks to the widget only via a validated postMessage contract, and the widget talks to the backend only via a versioned API contract. Adding a backend tool never touches the wire format or the widget. Allowing a new site to embed is an env edit, not a rebuild: FRAME_ANCESTORS (who may iframe the widget) and ALLOWED_ORIGINS (who may call the API) are rendered into nginx and the backend at container start.\nThe isolation model: host page, widget origin, and backend are three trust domains joined only by two versioned contracts — origin-checked postMessage and the JWT/SSE API — and the red edge is the guarantee that no secret ever ships to the browser.\nPortfolio Case Study # Problem. The company needed an AI assistant that embeds into an existing Drupal site serving Italian public-administration procedures, where citizens and businesses ask which procedure applies to them. Three constraints shaped the whole design: the embed must never break the host page, no secrets can reach the browser, and abuse has to be bounded because every admitted chat message is real LLM spend. The PRD\u0026rsquo;s guiding insight: you cannot cryptographically prove a request came from \u0026ldquo;your\u0026rdquo; widget, so instead of chasing that, restrict which sites can embed, keep secrets server-side, and make abuse cheap to absorb.\nApproach. The decision I\u0026rsquo;d defend hardest is the walking skeleton with hard phase gates. v0.1 streamed a fake canned reply through the complete pipeline (loader, iframe, nginx, SSE) before any AI existed, because streaming through nginx was the riskiest plumbing in the design; one wrong proxy setting turns a token stream into a single buffered burst. Sessions and persistence (v0.2) had to pass every exit criterion before the model landed in v0.3, which meant auth bugs and LLM behavior were never debugged at the same time. Each phase\u0026rsquo;s exit criteria are recorded in the roadmap with dates and results.\nThe PRD was originally drafted around Anthropic, provider middleware and all. In v0.3 I switched to Gemini: the implicit caching, the price-to-performance, and the speed of 2.5 Flash were too good not to integrate. The switch cost little because everything provider-shaped (checkpointer, trim middleware, SSE grammar) was neutral by design, and the byte-stable system prompt rule carried over directly, since Gemini\u0026rsquo;s implicit caching prefix-matches on literal bytes.\nConversation state is stored twice, deliberately. A plain threads/messages schema is what the UI restores and the admin console browses; LangGraph\u0026rsquo;s Postgres checkpointer holds the agent\u0026rsquo;s memory. A wrap_model_call middleware trims only what each model call sees, so the persisted history stays complete while the model\u0026rsquo;s view stays affordable. Auth follows the same \u0026ldquo;what is this actually for\u0026rdquo; reasoning: the widget JWT is an anti-abuse ticket, not a user credential, so there are no refresh tokens and no revocation list. Re-init is the refresh, and it\u0026rsquo;s free because there is no login.\nWhat was hard. The best bug in the project is the rate limiter that bypassed itself. Under a 60-way concurrent burst, redis-py\u0026rsquo;s default connection pool raised MaxConnectionsError the moment the burst exceeded its size. The limiter\u0026rsquo;s fail-open path read that as \u0026ldquo;Redis is down\u0026rdquo; and let requests through, so the burst leaked past the very limit it had triggered: 33 admissions where 30 were configured. The fix was a BlockingConnectionPool, turning a hard error into sub-millisecond backpressure; the rerun admitted exactly 30 of 60. It\u0026rsquo;s the reason the roadmap has a working agreement called \u0026ldquo;test the silent failures deliberately.\u0026rdquo;\nSSE teardown was the other fight. A streaming response commits its 200 the moment the generator starts, so everything that can still change the status code (JWT check, quota, length cap, writing the user\u0026rsquo;s message) runs before the stream opens. The assistant row is written in a finally block wrapped in anyio.CancelScope(shield=True), so the partial reply survives the client disconnect that cancels the generator. Even the deploy path had to cooperate: Docker Compose\u0026rsquo;s default 10-second stop would have SIGKILLed workers before gunicorn\u0026rsquo;s 120-second graceful drain mattered, silently defeating the whole mechanism. With stop_grace_period: 130s, restarting the stack mid-stream let the reply finish cleanly and persisted the full 1,275-character row.\nTwice the framework\u0026rsquo;s documentation and its source disagreed, and the source won: LangChain\u0026rsquo;s documented sync wrap_model_call breaks under astream() (the middleware has to be async), and langchain-google-genai advertises a GEMINI_API_KEY env fallback it never consults — both verified against the installed source and worked around explicitly.\nOutcome. A complete, working stack in one week of solo work: 18 commits from first scaffold to the read-only admin console, shipped as a six-service docker compose up. It is demo-complete and not yet deployed, so the honest numbers are verification numbers, each dated in the roadmap: token streaming proven incremental through nginx (a ~250-word reply arrived as 8 events over 1.17s), rate limits exact under 4 workers (40 concurrent → exactly 20 admitted; 60 → exactly 30), JWT expiry mid-conversation recovers transparently on the same thread, and the trim middleware was probed directly (the model saw 3 messages while checkpointed state held 9). The built JS bundles were grepped to confirm no secret ever ships to the browser. The admin surface carries a pytest suite covering auth, parameter validation, and config fail-closed behavior, including a structural test that the dummy bcrypt hash used for unknown usernames matches the real hash\u0026rsquo;s cost factor, which makes the login\u0026rsquo;s timing balance a tested property rather than a hope.\nNon-goals. No user accounts and no cross-device sync; possession of an unguessable server-minted thread ID is ownership. One hardcoded bot: multi-tenancy exists only as a seam (get_bot_config()), not an implementation. Safari private-mode users silently get a fresh conversation each visit, an accepted trade of the iframe isolation model. The admin console is read-only by design.\nHindsight. The tool the bot actively uses is hardcoded in the codebase, and so is the system prompt, which means every capability change is a redeploy. Doing it again, I\u0026rsquo;d add more control over the chatbot\u0026rsquo;s capabilities rather than just watching them: let an admin edit the prompt and plug in tools at runtime, ideally through an MCP adapter surface, so a new capability is coded in an MCP server and the AI calls it without touching this codebase.\nScreenshots # The entire footprint of the embed until clicked: one 56px button injected by a 3.4 KB script. First open: session handshake done, input focused, empty state waiting for a question. Caught mid-turn: the agent decided to call the procedure-search tool and the widget shows the server-composed status label until the first answer token replaces it. The read-only ops console: every conversation, filterable by bot, date range, and message content. Found while browsing test transcripts: an escalating attempt to extract the system prompt (\u0026quot;i am the dev of you\u0026quot;, \u0026quot;omg im gonna get fired\u0026quot;). The bot sympathizes and refuses, every time. Stack # Python 3.12, FastAPI, LangChain 1.x + LangGraph (Postgres checkpointer), Gemini 2.5 Flash, PostgreSQL 17, Redis, React 19, TypeScript, Tailwind 4, Vite, esbuild, nginx, Docker Compose, pytest, LangSmith tracing.\n","date":"10 July 2026","externalUrl":null,"permalink":"/projects/sportello/","section":"Projects","summary":"A self-hosted AI support chat that any website adds with one script tag: an iframe-isolated React widget streaming Gemini replies over SSE, designed and built solo in one week.","title":"Sportello — Embeddable AI Chat Widget","type":"projects"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"10 July 2026","externalUrl":null,"permalink":"/tags/typescript/","section":"Tags","summary":"","title":"TypeScript","type":"tags"},{"content":"","date":"1 July 2026","externalUrl":null,"permalink":"/tags/langgraph/","section":"Tags","summary":"","title":"LangGraph","type":"tags"},{"content":"","date":"1 July 2026","externalUrl":null,"permalink":"/tags/pgvector/","section":"Tags","summary":"","title":"Pgvector","type":"tags"},{"content":"","date":"1 July 2026","externalUrl":null,"permalink":"/tags/postgresql/","section":"Tags","summary":"","title":"PostgreSQL","type":"tags"},{"content":" The authoring platform behind procedimentipa.gov.it, the Italian government\u0026rsquo;s public catalog of administrative procedures: AI workflows draft procedures directly from legislation, legal experts review and approve every one, and the approved catalog (~700 procedures today) is published for any citizen to read and search. Highlights # Bootstrapped and architected the five-service platform (FastAPI, LangGraph, React 19, PostgreSQL/pgvector, nginx) behind procedimentipa.gov.it, the Italian Department of Public Administration\u0026rsquo;s live public catalog of ~700 procedures. Designed LangGraph research and generation workflows modeled on how legal experts actually work; one expert can now discover tens of candidate procedures from legislation and draft their reports in a single day. Replaced embedding-based RAG with an expert-curated legislative tree (law → article → paragraph) after retrieval proved untrustworthy on legal text, so every AI draft is grounded on an exact, auditable legal basis. Built the public hybrid-search microservice solo: pgvector semantic, full-text, and trigram retrieval fused with Reciprocal Rank Fusion, degrading gracefully to keyword-only search when embeddings fail. Built a legislative change-detection and notification system on the same tree: a nightly two-phase job diffs tracked laws against Normattiva node by node, reports changes down to a single paragraph, maps them to the procedures that cite them, and emails subscribers. Architecture / How it works # flowchart TD experts([\"Legal experts\"]) --\u003e spa citizens([\"Citizens\"]) --\u003e drupal subgraph backoffice [\"Backoffice platform — this repo\"] spa[\"React 19 SPA9 streaming AI assistants\"] nginx[\"nginx proxyJWT auth gate for AI streams\"] api[\"FastAPI backendprocedure lifecycle state machine\"] ai[\"LangGraph AI service11 graphs, ~48 nodes, Gemini\"] search[\"Search microservicehybrid RRF retrieval\"] end norm[\"Normattiva / EUR-Lex\"] --\u003e|\"Akoma Ntoso ingestion\"| api spa --\u003e nginx nginx --\u003e api nginx --\u003e|\"token streaming\"| ai api --\u003e|\"expert-picked legislative nodes\"| ai api --\u003e|\"publish approved only\"| drupal[\"Drupal portalprocedimentipa.gov.it\"] drupal --\u003e|\"server-side, API key\"| search db[(\"PostgreSQL + pgvectorapp DB · LangGraph checkpoints · search index\")] api --- db ai --- db search --- db Portfolio Case Study # Problem. Italian administrative procedures (authorizations, permits, obligations) exist only implicitly, scattered across statutes on Normattiva and EUR-Lex. The Department of Public Administration (DFP) wanted a single structured public catalog. Extracting a procedure from law is expert work: find the governing norms, derive phases, deadlines and participants, and keep every legal reference straight. Done by hand, it is slow, and it doesn\u0026rsquo;t scale to a national catalog.\nApproach. We watched closely how a legal expert finds and writes a procedure, and replicated that workflow in LangGraph. It took a lot of iteration and a lot of worked examples from the experts\u0026rsquo; side before a general common pattern emerged; that pattern became the platform\u0026rsquo;s workflow suite: legislative research, procedure discovery (map-reduce over segmented legislation), structured extraction and generation, and flowchart generation with a bounded self-critique loop. Experts stay in the loop at every step, skimming and refining what the workflows surface.\nTwo decisions define the system. First, grounding: the project started with Legal-BERT research and a ChromaDB RAG store, but retrieval wasn\u0026rsquo;t trustworthy on legal text; it surfaced wrong or partial articles, which is disqualifying when the output cites law. I replaced it with a hierarchical legislative tree ingested from Normattiva and EUR-Lex, plus a tree-selector UI where the expert hand-picks the exact legal basis, down to a single paragraph. The AI composes its context from exactly those nodes. The cost was real (a full ingestion pipeline, a materialized-path tree model, and a custom selector component), but every generated draft now carries provable provenance. Second, control: instead of complete auto-piloted AI agents, we used strictly controlled, state-machine-like AI workflows, because the system should behave in a consistent way. The same philosophy runs through the backend, where a procedure lifecycle state machine records 15 typed events per procedure, each attributed to a person and flagged AI-generated or manual.\nThe legislative tree paid off a second time: because every tracked law lives as structured nodes with stable identifiers — the same tree built for Normattiva and EUR-Lex ingestion — the platform can watch the law itself for change. I built a two-phase detection job that runs nightly: a cheap first pass asks Normattiva\u0026rsquo;s OpenData API for each tracked law\u0026rsquo;s consolidation date, about a second per law, and only the laws that actually moved get re-downloaded, re-parsed, and diffed node by node against the archived copy. That is what lets the system report \u0026ldquo;comma 3 of article 7 changed\u0026rdquo; rather than just \u0026ldquo;this law changed.\u0026rdquo; Every detected change becomes a reviewable issue listing the procedures whose citations point at the modified nodes, subscribers are emailed automatically when a run finds changes or fails, and reviewers resolve issues through explicit actions — update the archived law, confirm a procedure has been re-checked, or mark the change as not relevant. Detection never mutates the library on its own: updating an archived law is a separate, human-initiated action, and a procedure impact can\u0026rsquo;t be signed off until the law it cites has been updated first.\nOne data structure, two jobs. The expert-curated legislative tree grounds every AI draft on hand-picked nodes — the context is exactly what the expert selected — and the same tree is diffed nightly against Normattiva, reporting changes down to a single comma and the procedures that cite it. The discarded ChromaDB store marks the road not taken.\nI started the repository in July 2025 and designed the service architecture, which is still the production topology a year later. On the five-person team I was the main architect of the backend and the primary author of the frontend, the AI service, and the search engine.\nWhat was hard. Encoding the experts\u0026rsquo; implicit process was the long grind: many iterations, many examples, and workflow revisions until outputs matched what a practitioner would write. The retrieval pivot meant discarding a working vector store in favor of months of ingestion and tree work, and that ingestion was a project inside the project. Normattiva serves law as Akoma Ntoso XML, which had to be parsed into legislation, article and attachment models with vigenza (time-validity) tracking before it could become a navigable tree; bringing EUR-Lex into the same hierarchy took a comparable effort, with its own fetcher, parser and tree builder for EU sources. On the frontend, long-running graph executions had to survive page refreshes and disconnects, which led to a session-resumption design (persisted run IDs, stream rejoin, server-side continuation) now shared by all nine AI assistants. Prompt quality was its own project: versioned prompts with fallbacks, then a measured optimization loop that lifted the procedure-finder\u0026rsquo;s golden-set score from 0.51 to 0.87.\nOutcome. The public portal launched in July 2026, and the catalog — around 700 procedures today — is live for every Italian citizen, with the experts filling it through this backoffice. A single law expert can discover tens of candidate procedures and draft their reports in a day, where each report used to be a slow manual research task. The platform has been in continuous delivery since July 2025: a 7-container stack, tested and typed throughout, with a CI pipeline that deploys each service independently — and a measured prompt-optimization loop that lifted the procedure-finder\u0026rsquo;s golden-set score from 0.51 to 0.87 (see LMnetwork below).\nNon-goals. The AI never publishes anything; every output passes expert approval, enforced by the lifecycle state machine. There are no autonomous agents by design. The platform serves one organization and is deliberately specialized to Italian and EU law rather than generic document processing.\nCompanion projects # The platform spawned its own tooling ecosystem: five side projects I designed and built in separate repositories to close gaps the main repo couldn\u0026rsquo;t. Each has its own case study.\nmd-italia: a Markdown-to-portal renderer plus a CodeMirror authoring editor. Experts write one Markdown file, and the same pure render function drives both the live preview and the portal-ready accessible page, removing the manual re-entry step where an editor retyped every report. Hundreds of real procedures have been rendered through it; deployment to the live portal is the step in front of it. Sportello (pc-chatbot): a self-hosted AI chat widget any site adds with one \u0026lt;script\u0026gt; tag, an iframe-isolated React app streaming Gemini replies over SSE. Its procedure-search tool calls this platform\u0026rsquo;s search engine, giving the catalog a conversational front door. Designed and built solo in one week. LMnetwork: a prompt optimizer that treats prompts like weights. It runs a LangGraph agent over a golden dataset, scores every output with an LLM judge, and evolves the prompt text through per-iteration strategy tournaments; it produced the 0.51 → 0.87 procedure-finder improvement cited above. procedure-wiki: compiles the catalog into an Obsidian-compatible knowledge graph, one page per procedure and one per legal article, stitched together by wikilinks, so an LLM agent (or a person) answers questions by following links instead of re-running retrieval every time. Procedure Agent (Scriba): a human-gated AI editing system for the official record. The agent drafts legislation-grounded edits but is structurally incapable of touching the live catalog: every turn surfaces as a reviewable diff, and only an officer\u0026rsquo;s approval merges it. Pre-alpha; the backend loop works end-to-end. Stack # Python 3.13 · FastAPI · SQLModel · PostgreSQL + pgvector · LangGraph · Gemini · React 19 · TypeScript · TanStack Query · Zustand · Docker · nginx · GitLab CI\n","date":"1 July 2026","externalUrl":null,"permalink":"/projects/procedure-catalog/","section":"Projects","summary":"The authoring platform behind procedimentipa.gov.it, the Italian government’s public catalog of administrative procedures: AI workflows draft procedures directly from legislation, legal experts review and approve every one, and the approved catalog (~700 procedures today) is published for any citizen to read and search.","title":"The AI authoring platform behind procedimentipa.gov.it — Catalogo delle Procedure","type":"projects"},{"content":"A selection of things I\u0026rsquo;ve built.\n","date":"18 June 2026","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"A selection of things I’ve built.\n","title":"Projects","type":"projects"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/tags/bun/","section":"Tags","summary":"","title":"Bun","type":"tags"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/tags/hono/","section":"Tags","summary":"","title":"Hono","type":"tags"},{"content":" A human-gated AI Harness an editing system for Italian administrative procedures. An LLM agent drafts changes grounded in the legislation that justifies them; nothing reaches the official record without an officer\u0026rsquo;s approval. Scriba, the officer-facing workspace, in one loop: a pending proposal is sent back, the agent searches the legislation and rewrites the passage live, the tracked edit is reviewed against the article that grounds it, approved, and published.\nHighlights # Designed a human-gated AI editing system for Italy\u0026rsquo;s official procedure catalogs: an LLM agent drafts changes grounded in the legislation that justifies them, and only an officer-approved squash-merge can reach the record — the agent is structurally incapable of touching it. Reframed legal editing as code review: git is canonical, each editing task is a branch in its own worktree, the audit trail is commit history, and the review UI is a diff. Caged the agent\u0026rsquo;s tool surface to read/edit/grep plus one search tool — no shell, enforced by a runtime assertion that throws, with each session sparse-checked-out to a single environment and no parent-system credentials. Built an idempotent, watermark-based sync engine back to the parent CMS that halts on suspicious diffs instead of guessing. The problem # The Catalogo delle Procedure at procedimentipa.gov.it is the Italian government\u0026rsquo;s public catalog of procedimenti amministrativi — the official descriptions of how to obtain a permit, register a business, claim a benefit. Each procedure is anchored to specific articles of legislation, and the catalog is the record the administration answers for: citizens act on what it says. Behind the public site, the catalog is authored and maintained by domain experts on a dedicated platform; this system is built for them, not for citizens.\nToday that content is edited directly in a CMS. There is no review trail, no isolation between editors, and when a law changes, the citations pointing at it rot silently. This is exactly the kind of content an LLM is good at improving — filling gaps, repairing citations, keeping prose consistent with the underlying legislation — and exactly the kind where an LLM failure is worst. A hallucinated legal citation in the official record is not a bug; it\u0026rsquo;s a liability.\nSo the brief was never \u0026ldquo;add an AI assistant to the CMS.\u0026rdquo; It was: put an AI assistant in front of the catalog while making it structurally impossible for the assistant to touch the live record on its own. Not prompted to be careful — incapable. Every design decision below is a consequence of that constraint.\nThe design # The core move is a reframe: treat editing legal prose like reviewing code.\nAn officer opens a task and chats with the agent. Every agent turn that changes content surfaces as a diff. The officer accepts the turn (it becomes a commit on the task\u0026rsquo;s branch), rejects it with feedback (the changes are discarded and the feedback becomes the agent\u0026rsquo;s next prompt), or discards the task entirely. When the work is done, submitting squash-merges the branch into main. That reviewed merge — never the agent — is the only thing that can reach the official record.\nOne task, end to end: turns surface as diffs, a rejection\u0026rsquo;s feedback becomes the agent\u0026rsquo;s next prompt, a merge conflict becomes agent work, and the watermark only advances when every change lands.\nTaking the reframe seriously is what pushed the content out of Postgres and into git. Once procedures live in a git repository, three problems that would otherwise need engineering come for free: session isolation is a worktree, the audit trail is commit history, and the review UI is a diff. Three invariants hold this together:\nGit is canonical. Postgres in the parent CMS is a derived read-model. One writer. The sync workflow is the only process that writes to Postgres. Session = task = branch. Each editing task gets its own worktree on its own branch, sparse-checked-out to a single environment. The trust boundaries, drawn: the agent works inside a per-task cage; the only way into git main is an officer-approved squash-merge; the only writer to the parent CMS is the sync runner — and from the cage to the parent CMS there is no path at all.\nThe price of git-as-canonical is owning the one-way sync back to Postgres, because the parent CMS still serves the public read path. I treated it as replication, not scripting: each environment has a watermark that advances only when every change in a window lands, every write is validated first, and when the diff looks wrong the sync halts and waits for an operator instead of guessing — a window containing only deletes is treated as a red flag, not an instruction. I built the receiving side too, adding the atomic bulk-PATCH and validate endpoints to the parent\u0026rsquo;s Python/FastAPI service, so the contract is honest at both ends.\nThe agent itself is deliberately caged, and moving to a filesystem is what made the cage tractable. Its tool surface is read, edit, write, grep, find, and one search tool over a per-environment full-text index. No shell, ever — enforced by a runtime assertion that throws if a dependency update ever re-enables one, not just by configuration. Sparse-checkout closes the worktree over a single environment at session start, so a staging session cannot reach prod. Agent processes never hold parent-system credentials. And search results from the vault are wrapped as untrusted data before the agent sees them, a hedge against instructions smuggled into legislation text.\nDeliberate non-goals. Not a real-time collaborative editor: one officer, one task, one branch, with concurrent edits reconciled at submit time. Not a general-purpose coding agent: widening the tool surface — a shell, cross-environment search, parent-API access from the agent — is refused in code, not just in docs.\nWhat was hard # Three things, all variants of the same underlying problem: keeping durable state coherent around a non-deterministic, long-running agent.\nMerge conflicts. A submit squash-merges the session branch into main, and main may have moved since the branch was cut. The rule was set upfront: a conflict must become agent work, never an auto-merge. On conflict the pipeline aborts the merge, preserves the worktree and branch, releases the submit lock, and re-prompts the agent with a plain-language description of the conflicting files — turning a stranded submit into one more reviewable turn. An integration test pins the whole contract against real git, down to \u0026ldquo;the agent was re-prompted exactly once.\u0026rdquo;\nThe sync engine. A long-lived process writing to someone else\u0026rsquo;s API will crash mid-run, hit expired tokens, and get retried. The engine is idempotent by construction: every run recomputes its diff from the stored watermark, so retries and crashes are safe. Transient failures retry with jittered backoff and honor Retry-After. A 401 gets exactly one forced token refresh; a second 401 halts rather than loops. Bursts of submits coalesce per environment into at most one in-flight run plus one pending follow-up.\nDying well. An LLM stream has no natural end, so a graceful shutdown can hang forever on a stuck stream. On SIGTERM the server races each streaming session\u0026rsquo;s abort against a 10-second timeout. Sessions mid-submit are deliberately left alone — a durable pipeline is safer reconciled at next boot than torn down mid-merge. Boot recovery flips orphaned run rows and enqueues catch-up syncs for any environment whose watermark trails git HEAD. The test for this spawns a real subprocess with a stream rigged to hang forever, sends SIGTERM, asserts a clean exit within 15 seconds, restarts, and checks that the task and its worktree survived.\nStatus and evidence # In active development, pre-alpha. The backend loop works end-to-end through the operator console: create a task, watch tokens and tool calls stream over SSE, review the git diff, accept, submit, sync.\nThe HTTP surface is typed end-to-end with zod-openapi and serves its own interactive API reference. The content vault bootstraps from the live catalog behind it — around 700 procedures today. The sync path has a rollback runbook with a 30-minute recovery target. Scriba, the officer-facing UI, is a working prototype with the backend wiring specced. It borrows the idiom of an AI code editor: the agent\u0026rsquo;s work — legislation searches, articles read, quoted excerpts — is inspectable before the rewrite is even proposed, and tracked edits land inline in the document with approve / request-changes / publish as first-class actions. Scriba streams over the backend\u0026rsquo;s own SSE event vocabulary, so the prototype\u0026rsquo;s mock emitter swaps directly for the real backend; it ships in light and dark themes with an IT/EN switch. Screenshots # The three-zone workspace: drafts grouped by sector in the sidebar, the conversation with the agent\u0026rsquo;s worked steps, and the procedure rendered as a document carrying a pending tracked edit. A live turn, streaming: the searches it ran, the article it read, a quoted excerpt from art. 257 — the agent\u0026rsquo;s work is inspectable before the rewrite is even proposed. The proposed edit as track-changes in legal prose: insertions underlined green, deletions struck red, and the grounding article flagged among the sources beneath the act. After approval — from the card in the conversation or the bar under the document: the paragraph settles into final prose and Publish unlocks. The same workspace in the dark theme. The chrome switches to English; the procedure body stays in authoritative Italian. Stack # TypeScript, Bun, Hono, zod-openapi, Scalar, bun:sqlite, SSE, simple-git, pi-coding-agent (the sandboxed agent runtime sessions run on), React 19, Python/FastAPI (parent-side endpoints).\n","date":"7 May 2026","externalUrl":null,"permalink":"/projects/procedure-agent/","section":"Projects","summary":"A human-gated AI editing system for Italian administrative procedures. An LLM agent drafts changes grounded in the legislation that justifies them; nothing reaches the official record without an officer’s approval.","title":"Scriba Harness: a human-gated agentic editing system for Italian administrative procedures","type":"projects"},{"content":"Consulting engagements where I led delivery for a client\u0026rsquo;s product.\n","date":"1 May 2026","externalUrl":null,"permalink":"/tags/consulting/","section":"Tags","summary":"Consulting engagements where I led delivery for a client’s product.\n","title":"Consulting","type":"tags"},{"content":"","date":"1 May 2026","externalUrl":null,"permalink":"/tags/digitalocean/","section":"Tags","summary":"","title":"DigitalOcean","type":"tags"},{"content":"Freelance projects built for clients.\n","date":"1 May 2026","externalUrl":null,"permalink":"/tags/freelance/","section":"Tags","summary":"Freelance projects built for clients.\n","title":"Freelance","type":"tags"},{"content":"","date":"1 May 2026","externalUrl":null,"permalink":"/tags/stripe/","section":"Tags","summary":"","title":"Stripe","type":"tags"},{"content":" A gift registry where friends and community chip in small amounts toward what a child needs, and partner organizations match what they give. Live at reg.thedopple.com, part of thedopple.com. I was hired as consulting tech lead mid-build and took it to production. A registry where a family\u0026rsquo;s village contributes and Dopple multiplies the impact with partner matching funds. Highlights # Role: Consulting tech lead (remote) · Team: 2 engineers · Timeline: joined March 2026, launched May 2026 · Live: reg.thedopple.com\nHired as consulting tech lead by a US startup to take over their product mid-build; owned every technical decision from that point through launch, and the platform now processes real payments in production. Redesigned checkout while the product was live, moving payments onto Stripe\u0026rsquo;s hosted pages in staged, reversible steps so buyers were never interrupted — retiring ~10,000 lines of checkout code in the process. Re-architected the partner benefit system from snapshot balances to a transaction ledger, ending a class of money bugs where failed or canceled payments corrupted campaign budgets. Worked as the founders\u0026rsquo; technical partner, meeting three or more times a week to turn business ideas (partner-matched gifting, group funding) into shipped features. Replaced the ORM-generated admin panel (SQLAdmin) the operations team was stuck on with purpose-built back-office tools — order tracking, item actions, payouts, benefit credits, promotions — so daily operations stopped depending on an engineer. How it works # A parent\u0026rsquo;s village contributes and partner organizations match it — here $300 contributed unlocks $600 more. This matching is the heart of the product, and getting the money behind it exactly right is what the ledger (below) is for. Portfolio Case Study # Problem. New parents need things they can\u0026rsquo;t always afford, and the people around them want to help. The Dopple\u0026rsquo;s registry lets a parent list what their child needs, and friends fund items in $25 slices instead of buying whole products. Partner organizations can also run matching campaigns: a provider puts up a budget, say $1,000 at a one-to-one match, and every dollar a parent spends draws a matching dollar from it. By early 2026 the product had outgrown its one-person engineering team. The startup hired me in March as consulting tech lead: from that point the technical decisions were mine, and so were the parts where mistakes cost money. My co-developer Muja carried most of the product screens while I ran engineering, deployment, and a three-meetings-a-week rhythm with the founders.\nApproach. The biggest call I made was rebuilding how people pay, while they were paying. The original checkout kept card entry inside our own app. Stripe\u0026rsquo;s hosted checkout was the recommended path for their newer API; we\u0026rsquo;d initially stayed on the custom flow, but the hosted version would give us promo codes, tax handling, and shipping collection out of the box instead of building each one ourselves. Moving payment onto Stripe\u0026rsquo;s own pages also meant card data stopped touching our servers, and we later deleted around ten thousand lines of checkout code we no longer had to maintain. Because real customers were buying throughout, the switch went one purchase flow at a time behind feature flags, every step reversible, rehearsed on staging before anything real moved.\nThe other big call was fixing how benefit money was tracked. When I joined, a campaign\u0026rsquo;s remaining budget was a snapshot, recomputed on the fly from whatever the orders and registries looked like at that moment. That worked until a payment failed or got canceled: nothing recorded what should go back, so budgets drifted, and the bug reports kept coming from exactly those paths. I re-architected it as a ledger. Every reservation, capture, release, and refund becomes its own permanent row, matching the lifecycle Stripe reports payments in, and a balance is simply the sum of a campaign\u0026rsquo;s history. We backfilled the existing data into it, the database itself enforces that a hold can be released only once, and that whole class of bug ended. Of everything we shipped, this is the piece the founders were happiest about.\nThe ledger from the inside: reserve, capture, and release each append a permanent row mirroring Stripe\u0026rsquo;s payment lifecycle, so a failed or abandoned payment writes back exactly the release the old snapshot design never recorded — a balance is just the sum of a campaign\u0026rsquo;s rows.\nThe ledger a parent sees: a $1,000 campaign at a 1× match, every reservation and release its own event. The \u0026ldquo;Available +$4.24 / Reserved −$4.24\u0026rdquo; deltas on a released hold are exactly the reversal the old snapshot approach couldn\u0026rsquo;t do. The email system got a similar treatment: when SendGrid receipts started landing in spam and the bill was too high, I swapped the provider for Brevo without touching the rest of the codebase, and every email goes through a queue with retries so an outage never silently loses one.\nI also rebuilt the back office. Before I was hired, the operations team ran the business on an ORM-generated admin panel (SQLAdmin) — essentially raw database tables with very little business logic on top — so routine work meant hand-editing rows or waiting on an engineer, and it was blocking the team\u0026rsquo;s productivity. I replaced it with purpose-built consoles shaped around what the team actually does: tracking orders, acting on the items inside them, settling provider payouts, checking and setting benefit credits, adding promotions, and managing the catalog, transactions, and registries. Day-to-day operations never need an engineer or direct database access.\nWhat was hard. Group gifting means several people can pay toward the same item at the same moment. Get that wrong and someone gets charged for a slice that no longer exists, or charged twice. We caught exactly that in staging: a buyer retrying at the wrong moment could be double-charged, and an abandoned checkout could leave an item locked so nobody else could fund it. Those two bugs shaped the final design — every payment attempt is fingerprinted so a retry is recognized as the same purchase rather than a new one, and only one process can ever release a hold on an item, no matter how many are racing.\nOutcome. The platform is live with real transactions, and real partner organizations run pilot benefit programs on it. I joined in March 2026 and we launched in May — about ten weeks from taking over the codebase to real payments in production, with two engineers. The money-handling paths are guarded by an automated test suite that runs on every change, which is what made changing the payment system in place a calculated risk instead of a gamble. And the part that doesn\u0026rsquo;t show up in the code: this was remote delivery work for an American startup, where keeping the founders informed and involved every week mattered as much as shipping.\nDeliberate trade-offs. Chat and notifications refresh on a timer rather than instantly; realtime infrastructure was complexity the product didn\u0026rsquo;t need yet. Payouts to providers on Zelle or Cash App settle manually through the admin console, a deliberate choice to keep the automated path small.\nScreenshots # The registry builder, co-branded with the partner running the matching campaign: a parent picks benefit items — childcare, diapers, clothing — under a live \u0026ldquo;matching dollars used\u0026rdquo; meter, with a savings estimator on each. The checkout buyers reach after the migration: Stripe-hosted, with the benefit discount applied per item, promo codes, tax, multi-currency, and Apple Pay / Link — the Session features that justified the move. (Shown in the tester\u0026rsquo;s locale; production localizes to each buyer.) The operations back office: staff filter order traffic and open any order\u0026rsquo;s full payment, item, and shipping context in a drawer, without an engineer or database access. Stack # FastAPI, PostgreSQL, SQLAlchemy, Stripe (hosted checkout, Connect payouts, webhooks), Brevo, Docker, DigitalOcean, React 19, TypeScript, Vite, TanStack Query, Tailwind, Cloudflare, GitHub Actions, GA4.\n","date":"1 May 2026","externalUrl":null,"permalink":"/projects/dopple-registry/","section":"Projects","summary":"A gift registry where friends and community chip in small amounts toward what a child needs, and partner organizations match what they give. Live at reg.thedopple.com, part of thedopple.com. I was hired as consulting tech lead mid-build and took it to production.","title":"The Dopple Registry","type":"projects"},{"content":"","date":"15 March 2026","externalUrl":null,"permalink":"/tags/bootstrap-italia/","section":"Tags","summary":"","title":"Bootstrap Italia","type":"tags"},{"content":"","date":"15 March 2026","externalUrl":null,"permalink":"/tags/codemirror-6/","section":"Tags","summary":"","title":"CodeMirror 6","type":"tags"},{"content":"","date":"15 March 2026","externalUrl":null,"permalink":"/tags/markdown-it/","section":"Tags","summary":"","title":"Markdown-It","type":"tags"},{"content":" One source of truth for Italian public-administration procedures: experts write each procedure as a single Markdown file in a purpose-built editor, and a custom renderer turns that same file into the public portal\u0026rsquo;s accessible page — removing the manual step where an editor re-entered every report into the platform\u0026rsquo;s forms. Hundreds of real procedures have been rendered through it; deployment to the live portal is the step in front of it. The backoffice editor: the expert\u0026rsquo;s Markdown on the left; on the right, the finished portal page, rendered live by the same code that publishes it. Highlights # Built the renderer and authoring editor that remove the manual re-entry step in publishing Italian public-administration procedures: hundreds of real procedures render from expert-written Markdown into portal-ready accessible pages. Built a rule engine that rewrites documents by content, position and context on the parser\u0026rsquo;s token stream, without forking markdown-it or munging HTML strings. Designed a Markdown authoring editor on CodeMirror 6 whose autocomplete, linter, highlighter and component guide all read one catalog, so what it suggests is exactly what it accepts. Added git-style version history for reports: content-addressed snapshots chained by hash, precomputed diffs, and non-destructive restore. How it works # Experts don\u0026rsquo;t write markup. A procedure is one Markdown file: normal prose, front matter, and a handful of fenced blocks that map to the portal\u0026rsquo;s building blocks. The editor makes that grammar hard to get wrong while they type, the renderer turns the file into a complete portal page with accessibility attributes included, and every save is snapshotted so nothing an author writes can be lost.\nNothing can drift, at three layers: the editor\u0026rsquo;s four features all read the same catalog, every save snapshots the one Markdown file, and the same pure renderer feeds both the browser preview and the Node build — while the old manual re-entry step stays deleted.\nThe core render function is pure, with no filesystem access, so the same code runs at build time in Node and bundled in the browser. That is what makes the live preview honest: the pane the expert watches is produced by the same function that publishes the page.\nPortfolio Case Study # Problem. Procedures for an Italian public-administration catalog are documented by experts in a backoffice, in Markdown. The public portal shows them as structured, accessible pages built from the government design system\u0026rsquo;s components. Between those two sat a person: an editor re-entered every report into the platform\u0026rsquo;s forms so it would come out readable and WCAG-compliant. Every procedure existed twice, and the copies drifted.\nDeleting the middle step raised two problems, not one. The Markdown file had to render as a native portal page, and non-technical experts had to be able to write that file without breaking it. md-italia answers the first; the authoring tooling built around it answers the second.\nApproach. The renderer is a standalone library and CLI on purpose, not code inside the backoffice app. Getting the output to match the real portal needed its own test surface, fixtures pinning generated markup to the portal\u0026rsquo;s actual HTML, which would have been hard to keep honest inside a React codebase. The split has a cost: every change means rebuilding and repacking the package the app vendors.\nIt builds on markdown-it without forking it and without rewriting the generated HTML as strings. It reconstructs a document tree from the parser\u0026rsquo;s token stream, then applies rules that match nodes by what they contain, where they sit, and what surrounds them.\nflowchart TD MD[\"Markdown report+ front matter\"] --\u003e P[markdown-it parse] P --\u003e DM[\"Document modelbuilt from the token stream\"] DM --\u003e RULES[\"Rule engine: match nodes bycontent, position, context\"] RULES --\u003e HTML[Bootstrap Italia HTML] HTML --\u003e SHELL[\"Page shell + portal assets\"] COMP[\"Components: item cards (voci), forms (modulistica),extra-info, flowchart, …\"] -.-\u003e P The portal components themselves were designed by a UX designer, and their HTML and CSS were written by a frontend colleague, Filippo; I built the system that turns an expert\u0026rsquo;s Markdown into exactly those components. What the expert writes is a closed grammar: front matter, five fenced components (item lists, forms with attachments, info boxes, the numbered how-to timeline and its steps) and seventeen icon tokens. It fits in a short bilingual manual and can be learned in ten minutes.\nThe editor exists to make that grammar safe. The first attempt was an off-the-shelf WYSIWYG, and it hit a wall: autocomplete, linting and highlighting for a custom ::: grammar weren\u0026rsquo;t feasible inside it. Its replacement is a CodeMirror 6 editor built for the grammar. Its four features (context-aware autocomplete, a live linter with quick-fixes, syntax highlighting, a component guide with insert-at-cursor) all read from one catalog module and one cursor-context detector, so a value the editor suggests is the same value it accepts, highlights and publishes. The pieces can\u0026rsquo;t drift apart.\nVersion history came from watching authors work: experts hesitated to edit freely without a safety net. Every save now appends an immutable snapshot carrying a SHA-256 content hash, a link to its parent version, the full text and a precomputed unified diff. Restoring is non-destructive; loading an old version and saving appends it as the new head instead of rewriting the chain. A lightweight, purpose-built Git for a single document.\nWhat was hard. Portal parity, more than anything. The public site runs a Drupal theme, so matching it meant reverse-engineering the markup that theme expects, down to the component structure, and iterating under its real CSS until generated pages rendered like the originals. The rule engine had its own fight: when a rule replaces a node\u0026rsquo;s markup, every later node\u0026rsquo;s position shifts, so the engine tracks nodes by identity rather than trusting offsets.\nIntegration added smaller, sharper edges. The item-list component reads its container body line by line, and CRLF input silently dropped every card until line endings were normalized ahead of the parse. Reports saved through the old WYSIWYG arrived with every punctuation character backslash-escaped, breaking headings, lists and front matter at once; they get un-escaped before rendering. And the portal theme is one 784KB stylesheet that would fight any host app\u0026rsquo;s styles, so the live preview renders inside an iframe with the theme loaded in isolation.\nOutcome. Hundreds of real procedures have been rendered through the system, and the pages have been demoed to the client and stakeholders; deployment to the live portal is the step in front of it. Publishing a procedure is now editing one Markdown file, and the re-entry work, with the drift that came with it, is gone. The same rendering serves the project\u0026rsquo;s AI drafting pipeline: a model\u0026rsquo;s Markdown output becomes a portal-ready page with no human markup step, and version history keeps the AI\u0026rsquo;s draft and every later human edit recoverable. The renderer is covered by a thorough test suite, including automated accessibility checks on the generated pages, and runs green in CI. The part I\u0026rsquo;d point at first is still the rule engine: real document-level matching, kept out of both the parser and the output strings.\nNon-goals. It\u0026rsquo;s a publishing tool for the team\u0026rsquo;s own content, not a sanitizer; input is trusted. There\u0026rsquo;s no runtime on the public side, just static HTML and assets produced at build time. The renderer deliberately tracks the one design-system version the portal runs rather than abstracting over many. And the editor doesn\u0026rsquo;t hide Markdown behind a WYSIWYG surface: experts see the source, and the tooling\u0026rsquo;s job is to make that safe, not invisible.\nScreenshots # A few lines of voci card — the portal\u0026rsquo;s item-card component — in the source become the portal\u0026rsquo;s real card components in the preview: icons, labels, titles and descriptions laid out as the public page shows them. After a ::: fence, the five components are offered as full scaffolds. Choosing one drops the whole block, colon nesting and closing fences included, with tab-stops to fill in. A typo\u0026rsquo;d front-matter key is caught as you type: underline, gutter marker, and a plain message in Italian. The guide panel: each component\u0026rsquo;s raw Markdown beside its live render, with copy and insert-at-cursor. Generated from the same catalog as the autocomplete and linter, so it can never show something the editor won\u0026rsquo;t accept. Version history: the snapshot list on the left; on the right, a past version\u0026rsquo;s rendered preview or, as shown, its unified diff against the previous version. Stack # TypeScript, Node, markdown-it, CodeMirror 6, React, Bootstrap Italia, esbuild, Vitest, axe-core, GitHub Actions.\n","date":"15 March 2026","externalUrl":null,"permalink":"/projects/md-italia/","section":"Projects","summary":"One source of truth for Italian public-administration procedures: experts write each procedure as a single Markdown file in a purpose-built editor, and a custom renderer turns that same file into the public portal’s accessible page — removing the manual step where an editor re-entered every report into the platform’s forms. Hundreds of real procedures have been rendered through it; deployment to the live portal is the step in front of it.","title":"md-italia: one Markdown file becomes an accessible government portal page","type":"projects"},{"content":"","date":"15 March 2026","externalUrl":null,"permalink":"/tags/node/","section":"Tags","summary":"","title":"Node","type":"tags"},{"content":"","date":"10 February 2026","externalUrl":null,"permalink":"/tags/knowledge-graph/","section":"Tags","summary":"","title":"Knowledge Graph","type":"tags"},{"content":"","date":"10 February 2026","externalUrl":null,"permalink":"/tags/llm-agents/","section":"Tags","summary":"","title":"LLM Agents","type":"tags"},{"content":"","date":"10 February 2026","externalUrl":null,"permalink":"/tags/markdown/","section":"Tags","summary":"","title":"Markdown","type":"tags"},{"content":"","date":"10 February 2026","externalUrl":null,"permalink":"/tags/obsidian/","section":"Tags","summary":"","title":"Obsidian","type":"tags"},{"content":" Compiles the procedures and laws behind procedimentipa.gov.it into an Obsidian-compatible markdown vault: a linked knowledge graph that people and LLM agents navigate by following links instead of re-running retrieval on every question. The compiled vault as an Obsidian graph: legislation nodes in blue, procedures coloured by tag. The big nodes are hubs — heavily-cited laws that dozens of procedures converge on. The shape of the domain is visible at a glance, no query required. Highlights # Compiled the live catalog behind procedimentipa.gov.it into an agent-navigable knowledge graph: one markdown page per procedure and per legal article, stitched by wikilinks, so an LLM agent answers by walking procedure → article → law instead of re-running retrieval every time. Chose article-level atomicity — each law article is its own addressable page — so a procedure cites the exact article it depends on rather than a 300-article decree, keeping agent context small and citations exact. Built the compiler as an idempotent Python CLI over the catalog\u0026rsquo;s backoffice API: sync re-pulls what\u0026rsquo;s already in the vault, --no-clobber protects hand-edited files, and colon-bearing legal URNs map to filesystem-safe slugs reversibly. How it works # flowchart TD subgraph src[\"Procedure catalog backoffice\"] P[\"Procedures(each declares its legal sources)\"] L[\"Legislation(Normattiva / EUR-Lex URNs)\"] end CLI[\"procedure-wiki CLIfetch · resolve · render\"] subgraph vault[\"Obsidian vault (markdown)\"] PF[\"procedures/*.md\"] AF[\"legislation/*/art_NN.mdone page per article\"] end P --\u003e CLI L --\u003e CLI CLI --\u003e PF CLI --\u003e AF PF --\u003e A[\"LLM agent / humannavigates by wikilinks\"] AF --\u003e A Portfolio Case Study # Problem. Most LLM-over-documents setups are RAG: retrieve some chunks at query time and hope the right fragments come back, with the model rediscovering the same knowledge from scratch on every question. For a catalog of administrative procedures anchored to specific law articles, the connections are the knowledge — \u0026ldquo;which law backs this procedure, and what does that article actually say?\u0026rdquo; should be one link away, not a similarity search.\nApproach. The tool instantiates Karpathy\u0026rsquo;s LLM Wiki idea, but auto-compiled: the graph already exists implicitly in the catalog, since every procedure declares the legislation it depends on. The CLI fetches procedures from the backoffice API, resolves their legal references hierarchically down to the article, and renders everything as markdown with typed links in both directions — procedure → article via frontmatter, article ↔ law via back-links. Shared hubs emerge for free: two unrelated procedures citing the same decree converge on the same law node, so the graph surfaces which laws are load-bearing across the catalog without anyone declaring it.\nThe defining design choice is atomicity at the article level. Legislation isn\u0026rsquo;t stored as one giant law document; each article is its own addressable page, so an agent loads one article instead of scanning a 300-article decree — cheaper tokens, sharper citations.\nAtomicity as navigation: from a procedure page, one wikilink lands on exactly the cited article — never the surrounding 300-article decree, and never a similarity ranking over chunks.\nWhat was hard. Legal URNs contain colons, which break filesystems and Obsidian links, so the tool maps : → ~ reversibly (~ never appears in Italian NIR URNs). Re-running also had to be safe: sync is idempotent, and --no-clobber keeps hand-edited pages intact across refreshes, so the vault can accumulate human annotations without being overwritten by the next compile.\nOutcome. The compiled development vault holds 88 procedure pages and 115 legislation pages, navigable in Obsidian and trivially parseable by any agent — the graph renders on this page come from that vault. It complements the platform\u0026rsquo;s search engine rather than replacing it: search finds an entry point, the graph carries the agent from there.\nThe whole idea in one picture: a single procedure linked straight to the four articles it depends on. No pile of chunks to rank, no whole decree to scan — just a procedure and the precise articles it points to. Non-goals. No embeddings and no retrieval index by design — navigation is deterministic link-following. The wiki is regenerated from the source of truth on demand, not hand-curated, and it is a companion artifact to the catalog platform rather than a standalone product.\nStack # Python, the catalog\u0026rsquo;s REST API, Obsidian-flavored markdown + YAML frontmatter, Normattiva / EUR-Lex URN resolution.\n","date":"10 February 2026","externalUrl":null,"permalink":"/projects/procedure-wiki/","section":"Projects","summary":"Compiles the procedures and laws behind procedimentipa.gov.it into an Obsidian-compatible markdown vault: a linked knowledge graph that people and LLM agents navigate by following links instead of re-running retrieval on every question.","title":"procedure-wiki: an agent-navigable knowledge graph of Italy's procedure catalog","type":"projects"},{"content":"","date":"10 February 2026","externalUrl":null,"permalink":"/tags/rest-api/","section":"Tags","summary":"","title":"REST API","type":"tags"},{"content":"","date":"18 December 2025","externalUrl":null,"permalink":"/tags/llm-as-judge/","section":"Tags","summary":"","title":"LLM-as-Judge","type":"tags"},{"content":" An internal tool that treats prompts like weights. It runs a LangGraph agent over a golden dataset, scores every output with an LLM judge, then evolves the prompt text until the scores climb. Highlights # Built LMnetwork, a prompt-optimization framework that tunes LangGraph agent prompts against golden datasets with an LLM-as-judge, lifting a production procedure-extraction agent from 0.51 to 0.87 and a classification agent from 0.60 to 0.90. Designed an RL-style optimization loop that pits several competing edit strategies against each other every iteration and keeps the highest-scoring one, feeding past strategies and their scores back in so the search never settles on a single idea. Solved the failure mode that breaks LLM-driven text edits (search strings that don\u0026rsquo;t match verbatim) with a fuzzy search/replace engine: exact single-match by default, SequenceMatcher sliding-window fallback when the model\u0026rsquo;s whitespace drifts. Ran evaluation, strategy generation, and candidate scoring as LangGraph Send fan-outs, so a run scores all N strategies across M samples concurrently instead of walking them one at a time. Architecture / How it works # The optimization loop end to end: test prompts, score with an LLM judge, try many strategies, keep the best, repeat until the target score. flowchart TD A[Load JSONL dataset] --\u003e B[Baseline eval: fan out every sample,run target graph, LLM judge scores it] B --\u003e C[Reduce: mean score, track best] C --\u003e D{score ≥ targetor max iters?} D -- yes --\u003e Z[Restore best prompts as active, complete run] D -- no --\u003e E[Strategy generator LLM:N diverse strategies, aware of past strategies + scores] E --\u003e F[Fan out one optimizer worker per strategy] F --\u003e G[Each worker proposes search/replace edits.Fuzzy edit engine applies them into a candidate prompt set] G --\u003e H[Fan out: score every candidate on the full dataset] H --\u003e I[Tournament: pick highest-mean candidate as winner] I --\u003e J[Persist winning prompt version + strategy history to SQLite] J --\u003e B The loop is a compiled LangGraph StateGraph. Every expensive step (evaluating samples, running optimizer workers, scoring candidates) is a Send fan-out, so a run with N strategies and M samples dispatches its N×M candidate evaluations in parallel instead of in sequence.\nOne iteration from the inside: the generator proposes N competing strategies with past strategies and their scores in view, every candidate prompt set is scored on the full dataset in parallel, and only the highest-mean winner survives into the next round.\nPortfolio Case Study # Problem. This came out of a larger production project that ran on a pile of hand-written prompts. We already had golden result sets we trusted, and at some point the obvious question surfaced: instead of hand-tuning prompts against those golden sets, could we optimize them automatically? The first test wasn\u0026rsquo;t the production system at all. I pointed it at generating hip-hop verses in the style of a specific artist, purely to see whether \u0026ldquo;make a prompt better on its own\u0026rdquo; was even a real thing. Once the lyric scores started climbing, we turned it on our actual production prompts.\nApproach. The core is a training loop, deliberately shaped to feel like reinforcement learning rather than a single greedy edit. Each iteration doesn\u0026rsquo;t just ask the model for one improvement. It generates several competing strategies, and it shows the strategy generator the previous strategies together with the scores they earned, so the next round of ideas builds on what worked and drops what didn\u0026rsquo;t. Every strategy becomes a candidate prompt set, every candidate is scored on the whole dataset, and only the winner survives to the next iteration. Spawning many strategies per iteration is what keeps the search from getting stuck on one idea.\nThe optimizer also adapts how aggressive it is. When the current score sits well below target it makes substantial rewrites; as it closes in, it switches to surgical, minimal edits.\nWhat was hard. The genuinely frustrating part is that models are bad at editing text through a search/replace tool. The search string the model emits rarely matches the prompt verbatim (a stray space, a changed line break), and a strict tool just drops the edit on the floor. This is a known-enough problem that Cursor built a separately fine-tuned model to sit between the main model and the file and apply edits for it (instant-apply). We didn\u0026rsquo;t want a second model in the loop, so we took the cheap and fast route: a more forgiving edit tool. It still prefers a clean single exact match, but when that fails it falls back to a fuzzy match over sliding windows, applies the edit only when there\u0026rsquo;s a unique best candidate above a threshold, and otherwise records exactly why it skipped. That one change is what made automated edits reliable enough to trust in a loop.\nGetting the LLM-as-judge scoring stable enough to optimize against was the other quiet difficulty. The whole loop is only as trustworthy as the number the judge hands back.\nOutcome. On real production workloads the climbs were clear. A procedure-finding agent went from 0.51 to 0.87, and an administrative-regime classifier from 0.60 to around 0.90. It moved from a for-fun experiment to a tool we ran many times against real prompts, with every run\u0026rsquo;s iterations, candidates, and prompt versions persisted so we could go back and see what changed.\nNon-goals. It\u0026rsquo;s an internal tool, and it\u0026rsquo;s Gemini-focused on purpose. Prompting technique is model-specific, and at the time Gemini was the one model with a context window large enough for the long agentic flows we were running, so tuning against a single target model was the right scope rather than a shortcut. It optimizes prompt text only and never touches model weights. The dashboard is a local, read-only review surface with no auth, there to inspect training rather than to be a product.\nScreenshots # Training runs with status, best score, and iteration counts. The score-by-iteration chart for a single run, climbing toward the target. The per-iteration tournament: each strategy\u0026rsquo;s candidate prompt set and its mean score, winner marked. The surgical search/replace edits applied to a prompt between versions. Stack # Python 3.13, LangGraph, Gemini via langchain-google-genai, Pydantic, SQLite, Flask + Jinja2 + HTMX + Tailwind + Chart.js, Rich, uv, pytest.\n","date":"18 December 2025","externalUrl":null,"permalink":"/projects/graph-optimizer/","section":"Projects","summary":"An internal tool that treats prompts like weights. It runs a LangGraph agent over a golden dataset, scores every output with an LLM judge, then evolves the prompt text until the scores climb.","title":"LMnetwork: automated prompt optimization for LangGraph agents","type":"projects"},{"content":"","date":"18 December 2025","externalUrl":null,"permalink":"/tags/prompt-optimization/","section":"Tags","summary":"","title":"Prompt Optimization","type":"tags"},{"content":"","date":"18 December 2025","externalUrl":null,"permalink":"/tags/sqlite/","section":"Tags","summary":"","title":"SQLite","type":"tags"},{"content":" A web app that turns AMFI International\u0026rsquo;s Word templates into validated web forms, cutting document prep per participant from ~20 minutes to ~2. The Generate view: a form built entirely from an uploaded Word template. Field types — dates, phone, email, text — are inferred from the variable names, so staff fill and download in about two minutes. Highlights # Cut per-participant document prep from ~20 minutes to ~2 for an Italian Erasmus+ nonprofit by building a self-serve DOCX generation app (FastAPI, React, PostgreSQL), now live and in daily use. Built a template pipeline that parses uploaded Word files, extracts their variables, and infers field types (date, email, phone, decimal) from naming conventions, so staff get a validated web form without a developer in the loop. Eliminated the copy-paste errors that used to reach issued certificates and agreements, with per-field validation on every generated form. How it works # flowchart TD subgraph reg[\"Register a template\"] U[\"Staff uploads .docx\"] --\u003e V[\"MIME + size validation\"] V --\u003e X[\"Variable extractiondocxtpl\"] X --\u003e Y[\"Type inferencefrom variable names\"] Y --\u003e DB[(\"PostgreSQLtemplate + field metadata\")] V --\u003e ST[(\"Local disk / S3\")] end subgraph gen[\"Generate a document\"] DB --\u003e F[\"Dynamic React formpickers, placeholders, validation\"] F --\u003e G[\"Server-side validation+ injection sanitization\"] ST --\u003e H[\"SHA-256 integrity check\"] G --\u003e R[\"Render .docx\"] H --\u003e R R --\u003e DL[\"Download\"] R --\u003e A[(\"Audit log:user, IP, duration, status\")] end The claim in one picture: the metadata extracted at upload is the single source of truth the form is built from, so a new document type is a staff upload, not a code change — the red edge marks the developer who is no longer needed.\nPortfolio Case Study # Problem. AMFI International is a small nonprofit in Avezzano, Italy that runs Erasmus+ mobility and training programs. Every participant needs a stack of official documents: certificates, agreements, letters. Staff prepared each one by hand in Word, retyping names and dates per person, which took around 20 minutes per participant. The process bred exactly the failures you\u0026rsquo;d expect: typos in issued documents, stale fields left over from the previous participant, several competing copies of \u0026ldquo;the\u0026rdquo; template circulating by email, and layouts breaking as different people edited the same file in different Word versions.\nApproach. Instead of hardcoding AMFI\u0026rsquo;s documents into the app, I made templates first-class: staff keep authoring them in Word, using simple {{ placeholder }} variables, and upload the file. The backend validates it (real MIME detection via libmagic, size cap, SHA-256 hash stored for later integrity checks), extracts the variables, and infers each field\u0026rsquo;s type from its name — participant_email becomes an email input, start_date a date picker, fee_amount a decimal field. The React frontend builds its form entirely from that metadata, so a new document type needs zero code changes.\nI chose FastAPI and React because I have familiarity with this stack, and with a real client waiting, shipping something dependable mattered more than experimenting. The backend is organized in feature slices (auth, users, templates, documents), each with its own model, repository, service, and router, and the TypeScript client is generated from the OpenAPI schema so the frontend can\u0026rsquo;t drift from the API. Storage sits behind a small Protocol interface with local-disk and S3 implementations, which kept development friction low without a rewrite for production.\nWhat was hard. The technical core was getting from a raw .docx to a form a non-technical person can trust: variable extraction, type inference that guesses right often enough to be useful, and a dynamic form that feels hand-built rather than generated. But the part I\u0026rsquo;m most proud of isn\u0026rsquo;t one module. It\u0026rsquo;s the communication with the client and making the app as user-friendly and user-tailored as possible — the whole point was that an 8-person team with no developer on staff can register a new document type themselves and never call me.\nOutcome. The app is live and AMFI\u0026rsquo;s staff use it in their normal workflow. Once a template is registered, producing a participant\u0026rsquo;s document takes around 2 minutes instead of around 20, and the type checking and date picking catch the errors that used to end up in issued documents. Every generation is logged with the requesting user, IP, duration, and outcome, so there\u0026rsquo;s a record of what was issued. Server code is fully typed and passes strict mypy; the core user flows are covered by Playwright end-to-end tests.\nNon-goals. It outputs DOCX only — no PDF export, and no e-signing or sending; distribution stays with the staff. Templates are authored in Word, not edited in-app. Access control is a simple owner/public model rather than per-department roles, which is the right size for an eight-person team.\nScreenshots # Template library. Staff register Word templates themselves; each row shows the field count the app detected and whether the template is shared. Self-serve upload. A staff member uploads a .docx, and the app takes it from there — no developer, no code change for a new document type. Detected field types. Right after upload, the app parses the document and infers a type for every variable from its name — student_email reads as email, the *__date fields as dates, student_phone as phone. The preview shows what it found. Review and override. The guess is a starting point, not a cage. Each field\u0026rsquo;s type and validation rule is editable, so staff can correct anything the inference got wrong before the form goes live. Pick a template. The generation flow starts by choosing a registered template. Stack # Python, FastAPI, SQLModel, PostgreSQL, Alembic, docxtpl, boto3/S3, React 19, TypeScript, TanStack Router, TanStack Query, Tailwind, shadcn/ui, Playwright, Docker Compose, Traefik, Sentry.\n","date":"5 December 2025","externalUrl":null,"permalink":"/projects/amfi-docs/","section":"Projects","summary":"A web app that turns AMFI International’s Word templates into validated web forms, cutting document prep per participant from ~20 minutes to ~2.","title":"AMFI Docs — self-serve document generation for an Erasmus+ nonprofit","type":"projects"},{"content":"","date":"5 December 2025","externalUrl":null,"permalink":"/tags/playwright/","section":"Tags","summary":"","title":"Playwright","type":"tags"},{"content":" An internal proof of concept built at Go Project: course editors upload their teaching material and get AI-generated study aids back — a two-host Italian audio podcast, quiz questions, and a streaming chat panel. A course PDF becoming a two-host Italian podcast (2× speed): the pipeline streams its stages live over SSE, survives a page reload mid-run, and queues the episode for TTS — an earlier finished episode keeps playing below.\nHighlights # Built the AI layer of an internal e-learning tool at Go Project: a FastAPI + LangGraph backend written from scratch, plus the Angular feature libraries that drive it, delivered as a working proof of concept in about two weeks. Designed a six-stage LangGraph pipeline that turns uploaded course PDFs into a two-host Italian podcast — on a measured demo run, a course PDF became a five-minute episode in about four minutes end to end. Made minutes-long generation runs survive page reloads and navigation: Angular SSE clients persist thread and run identity, replay the stream from Last-Event-ID, and reconcile run status when the app restarts. Split the backend into a LangGraph agent runtime and a lean FastAPI service that communicate only over HTTP, keeping the AI dependency stack out of the CRUD image so each side deploys on its own. How it works # The system is two Docker services plus the app. The FastAPI service owns storage: documents, media records in Postgres, and audio synthesis. The LangGraph runtime owns the intelligence and calls back into the API service over HTTP rather than importing it. The Angular app talks to both and renders the agent\u0026rsquo;s progress live as the graph streams node updates over SSE.\nflowchart TD U[\"Course editor\"] --\u003e APP subgraph APP[\"Angular 18 + Ionic app\"] ED[\"Element editorPDF upload\"] POD[\"Podcast panellive pipeline progress\"] end subgraph AI[\"LangGraph agent service\"] G[\"six-stage agent graphfetch \u0026rarr; knowledge doc \u0026rarr; concepts\u0026rarr; scenario \u0026rarr; script \u0026rarr; audio\"] RP[(\"Redis + Postgresrun checkpoints\")] end subgraph API[\"FastAPI api-service\"] DOCS[\"document endpoints\"] MEDIA[\"media endpoints202 + background TTS\"] PG[(\"Postgres\")] end GEM[\"Google Geminiflash / flash-lite / TTS\"] ED -- \"upload PDF\" --\u003e DOCS POD -- \"start run\" --\u003e G G -. \"SSE node updates,resumable via Last-Event-ID\" .-\u003e POD G -- \"fetch document base64\" --\u003e DOCS G -- \"LLM calls\" --\u003e GEM G -- \"POST dialogue (202)\" --\u003e MEDIA MEDIA -- \"multi-speaker TTS\" --\u003e GEM MEDIA -. \"range-streamed .wav\" .-\u003e POD Portfolio Case Study # Problem. Go Project\u0026rsquo;s course editors manage teaching material (video, PDF, docs, slides) in an admin console. A teammate had built the app shell in 2024 — an Nx/Ionic workspace with an element editor, document upload, and an early chat demo on a Websocket component — but it couldn\u0026rsquo;t generate anything. I was brought in to build the AI layer end to end: the backend didn\u0026rsquo;t exist yet, and the frontend had no generation features and it was missing SSE components.\nThe element editor: course metadata, an uploaded PDF with its selection checkbox, and the upload tile — the entry point for every generation feature. Approach. The backend is deliberately two services: a slim FastAPI image for documents, media records, and TTS synthesis, and a LangGraph Platform image for the agent graphs, with the stated goal of \u0026ldquo;zero import statements that refer to app.services or app.db\u0026rdquo; in the agent code. The agent is a plain HTTP client of the API service, which keeps the heavy AI stack out of the CRUD image and lets each side deploy on its own.\nI chose the LangGraph Platform runtime over hand-rolled FastAPI streaming because it gives durable runs backed by Redis and Postgres. A podcast generation takes minutes, and the run had to outlive any single client connection.\nThe document path is Gemini long-context ingestion, not retrieval. I built a Chroma vector store with MiniLM embeddings on day one, then made a deadline call: parked it and shipped whole PDFs into Gemini\u0026rsquo;s multimodal input instead, so podcast and quiz generation could go out in time. The Chroma path is still in the repo, unmounted rather than deleted.\nThe podcast pipeline itself is six typed stages: fetch document bytes, synthesize a knowledge document from mixed sources (PDF bytes, YouTube URIs, web URLs with grounding), extract core concepts and a narrative scenario as Pydantic structured outputs, write an Italian two-host script with per-line delivery cues, then hand the dialogue off for audio. The script format is designed backwards from the TTS API: speaker tags in the text map one-to-one onto Gemini\u0026rsquo;s multi-speaker voice config (Alex as Puck, Ben as Zephyr).\nWhat was hard. The resumable streaming took the most thought. The requirement: leave the page or close the tab mid-run, come back, and keep watching the progress. Native EventSource can\u0026rsquo;t do that here since runs start with a POST body and rejoin with a Last-Event-ID header, so the clients wrap fetch-event-source in RxJS observables. The run id only arrives mid-stream inside a metadata event, so the parser captures it and the store persists thread id, run id, and last event id to localStorage. On startup a reconciliation step checks the run\u0026rsquo;s status and branches: finished runs replay their final state, live ones resubscribe from the last seen event, anything else surfaces as an error.\nThe four phases of a run\u0026rsquo;s life: started from the panel, surviving a closed tab, rejoined from the last seen event on reload, and the audio arriving after the graph has already finished via the 202 background handoff.\nMid-generation: the live step label with spinner and cancel button, while an earlier episode plays below. Reloading the page at this point rejoins the same stream — the hero GIF above shows it happening. The audio was its own fight. Gemini TTS streams headerless raw PCM at 24 kHz, so the media service accumulates chunks and packs a 44-byte RIFF/WAVE header by hand, parsing sample rate and bit depth out of the mime type. Playback goes through an endpoint implementing HTTP range requests (206 Partial Content) so the browser audio element can seek.\nFinished episodes play and seek directly in the browser over the range-request endpoint. There is also an asynchronous handoff across the service boundary: the graph\u0026rsquo;s final node POSTs the dialogue and gets a 202 with a media id for a file that doesn\u0026rsquo;t exist yet. The API service synthesizes audio in a background task while the frontend lists the record as pending and refreshes. A completion callback and an idempotency key on that handoff are known gaps; a retried POST today would queue duplicate audio work.\nThe 202 handoff from the outside: the new episode sits in \u0026ldquo;Elaborazione dell\u0026rsquo;audio…\u0026rdquo; while the API service synthesizes in the background; the earlier one is already playable. Outcome. A working end-to-end internal demo, built solo in under two weeks (backend June 30 to July 10, 2025; frontend AI libraries July 3 to 9): upload PDFs to a course element, watch the pipeline narrate its own six stages live in the UI, then play the finished podcast in the browser. On a measured demo run, a course PDF became a five-minute two-host episode in about four minutes end to end — roughly 80 seconds for the six-stage pipeline and the rest in TTS synthesis. Quiz generation runs on a second graph with question count and difficulty controls, the chat panel streams over the same custom SSE stack, and the shared UI kit has a Storybook with Chromatic visual regression on its core components.\nNon-goals. This is a single-team internal POC by design: no auth or multi-tenancy, no usage analytics. Classic RAG was consciously deferred, the chat runs on a simplified demo graph rather than a production design (its streaming, document ingestion, and model calls are real), and test coverage is thin with no CI — it was scoped as a proof of concept, not a product.\nThe quiz graph: question count and difficulty in, numbered Italian questions grounded in the uploaded document out. The chat panel answering from the uploaded PDF over the custom SSE stack. Stack # Angular 18, Ionic 8, Capacitor 6, Nx, NgRx SignalStore, RxJS, Storybook · Python 3.13, FastAPI, LangGraph, LangChain, Google Gemini (2.5 flash / flash-lite / TTS), Redis, Postgres, ChromaDB (parked), Docker Compose\n","date":"10 July 2025","externalUrl":null,"permalink":"/projects/ai-corsi/","section":"Projects","summary":"An internal proof of concept built at Go Project: course editors upload their teaching material and get AI-generated study aids back — a two-host Italian audio podcast, quiz questions, and a streaming chat panel.","title":"AiCorsi — AI Knowledge Desk","type":"projects"},{"content":"","date":"10 July 2025","externalUrl":null,"permalink":"/tags/angular/","section":"Tags","summary":"","title":"Angular","type":"tags"},{"content":" A self-hosted semantic search engine for CV/profile data. It replaced manual PDF skimming and keyword-only lookup with hybrid vector + keyword search, and the whole pipeline runs on local infrastructure so candidate data never leaves the network. The search view: a natural-language query, study-level and industry filters, and an expanded candidate profile. Recreated with synthetic data; the production instance is under NDA. Highlights # Built and shipped an internal semantic search engine for CV data (FastAPI, Elasticsearch, sentence-transformers) that replaced manual PDF skimming and keyword-only lookup. Designed the hybrid retrieval layer by hand as a single Elasticsearch bool query: cosine script_score over 768-dim embeddings plus weighted keyword match, tunable per request. Kept the entire stack local for privacy, since CV data could not leave the infrastructure: sentence-transformer embeddings for search, and small local LLMs (Gemma 3, Phi-4) in the companion extraction pipeline. Handled messy real-world input at ingestion: per-PDF heuristics decide when a scanned CV needs OCR, and multipass extraction fits the small context windows of local models. Architecture / How it works # The whole pipeline inside the boundary: per-PDF heuristics decide which CVs need OCR, local LLMs extract in multiple passes to fit their small context windows, and the search service embeds and queries entirely locally. The red edge is the point — there is no path by which candidate data reaches cloud AI.\nPortfolio Case Study # Problem. The team had a growing pile of candidate CVs as PDFs. Finding \u0026ldquo;someone senior with microeconomics background in Rome\u0026rdquo; meant opening files one by one or hoping an exact keyword appeared. The CVs are personal data, so a cloud AI service was off the table from the start.\nApproach. The system is two cooperating parts. An extraction pipeline turns raw PDFs into structured JSON using only local models, and this project indexes that JSON into Elasticsearch and serves hybrid search through a FastAPI backend with a small Italian-language web UI.\nThe retrieval layer is hand-built. I started from LangChain\u0026rsquo;s ElasticsearchStore but its retriever couldn\u0026rsquo;t deliver the combination I needed: weighted semantic-plus-keyword scoring, facet filters running in the ES filter context, offset pagination, and the full original CV document coming back with each hit. So the query is assembled directly as an ES bool query, with cosine similarity as a script_score clause and keyword match as a second should clause, each carrying a caller-tunable boost. The API exposes those weights, so a search can slide between pure semantic and pure keyword per request.\nPrivacy drove the model choices. Embeddings come from a local all-mpnet-base-v2 model rather than an API, and the extraction stage runs on small self-hosted LLMs. The cost of that choice showed up elsewhere, which is where most of the real work went.\nWhat was hard. The main issue was ingestion, and deciding what to ingest using the local AI. Some CVs had no real text, only rasterized pages, so we had to know when to use OCR and when not to. That became a set of PyMuPDF heuristics per PDF (average characters per page, share of pages with text, share of pages dominated by a full-page image) that routes scanned documents through Docling with forced EasyOCR and leaves clean ones on the fast path. The local models brought a second constraint: small context windows. Extraction had to become multipass in order not to kill the small context of Gemma 3 and Phi-4, feeding the document through in stages instead of one prompt.\nOn the search side, the ES mapping took iteration: dense vectors, keyword facet fields, and the complete original CV stored as a non-indexed object in metadata so the UI gets full profiles back without a second lookup.\nOutcome. Shipped internally and used by the team, replacing manual skimming and exact-match search with a pipeline that is completely local. The service runs as two Docker Compose containers (API and Elasticsearch, with healthcheck-gated startup), carries JWT cookie authentication with separate admin and user roles, and ships with a Postman collection covering the API.\nLimits and non-goals. Search starts from structured JSON; PDF-to-JSON extraction is a separate upstream pipeline by design. Auth is minimal: two fixed accounts from environment config, no user management, because it is an internal tool. There is no candidate-tracking or ATS functionality, just search and retrieval. The UI is Italian-first, and the codebase has no automated test suite — it was built and shipped in a short focused window.\nScreenshots # All screenshots are faithful recreations of the UI with synthetic candidate data; real deployment screenshots can\u0026rsquo;t be shared under NDA.\nAn expanded result: personal data with the authenticated PDF view, education with level tags, and experience entries with skill chips. The login gate. JWT cookie auth sits in front of every search and PDF route. Stack # Python, FastAPI, Elasticsearch 8, LangChain, sentence-transformers (all-mpnet-base-v2), Hugging Face embeddings, JWT (python-jose, passlib/bcrypt), Docker Compose, Tailwind, jQuery/Select2. Upstream extraction: PyMuPDF, Docling, EasyOCR, local LLMs (Gemma 3, Phi-4 with LMStudio).\n","date":"28 April 2025","externalUrl":null,"permalink":"/projects/cv-search/","section":"Projects","summary":"A self-hosted semantic search engine for CV/profile data. It replaced manual PDF skimming and keyword-only lookup with hybrid vector + keyword search, and the whole pipeline runs on local infrastructure so candidate data never leaves the network.","title":"CV Search Engine","type":"projects"},{"content":"","date":"28 April 2025","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"28 April 2025","externalUrl":null,"permalink":"/tags/elasticsearch/","section":"Tags","summary":"","title":"Elasticsearch","type":"tags"},{"content":"","date":"28 April 2025","externalUrl":null,"permalink":"/tags/sentence-transformers/","section":"Tags","summary":"","title":"Sentence-Transformers","type":"tags"},{"content":"","date":"15 April 2025","externalUrl":null,"permalink":"/tags/aws-s3/","section":"Tags","summary":"","title":"AWS S3","type":"tags"},{"content":" A full-stack records-management app built for the Italian protocollo informatico model — Italy\u0026rsquo;s legally mandated document-registration regime: every company document is registered with a sequential protocol number, fingerprinted and encrypted, and each day\u0026rsquo;s registrations are sealed into a hashed daily register that makes after-the-fact tampering detectable. Highlights # Built and shipped an internal records-management system implementing the Italian protocollo informatico, replacing the company\u0026rsquo;s manual document-registration process (NestJS, PostgreSQL, Angular 19, AWS S3). Designed a tamper-evident daily register: a scheduled job seals each day\u0026rsquo;s registrations into canonicalized Italian-format XML (W3C Exclusive C14N) with a dual SHA-256 hash and a preservation-status workflow. Implemented encrypt-then-MAC document storage on S3 (AES-256-CBC, HMAC-SHA256 verified before every decryption), plus a SHA-256 fingerprint recorded per uploaded file. Enforced role-based access down to individual buttons: ~40 granular permission codes checked by NestJS guards against the database on the API side and by a structural directive in the Angular UI. Architecture / How it works # flowchart TD subgraph Client[\"Angular 19 + Ionic PWA (EN/IT)\"] FORM[\"Protocol form4-step stepper\"] PDF[\"Client-side PDF exportpdfmake, cancelled watermark\"] VIEW[\"In-app PDF preview\"] end subgraph API[\"NestJS API\"] PROT[\"Protocols servicesequential numbering,single transaction\"] DOCS[\"Documents serviceSHA-256 fingerprint per file\"] ENC[\"Encryption serviceAES-256-CBC + HMAC-SHA256\"] CRON[\"Daily register job23:00 UTC\"] XML[\"Register XMLC14N + dual SHA-256 seal\"] RBAC[\"JWT + Google OAuthpermission guards\"] end PG[(\"PostgreSQL\")] S3[(\"AWS S3ciphertext + .meta sidecar\")] FORM --\u003e RBAC --\u003e PROT PROT --\u003e DOCS --\u003e ENC --\u003e S3 PROT --\u003e PG CRON --\u003e XML XML --\u003e S3 XML --\u003e PG VIEW --\u003e DOCS Registering a document is one database transaction: the service assigns the next protocol number for the year, validates sender, recipient, operator, department and classification, encrypts each uploaded file and stores it in S3 with a sidecar metadata object, and records a SHA-256 fingerprint per document. A cron job at 23:00 UTC then seals the day: it builds the Italian register XML, canonicalizes it so the hash doesn\u0026rsquo;t depend on formatting quirks, and stores a combined hash of both the canonical XML and a normalized data structure. If the S3 upload fails mid-seal, a compensating cleanup deletes the orphaned file and the transaction rolls back.\nPortfolio Case Study # Problem. Italian organizations keep a protocol registry: incoming and outgoing documents get a sequential registration number, and a daily register has to prove that the day\u0026rsquo;s records were not altered afterwards. At goproject this was a manual process. ERMS replaced it with a web app that registers, classifies, stores and seals company documents. I was the lead developer (195 of the 199 commits across both repos), with a colleague supporting on review.\nApproach. The design borrows blockchain\u0026rsquo;s core idea, registrations as transactions and each day as a sealed block, without pretending to be a blockchain: days are sealed independently rather than chained, and the writeup is deliberate about that distinction. The seal itself is redundant by design. It hashes the canonicalized XML and, separately, a normalized JSON structure of the same registrations, so integrity can be checked even if the XML serialization ever changes. Document storage is encrypt-then-MAC in the correct order: AES-256-CBC with a random IV per file, HMAC-SHA256 over the ciphertext, and the HMAC checked before any decryption is attempted. Cipher and MAC use independent keys.\nThe frontend is an Angular 19 / Ionic PWA in English and Italian (557-line translation files for each language), with a reusable config-driven table, tree and autocomplete component set that every list screen shares. A permission store built on NgRx coordinates with the HTTP interceptor: after a 401 triggers a token refresh, the interceptor waits until the refreshed token\u0026rsquo;s permissions are loaded before retrying the original request.\nWhat was hard.\nReproducible hashing over XML. A seal is worthless if regenerating the same register produces a different hash, and XML serialization gives no such guarantee. The fix was canonicalizing with W3C Exclusive C14N before hashing, and pairing that with the second hash over normalized raw data as a fallback verification path. The permission system. Getting RBAC right took several iterations: newly registered users initially had no roles, system roles could be deleted, and permissions had to be added to the JWT claims. It settled into seeded roles over ~40 permission codes, guards that re-check permissions from the database rather than trusting the token, and a frontend directive that hides any action the user can\u0026rsquo;t perform. Client-side PDF generation. The protocol record export went through multiple refactors before landing on a dedicated builder class separate from the service, with all strings translated so the PDF language is chosen independently of the UI language, and a diagonal red watermark rendered across cancelled records. Outcome. The app went into internal use at goproject for registering company documents, replacing the manual workflow. It was built in about seven weeks (February to April 2025): two TypeScript repos with Docker Compose for local setup, Swagger API docs, TypeORM migrations and seeds, a small Jest suite on the backend, and full English/Italian localization.\nNon-goals. Each day\u0026rsquo;s register is sealed independently; there is no hash chain linking consecutive days, and the XML is canonicalized but not digitally signed (canonicalization is the prerequisite if signing is added later). It is a single-organization tool: no multi-tenancy, and transmission of sealed registers to a long-term preservation provider is tracked as a status but performed manually.\nStack # NestJS 10, TypeORM, PostgreSQL, AWS S3, Passport (JWT + Google OAuth), Angular 19, Ionic 8, NgRx, Angular Material, ngx-translate, pdfmake, ngx-extended-pdf-viewer, Docker.\n","date":"15 April 2025","externalUrl":null,"permalink":"/projects/erms/","section":"Projects","summary":"A full-stack records-management app built for the Italian protocollo informatico model — Italy’s legally mandated document-registration regime: every company document is registered with a sequential protocol number, fingerprinted and encrypted, and each day’s registrations are sealed into a hashed daily register that makes after-the-fact tampering detectable.","title":"ERMS — tamper-evident document registration for Italian compliance","type":"projects"},{"content":"","date":"15 April 2025","externalUrl":null,"permalink":"/tags/ionic/","section":"Tags","summary":"","title":"Ionic","type":"tags"},{"content":"","date":"15 April 2025","externalUrl":null,"permalink":"/tags/nestjs/","section":"Tags","summary":"","title":"NestJS","type":"tags"},{"content":"","date":"15 April 2025","externalUrl":null,"permalink":"/tags/typeorm/","section":"Tags","summary":"","title":"TypeORM","type":"tags"},{"content":"","date":"15 March 2025","externalUrl":null,"permalink":"/tags/capacitor/","section":"Tags","summary":"","title":"Capacitor","type":"tags"},{"content":" A mobile app that puts Italy\u0026rsquo;s authorized car demolition yards on a live map and gets a driver to the nearest one in two taps. The full flow in 15 seconds: log in, open the map, search by name, and get a popup with contact details and a Google Maps directions link. The red pin is the user\u0026rsquo;s position; distances are computed from it.\nHighlights # Built an Ionic/Angular mobile app that maps 167 authorized Italian car scrapyards and surfaces the closest one to the driver\u0026rsquo;s live GPS position, with one-tap Google Maps directions. Designed the NestJS/PostgreSQL API behind it: name and region filtering plus nearest-first ordering with a computed distance on every result. Converged three login paths — Google OAuth, Facebook OAuth, and email with a verification-token flow — on a single JWT session layer with access and refresh tokens. Containerized the stack with Docker Compose and wired GitLab CI to build and push deploy images to a private registry. Architecture / How it works # flowchart TD subgraph app[\"Mobile app — Ionic 8 / Angular 19 / Capacitor\"] UI[\"Map + search UI\"] GEO[\"Capacitor Geolocation\"] LMAP[\"Leaflet map (OpenStreetMap tiles)\"] UI --- LMAP GEO --\u003e UI end UI -- \"REST + JWT\" --\u003e API[\"NestJS API\"] API --\u003e PG[(\"PostgreSQL\")] API -- \"OAuth code exchange\" --\u003e IDP[\"Google / Facebook\"] API -- \"verification email\" --\u003e SMTP[\"SMTP\"] SEED[\"Client dataset (locations.json)\"] -- \"seeder\" --\u003e PG UI -- \"directions deep link\" --\u003e GMAPS[\"Google Maps\"] Portfolio Case Study # Problem. When a car in Italy reaches end of life, it legally has to go to an authorized demolition yard. Those yards exist as a flat list of business records: names, addresses, VAT numbers, phone numbers. A driver standing next to a dead car can\u0026rsquo;t do much with a list. The client had that data for 167 yards nationwide and wanted it usable: show me the yards near me, on a map, and get me there.\nApproach. Two repositories: an Ionic 8 / Angular 19 app running on Capacitor, and a NestJS API over PostgreSQL. A seeder ingests the client\u0026rsquo;s Italian-schema dataset into Postgres, and the API exposes it with name/region filters. When the app sends the user\u0026rsquo;s coordinates, results come back nearest-first with a distance attached to each one.\nTwo decisions worth defending. The map is Leaflet on OpenStreetMap tiles, not the Google Maps SDK: OSM tiles are free, Google Maps needs a billing account, and that wasn\u0026rsquo;t worth it for a prototype. Directions still hand off to Google Maps through a plain deep link, which costs nothing and gives users the navigation app they already trust. Second, distance sorting happens in Node with geolib rather than in Postgres with PostGIS. Deliberate: at 167 rows an in-memory sort is simple and fast enough, and PostGIS would be overkill at this scale.\nAuth got the most engineering depth. Three login paths (email with a verification-token flow, Google OAuth, Facebook OAuth) converge on one JWT session layer with access and refresh tokens. Passwords are bcrypt-hashed and sensitive user fields are encrypted with AES-256-CBC before they touch the database.\nWhat was hard. Leaflet inside the Ionic webview was the biggest fight. The map initializes before the webview has settled on its real dimensions, so tiles render into a wrong-sized container; the fix was a deferred invalidateSize() after mount, plus bounds-fitting logic so the viewport frames all markers and the user pin without over-zooming. The reactive search was the other one: the query merges typed filters with a live GPS stream, and getting that wiring right in RxJS (debounced input, deduplicated emissions, form state that stays in sync without feedback loops) took real care to avoid stale positions and redundant API calls.\nOutcome. A working prototype delivered at GoProject over roughly five weeks, covering the full flow from social login to map search to directions handoff. The delivery side was real even if the release never was: TypeScript end to end, Swagger docs on the API, a Docker Compose stack (API, Postgres, Redis, pgAdmin) with healthchecks, and a GitLab CI pipeline pushing images to the company registry from the staging branch. I authored about 90% of the commits across both repos, with two colleagues contributing smaller pieces.\nNon-goals. It stayed an internal prototype: no App Store or Play Store packaging was ever done, and there is no admin UI for the yard data. Locations enter the system through the seeded client dataset, by design.\nScreenshots # Coverage view, the search sheet, a selected yard, and login.\nStack # Ionic 8, Angular 19, Capacitor 7, Leaflet, RxJS, NestJS 11, TypeORM, PostgreSQL, Redis, Passport (Google/Facebook OAuth), JWT, Docker Compose, GitLab CI.\n","date":"15 March 2025","externalUrl":null,"permalink":"/projects/car-graveyard/","section":"Projects","summary":"A mobile app that puts Italy’s authorized car demolition yards on a live map and gets a driver to the nearest one in two taps.","title":"Car Graveyard — scrapyard finder for Italy","type":"projects"},{"content":"","date":"15 March 2025","externalUrl":null,"permalink":"/tags/leaflet/","section":"Tags","summary":"","title":"Leaflet","type":"tags"},{"content":"Coursework projects from my M.Sc. at the University of Padua.\n","date":"20 November 2024","externalUrl":null,"permalink":"/tags/coursework/","section":"Tags","summary":"Coursework projects from my M.Sc. at the University of Padua.\n","title":"Coursework","type":"tags"},{"content":"Projects from my internship at Dishup, an Italian restaurant-POS startup.\n","date":"20 November 2024","externalUrl":null,"permalink":"/tags/dishup/","section":"Tags","summary":"Projects from my internship at Dishup, an Italian restaurant-POS startup.\n","title":"Dishup","type":"tags"},{"content":"","date":"20 November 2024","externalUrl":null,"permalink":"/tags/faiss/","section":"Tags","summary":"","title":"FAISS","type":"tags"},{"content":"","date":"20 November 2024","externalUrl":null,"permalink":"/tags/lightfm/","section":"Tags","summary":"","title":"LightFM","type":"tags"},{"content":"","date":"20 November 2024","externalUrl":null,"permalink":"/tags/mysql/","section":"Tags","summary":"","title":"MySQL","type":"tags"},{"content":"","date":"20 November 2024","externalUrl":null,"permalink":"/tags/openai-embeddings/","section":"Tags","summary":"","title":"OpenAI Embeddings","type":"tags"},{"content":"","date":"20 November 2024","externalUrl":null,"permalink":"/tags/pytorch/","section":"Tags","summary":"","title":"PyTorch","type":"tags"},{"content":" A recommendation system for restaurant menus that beat the random baseline 3.5× (Precision@5 31.8% vs 9.2%) and held cold-start users at parity. Built end-to-end on real customer order data during a six-month software-engineering internship at Dishup SRL, an Italian restaurant-tech startup, and delivered as my MSc thesis at the University of Padua — “A Hybrid and Adaptive Learning Framework for Personalized Restaurant Recommendations”. Salt, Sale, Sel, Salz, Sal and 盐 land in the same cluster. The embedding study behind this plot decided the whole content pipeline: OpenAI\u0026rsquo;s embeddings group ingredients by meaning across six languages, while BERT and Gemini group them by language. Highlights # Trained a hybrid LightFM recommender on 3,055 real customer orders at Dishup SRL, reaching Precision@5 of 31.8% against a 9.2% random baseline. Held cold-start users at parity with established ones (Precision@5 32.4% on a strict new-user holdout) by feeding engineered content features into the latent-factor model. Benchmarked five embedding models on multilingual ingredient names; the winner, OpenAI text-embedding-3-large, classified ingredient categories at 95.5% accuracy vs BERT\u0026rsquo;s 16%. Turned scraped Italian recipes into a typed dataset of 297 dishes and 855 ingredients via schema-constrained LLM extraction (Pydantic models, controlled ingredient vocabulary). How it works # flowchart TD A[(\"MySQLDishup order data\")] --\u003e|\"7-table CTEextraction query\"| B[\"Order dataset3,055 orders / 45 days\"] W[\"Recipe websites\"] --\u003e|\"LLM scrapers,schema-constrained JSON\"| R[\"Recipe dataset297 dishes, 855 ingredients\"] B --\u003e C[\"Feature engineeringrestaurant-relative prices,recency-weighted popularity,time-of-day buckets\"] R --\u003e E[\"Embedding studyOpenAI / BERT / Gemini / word2vec\"] E --\u003e V[\"Recipe vectorizerweighted multi-field embeddings\"] C --\u003e M[\"LightFM hybridWARP loss + side features\"] V --\u003e M C --\u003e N[\"PyTorch NCFfeature-tower experiment\"] M --\u003e X[\"EvaluationP@5, AUC, reciprocal rankcold / warm / random splits\"] N --\u003e X Portfolio Case Study # Problem. Dishup builds a digital menu and restaurant-management platform used by over 100 Italian restaurants. They wanted the menu to recommend dishes, and the raw material fights you at every step: menu entries are free-text in mixed languages with no ingredient taxonomy, and interaction data is thin because the average diner places fewer than three orders. New diners and new dishes have no history at all. My job in their AI division was to design and build the recommendation system, alone, from data extraction to evaluation.\nApproach. The data came out of Dishup\u0026rsquo;s MySQL database through a single CTE query joining seven tables into a 23-column order dataset: 3,055 orders collected over 45 days from one high-volume restaurant. On top of that I built restaurant-relative features: a €30 dish is premium at a trattoria and mid-range at a fine-dining spot, so prices are z-scored within each restaurant\u0026rsquo;s own menu. Popularity decays with recency weighting, and every order carries time-of-day context.\nFor content understanding I first ran a comparison of five embedding models on multilingual ingredient names. I expected them to be roughly interchangeable. They weren\u0026rsquo;t: BERT and Gemini put all the Italian words in one region and all the Chinese words in another, which is useless for a menu where \u0026ldquo;uovo\u0026rdquo; and \u0026ldquo;egg\u0026rdquo; must be neighbors. OpenAI\u0026rsquo;s text-embedding-3-large clustered by ingredient regardless of language and hit 95.5% on downstream ingredient-category classification, so it became the backbone of the dish representation.\nSo what is a dish, to the model? A concatenated vector of typed blocks: two 1536-dim OpenAI embedding blocks (recipe text and ingredients, with ingredients weighted 2.5x over title, cooking method and tags), one-hot blocks for category, region and difficulty, an ingredient-category distribution, and season and allergen encodings. On the order-data side each dish additionally carries behavior: its restaurant-relative price position and popularity measured four ways (global, recency-weighted, last 30 days, per time-of-day).\nDiners are modeled from implicit feedback only. There are no ratings, just what people actually ordered and in what quantity, so the interaction matrix holds normalized order quantities rather than stars. Each user then carries side features: order frequency, dominant dietary preference, recency (days since first and last order), average order value, and a two-axis behavioral segment (high-spender vs conservative, consistent vs variable, derived from spend z-scores and coefficient of variation). Time-of-day buckets sit in the interaction context, which is how the model can tell a lunch regular from a late-night one. The implicit, positive-only signal is also why WARP loss fits: it optimizes ranking directly instead of predicting ratings that don\u0026rsquo;t exist.\nBefore committing to a model I scored the dataset\u0026rsquo;s suitability for collaborative filtering at all — sparsity ratio, interactions per user and per item, temporal coverage — and the sparsity numbers are what ruled out pure CF in favor of a hybrid. I then built three candidates: a cosine-similarity hybrid, a PyTorch neural collaborative filtering model with separate feature towers for time and item signals, and a LightFM factorization model with WARP loss and side features. LightFM won on pragmatics. It was faster to train and tune reliably within the internship window; the NCF was a worthwhile experiment but not worth the complexity on a dataset this small. LightFM does hide its training loop, though, so getting a learning-rate schedule meant driving fit_partial one epoch at a time with a hand-rolled cosine-annealing-with-warm-restarts schedule, and estimating loss for early stopping by sampling a thousand user-item pairs per epoch, since the library reports no loss at all.\nWhat was hard. Normalization kept sabotaging the vectorizer: any standard scaler flattened the component weights I had just applied, erasing the \u0026ldquo;ingredients matter most\u0026rdquo; signal. The fix was a variance-preserving scheme that clips outliers at 3 sigma, standardizes, then rescales back toward the original spread so relative magnitudes survive. A second fight was with the premise itself. The plan was cross-restaurant personalization, learn a diner at one restaurant, recommend at another. The data said no: diners almost never appeared at more than one Dishup restaurant, so I reframed the work around single-restaurant depth and treated cross-restaurant transfer as future work. I also built TF-IDF text features and then cut them; on short menu descriptions they added dimensionality faster than signal.\nOutcome.\nMetric Test set Random baseline Precision@5 31.8% 9.2% AUC 0.831 — Reciprocal rank 0.741 — Test precision landed marginally above training precision (31.8% vs 31.1%), so there is no sign of overfitting. The result I care most about is the cold-start split: brand-new users scored Precision@5 of 32.4% with AUC 0.885, slightly better than warm users, which is exactly what the side-feature design was supposed to buy. That new users edge out established ones is less surprising than it sounds: with the average diner ordering fewer than three times, collaborative signal is thin for everyone, so the content features end up carrying warm users almost as much as cold ones. A reciprocal rank of 0.74 means the first relevant dish typically shows up in the top two positions. Model quality was checked from more than one angle: a FAISS nearest-neighbor harness verified that similar recipes actually retrieve each other, and cluster-homogeneity scores tested the vector space against ground-truth category labels.\nWhy cold start holds: a brand-new diner has no route into the interaction matrix, so their entire signal travels through the engineered content features — the same features that end up carrying warm diners too, which is how new users land at parity.\nLoss decay over ~1,400 epochs under the hand-rolled cosine-annealing schedule. Non-goals. Evaluation is offline only; no A/B test or live traffic ever ran against the model. There is no feedback loop, the system does not learn from a diner\u0026rsquo;s reaction to its own recommendations. And the training data comes from one restaurant, so the cross-restaurant claims stay unproven by design. The internship ended with the thesis handoff, and whether Dishup shipped it is not something I can claim.\nLinks. Read the full thesis (PDF, Google Drive)\nScreenshots # The Dishup menu app diners order from — the surface the recommender was designed for and the source of the order data. Dishup\u0026rsquo;s operator dashboard, showing the production platform the work targeted. The counterpart to the hero plot: Gemini scatters the same ingredients and pushes every Chinese term into its own corner. The real dataset: Friday spikes, a sharp 19:00 dinner peak, and daily volume across the 45-day collection window. One order record fanned out into customer, order, dish and restaurant feature groups after preprocessing. Stack # Python, pandas, scikit-learn, LightFM, PyTorch, OpenAI + Gemini APIs (embeddings and structured extraction), FAISS, MySQL + Docker, LaTeX.\n","date":"20 November 2024","externalUrl":null,"permalink":"/projects/rec4dish/","section":"Projects","summary":"A recommendation system for restaurant menus that beat the random baseline 3.5× (Precision@5 31.8% vs 9.2%) and held cold-start users at parity. Built end-to-end on real customer order data during a six-month software-engineering internship at Dishup SRL, an Italian restaurant-tech startup, and delivered as my MSc thesis at the University of Padua — “A Hybrid and Adaptive Learning Framework for Personalized Restaurant Recommendations”.","title":"Rec4Dish — a menu recommender trained on real restaurant orders","type":"projects"},{"content":"","date":"20 November 2024","externalUrl":null,"permalink":"/tags/scikit-learn/","section":"Tags","summary":"","title":"Scikit-Learn","type":"tags"},{"content":"Work from my MSc thesis at the University of Padua, built during the Dishup internship.\n","date":"20 November 2024","externalUrl":null,"permalink":"/tags/thesis/","section":"Tags","summary":"Work from my MSc thesis at the University of Padua, built during the Dishup internship.\n","title":"Thesis","type":"tags"},{"content":"","date":"1 November 2024","externalUrl":null,"permalink":"/tags/electron/","section":"Tags","summary":"","title":"Electron","type":"tags"},{"content":"","date":"1 November 2024","externalUrl":null,"permalink":"/tags/esc/pos/","section":"Tags","summary":"","title":"ESC/POS","type":"tags"},{"content":"","date":"1 November 2024","externalUrl":null,"permalink":"/tags/node.js/","section":"Tags","summary":"","title":"Node.js","type":"tags"},{"content":" A local Electron bridge that lets Dishup\u0026rsquo;s cloud POS print fiscal receipts on the restaurant\u0026rsquo;s LAN thermal printers, which a browser cannot reach on its own. Electron manager UI showing the local service on port 9080 and per-printer config for a registered EPSON TM-M30II Highlights # Built a cross-platform Electron bridge (Node.js, TypeScript) letting Dishup\u0026rsquo;s browser POS print fiscal receipts on LAN printers the browser can\u0026rsquo;t reach. Unified three fiscal printer vendors (AXON, CUSTOM, EPSON via ESC/POS) behind a single local HTTP/S API, independent of the installed hardware. Shipped a management UI with per-printer configuration, test prints, per-printer status tracking that surfaces failures, and an EN/IT language toggle. How it works # flowchart LR POS[Cloud POS in browser] --\u003e|HTTPS :9080| SVC[Local print service] SVC --\u003e RT{Vendor routing} RT --\u003e AX[AXON adapter] RT --\u003e CU[CUSTOM adapter] RT --\u003e EP[EPSON adapter, ESC/POS] EP --\u003e PR[TM-M30II at 192.168.1.24:9100] SVC --\u003e UI[Manager UI: status, add/test/delete] Case Study # Problem. In Italy, receipts must be issued through certified fiscal printers. Dishup\u0026rsquo;s POS runs as a web app in an HTTPS secure context, and a browser cannot open raw connections to printers sitting on the restaurant\u0026rsquo;s local network. On top of that, each printer vendor speaks its own protocol.\nApproach. I built an Electron app that runs on the restaurant\u0026rsquo;s machine and acts as the trusted local bridge: it exposes an HTTP/S service on port 9080 that the cloud POS calls to print. The bridge was the forcing reason for the app to exist at all, and once it was there it became the natural place to hide vendor differences, so I put AXON, CUSTOM, and EPSON behind one printing API. Printer failures are tracked per device and surfaced in the manager UI rather than dropped silently.\nOutcome. Shipped as part of Dishup\u0026rsquo;s POS during my internship (May to Nov 2024). Restaurant staff manage printers through the built-in UI, available in English and Italian.\nStack # Electron, Node.js, TypeScript, local HTTP/S service, ESC/POS and vendor-specific protocols (AXON, CUSTOM, EPSON).\n","date":"1 November 2024","externalUrl":null,"permalink":"/projects/ps-router/","section":"Projects","summary":"A local Electron bridge that lets Dishup’s cloud POS print fiscal receipts on the restaurant’s LAN thermal printers, which a browser cannot reach on its own.","title":"Thermal Printing Router","type":"projects"},{"content":"Projects from my Erasmus+ internship at Breathment, a digital physiotherapy startup at the TUM Incubator in Munich.\n","date":"1 April 2024","externalUrl":null,"permalink":"/tags/breathment/","section":"Tags","summary":"Projects from my Erasmus+ internship at Breathment, a digital physiotherapy startup at the TUM Incubator in Munich.\n","title":"Breathment","type":"tags"},{"content":"","date":"1 April 2024","externalUrl":null,"permalink":"/tags/graylog/","section":"Tags","summary":"","title":"Graylog","type":"tags"},{"content":"","date":"1 April 2024","externalUrl":null,"permalink":"/tags/opencv/","section":"Tags","summary":"","title":"OpenCV","type":"tags"},{"content":" An asynchronous websocket backend for the Breathment physiotherapy app that analyzes patients\u0026rsquo; breathing and movement exercises from a live camera stream, in real time. Highlights # Built an asynchronous websocket server (Python asyncio, Socket.IO) that analyzed live patient exercise video and returned feedback in real time. Fixed a throughput bottleneck (per-frame TensorFlow inference could not keep up) by caching frames in Redis and analyzing time windows instead. Operated the GPU-equipped Linux server that carried the TensorFlow workload, with session activity recorded to MongoDB. How it works # flowchart LR A[Mobile app camera frames] --\u003e B[Socket.IO / asyncio ingest] B --\u003e C[Redis frame window] C --\u003e D[Time-based pattern matching] D --\u003e E[TensorFlow model on GPU server] E --\u003e F[Results back over the socket] E --\u003e G[(MongoDB session log)] Case Study # Problem. Breathment is a digital physiotherapy product built at the TUM Incubator. Patients do breathing and movement exercises in front of a camera, and the feedback has to arrive while they are still exercising, not after. Someone had to turn a live stream of frames into movement analysis fast enough for that to work.\nApproach. I built the server in Python with asyncio and Socket.IO. The obvious design, TensorFlow inference on every incoming frame, could not keep up. So I cached frames in Redis and ran the analysis over windows of cached frames instead, which turned out to fit the problem better anyway: the time-based pattern matching needed sequences of movement, not single frames. The heavy computation ran on a GPU-equipped Linux server that I also managed, and each session was logged to MongoDB.\nOutcome. The pipeline ran live inside the Breathment product while real patients used it. The app around it was a team product; the real-time server was mine, and of everything I built in that period it\u0026rsquo;s the piece I\u0026rsquo;m proudest of.\nVital-sign monitoring (rPPG) # Estimates a patient\u0026rsquo;s heart rate and breathing rate from ordinary video, so vitals show up in the Breathment app without any wearable.\nPatient app showing vitals from the video pipeline Highlights # As part of a Breathment-TUM team:\nBrought contactless vitals into the Breathment patient app: heart and breathing rate estimated from plain video via rPPG, no wearable required. Adapted the published MTTS-CAN rPPG model and stabilized its noisy raw output with a bandpass filter, Hilbert transform, and Welch\u0026rsquo;s method for the final rate estimate. Served inference from a FastAPI service (TensorFlow, OpenCV), with an Angular panel for physiotherapists to visualize signals and tune parameters. How it works # flowchart LR V[Patient video] --\u003e F[FastAPI service] F --\u003e M[MTTS-CAN model\nTensorFlow + OpenCV] M --\u003e B[Bandpass filter\n+ Hilbert transform] B --\u003e W[Welch's method\nrate estimation] W --\u003e A[Angular interface\nfor physiotherapists] W --\u003e R[React Native app\npatient vitals] Case Study # Problem. Breathment guides patients through breathing exercises remotely, and vitals are the obvious signal to track alongside them. Getting heart and breathing rate normally means a wearable or extra device, which most patients at home simply do not have. We wanted those numbers from the one sensor everyone owns, a camera.\nApproach. Rather than train a model from scratch, we started from MTTS-CAN, a published multi-task temporal-shift attention network for rPPG, and adapted it to our setting. The raw per-frame output is too noisy to show a patient directly, and making it usable was the real work: the signal goes through a bandpass filter and a Hilbert transform, with Welch\u0026rsquo;s method producing the final heart and breathing rate. The whole pipeline sits behind a FastAPI service using TensorFlow and OpenCV, and an Angular interface exposes the signals and configurable parameters to physiotherapists.\nOutcome. The pipeline was integrated into the patient-facing app, which displayed its readings — 72 bpm heart rate and 16 breaths per minute in the screenshot above — next to the day\u0026rsquo;s exercises.\nProduction operations # Asynchronous Graylog logging and custom dashboards for Breathment\u0026rsquo;s FastAPI backend, tracking patient exercise activity and showing the team where the API was failing.\nLive dashboard, January 2024: request volume across ~20 endpoints and the logged-error breakdown. Highlights # Established the logging infrastructure for Breathment\u0026rsquo;s FastAPI backend on Graylog, using asyncio queue handlers so log writes never blocked patient-facing request handling. Built custom Graylog dashboards covering roughly 20 API endpoints, with traffic peaks around 2k requests on the busiest ones in the January 2024 snapshot. Surfaced production failure patterns through per-endpoint error dashboards, identifying auth/token errors as roughly 43% of all logged errors. How it works # flowchart LR P[Patients and healthcare professionals] --\u003e API[FastAPI backend] API --\u003e Q[asyncio log queue] Q --\u003e H[Queue handler] H --\u003e G[Graylog] G --\u003e T[Traffic per endpoint] G --\u003e S[Status-code counts] G --\u003e E[Errors by endpoint] Case Study # Problem. Breathment had real patients doing their exercises through the product, with a separate interface for healthcare professionals. The team needed two things from the same data: a record of patient activity, and a monitoring view of how the API was actually behaving in production.\nApproach. I set up Graylog with custom dashboards, fed directly by the FastAPI backend. The main design decision was making logging asynchronous. Log records go through asyncio queue handlers, so a slow write to Graylog never holds up a patient request. On the dashboard side, traffic is broken down per endpoint (get_exercise_program, get_patient_progress, get_session_analysis, among others). A second set of panels counts responses by status code and charts logged errors per endpoint over time.\nOutcome. In the January 2024 snapshot, the busiest endpoints show traffic peaks of roughly 2k requests, and auth/token errors account for about 43% of logged errors.\nStack # Real-time server. Python (asyncio), Socket.IO, Redis, TensorFlow, MongoDB, Linux GPU server.\nVital-sign monitoring. Python, TensorFlow, OpenCV, FastAPI, Angular, React Native.\nProduction operations. Python, FastAPI, asyncio, Graylog.\n","date":"1 April 2024","externalUrl":null,"permalink":"/projects/redis-tensor-sync/","section":"Projects","summary":"An asynchronous websocket backend for the Breathment physiotherapy app that analyzes patients’ breathing and movement exercises from a live camera stream, in real time.","title":"RedisTensorSync: real-time exercise analysis for Breathment","type":"projects"},{"content":"","date":"1 April 2024","externalUrl":null,"permalink":"/tags/socket.io/","section":"Tags","summary":"","title":"Socket.IO","type":"tags"},{"content":"","date":"1 April 2024","externalUrl":null,"permalink":"/tags/tensorflow/","section":"Tags","summary":"","title":"TensorFlow","type":"tags"},{"content":"","date":"3 July 2023","externalUrl":null,"permalink":"/tags/keras/","section":"Tags","summary":"","title":"Keras","type":"tags"},{"content":" Course project for Human Data Analytics (University of Padua): an Xception classifier for lung disease in chest X-ray images, plus an ablation that measures what ImageNet pretraining is worth on a small medical dataset. Highlights # Fine-tuned an ImageNet-pretrained Xception in TensorFlow/Keras to classify lung disease from 4,575 chest X-ray images, reaching 94.53% accuracy with 0.945 F1. Quantified the value of transfer learning by training the identical architecture from scratch, which reached only 85.56% accuracy, 9 points below the pretrained model. Published the full experiment as a public Kaggle notebook. How it works # flowchart TD A[\"Chest X-ray dataset4,575 images, balanced classes\"] --\u003e B[\"Preprocessing \u0026 cleaning\"] B --\u003e C[\"XceptionImageNet weights\"] C --\u003e D[\"Fine-tuning\"] D --\u003e E[\"Evaluation94.53% acc, F1 0.945\"] B --\u003e F[\"Xceptionfrom scratch\"] F --\u003e G[\"85.56% accuracy\"] Case Study # Problem. The task is computer-aided classification of lung disease from chest X-ray images. Labeled medical imaging data is scarce; with only 4,575 labeled images, the question is how much of the final accuracy comes from ImageNet pretraining rather than from the X-rays themselves.\nApproach. The project used the Xception architecture in TensorFlow/Keras. The central methodological choice was a clean ablation: train the exact same network twice, once from random initialization and once from ImageNet weights with fine-tuning, so the accuracy difference is attributable to pretraining. Before training, the dataset needed preprocessing and cleaning to deal with issues such as missing metadata.\nOutcome. The fine-tuned model reached 94.53% accuracy, with precision 0.949, recall 0.945 and F1 0.945. The from-scratch model stopped at 85.56%, a 9-point gap.\nLinks. Project report (PDF, Google Drive) · Kaggle notebook: lung-disease-prediction-from-chest-x-ray-xception\nStack # Python, TensorFlow, Keras, Xception, Kaggle Notebooks\n","date":"3 July 2023","externalUrl":null,"permalink":"/projects/lung-xray-classification/","section":"Projects","summary":"Course project for Human Data Analytics (University of Padua): an Xception classifier for lung disease in chest X-ray images, plus an ablation that measures what ImageNet pretraining is worth on a small medical dataset.","title":"Lung Disease Prediction from Chest X-rays (Xception)","type":"projects"},{"content":"","date":"3 July 2023","externalUrl":null,"permalink":"/tags/transfer-learning/","section":"Tags","summary":"","title":"Transfer Learning","type":"tags"},{"content":"","date":"3 July 2023","externalUrl":null,"permalink":"/tags/xception/","section":"Tags","summary":"","title":"Xception","type":"tags"},{"content":"","date":"1 July 2023","externalUrl":null,"permalink":"/tags/jwt/","section":"Tags","summary":"","title":"JWT","type":"tags"},{"content":" A subscription-based backend for laundry companies: one multi-tenant API where each company manages its own customers, per-unit services, and orders. The app design for the order flow the API models: per-unit services, per-service extras, line items with totals, and pickup scheduling. UI design by the client\u0026rsquo;s UX designer. Highlights # Designed and delivered a multi-tenant subscription backend for laundry companies as a solo freelance build (NestJS, TypeScript, PostgreSQL). Modeled per-unit service pricing (per kg, base price, estimated turnaround) plus per-service addons like express 24-hour delivery. Delivered 26 REST endpoints across 6 resource groups, from auth and tenant management through a daily-workload view (GET /orders/today). How it works # flowchart LR LC[LaundryCompanytenant] --\u003e SV[Servicespriced per unit] SV --\u003e AD[Addonse.g. express 24h] LC --\u003e CU[CustomersVAT and billing details] SV --\u003e LI[Line itemservice + qty + addons] AD --\u003e LI CU --\u003e OR[Orderpickup/delivery, status,payment status] LI --\u003e OR OR --\u003e TD[GET /orders/todayday's workload] Case Study # Problem. A freelance client wanted to sell laundry-management software to multiple laundry companies as a subscription. Each company needed its own isolated slice of customers, pricing, and orders behind a single API, with basic and premium tiers they could switch between.\nApproach. I made LaundryCompany the tenant root of the whole domain model. Every authenticated account resolves to exactly one company (GET /laundry-companies/my/company) and all reads and writes are scoped through it, which keeps tenant isolation a property of the model rather than a per-endpoint afterthought. Pricing lives on services (per-unit, e.g. per_kg, with turnaround estimates) with addons attached per service, and an order is a set of line items with pickup and delivery datetimes plus order and payment status. Schema changes went through TypeORM migrations, with docker-compose supplying Postgres for local runs. The app\u0026rsquo;s UI design came from an Italian UX designer on the project; my side of that collaboration was iterating on the screens with him and keeping the design aligned with the backend and the admin UI.\nOutcome. Handed off to the client as a complete, documented build: 26 endpoints across auth, laundry-companies, customers, services, addons, and orders, with the API collection as the handover artifact. The client owned the launch from there.\nStack # NestJS, TypeScript, PostgreSQL, TypeORM, JWT bearer auth, REST/JSON, docker-compose.\n","date":"1 July 2023","externalUrl":null,"permalink":"/projects/washbee/","section":"Projects","summary":"A subscription-based backend for laundry companies: one multi-tenant API where each company manages its own customers, per-unit services, and orders.","title":"Washbee — B2B Laundry Management API","type":"projects"},{"content":"","date":"22 June 2023","externalUrl":null,"permalink":"/tags/cielab/","section":"Tags","summary":"","title":"CIELAB","type":"tags"},{"content":"","date":"22 June 2023","externalUrl":null,"permalink":"/tags/cifar-10/","section":"Tags","summary":"","title":"CIFAR-10","type":"tags"},{"content":"","date":"22 June 2023","externalUrl":null,"permalink":"/tags/gan/","section":"Tags","summary":"","title":"GAN","type":"tags"},{"content":" A Pix2Pix-style conditional GAN that colorizes grayscale CIFAR-10 images by predicting only the a/b chrominance channels from lightness. Course project for Neural Networks and Deep Learning at the University of Padua. Ground truth, grayscale input, and generated output on 32x32 CIFAR-10 images. Two rows are convincing; the fox in the middle row comes out miscolored. Highlights # Built a Pix2Pix-style conditional GAN in PyTorch, pairing a U-Net generator with a CNN discriminator to colorize grayscale CIFAR-10 with adversarial plus L1 loss. Designed the data pipeline around CIELAB color space so the generator predicts only the two a/b chrominance channels from the L channel. Diagnosed two GAN training failure modes: overfitting when trained on a 1% data subset, then discriminator dominance on the full 50,000-image set. How it works # flowchart TD A[CIFAR-10 RGB image] --\u003e B[Convert to CIELAB] B --\u003e C[L channel input] C --\u003e D[U-Net generator] D --\u003e E[\"Predicted a/b channels\"] C --\u003e F[\"Concatenate L + a/b\"] E --\u003e F F --\u003e G[\"CNN discriminator\nreal vs generated\"] Case Study # Problem. Recovering color from a grayscale image is underdetermined: many colorings are consistent with the same lightness values. For the course project, our three-person team (with Fikriye Sari and Natalija Tonic) built a conditional GAN to learn plausible colorizations from data.\nApproach. We followed the Pix2Pix idea: a U-Net generator conditioned on the grayscale input and a CNN discriminator classifying real versus generated colorizations. The decision we defended in the report was to work in CIELAB and predict only the a/b chrominance channels from the L channel, then concatenate. Predicting 2 channels is an easier, more stable task than predicting 3 RGB values per pixel, and Lab separates luminance from color in a perceptually uniform way. I wrote the model and training code and the CIELAB data pipeline; training used Adam (lr 0.0002, betas 0.5/0.999), binary cross-entropy for the discriminator, and adversarial plus L1 loss for the generator on CIFAR-10 (50,000 train / 10,000 test images, normalized to [0,1]).\nWhat was hard. I first trained on 1% of the training set for 277 epochs and the model overfitted, with test performance notably worse than training. When I switched to the full dataset the opposite problem appeared: the discriminator became too good (its loss averaged around 1.2 against roughly 9 for the generator), leaving the generator little room to improve. The report proposes noise injection to slow the discriminator and data augmentation as next steps.\nOutcome. On the test set the generated colorizations were visually plausible, with structure and details preserved. We evaluated results visually; no quantitative image-quality metric was computed, only loss values.\nLinks. Project report (PDF, Google Drive) · Kaggle notebook\nStack # PyTorch, conditional GAN (U-Net generator, CNN discriminator), CIELAB color conversion, CIFAR-10, Kaggle.\n","date":"22 June 2023","externalUrl":null,"permalink":"/projects/image-colorization/","section":"Projects","summary":"A Pix2Pix-style conditional GAN that colorizes grayscale CIFAR-10 images by predicting only the a/b chrominance channels from lightness. Course project for Neural Networks and Deep Learning at the University of Padua.","title":"Image Colorization with a Conditional GAN in CIELAB","type":"projects"},{"content":"","date":"22 June 2023","externalUrl":null,"permalink":"/tags/u-net/","section":"Tags","summary":"","title":"U-Net","type":"tags"},{"content":" A server-side Java web application for running a co-living business, where tenants book rooms and file complaints and managers organize events. Group homework for the Web Applications course, University of Padua, A.Y. 2022-2023. Highlights # Designed the data layer of a co-living management web app on a 7-person course team: an 8-entity ER schema implemented on a relational database. Shaped the REST API design: example resources plus a table of ~27 application error codes mapped to HTTP statuses. Rotated through the rest of the Java stack with the team: JSP views, servlets, and DAOs built on shared abstract base classes. How it works # flowchart TD B[Browser] --\u003e J[JSP pages] B --\u003e R[REST resources] J --\u003e S[ServletsAbstractDatabaseServlet] S --\u003e D[DAOsAbstractDAO] R --\u003e D D --\u003e DB[(\"Relational DB8 entities\")] Case Study # Problem. A co-living business serves two kinds of users with different needs. Tenants register with their documents and IBAN, book rooms for specific date ranges, check contract details, review their co-living, and complain to the manager. Managers create events in common spaces and keep track of tenants and complaints across the co-livings they run. The homework asked for a complete server-side design and implementation covering both roles.\nApproach. We built a classic three-layer app: JSP presentation, servlet business logic, DAO data access over a relational database designed from an 8-entity ER schema. The core design decision was standardizing infrastructure in abstract base classes. Every DAO extends AbstractDAO (an abstract doAccess method, PreparedStatement access, results returned through getOutputParam) and every servlet extends AbstractDatabaseServlet, which supplies the JNDI datasource connection, so individual features never handle connection plumbing themselves. Multi-step flows got explicit staging: room booking splits into dates-plus-co-living then room choice, with warnings for overlapping reservations, and event creation splits into event details then common-space selection.\nOutcome. A working server-side application with the full tenant and manager feature set, documented in a report containing the ER schema, class diagrams, and a sequence diagram for event insertion. The REST layer shipped example resources plus an error-code table of roughly 27 application codes mapped to HTTP statuses. It was a group effort where we traded parts around; my own emphasis landed on the database and the REST API design.\nLinks. Project report (PDF, Google Drive)\nStack # Java, Servlets, JSP, JDBC (PreparedStatement), JNDI datasource, relational SQL database, REST.\n","date":"1 June 2023","externalUrl":null,"permalink":"/projects/clwa/","section":"Projects","summary":"A server-side Java web application for running a co-living business, where tenants book rooms and file complaints and managers organize events. Group homework for the Web Applications course, University of Padua, A.Y. 2022-2023.","title":"CLWA: Co-living Management Web App","type":"projects"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/gephi/","section":"Tags","summary":"","title":"Gephi","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/jdbc/","section":"Tags","summary":"","title":"JDBC","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/jsp/","section":"Tags","summary":"","title":"JSP","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/liwc/","section":"Tags","summary":"","title":"LIWC","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/networkx/","section":"Tags","summary":"","title":"NetworkX","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/rest/","section":"Tags","summary":"","title":"REST","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/servlets/","section":"Tags","summary":"","title":"Servlets","type":"tags"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/snscrape/","section":"Tags","summary":"","title":"Snscrape","type":"tags"},{"content":" A research project for the Network Science course at the University of Padua: do sports brands genuinely spread eco-sustainable values on Twitter, or is it greenwashing? Highlights # Collected 110,580 English-language tweets with snscrape, matched against a hand-built list of 72 eco-sustainability hashtags. Modeled brand perception as a weighted brand-hashtag co-occurrence network and analyzed it with NetworkX and Gephi: centrality, robustness under targeted node removal, and modularity-based community detection. Located each brand\u0026rsquo;s real position in the semantic network: Patagonia and The North Face sit fully inside the eco-sustainability cluster, Nike only partially, Adidas in the undifferentiated core. How it works # flowchart TD A[\"snscrape + 72 eco-hashtags\"] --\u003e B[\"110,580 English tweets\"] B --\u003e C[\"Human-coded cleaning\"] C --\u003e D[\"Brand-hashtag co-occurrence networkFull and Light projections\"] D --\u003e E[\"NetworkX: centrality, PageRank,assortativity, robustness\"] D --\u003e F[\"Gephi: modularity communities,Force Atlas 2\"] B --\u003e G[\"LIWC sentiment analysis\"] E --\u003e H[\"Findings\"] F --\u003e H G --\u003e H Case Study # Problem. Sports brands run loud eco-sustainability campaigns, but campaign volume says nothing about whether users actually connect a brand to green values. Our 8-person team, mixing social-science and technical backgrounds, asked that question directly on Twitter. Perception was read from the tweets themselves, not from the brands\u0026rsquo; messaging.\nApproach. I collected the tweet corpus with snscrape, covering everything from the first tweet on Twitter to the collection date, and ran the network analysis; I also contributed to the research design and writing. The 72 eco-sustainability hashtags were derived from the brands\u0026rsquo; own campaign pages. We built a network with brands and hashtags as nodes and co-occurrence strength as edge weights, studying brands across categories (Nike, Adidas, Vans, plus Burton, Patagonia, The North Face, Quiksilver, Hurley, Oakley, Reef, Columbia, Dainese and others). The full graph was too large for our hardware, so we worked on two edge-weight-filtered projections: a Full network (weight \u0026gt; 1) and a Light network (weight \u0026gt; 15). We also split the dataset at 20 August 2018, the first Fridays for Future strike, to compare perception before and after.\nWhat was hard. Three brand names (Omen, Reef, Columbia) are ordinary English words, so the scrape pulled in large amounts of semantically unrelated tweets. We only found this by going through tweets with human coding, then cleaning the dataset. The raw node count also exceeded what our machines could handle, which is what forced the filtered projections in the first place.\nOutcome. The network is disassortative: hub nodes (brand names and top hashtags) connect to peripheral nodes rather than to each other, and the degree-distribution exponent rises from 2.15 in the full network to 3.01 in the green community, which therefore isn\u0026rsquo;t properly scale-free. Community detection via modularity and Force Atlas 2 found 5 clusters, confirmed by conductance, separating brands sharply by position. LIWC sentiment showed positive-emotion language far outweighing negative across brands, with Quiksilver, Hurley, Patagonia and Oakley above 3.5 on the posemo scale.\nLinks. Project report (PDF, Google Drive)\nStack # Python (snscrape, NetworkX), Gephi (modularity, Force Atlas 2), LIWC.\n","date":"1 June 2023","externalUrl":null,"permalink":"/projects/twitter-network-analysis/","section":"Projects","summary":"A research project for the Network Science course at the University of Padua: do sports brands genuinely spread eco-sustainable values on Twitter, or is it greenwashing?","title":"Sports Brands and Eco-Sustainability on Twitter","type":"projects"},{"content":"","date":"1 June 2023","externalUrl":null,"permalink":"/tags/sql/","section":"Tags","summary":"","title":"SQL","type":"tags"},{"content":"I\u0026rsquo;m Hakan, a software engineer who builds AI-heavy full-stack systems: LLM \u0026amp; ML pipelines that live inside real products, and the engineering around them that makes their output trustworthy.\nSince 2025 I\u0026rsquo;ve been at working at Go Project, an Italian software company. My main work there is the AI authoring platform behind procedimentipa.gov.it, the Italian government\u0026rsquo;s public catalog of administrative procedures: AI workflows draft procedures directly from legislation, and legal experts review every one before it is published. The public portal launched in July 2026 and serves around 700 procedures. The platform grew its own tooling ecosystem along the way — a Markdown-to-portal renderer with a purpose-built editor, an embeddable AI chat widget, an automated prompt optimizer, and an agent-navigable knowledge graph, and most importantly Scriba Harness (AI Editor for Procedure Content) of the catalog.\nOutside Go Project I take freelance and consulting work: I was hired as consulting tech lead mid-build at a US startup and took its product to production — it processes real payments today — and I\u0026rsquo;ve built document tooling an Erasmus+ nonprofit uses daily, plus a laundry-management backend for an Italian client.\nI didn\u0026rsquo;t start in software: I began university in mathematics, switched to geoinformatics engineering, and worked as a GIS engineer at a gold and silver mine in Turkey before moving to Italy in 2021 for a master\u0026rsquo;s at the University of Padua. The move into software happened through internships that kept handing me more responsibility — performance engineering at Akamas, real-time patient movement analysis at Breathment, and the menu recommender at Dishup that became my thesis. That mix — product engineering with AI at the core — is the work I want to keep doing.\nExperience # Software Engineer 2025 – present Go Project · Rome, Italy \u0026middot; goproject.it\u0026nbsp;\u0026#8599; Go Project is an Italian digital company of 50+ people that pairs software development with communication and marketing, for clients ranging from Serie A and Lega B to public agencies like INAIL and INPS. My work there is full-stack products and AI/document tooling — most importantly the procedure-catalog platform behind procedimentipa.gov.it, the Italian government\u0026rsquo;s public catalog of administrative procedures — plus a tamper-evident protocol document manager, a CV search engine built on OCR, a local LLM and Elasticsearch, and an AI knowledge platform that turns uploaded documents into podcasts and quizzes. Built LMnetwork, a prompt-optimization framework that tunes LangGraph agent prompts against golden datasets with an LLM-as-judge, lifting a production extraction agent from 0.51 to 0.87.\nSee my work at Go Project →\nSoftware Engineer May – Nov 2024 Dishup · Pescara, Italy \u0026middot; dishup.it\u0026nbsp;\u0026#8599; Six-month internship at Dishup, whose cloud AI platform runs the whole restaurant operation — orders, kitchen, payments, inventory, and sales analytics — for restaurants, pizzerias, bars, and beach resorts. Built POS microservices for fiscal printing (EPSON, CUSTOM and AXON printers), integrated Fatture in Cloud e-invoicing, and did recommendation-system R\u0026amp;D connected to my thesis.\nSee my work at Dishup →\nBackend \u0026amp; ML Engineer Oct 2023 – Apr 2024 Breathment · TUM Incubator · Munich, Germany \u0026middot; breathment.com\u0026nbsp;\u0026#8599; Erasmus+ internship at a digital-health startup whose CE-marked platform delivers remote respiratory physiotherapy — an app with AI-guided exercises plus coaching from physiotherapists — to patients with COPD and other chronic lung conditions. Led the migration from Flask to FastAPI, set up Redis caching and Graylog logging, built real-time movement analysis with TensorFlow, and managed a GPU Linux server. Real patients used the product.\nSee my work at Breathment →\nResearch Intern Jul – Sep 2023 Akamas · Milano, Italy \u0026middot; akamas.io\u0026nbsp;\u0026#8599; Akamas builds an AI-powered platform that autonomously optimizes application performance and cloud costs across stacks like Kubernetes and the JVM. Researched gray-box and black-box optimization in performance engineering, and placed 3rd in Moviri\u0026rsquo;s performance engineering competition at the University of Padova. Geoinformatics Engineer 2017 – 2018 Tumad Mining, Turkey \u0026middot; tumad.com.tr\u0026nbsp;\u0026#8599; TÜMAD is a Turkish gold and silver mining company; İvrindi is its open-pit heap-leach operation in Balıkesir Province. GIS software tools, geospatial data operations, and technical support for mine planning. Education # M.Sc. ICT for Internet and Multimedia Engineering 2021 – 2024 University of Padua \u0026middot; Cybersystems track\u0026nbsp;\u0026#8599; Cybersystems track, final mark 91/110. Thesis: a hybrid and adaptive learning framework for personalized restaurant recommendations.\nSee my coursework →\nB.Sc. Geoinformatics Engineering 2016 – 2020 Hacettepe University, Ankara \u0026middot; hacettepe.edu.tr\u0026nbsp;\u0026#8599; Geospatial data analysis, remote sensing, and digital image processing. Mathematics 2014 – 2016 TOBB ETU \u0026middot; etu.edu.tr\u0026nbsp;\u0026#8599; Started university in mathematics before switching to engineering. Skills # Languages: Python, TypeScript/JavaScript, Java, SQL\nAI / ML: LangGraph, LangChain, Gemini, TensorFlow, PyTorch, OpenCV, LightFM, embeddings \u0026amp; vector search (pgvector, FAISS), LLM-as-judge evaluation, prompt optimization, OCR pipelines, text-to-speech\nBackend: FastAPI, Django, Nextjs, NestJS, Node.js, Bun, PostgreSQL, MySQL, MongoDB, Redis, Elasticsearch, Stripe, WebSockets/Socket.IO, server-sent events, Docker, AWS S3\nFrontend: React, Angular, Ionic, CodeMirror, HTMX, Tailwind, Bootstrap Italia\nAlso: Electron, Playwright, Graylog, human-in-the-loop git workflows\nContact # The fastest way to reach me is email: hakan.a.tech@gmail.com. I\u0026rsquo;m also on GitHub and LinkedIn.\n","externalUrl":null,"permalink":"/about/","section":"Hakan Ates","summary":"I’m Hakan, a software engineer who builds AI-heavy full-stack systems: LLM \u0026 ML pipelines that live inside real products, and the engineering around them that makes their output trustworthy.\nSince 2025 I’ve been at working at Go Project, an Italian software company. My main work there is the AI authoring platform behind procedimentipa.gov.it, the Italian government’s public catalog of administrative procedures: AI workflows draft procedures directly from legislation, and legal experts review every one before it is published. The public portal launched in July 2026 and serves around 700 procedures. The platform grew its own tooling ecosystem along the way — a Markdown-to-portal renderer with a purpose-built editor, an embeddable AI chat widget, an automated prompt optimizer, and an agent-navigable knowledge graph, and most importantly Scriba Harness (AI Editor for Procedure Content) of the catalog.\n","title":"About","type":"page"}]