موتور جستجوی دیتاست لینکدین
یک وباپلیکیشن کامل برای جستجو و فیلتر روی دیتاست پروفایلهای لینکدین؛ بکاند Django/DRF، فرانتاند React/TypeScript، موتور جستجو Elasticsearch (با fallback روی PostgreSQL Full-Text Search) و مانیتورینگ با استک ELK — همه بهصورت داکرایزشده و قابل اجرا با یک دستور.
این پروژه پاسخ تسک آزمایشی «متخصص فولاستک» مؤسسه Cyberyan است.
فهرست
اجرای سریع
پیشنیاز: فقط Docker و Docker Compose (نسخه ۲ به بالا). هیچ چیز دیگری لازم نیست.
git clone <repo> && cd cyberyan-linkedin-search
cp .env.example .env # مقادیر پیشفرض برای اجرای محلی آماده است
docker compose up --build # یا: make up
اولین اجرا حدود ۳ تا ۵ دقیقه طول میکشد (بیلد ایمیجها + بالا آمدن Elasticsearch). کانتینر بکاند بهصورت خودکار:
- منتظر آماده شدن Postgres و Elasticsearch میماند،
- مایگریشنها را اجرا میکند،
- دیتاست CSV را ایمپورت میکند (۲۸۴ پروفایل یکتا)،
- ایندکس Elasticsearch را میسازد،
- سه کاربر نمونه میسازد.
سپس:
| سرویس | آدرس |
|---|---|
| وباپلیکیشن (SPA) | http://localhost:8080 |
| مستندات تعاملی API (Swagger) | http://localhost:8080/api/docs/ |
| مستندات ReDoc | http://localhost:8080/api/redoc/ |
| پنل ادمین جنگو | http://localhost:8080/admin/ |
| Kibana | http://localhost:5601 |
کاربران نمونه (از .env خوانده میشوند):
| ایمیل | رمز | نقش | دسترسی |
|---|---|---|---|
admin@example.com |
Admin-Passw0rd! |
admin | همهچیز + بازسازی ایندکس + دیدن اطلاعات تماس |
analyst@example.com |
Analyst-Passw0rd! |
analyst | سرچ + دیدن اطلاعات تماس |
viewer@example.com |
Viewer-Passw0rd! |
viewer | سرچ، اطلاعات تماس ماسکشده |
در محیط واقعی حتماً
DJANGO_SECRET_KEY،POSTGRES_PASSWORDو رمزهای نمونه را عوض کنید. فایل.env.exampleدستور تولید کلید امن را هم دارد.
دستورهای پرکاربرد
make help # فهرست همه دستورها
make up # بالا آوردن کل استک
make dev # اجرا با hot-reload (Vite روی :5173، runserver روی :8000)
make down # خاموش کردن
make clean # خاموش کردن + پاک کردن volumeها
make logs # دیدن لاگ همه سرویسها
make seed # ایمپورت مجدد دیتاست + بازسازی ایندکس
make reindex # فقط بازسازی ایندکس Elasticsearch
make test # اجرای تستهای بکاند
make lint # lint + type-check هر دو بخش
make kibana-setup # ساخت data view لاگها در Kibana
اجرا بدون Elasticsearch
اگر میخواهید سبکتر اجرا کنید (مثلاً برای مرور سریع کد)، کافی است SEARCH_BACKEND=postgres را در .env بگذارید. در این حالت سرچ روی Full-Text Search خود PostgreSQL اجرا میشود و کل API و فرانتاند بدون هیچ تغییری کار میکنند. دلیل این طراحی در بخش سرچ توضیح داده شده است.
معماری کلی
┌──────────────────────────────────────────────┐
مرورگر ────────▶│ nginx (frontend) :8080 │
│ • سرو کردن باندل React │
│ • پراکسی /api → backend (بدون CORS) │
└───────────────┬──────────────────────────────┘
│
┌───────────────▼──────────────────────────────┐
│ Django + DRF + gunicorn :8000 │
│ ┌────────────────────────────────────────┐ │
│ │ apps.accounts JWT / نقشها │ │
│ │ apps.profiles مدل دامنه + ایمپورتر │ │
│ │ apps.search لایه سرچ (Strategy) │ │
│ │ apps.core لاگ، خطا، سلامت، throttle│ │
│ └────────────────────────────────────────┘ │
└───┬───────────┬──────────────┬───────────────┘
│ │ │
┌─────────▼──┐ ┌─────▼──────┐ ┌────▼──────────┐
│ PostgreSQL │ │ Redis │ │ Elasticsearch │
│ منبع حقیقت │ │ cache + │ │ ایندکس سرچ │
│ + FTS │ │ throttle │ └────┬──────────┘
└────────────┘ └────────────┘ │
│ │
│ لاگ JSON (async TCP) │
└────────▶ Logstash ───────┘
│
Kibana :5601
اصول طراحی:
- PostgreSQL منبع حقیقت است، Elasticsearch یک projection دورریختنی. ایندکس همیشه از دیتابیس قابل بازسازی است (
make reindex)، پس از دست رفتنش فاجعه نیست. - لایه سرچ پشت یک interface است (
SearchBackend). دو پیادهسازی دارد: Elasticsearch و PostgreSQL. ویو، سریالایزر و فرانتاند اصلاً نمیدانند کدام موتور جواب داده است. - ویوها نازکاند. اعتبارسنجی در سریالایزر، منطق رتبهبندی در بکاند سرچ، ماسککردن داده حساس در لایه سریالایز.
- هیچ اطلاعات تماسی وارد Elasticsearch نمیشود. ایمیل و شماره تلفن فقط در Postgres میمانند.
جزئیات کامل: docs/ARCHITECTURE.md
منطق سرچ و فیلترها
یک درخواست، سه بخش
هر درخواست سرچ از سه چیز تشکیل شده است:
| بخش | نقش | تأثیر روی امتیاز |
|---|---|---|
q (کلیدواژه) |
چه کسی «مرتبط» است | ✅ امتیاز میدهد |
| فیلترها | چه کسی اصلاً «واجد شرایط» است | ❌ فقط حذف میکند |
sort / صفحهبندی |
ترتیب و برش نتایج | — |
این تفکیک عمدی است: در Elasticsearch فیلترها در filter context قرار میگیرند، پس کش میشوند و روی scoring اثر نمیگذارند.
کلیدواژه چطور امتیاز میگیرد
{
"bool": {
"must": [{ // باید جایی مچ شود
"multi_match": {
"query": "product manager",
"type": "best_fields",
"fields": ["full_name^6", "job_title^4", "skills^3.5", "company^2.5",
"headline^2", "past_companies^1.5", "industry^1.5",
"majors^1.5", "education", "location_name", "summary", "interests"],
"minimum_should_match": "2<70%", // یککلمهای: اجباری. چندکلمهای: ۷۰٪ کافی است
"fuzziness": "AUTO" // «pyhton» هم «python» را پیدا میکند
}
}],
"should": [{ // فقط امتیاز اضافه میکند
"multi_match": { "type": "phrase", "fields": ["full_name^8", "job_title^6",
"skills^5", "company^4", "headline^3"], "boost": 4 }
}],
"filter": [ /* فیلترهای ساختاریافته - بدون امتیاز */ ]
}
}
سه ایده پشت این کوئری:
- بوست هر فیلد یک تصمیم محصولی است. وقتی کاربر «سارا» را جستجو میکند، منظورش یک آدم است نه کسی که در خلاصهاش کلمه «سارا» آمده — پس
full_name^6در برابرsummaryبدون بوست. fuzziness: AUTOغلط تایپی را میبخشد، ولیprefix_length: 1جلوی انفجار عبارتهای بیربط را میگیرد.- بوست عبارت دقیق (phrase) باعث میشود «product manager» بهعنوان یک عبارت، بالاتر از پروفایلی بیاید که این دو کلمه را جدا و بیربط دارد.
فیلترها
| پارامتر | مثال | معنی |
|---|---|---|
skills |
skills=python,django |
مهارتها |
roles |
roles=engineering |
خانواده شغلی |
seniority |
seniority=senior,manager |
سطح ارشدیت |
countries |
countries=united states |
کشور |
industries |
industries=banking |
صنعت کارفرمای فعلی |
companies |
companies=garver |
نام کارفرمای فعلی |
min_experience / max_experience |
min_experience=5 |
بازه سالهای تجربه |
قاعده ترکیب: مقادیر داخل یک فیلتر با OR و فیلترهای مختلف با AND ترکیب میشوند.
skills=python,django & countries=germany
⇒ (python OR django) AND (germany)
این دقیقاً همان چیزی است که کاربر از یک پنل فیلتر انتظار دارد: تیک زدن مهارت دوم نتایج را بیشتر میکند، ولی تیک زدن یک کشور نتایج را محدودتر.
Facetها (شمارنده کنار هر فیلتر)
هر پاسخ سرچ، بههمراه نتایج، شمارش aggregation هر فیلتر را هم برمیگرداند. یعنی کاربر قبل از کلیک میداند «germany» چند نتیجه دارد و بنبست نمیخورد. مقادیر انتخابشده حتی اگر شمارش صفر داشته باشند در پنل میمانند تا کاربر بتواند تیکشان را بردارد.
fallback روی PostgreSQL
بکاند دوم از tsvector وزندار استفاده میکند که ایمپورتر آن را میسازد:
| وزن | فیلدها |
|---|---|
| A | نام کامل |
| B | عنوان شغلی، مهارتها، شرکتها |
| C | صنعت، تحصیلات، موقعیت مکانی |
| D | خلاصه |
بهعلاوه یک pass با pg_trgm روی نام، تا غلط تایپی (jhon → john) هم کار کند.
چرا اصلاً این بکاند دوم وجود دارد؟ چون یک API سرچ که بدون Elasticsearch اصلاً بالا نمیآید شکننده است: تستها به کلاستر نیاز پیدا میکنند، مرورکننده کد برای اجرای پروژه به کلاستر نیاز دارد، و یک قطعی ES کل محصول را میخواباند. با یک متغیر محیطی، سیستم روی حالت کمتوانتر ولی سالم میرود.
توضیح کاملتر همراه با مثال کوئریها: docs/SEARCH.md
ساختار ذخیرهسازی داده
دیتاست خام، هر آدم را در یک ردیف بسیار پهن CSV نگه میدارد که چند ستونش خودشان لیست دیکشنری سریالایزشدهاند. ذخیره کردن همان بلوب، فیلتر و aggregation را غیرممکن میکند؛ پس داده به پنج جدول نرمال شده است:
Company ◀──── Experience ────▶ Profile ◀──── Education ────▶ School
│
└──── ProfileSkill ────▶ Skill
| جدول | نکته طراحی |
|---|---|
Profile |
ایندکس GIN روی search_vector و seniority_levels؛ ایندکس ترکیبی روی (کشور، خانواده شغلی) |
Skill |
کلید یکتا slug است؛ «Team Building» و «team building» یک رکورد میشوند. profile_count denormalize شده تا مرتبسازی فیلترها ارزان باشد |
Company / School |
یکتا بر اساس نام، با کش درونحافظهای در ایمپورتر (بدون N+1) |
ProfileSkill |
جدول واسط با position تا ترتیب اصلی مهارتها حفظ شود |
سه ستون متنی denormalizeشده (skills_text، companies_text، education_text) روی Profile نگهداری میشوند، چون SearchVector جنگو نمیتواند از رابطه many-to-many عبور کند. نتیجه: بهروزرسانی کل tsvector با یک کوئری UPDATE انجام میشود.
ایمپورتر idempotent است: کلید طبیعی، شناسه لینکدین (و در نبودش URL پروفایل) است، پس اجرای دوبارهاش بهجای تکرار داده، آن را بهروز میکند.
کیفیت دیتاست ورودی
فایل CSV دادهشده یک export واقعی و خراب است، نه یک CSV تمیز. اگر مستقیم به csv.DictReader بدهید، بیسروصدا دادهی غلط تولید میکند. چهار نقص مستقل در فایل هست:
| نقص | نشانه |
|---|---|
| واترمارک تزریقشده | خیلی از ردیفها با یک ستون اضافه شروع میشوند: H:\New folder\leak\...@BreachedData1 — همهچیز یک خانه به راست میلغزد |
| ستونهای غایب | اکثر رکوردها بهجای مقدار خالی، فیلد اختیاری (مثل job_title_role) را اصلاً ننوشتهاند — بقیه مقادیر به چپ میلغزند |
| کوتیشن شکسته | چند مقدار summary کوتیشن escapeنشده دارند و یک رکورد را بین چند خط فیزیکی میشکنند |
| دو export به هم چسبیده | خط هدر یک بار دیگر وسط فایل تکرار شده و حدود ۴۰ نفر تکراریاند |
اثر عملی: با خواندن ساده، فقط ۱۰ ردیف از ۳۲۳ کاملاً سالماند.
راهکار: خواندن خودترمیمگر
هر ستون یک امضا دارد (linkedin_url باید شامل linkedin.com/in/ باشد، skills باید یک literal لیست پایتون باشد، location_geo باید شبیه lat,lon باشد و…). ماژول apps/profiles/dataset.py هر جفت (ستون، مقدار) را با این امضاها امتیاز میدهد و بعد یک الگوریتم همترازی بهسبک Needleman–Wunsch اجرا میکند که دو عمل ویرایشی مجاز دارد:
- رد کردن ستون — رکورد اصلاً این فیلد را ندارد؛
- دور انداختن مقدار — یک تکه سرگردان که به هیچ ستونی تعلق ندارد.
همترازی با بیشترین امتیاز، رکورد بازسازیشده است. سپس هر مقدار دوباره اعتبارسنجی میشود و هر چیزی که هنوز امضایش را رد نکند دور انداخته میشود — ذخیره نکردن بهتر از ذخیره کردن شماره تلفن در ستون ایمیل است.
$ docker compose exec backend python manage.py import_profiles --truncate
{
"profiles_created": 284, "skills": 2376, "companies": 1371, "schools": 610,
"experiences": 2128, "educations": 870,
"physical_rows": 336, "duplicate_headers": 1, "repaired_rows": 16,
"orphan_fragments": 12, "duplicate_records": 39, "discarded_values": 175,
"records": 284, "elapsed_seconds": 15.4
}
غنیسازی از داده تودرتو: وقتی فیلدهای سطحبالا غایباند، ایمپورتر همان اطلاعات را از داخل بلوب experience بیرون میکشد. نتیجه:
| فیلد | فقط ستون سطحبالا | بعد از غنیسازی |
|---|---|---|
job_title_role |
۱۰۸ | ۱۸۴ |
| کارفرمای فعلی | ۲۱۹ | ۲۶۵ |
هفت تست اختصاصی (tests/test_dataset_repair.py) هر کدام یکی از این نقصها را بازتولید میکنند تا رگرسیون در منطق ترمیم، بیصدا رد نشود.
جزئیات کامل: docs/DATA.md
API
پایه: /api/v1/ — مستندات تعاملی و همیشه بهروز: http://localhost:8080/api/docs/
| متد | مسیر | توضیح | دسترسی |
|---|---|---|---|
POST |
/auth/register/ |
ثبتنام (همیشه نقش viewer) |
عمومی |
POST |
/auth/login/ |
گرفتن access/refresh token | عمومی |
POST |
/auth/refresh/ |
چرخش توکن | عمومی |
POST |
/auth/logout/ |
بلاکلیست کردن refresh token | لاگین |
GET |
/auth/me/ |
هویت و سطح دسترسی فعلی | لاگین |
GET |
/search/ |
سرچ کلیدواژهای + فیلتر + facet | لاگین |
GET |
/search/suggest/ |
پیشنهاد تایپآهسته (autocomplete) | لاگین |
POST |
/search/reindex/ |
بازسازی ایندکس | فقط admin |
GET |
/profiles/ |
لیست ساده پروفایلها | لاگین |
GET |
/profiles/{id}/ |
پروفایل کامل + سوابق | لاگین |
GET |
/profiles/skills/ |
دیکشنری مهارتها برای type-ahead | لاگین |
GET |
/health/live/ |
liveness probe | عمومی |
GET |
/health/ready/ |
readiness (چک DB و موتور سرچ) | عمومی |
نمونه
# ۱) لاگین
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login/ \
-H 'Content-Type: application/json' \
-d '{"email":"analyst@example.com","password":"Analyst-Passw0rd!"}' | jq -r .access)
# ۲) سرچ: کلیدواژه + دو فیلتر + مرتبسازی
curl -s -G http://localhost:8080/api/v1/search/ \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'q=leadership' \
--data-urlencode 'skills=management,training' \
--data-urlencode 'countries=united states' \
--data-urlencode 'min_experience=10' \
--data-urlencode 'sort=-experience' | jq
{
"count": 41, "page": 1, "page_size": 20, "total_pages": 3,
"took_ms": 12, "backend": "elasticsearch",
"results": [{
"id": 118, "score": 14.82,
"full_name": "joseph holland",
"headline": "recruiting manager at garver",
"job_title_role": "human_resources",
"seniority_levels": ["manager"],
"location_country": "united states",
"years_experience": 12,
"skills": ["recruiting", "leadership", "human resources"],
"highlight": { "skills": ["<mark>leadership</mark>"] }
}],
"facets": {
"skills": [{ "key": "leadership", "count": 41 }, { "key": "management", "count": 33 }],
"country": [{ "key": "united states", "count": 41 }]
}
}
قالب یکسان خطاها
هر خطا — اعتبارسنجی، دسترسی، rate limit یا خطای پیشبینینشده — دقیقاً یک شکل دارد:
{
"error": {
"code": "validation_error",
"message": "The request payload failed validation.",
"details": { "min_experience": ["min_experience cannot be greater than max_experience."] },
"request_id": "9f2c1a7e8b4d4c0e"
}
}
request_id همان مقداری است که در هدر X-Request-ID پاسخ برمیگردد؛ کپی کردنش در Kibana، کل ردپای آن درخواست را نشان میدهد.
مرجع کامل: docs/API.md — اسکیمای OpenAPI: docs/openapi.yaml
امنیت
| لایه | اقدام |
|---|---|
| احراز هویت | JWT با عمر کوتاه (۱۵ دقیقه) + refresh چرخشی + بلاکلیست بعد از چرخش |
| رمز عبور | هش Argon2، حداقل ۱۰ کاراکتر، اعتبارسنجهای استاندارد جنگو |
| مجوزدهی | پیشفرض deny؛ سه نقش (admin / analyst / viewer)؛ بازسازی ایندکس فقط admin |
| حریم خصوصی | ایمیل و تلفن برای نقش viewer ماسک میشود و اصلاً وارد Elasticsearch نمیشود |
| Rate limiting | سقف جدا برای anon / user / search / login (روی IP برای مسیرهای اعتبارسنجی) |
| ورودی | همه پارامترها از سریالایزر رد میشوند؛ سقف طول کوئری، تعداد فیلترها و اندازه صفحه |
| هدرها | HSTS، nosniff، X-Frame-Options: DENY، Referrer-Policy، CSP سختگیرانه در nginx |
| XSS | snippetهای highlight بدون dangerouslySetInnerHTML رندر میشوند |
| اسرار | همه از متغیر محیطی؛ settings/prod.py اگر کلید ضعیف یا DEBUG=true ببیند بالا نمیآید |
| کانتینر | کاربر غیرroot، no-new-privileges، فایلسیستم read-only برای nginx، بیلد چندمرحلهای |
| شبکه | Postgres/Redis/Elasticsearch روی شبکهی internal داکر؛ فقط SPA و Kibana منتشر میشوند |
| CI | bandit، pip-audit، npm audit و manage.py check --deploy |
توضیح تصمیمها و trade-offها (از جمله اینکه چرا احراز هویت Elasticsearch در حالت dev خاموش است): docs/SECURITY.md
مانیتورینگ و لاگ (ELK)
هر لاگ یک شیء JSON است، نه یک خط متن. هر رکورد request_id درخواستی که تولیدش کرده را حمل میکند، پس یک کلیک کاربر تا انتها قابل ردیابی است.
{"@timestamp":"2026-09-03T04:00:08Z","level":"INFO","logger":"audit.search",
"event":"search_executed","request_id":"bb3c21c5","user_id":2,
"backend":"postgres","result_count":80,"took_ms":31,
"query":"leadership","filters":{"skills":["management"],"countries":["united states"]},
"sort":"relevance","page":1,"page_size":3}
مسیر: Django → (TCP غیرمسدودکننده) → Logstash → Elasticsearch → Kibana
- ارسال با
python-logstash-asyncانجام میشود: بافر درونحافظهای و ترد جدا، پس قطعی Logstash هیچوقت یک ترد درخواست را بلاک نمیکند (این رفتار در همین ریپو تست شده: با خاموش بودن Logstash فقط یک warning میآید و API سالم میماند). - pipeline لاگها را بر اساس نوع رویداد به ایندکسهای جدا مسیردهی میکند:
app-logs-search-*،app-logs-audit-*،app-logs-error-*،app-logs-app-*— پس «هر چیز قدیمیتر از ۳۰ روز را پاک کن» یک دستور حذف ایندکس است. - سرچ کندتر از ۵۰۰ms برچسب
slow_searchو سرچ بدون نتیجه برچسبzero_resultsمیگیرد. - یک index template قالببندی فیلدها را قفل میکند تا Kibana مجبور به حدس زدن نوع داده نشود.
make kibana-setup را اجرا کنید تا data view ساخته شود، بعد در Discover:
event: "search_executed" and result_count: 0 # کوئریهایی که چیزی پیدا نکردند
event: "search_executed" and took_ms > 200 # سرچهای کند
level: "ERROR" # خطاها با stack trace
request_id: "bb3c21c5..." # ردیابی یک درخواست خاص
جزئیات و runbook: docs/OPERATIONS.md
تست و کیفیت کد
make test # ۶۱ تست بکاند
make lint # ruff + black + mypy + eslint + tsc
make audit # bandit + pip-audit + npm audit
| فایل تست | چه چیزی را تضمین میکند |
|---|---|
test_dataset_repair.py |
هر چهار نقص واقعی فایل CSV درست ترمیم میشوند |
test_importer.py |
نرمالسازی، غنیسازی از داده تودرتو، idempotent بودن |
test_query_builder.py |
کوئری Elasticsearch (تابع خالص — بدون نیاز به کلاستر) |
test_search_api.py |
قرارداد کامل API: کلیدواژه، OR/AND فیلترها، facet، مرتبسازی، صفحهبندی، خطاها |
test_auth_api.py |
ثبتنام، لاگین، سطح دسترسی، ماسک شدن PII بر اساس نقش |
test_indexing.py |
داکیومنت ایندکس با mapping همخوان است و هرگز PII حمل نمیکند |
test_privacy.py |
توابع ماسککردن هیچوقت مقدار اصلی را لو نمیدهند |
تستها روی بکاند PostgreSQL اجرا میشوند و به Elasticsearch نیاز ندارند؛ pipeline گیتهاب (.github/workflows/ci.yml) همینها را بهعلاوه بیلد هر دو ایمیج داکر اجرا میکند.
ساختار پروژه
.
├── backend/
│ ├── config/settings/{base,dev,prod,test}.py تنظیمات تفکیکشده بر اساس محیط
│ ├── apps/
│ │ ├── core/ لاگ ساختیافته، middleware، قالب خطا، throttle، health
│ │ ├── accounts/ کاربر سفارشی، نقشها، JWT
│ │ ├── profiles/ مدل دامنه، خواننده خودترمیم CSV، ایمپورتر، ماسک PII
│ │ └── search/ types، ایندکس ES، دو بکاند سرچ، ویو و سریالایزر
│ ├── tests/ ۶۱ تست
│ ├── Dockerfile بیلد چندمرحلهای، کاربر غیرroot
│ └── entrypoint.sh انتظار برای وابستگیها، migrate، seed
├── frontend/
│ ├── src/
│ │ ├── api/ کلاینت axios با refresh خودکار توکن
│ │ ├── components/ SearchBar، FilterPanel، FacetGroup، ResultCard، Drawer
│ │ ├── hooks/ debounce، همگامسازی وضعیت سرچ با URL
│ │ └── pages/ Login، Search
│ ├── Dockerfile بیلد Node → سرو با nginx
│ └── nginx.conf پراکسی API + هدرهای امنیتی + CSP
├── elk/
│ └── logstash/ pipeline، تنظیمات، index template
├── data/ دیتاست خام CSV
├── docs/ معماری، API، سرچ، داده، امنیت، عملیات، openapi.yaml
├── docker-compose.yml استک کامل
├── docker-compose.dev.yml overlay توسعه (hot reload)
├── docker-compose.prod.yml overlay پروداکشن (auth روی ES، محدودیت منابع)
└── Makefile
مستندات تکمیلی
| سند | محتوا |
|---|---|
docs/ARCHITECTURE.md |
تصمیمهای معماری، مسیر یک درخواست، الگوی Strategy در لایه سرچ |
docs/SEARCH.md |
منطق سرچ و فیلتر، آنالایزرها، رتبهبندی، مقایسه دو بکاند |
docs/API.md |
مرجع کامل endpointها با نمونه درخواست/پاسخ |
docs/DATA.md |
تحلیل کیفیت دیتاست و الگوریتم ترمیم |
docs/SECURITY.md |
مدل تهدید، لایههای دفاعی، trade-offها |
docs/OPERATIONS.md |
اجرا، ELK، runbook خطاهای رایج |