Plain-Persian bug reports in, structured AI drafts out.
Farsi bug-report intake: a non-technical user describes a problem in plain Persian (with screenshots), a Gemini vision model turns it into a structured, classified draft, and QA and managers work it through a simple lifecycle.
Highlights
- One multimodal call turns free-form Farsi text plus screenshots into a title, description, repro steps, severity, type and module.
- The model never picks IDs. Module and duplicate verdicts are validated against real catalog rows and real candidate reports.
- Duplicate detection grounded in the same-module backlog, stored as a
duplicate_oflink. - Truncated-JSON salvage keeps every completed field when a token-heavy Persian response is cut off.
- Human in the loop: AI triage suggests approve/reject on the Manager’s approvals page; people decide.
- RTL-first UI: Vazirmatn, Persian digits, Jalali dates, responsive down to phone width.
Screenshots
All screenshots show the real app running locally with fictional demo data (app.seed.seed_demo); the AI fields are seeded, not produced by a live model call.
![]() | ![]() |
| Reports dashboard: status counters, filters, severity and type at a glance (demo data) | AI draft review: the reporter’s raw text next to the editable structured draft and AI verdict (demo data) |
![]() | ![]() |
| Approvals: AI category, recommendation and reasoning for each new report (demo data) | Intake form: describe the problem in plain Persian, pick a module and its sections, attach screenshots (demo data) |

Why it’s interesting
- Free-form Farsi + screenshots in, structured draft out. One multimodal call (
app/services/llm.py,LLMService.organize) reads the raw text and the attached images and returns a Persian title, description, steps, severity, report type, and module/section. - The model is never trusted with identifiers. It picks a module by
keyfrom the real catalog and the service maps that to a row id (orNone). A duplicate verdict is only accepted if the returned code matches one of the candidate reports shown in the prompt. - Duplicate detection grounded in existing reports. Candidate reports from the same module (code, status, snippet, latest comments) are put in the prompt and capped in size; the model may only flag a duplicate against one of them, and the link is stored as
duplicate_of. - Truncated-JSON recovery. Persian is token-heavy and thinking models can hit
finish_reason=lengthmid-object._tolerant_jsonstrips code fences and surrounding prose, and_salvage_truncatedrecovers every completed top-level key/value pair instead of discarding the response. - Human stays in the loop. The LLM only suggests; nothing it returns is written without a person confirming. Roles (Reporter / QA / Manager) live in the database, not in the Keycloak token.
Architecture
flowchart LR
U[Reporter / QA / Manager] --> FE[Next.js 16 frontend<br/>RTL, Farsi, Jalali dates]
FE -->|/api/proxy injects Bearer| API[FastAPI backend]
KC[Keycloak OIDC] --- FE
KC --- API
API --> PG[(PostgreSQL)]
API -->|text + screenshots| LLM[Gemini via OpenRouter]
API -.->|critical bugs, optional| BOT[chat-bot webhook sidecar]
The frontend never calls the API directly: a same-origin proxy route attaches the access token server-side. The backend has routers, services (llm, reports, notifications, report_io, attachments), async SQLAlchemy models and Alembic migrations. Report lifecycle: submitted -> approved (Manager) -> in_progress (QA) -> done, or rejected.
Tech stack
- Backend: Python 3.12, FastAPI, async SQLAlchemy 2, Alembic, Pydantic v2, PostgreSQL (
pg_trgmsearch indexes),uv - Frontend: Next.js 16 App Router, React 19, TypeScript, Tailwind v4, TanStack Query, NextAuth (Keycloak), Radix UI, Jalali calendar
- LLM: OpenAI-compatible client pointed at OpenRouter (default
google/gemini-3.6-flash, native vision)
Key techniques
- Multimodal prompting with base64 screenshots:
app/services/llm.py,app/services/reports.py - Tolerant parsing and truncated-JSON salvage:
_tolerant_json,_salvage_truncatedinapp/services/llm.py - Prompts that ask for JSON plus an appended key/type shape hint instead of
response_format(some Gemini models on OpenRouter reject it) - Duplicate and classification verdicts validated against real rows:
LLMService.organize - Sha256 de-duplication of re-uploaded attachments:
app/services/attachments.py - Bulk report import/export with two-pass
duplicate_ofre-linking:app/services/report_io.py - Role-based permissions read from the DB:
app/core/permissions.py
Getting started
Requires Python 3.12 + uv, Node 20+, PostgreSQL, and a Keycloak realm (or ENV=dev with DEV_AUTH=true locally).
# backend
cd backend
cp .env.example .env # set DATABASE_URL, KEYCLOAK_*, LLM_API_KEY, LLM_ENABLED=true
uv sync
uv run alembic upgrade head
uv run python -m app.seed.seed_catalog
uv run uvicorn app.main:app --reload --port 8000
# frontend
cd ../frontend
cp .env.example .env.local
npm install
npm run dev # http://localhost:3000
The seeded module/section catalog is a small sample; replace it with your product’s own.
Demo mode (no Postgres, Keycloak or LLM key)
A local SQLite database with fictional reports, and the dev-only auth bypass (ENV=dev + DEV_AUTH=true, signed in as a Manager):
# backend
cd backend
export DATABASE_URL=sqlite+aiosqlite:///./demo.db ENV=dev DEV_AUTH=true
uv run python -m app.seed.seed_demo # drops and recreates demo.db
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
# frontend (another shell)
cd frontend
NEXTAUTH_SECRET=local-dev-only BACKEND_URL=http://127.0.0.1:8000 \
DEV_AUTH=true NEXT_PUBLIC_DEV_AUTH=true npm run dev
The LLM stays off in demo mode, so “re-draft with AI” is unavailable; the drafts shown are seeded. scripts/capture-screenshots.js regenerates docs/images/ from a running demo (needs Playwright).
Tests
cd backend
LLM_ENABLED=0 uv run pytest -m "not integration" # 91 tests in 7 modules, in-memory SQLite, LLM stubbed
uv run ruff check . && uv run ty check
cd ../frontend && npx tsc --noEmit && npx eslint
The last backend run passed (91 tests). The frontend has no test suite, only type-check and lint.
Notes
The critical-bug notifier posts to an external chat-bot sidecar that is not part of this repo; it is off by default (NOTIFY_ENABLED=false). Users and roles are Keycloak-provisioned; there is no local signup.
License
MIT, see LICENSE.
Built by Sepehr Radmard · LinkedIn · GitHub · more projects on my profile



