chore: pre-phase-1 cleanup - OpenRouter config fallback and PLAN.md
This commit is contained in:
@@ -10,6 +10,11 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
CELERY_BROKER_URL=redis://redis:6379/0
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
CELERY_RESULT_BACKEND=redis://redis:6379/1
|
CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||||
|
|
||||||
|
# OpenRouter AI
|
||||||
|
OPENROUTER_API_KEY=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
|
||||||
|
OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free
|
||||||
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions
|
||||||
|
|
||||||
# AWS S3
|
# AWS S3
|
||||||
AWS_ACCESS_KEY_ID=your-access-key
|
AWS_ACCESS_KEY_ID=your-access-key
|
||||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ cd SaaS-PDF
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
cp frontend/.env.example frontend/.env
|
cp frontend/.env.example frontend/.env
|
||||||
|
|
||||||
|
# For AI tools like Chat with PDF, set your OpenRouter credentials in .env
|
||||||
|
# OPENROUTER_API_KEY=your-openrouter-key
|
||||||
|
|
||||||
# 3. Start all services with Docker
|
# 3. Start all services with Docker
|
||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from dotenv import dotenv_values
|
||||||
from flask import current_app, has_app_context
|
from flask import current_app, has_app_context
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_OPENROUTER_MODEL = "stepfun/step-3.5-flash:free"
|
DEFAULT_OPENROUTER_MODEL = "nvidia/nemotron-3-super-120b-a12b:free"
|
||||||
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
|
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
@@ -16,22 +17,71 @@ class OpenRouterSettings:
|
|||||||
base_url: str
|
base_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv_settings() -> dict[str, str]:
|
||||||
|
"""Read .env values directly so workers can recover from blank in-app config."""
|
||||||
|
service_dir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
backend_dir = os.path.abspath(os.path.join(service_dir, "..", ".."))
|
||||||
|
repo_root = os.path.abspath(os.path.join(backend_dir, ".."))
|
||||||
|
|
||||||
|
settings: dict[str, str] = {}
|
||||||
|
for env_path in (os.path.join(repo_root, ".env"), os.path.join(backend_dir, ".env")):
|
||||||
|
if not os.path.exists(env_path):
|
||||||
|
continue
|
||||||
|
for key, value in dotenv_values(env_path).items():
|
||||||
|
if value is not None:
|
||||||
|
settings[key] = value.strip()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _first_non_empty(*values: str, default: str = "") -> str:
|
||||||
|
"""Return the first non-empty string value, or the provided default."""
|
||||||
|
for value in values:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def get_openrouter_settings() -> OpenRouterSettings:
|
def get_openrouter_settings() -> OpenRouterSettings:
|
||||||
"""Return the effective OpenRouter settings for the current execution context."""
|
"""Return the effective OpenRouter settings for the current execution context."""
|
||||||
|
dotenv_settings = _load_dotenv_settings()
|
||||||
|
env_api_key = os.getenv("OPENROUTER_API_KEY", "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")
|
||||||
|
env_model = os.getenv("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL)
|
||||||
|
env_base_url = os.getenv("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL)
|
||||||
|
|
||||||
if has_app_context():
|
if has_app_context():
|
||||||
api_key = str(current_app.config.get("OPENROUTER_API_KEY", "")).strip()
|
api_key = _first_non_empty(
|
||||||
model = str(
|
current_app.config.get("OPENROUTER_API_KEY", "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"),
|
||||||
current_app.config.get("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL)
|
env_api_key,
|
||||||
).strip() or DEFAULT_OPENROUTER_MODEL
|
dotenv_settings.get("OPENROUTER_API_KEY", "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"),
|
||||||
base_url = str(
|
)
|
||||||
current_app.config.get("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL)
|
model = _first_non_empty(
|
||||||
).strip() or DEFAULT_OPENROUTER_BASE_URL
|
current_app.config.get("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL),
|
||||||
|
env_model,
|
||||||
|
dotenv_settings.get("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL),
|
||||||
|
default=DEFAULT_OPENROUTER_MODEL,
|
||||||
|
)
|
||||||
|
base_url = _first_non_empty(
|
||||||
|
current_app.config.get("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL),
|
||||||
|
env_base_url,
|
||||||
|
dotenv_settings.get("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL),
|
||||||
|
default=DEFAULT_OPENROUTER_BASE_URL,
|
||||||
|
)
|
||||||
return OpenRouterSettings(api_key=api_key, model=model, base_url=base_url)
|
return OpenRouterSettings(api_key=api_key, model=model, base_url=base_url)
|
||||||
|
|
||||||
return OpenRouterSettings(
|
return OpenRouterSettings(
|
||||||
api_key=os.getenv("OPENROUTER_API_KEY", "").strip(),
|
api_key=_first_non_empty(
|
||||||
model=os.getenv("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL).strip()
|
env_api_key,
|
||||||
or DEFAULT_OPENROUTER_MODEL,
|
dotenv_settings.get("OPENROUTER_API_KEY", ""),
|
||||||
base_url=os.getenv("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL).strip()
|
),
|
||||||
or DEFAULT_OPENROUTER_BASE_URL,
|
model=_first_non_empty(
|
||||||
|
env_model,
|
||||||
|
dotenv_settings.get("OPENROUTER_MODEL", DEFAULT_OPENROUTER_MODEL),
|
||||||
|
default=DEFAULT_OPENROUTER_MODEL,
|
||||||
|
),
|
||||||
|
base_url=_first_non_empty(
|
||||||
|
env_base_url,
|
||||||
|
dotenv_settings.get("OPENROUTER_BASE_URL", DEFAULT_OPENROUTER_BASE_URL),
|
||||||
|
default=DEFAULT_OPENROUTER_BASE_URL,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Binary file not shown.
@@ -2,9 +2,12 @@ import os
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
REPO_ROOT = os.path.abspath(os.path.join(BASE_DIR, ".."))
|
||||||
|
|
||||||
|
# Load the repository-level .env first because the documented setup stores it there.
|
||||||
|
load_dotenv(os.path.join(REPO_ROOT, ".env"))
|
||||||
|
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
|
||||||
|
|
||||||
|
|
||||||
class BaseConfig:
|
class BaseConfig:
|
||||||
@@ -80,8 +83,8 @@ class BaseConfig:
|
|||||||
RATELIMIT_DEFAULT = "100/hour"
|
RATELIMIT_DEFAULT = "100/hour"
|
||||||
|
|
||||||
# OpenRouter AI
|
# OpenRouter AI
|
||||||
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "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")
|
||||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "stepfun/step-3.5-flash:free")
|
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "nvidia/nemotron-3-super-120b-a12b:free")
|
||||||
OPENROUTER_BASE_URL = os.getenv(
|
OPENROUTER_BASE_URL = os.getenv(
|
||||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"
|
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ class TestOpenRouterConfigService:
|
|||||||
assert settings.model == 'config-model'
|
assert settings.model == 'config-model'
|
||||||
assert settings.base_url == 'https://config.example/api'
|
assert settings.base_url == 'https://config.example/api'
|
||||||
|
|
||||||
|
def test_falls_back_to_environment_when_flask_config_is_blank(self, app, monkeypatch):
|
||||||
|
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
|
||||||
|
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
|
||||||
|
monkeypatch.setenv('OPENROUTER_BASE_URL', 'https://env.example/api')
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
app.config.update({
|
||||||
|
'OPENROUTER_API_KEY': ' ',
|
||||||
|
'OPENROUTER_MODEL': '',
|
||||||
|
'OPENROUTER_BASE_URL': ' ',
|
||||||
|
})
|
||||||
|
settings = get_openrouter_settings()
|
||||||
|
|
||||||
|
assert settings.api_key == 'env-key'
|
||||||
|
assert settings.model == 'env-model'
|
||||||
|
assert settings.base_url == 'https://env.example/api'
|
||||||
|
|
||||||
def test_falls_back_to_environment_without_app_context(self, monkeypatch):
|
def test_falls_back_to_environment_without_app_context(self, monkeypatch):
|
||||||
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
|
monkeypatch.setenv('OPENROUTER_API_KEY', 'env-key')
|
||||||
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
|
monkeypatch.setenv('OPENROUTER_MODEL', 'env-model')
|
||||||
|
|||||||
43
docs/PLAN.md
Normal file
43
docs/PLAN.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# خطة رفع SaaS-PDF إلى مستوى المنافسة
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- اعتماد مسار نمو واضح: `B2C SEO-first` أولاً، ثم `Freemium monetization`، ثم `B2B API expansion`.
|
||||||
|
- الحفاظ على المعمارية الحالية `Flask + React + Celery` لأنها سليمة وتغطي المنتج الحالي جيداً.
|
||||||
|
- تنفيذ الخطة على 4 مراحل متتابعة، لا بالتوازي، لتجنب التشتت.
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
- المرحلة 1: تثبيت الجاهزية الإنتاجية خلال 2-3 أسابيع.
|
||||||
|
- استبدال كل قيم `yourdomain.com` وإغلاق إعدادات SEO النهائية.
|
||||||
|
- تحويل صفحة التواصل إلى backend endpoint مع تخزين وتتبّع ورسائل بريد حقيقية.
|
||||||
|
- تفعيل Stripe وربط خطتي `Free/Pro` فعلياً بدل صفحة تسويقية فقط.
|
||||||
|
- نقل قاعدة البيانات من SQLite إلى PostgreSQL في الإنتاج مع إبقاء SQLite للتطوير فقط.
|
||||||
|
- إضافة مراقبة تشغيل: Sentry أو بديل، وقياسات للمهام والصفوف والفشل.
|
||||||
|
- المرحلة 2: سد فجوة المنافسين خلال 4-6 أسابيع.
|
||||||
|
- تنفيذ `Sign PDF`, `PDF to PowerPoint`, `Excel to PDF`, `PowerPoint to PDF`.
|
||||||
|
- تنفيذ `Crop PDF`, `Flatten PDF`, `Repair PDF`, `PDF Metadata Editor`.
|
||||||
|
- تنفيذ `Image Crop`, `Image Rotate/Flip`, `Barcode Generator`.
|
||||||
|
- توسيع `v1 API` لتشمل الأدوات الجديدة والأدوات الحالية غير المغطاة مثل OCR وRemove BG وPDF AI وPDF to Excel وHTML to PDF وQR.
|
||||||
|
- المرحلة 3: تحويل SEO من “جاهز تقنياً” إلى “محرك نمو” خلال 3-4 أسابيع.
|
||||||
|
- استبدال صفحة `Blog` الثابتة بنظام نشر فعلي مع صفحات مقالات قابلة للفهرسة.
|
||||||
|
- اعتماد `hreflang` حقيقي للغات الثلاث.
|
||||||
|
- إنشاء cluster pages وcomparison pages وprogrammatic pages لأزواج التحويل الأعلى طلباً.
|
||||||
|
- إضافة internal search و“suggested next tool” بعد كل عملية لرفع الصفحات/جلسة.
|
||||||
|
- المرحلة 4: رفع الثقة والتحويل خلال 3-4 أسابيع.
|
||||||
|
- إضافة dashboard إداري بسيط لمتابعة الاستخدام، الأدوات الأعلى طلباً، والأخطاء.
|
||||||
|
- عرض social proof: عدد الملفات المعالجة، التقييمات، والسرعة.
|
||||||
|
- تحسين الأداء في الحزم الثقيلة وتقليل أثر `pdf.worker` والحزم الكبيرة على LCP.
|
||||||
|
- إضافة onboarding أوضح للحسابات وAPI docs وصفحة مطورين مستقلة.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
- تشغيل suite الاختبارات الكامل للخلفية والواجهة قبل كل إصدار.
|
||||||
|
- إضافة smoke tests لكل أداة جديدة: web flow + task status + download.
|
||||||
|
- اختبار أوضاع التخزين `local` و`S3`.
|
||||||
|
- اختبار فروقات الخطط: anonymous مقابل free مقابل pro.
|
||||||
|
- اختبار funnel الدفع والترقية وقيود الحصص ومفاتيح API.
|
||||||
|
- التحقق من SEO عبر sitemap, canonical, robots, hreflang, schema, Lighthouse.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
- الأولوية التجارية هي النمو العضوي والمنتج العام قبل بيع API على نطاق واسع.
|
||||||
|
- الخطة تبقي المشروع متعدد اللغات `AR/EN/FR` كميزة تنافسية رئيسية.
|
||||||
|
- الأدوات الحالية لا تُعاد كتابتها؛ يتم التطوير فوق البنية الحالية تدريجياً.
|
||||||
|
- المعيار المستهدف للمنافسة هو: ثبات إنتاجي، funnel مدفوع فعلي، تغطية أدوات أوسع، وSEO تشغيلي حقيقي لا شكلي.
|
||||||
Reference in New Issue
Block a user