diff --git a/.dockerignore b/.dockerignore
index 50bf606..851f22d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,14 +1,14 @@
-node_modules
-.next
-.git
-.gitea
-data
-npm-debug.log*
-.env*.local
+bin/
+obj/
+data/
+.git/
+.gitea/
+.claude/
+.vs/
+.vscode/
+.idea/
.env
-.DS_Store
-*.tsbuildinfo
-README.md
+.env.local
+*.user
Dockerfile
-.dockerignore
docker-compose.yml
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 20d8780..79921d8 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -29,11 +29,5 @@ jobs:
git checkout FETCH_HEAD
- name: Docker Build Test
- env:
- NODE_IMAGE: mirror.soroushasadi.com/node:20-alpine
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
- docker build \
- --build-arg NODE_IMAGE="$NODE_IMAGE" \
- --build-arg NPM_TOKEN="$NPM_TOKEN" \
- -t soroushasadi-site:test .
\ No newline at end of file
+ docker build -t soroushasadi-site:test .
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 4651094..f940404 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -32,45 +32,33 @@ jobs:
run: |
cat > .env << EOF
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
- ADMIN_SESSION_SECRET=${{ secrets.ADMIN_SESSION_SECRET }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
CONTACT_INBOX=${{ secrets.CONTACT_INBOX }}
CONTACT_FROM=${{ secrets.CONTACT_FROM }}
- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
EOF
- name: Build Container
- env:
- NODE_IMAGE: mirror.soroushasadi.com/node:20-alpine
- run: |
- docker compose build
+ run: docker compose build
- name: Deploy
- run: |
- docker compose up -d --remove-orphans
+ run: docker compose up -d --remove-orphans
- name: Wait For Health Check
run: |
for i in $(seq 1 30); do
-
STATUS=$(docker inspect \
--format='{{.State.Health.Status}}' \
soroushasadi-site 2>/dev/null)
-
echo "Status: $STATUS"
-
if [ "$STATUS" = "healthy" ]; then
echo "Deployment successful"
exit 0
fi
-
sleep 5
done
-
docker logs soroushasadi-site --tail 100
exit 1
- name: Cleanup
if: success()
- run: |
- docker image prune -f
\ No newline at end of file
+ run: docker image prune -f
diff --git a/.gitignore b/.gitignore
index 347cc55..995fd3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,39 +1,26 @@
-# dependencies
-node_modules
-.pnp
-.pnp.js
+# .NET
+bin/
+obj/
+*.user
+.vs/
+appsettings.*.local.json
-# next.js
-.next/
-out/
-build/
-
-# production
-dist/
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
-
-# misc
-.DS_Store
-*.pem
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# env
-.env*.local
-.env
-
-# vercel
-.vercel
-
-# CMS data (SQLite DB + uploaded media live in the mounted volume)
+# CMS data (SQLite DB + uploads live in the Docker volume)
/data
-# local tooling / agent state
-.claude/
+# Environment
+.env
+.env.local
+.env*.local
+# IDE
+.idea/
+.vscode/
+
+# OS
+.DS_Store
+*.pem
+Thumbs.db
+
+# Claude agent state
+.claude/
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index 1a2a6a1..0000000
--- a/.npmrc
+++ /dev/null
@@ -1,13 +0,0 @@
-# All npm traffic is proxied through the Nexus npm-group repository. npm rewrites
-# the registry.npmjs.org hosts found in package-lock.json to this mirror at
-# install time (default replace-registry-host=npmjs), so the committed lockfile
-# is reused as-is — no regeneration needed.
-registry=http://mirror.soroushasadi.com/repository/npm-group/
-
-# Auth is never committed. CI and the Docker build append an `_authToken` line
-# from the NPM_TOKEN secret at install time; for local installs put the token in
-# your personal ~/.npmrc. See .gitea/workflows/*.yml.
-
-# Trim install noise and avoid extra round-trips to the public registry.
-audit=false
-fund=false
diff --git a/Dockerfile b/Dockerfile
index 535dce5..0f4e2a2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,64 +1,38 @@
-ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
+ARG DOTNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-alpine
+ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-alpine
-# ---------------------------------------------------------------------------
-# 1. Builder — installs deps (including native better-sqlite3 compilation)
-# then produces the standalone Next.js server bundle.
-# ---------------------------------------------------------------------------
-FROM ${NODE_IMAGE} AS builder
-WORKDIR /app
+# ── Build ─────────────────────────────────────────────────────────────────────
+FROM ${SDK_IMAGE} AS build
+WORKDIR /src
-# libc6-compat: needed by Next.js SWC binaries on Alpine.
-# python3 / make / g++: needed to compile better-sqlite3 native addon.
-RUN apk add --no-cache libc6-compat python3 make g++ ca-certificates
+COPY SoroushAsadi.Web.csproj ./
+RUN dotnet restore --runtime linux-musl-x64
-ARG NPM_TOKEN=""
-COPY package.json package-lock.json .npmrc ./
-RUN if [ -n "$NPM_TOKEN" ]; then \
- echo "//mirror.soroushasadi.com/repository/npm-group/:_authToken=${NPM_TOKEN}" >> .npmrc ; \
- fi \
- && npm ci \
- && echo "=== post-install check ===" \
- && (test -d node_modules/next \
- && echo "OK: node_modules/next found: $(cat node_modules/next/package.json | grep '\"version\"' | head -1)" \
- || (echo "FATAL: node_modules/next is missing after npm ci" && exit 1))
-
-ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
-# Diagnose what's in .bin after install, then invoke next directly via node
-# to bypass any PATH / symlink resolution issues with `npm run`.
-RUN ls node_modules/.bin/next 2>&1 || echo "WARN: next not in .bin" ; \
- node node_modules/next/dist/bin/next build
+RUN dotnet publish SoroushAsadi.Web.csproj \
+ --no-restore \
+ --runtime linux-musl-x64 \
+ --self-contained false \
+ -c Release \
+ -o /app/publish
-# ---------------------------------------------------------------------------
-# 2. Runner — minimal image. Standalone server + static assets only.
-# Content DB + uploads live in /data (mounted volume).
-# ---------------------------------------------------------------------------
-FROM ${NODE_IMAGE} AS runner
+# ── Runtime ───────────────────────────────────────────────────────────────────
+FROM ${DOTNET_IMAGE} AS runner
WORKDIR /app
-RUN apk add --no-cache libc6-compat ca-certificates
+RUN apk add --no-cache ca-certificates \
+ && addgroup -g 1001 dotnet \
+ && adduser -u 1001 -G dotnet -h /home/dotnet -D dotnet
-ENV NODE_ENV=production \
- NEXT_TELEMETRY_DISABLED=1 \
- PORT=3000 \
- HOSTNAME=0.0.0.0 \
- DATA_DIR=/data
+COPY --from=build /app/publish ./
-RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -h /home/nextjs -D nextjs
+ENV ASPNETCORE_ENVIRONMENT=Production \
+ ASPNETCORE_URLS=http://+:3000 \
+ DataDir=/data
-COPY --from=builder /app/.next/standalone ./
-COPY --from=builder /app/.next/static ./.next/static
-COPY --from=builder /app/public ./public
-
-# Native addon compiled in builder on Alpine/musl — copy explicitly as a
-# safety net in case file tracing misses the .node binary.
-COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
-COPY --from=builder /app/node_modules/bindings ./node_modules/bindings
-COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path
-
-RUN mkdir -p /data/uploads && chown -R nextjs:nodejs /data /app
-USER nextjs
+RUN mkdir -p /data/uploads && chown -R dotnet:dotnet /data /app
+USER dotnet
VOLUME ["/data"]
EXPOSE 3000
-CMD ["node", "server.js"]
+ENTRYPOINT ["dotnet", "SoroushAsadi.Web.dll"]
diff --git a/Models/ContentSection.cs b/Models/ContentSection.cs
new file mode 100644
index 0000000..ac8eb7b
--- /dev/null
+++ b/Models/ContentSection.cs
@@ -0,0 +1,13 @@
+namespace SoroushAsadi.Models;
+
+///
@(fa ? "یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")
+@post.Excerpt
+@(fa ? "مقاله پیدا نشد." : "Post not found.")
+ } + else + { +@Model.ReadTime @(fa ? "دقیقه مطالعه" : "min read")
+{Inline(line)}
\n"); + } + return sb.ToString(); + } + + private static string Inline(string s) + { + // **bold**, `code`, &, <, > + var sb = new StringBuilder(); + int i = 0; + while (i < s.Length) + { + if (i + 1 < s.Length && s[i] == '*' && s[i + 1] == '*') + { + int end = s.IndexOf("**", i + 2); + if (end >= 0) { sb.Append(""); sb.Append(Esc(s[(i + 2)..end])); sb.Append(""); i = end + 2; continue; } + } + if (s[i] == '`') + { + int end = s.IndexOf('`', i + 1); + if (end >= 0) { sb.Append(""); sb.Append(Esc(s[(i + 1)..end])); sb.Append(""); i = end + 1; continue; }
+ }
+ sb.Append(s[i] switch { '&' => "&", '<' => "<", '>' => ">", _ => s[i].ToString() });
+ i++;
+ }
+ return sb.ToString();
+ }
+ private static string Esc(string s) => s.Replace("&","&").Replace("<","<").Replace(">",">");
+}
+
+/// Default article bodies (Markdown).
+internal static class DefaultBodies
+{
+ public const string RagEval = """
+## Why standard metrics fail for RAG
+
+BLEU and ROUGE measure n-gram overlap against a reference answer. In a RAG system, there is often no single correct reference — a question about company policy may have dozens of valid phrasings. High BLEU does not mean the system cited the right source; low BLEU does not mean it was wrong.
+
+## The three metrics that actually matter
+
+**Faithfulness** measures whether every claim in the generated answer can be traced back to a retrieved passage. A faithfulness score of 1.0 means the model invented nothing. Tools like RAGAS implement this with an LLM judge.
+
+**Context Precision** asks: of the passages retrieved, how many were actually relevant to the question? Low precision wastes context window and increases hallucination risk.
+
+**Answer Relevancy** checks whether the final response actually addresses what was asked — not just whether it sounds good.
+
+## Building an eval harness
+
+Start with a **golden dataset**: 100–200 question/answer pairs that domain experts have verified. Run your pipeline against them nightly. Track the three metrics above over time. A drop in Faithfulness after a model upgrade is a red flag; a drop in Context Precision after a chunking change means your retrieval is degrading.
+
+The harness does not have to be complex. A spreadsheet with automatic scoring via the OpenAI or Anthropic API is enough to start catching regressions before they reach production.
+""";
+
+ public const string N8nPatterns = """
+## The problem with "just use n8n"
+
+n8n is excellent for integrating SaaS tools. It becomes fragile when you try to use it as an agent orchestrator — long-running loops, conditional retries, and LLM calls that can fail in non-obvious ways.
+
+## Separating orchestration from integration
+
+The pattern that works: **n8n handles triggers and integrations; LangGraph handles agent logic**.
+
+An n8n workflow watches a Slack channel. When a message matches a pattern, it calls a LangGraph endpoint with the raw payload. LangGraph runs the multi-step reasoning loop, maintains state, and returns a structured result. n8n takes that result and routes it — posts to Jira, sends an email, updates a database row.
+
+## Making agents auditable
+
+Every LangGraph state transition should emit an event to a structured log. We use a Postgres table with columns: `run_id`, `step`, `input`, `output`, `timestamp`. This table becomes the audit trail that compliance teams and on-call engineers both need.
+
+Add a `human_in_the_loop` node for any action that cannot be undone — deleting records, sending external emails, approving payments. The node pauses execution and posts to Slack; a human approves or rejects; execution resumes.
+
+## Handling failures gracefully
+
+LLM calls fail. Build **retry with exponential backoff** into every LangGraph node that calls an LLM. Set a hard limit of 3 retries, then route to a dead-letter state that pages the on-call engineer. Never silently swallow errors in agentic pipelines — a swallowed error is an invisible outage.
+""";
+
+ public const string VertexCost = """
+## Anti-pattern 1: calling Gemini Ultra for everything
+
+Gemini Ultra (or GPT-4-class models) costs 10–30× more per token than smaller models. Many teams default to the most capable model because it "just works" during prototyping, then never re-evaluate.
+
+**Fix**: build a **model router**. Classify each incoming request by complexity. Simple lookups, short summaries, and classification tasks go to Gemini Flash or Haiku. Only complex reasoning, multi-step synthesis, and long-context tasks go to Pro or Ultra. In most production systems, 60–80% of requests can be served by the cheaper tier.
+
+## Anti-pattern 2: no context caching
+
+Vertex AI supports prompt caching (as does the Anthropic API). A system prompt that is 10k tokens, sent with every request at $3/M tokens, costs $30 for every million calls before the user has typed a single word.
+
+**Fix**: cache any context that is static or changes infrequently — system prompts, retrieved document sets, few-shot examples. Cache hits cost ~10% of full input price.
+
+## Anti-pattern 3: synchronous batch jobs
+
+Teams run nightly document processing jobs synchronously — one document at a time, each blocked on the previous. This is slow and expensive because you pay for idle wait time between calls.
+
+**Fix**: use the Vertex AI batch prediction API for jobs over ~1,000 documents. Batch jobs run asynchronously, are eligible for spot discounts, and typically cost 50% less per token than online serving.
+""";
+
+ public const string K8sInference = """
+## The baseline architecture
+
+A single Kubernetes `Deployment` behind a `ClusterIP` `Service`, fronted by an Ingress. Works fine up to ~50 RPS for a small model. Falls apart when traffic spikes, when GPU pods take 3 minutes to schedule, or when the model server has a 2-second cold-start.
+
+## Autoscaling with KEDA
+
+HPA (Horizontal Pod Autoscaler) scales on CPU and memory. LLM inference is GPU-bound and queue-depth-bound — neither maps to CPU utilization well.
+
+KEDA (Kubernetes Event-Driven Autoscaling) scales on arbitrary metrics — queue depth, Pub/Sub lag, Redis list length. We publish inference request counts to a Redis stream; KEDA scales the model server pods when the stream depth exceeds a threshold. Scaling-up latency drops from minutes (cluster autoscaler cold start) to seconds (replica scale-up from 1 to N).
+
+## GPU sharing with time-slicing
+
+For models that fit in 4–8 GB VRAM, full GPU dedication is wasteful. NVIDIA's time-slicing MIG (Multi-Instance GPU) lets multiple pods share one A100, each getting a guaranteed slice.
+
+Configure `nvidia.com/gpu: 1` and set the time-slice profile to `1g.10gb`. A single A100 80GB can serve 8 concurrent model instances at 10 GB each — 8× the throughput per GPU.
+
+## Request hedging for tail latency
+
+p50 latency is 12ms. p99 is 280ms. The tail is dominated by KV-cache misses and occasional GC pauses. **Hedged requests**: after 40ms, send a duplicate request to a second replica. Take whichever response arrives first; cancel the other. This cuts p99 from 280ms to ~45ms with only ~15% increase in total compute.
+""";
+
+ public const string FlutterAI = """
+## Why on-device inference matters
+
+Cloud inference requires a network round-trip, exposes user data to a server, and fails in offline scenarios. For consumer apps — messaging, health, productivity — on-device inference is often a requirement, not a nice-to-have.
+
+## Gemini Nano and LiteRT
+
+Google's Gemini Nano is a 1.8B parameter model quantized to run on mobile NPUs (Neural Processing Units). The Flutter integration uses the `google_ai_dart_sdk` package with `GeminiNanoModel`, falling back to cloud inference when the device model is unavailable.
+
+LiteRT (formerly TensorFlow Lite) handles vision and custom small models. For classification and embedding tasks, a 50MB quantized model runs in under 20ms on a mid-range Android device.
+
+## Streaming UX without a network
+
+The key insight: users tolerate slightly slower responses if they can see text appearing token by token. Even on-device inference can stream — Gemini Nano's Dart SDK exposes a `generateContentStream` method. Pipe tokens directly to a Flutter `StreamBuilder` for a responsive feel regardless of total generation time.
+
+## Battery and thermal management
+
+On-device inference heats the chip. Implement **thermal throttling**: check `DeviceInfo.thermalState` (iOS) or subscribe to the battery API on Android. Reduce `maxTokens` from 512 to 128 during sustained load. Schedule background inference tasks during charging. Users notice neither the throttling nor the scheduling — they notice when their phone gets too hot.
+""";
+
+ public const string EnterpriseRoadmap = """
+## Days 1–30: discovery
+
+The most expensive mistake in enterprise AI is building the wrong thing fast. Discovery is not a formality — it is the work.
+
+Interview 8–12 stakeholders across business units. For each, ask: what manual task takes more than 2 hours per week? What decision do you make with incomplete information? What report do you wish existed but is too expensive to build?
+
+Map the candidates on a 2×2: **impact** (revenue, cost, risk) vs **feasibility** (data quality, integration complexity, regulatory constraints). The top-right quadrant is your first sprint.
+
+## Days 31–60: prototype and validate
+
+Pick one use case from the top-right. Build a prototype in 3 weeks. The prototype does not have to be production-grade — it has to be **testable by domain experts**.
+
+Run a structured eval: 100 questions, domain expert scores each answer 1–5. Set a threshold (e.g., ≥4.0 average) before the sprint begins. If the prototype clears it, proceed to production hardening. If it doesn't, investigate root cause — usually data quality or chunking strategy — before committing engineering resources.
+
+## Days 61–90: first production deployment
+
+Scope the first deployment to a single team of 10–20 people. This limits blast radius and generates real usage data fast.
+
+Instrument everything: latency, cost per query, thumbs-up/thumbs-down from users, faithfulness score from the automated harness. Review metrics weekly with the business owner. Adjust chunking, retrieval strategy, or model tier based on what the data shows — not intuition.
+
+At day 90, you have a live system, a tuned eval harness, and a clear picture of what the second use case should be. That is the foundation for a credible 12-month roadmap.
+""";
+}
diff --git a/Pages/Contact.cshtml b/Pages/Contact.cshtml
new file mode 100644
index 0000000..1a54fbe
--- /dev/null
+++ b/Pages/Contact.cshtml
@@ -0,0 +1,2 @@
+@page "/contact"
+@model SoroushAsadi.Pages.ContactModel
diff --git a/Pages/Contact.cshtml.cs b/Pages/Contact.cshtml.cs
new file mode 100644
index 0000000..1aba762
--- /dev/null
+++ b/Pages/Contact.cshtml.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using SoroushAsadi.Services;
+
+namespace SoroushAsadi.Pages;
+
+/// + + @(fa ? "مهندس هوش مصنوعی · مشاور · معمار راهکار" : "AI Engineer · Consultant · Solution Architect") + +
+ + ++ @(fa ? "طراحی سامانههای" : "Architecting") + @(fa ? "هوش مصنوعی" : "production-grade AI") + @(fa ? "در مقیاس سازمانی." : "for the enterprise.") +
+ + ++ @(fa + ? "از راهبرد تا تولید — ساخت پایپلاینهای LLM، عاملهای خودکار، و معماریهای ابری که در میلیونها رویداد در روز پایدار میمانند." + : "From strategy to deployment — building LLM pipelines, autonomous agents, and cloud architectures that hold up at millions of events per day.") +
+ + +@(fa ? "از اولین جلسهی راهبرد تا استقرار تولید — یک شریک مهندسی برای کل چرخهی عمر هوش مصنوعی شما." : "From the first strategy session to production rollout — one engineering partner for the full AI lifecycle.")
+@desc
+@(fa ? "مسیری که هر پرسش در یک سامانهی RAG تولیدی طی میکند — هر مرحله قابل اندازهگیری، قابل ممیزی و بهینهشده برای تأخیر." : "The path every query takes through a production RAG system — each stage measurable, auditable, and tuned for latency.")
+@ndesc
+@(fa ? "تأخیر سرتاسری زیر ۵۰ میلیثانیه · هر مرحله مشاهدهپذیر" : "Sub-50ms end-to-end · every stage observable")
+@(fa ? "هر چه ساخته میشود از این پایهها بیرون میآید — انتخابشده برای عمر طولانی، نه ترند روز." : "Everything I ship sits on this foundation — chosen for longevity, not hype cycles.")
+@(fa ? "سامانههایی که در میلیونها رویداد در روز پایدار میمانند — اینها معیارهایی هستند که اندازه میگیریم." : "Systems that survive millions of events per day — these are the metrics I optimize for.")
+@(fa ? "گزیدهای از پروژههای واقعی. روی هر کارت بزنید تا جزئیات معماری را ببینید." : "A selection of real engagements. Tap any card for the gallery and architecture details.")
+@pclient · @pyear
+@(fa ? "یافتهها از پروژههای واقعی — نه ترجمهی مقاله، نه فهرست hype." : "Findings from real engagements — not translated articles, not hype lists.")
+@excerpt
+@(fa ? "بدون هزینه، بدون تعهد. موارد کاربردی، محدودیتها و گام بعدی را با هم بررسی میکنیم." : "No cost, no commitment. We map the use case, the constraints, and the next step together.")
+| Name | {Esc(form.Name)} |
| Company | {Esc(form.Company)} |
| Service | {Esc(form.Service)} |
| Budget | {Esc(form.Budget)} |
| Locale | {Esc(form.Locale)} |
{Esc(form.Message)}
+- Edit every section of the site. {edited > 0 ? `${edited} section${edited > 1 ? 's' : ''} customized.` : 'All sections are at their defaults.'} -
-ADMIN_PASSWORD is set, so the dev default
- (admin) is in use. Set one in your environment before going live.
- {s.desc.en}
- - ); - })} -- Edit the full bilingual body of each blog post (lead + content blocks). -
- -- Edit the lead and body blocks for both languages, then save. Changes go live immediately. -
- -- Edit the full bilingual body of each post.{' '} - {editedCount > 0 - ? `${editedCount} article${editedCount > 1 ? 's' : ''} customized.` - : 'All articles are at their defaults.'}{' '} - Titles, excerpts and read time live under the{' '} - - Journal - {' '} - section. -
-- Edit both languages with the FA / EN tabs, then save. Changes go live immediately. -
- -- {fa.title} -
- -- {en.description} -
-- {fa.description} -
- -| Name | ${escape(body.name!)} |
| Company | ${escape(body.company ?? '')} |
| Service | ${escape(body.service!)} |
| Budget | ${escape(body.budget!)} |
| Locale | ${escape(body.locale ?? '')} |
${escape(body.message!)}
-