الميزات: إضافة صفحات الأسعار والمدونة، وتفعيل ميزة تقييم الأدوات

- إضافة روابط جديدة في تذييل صفحات الأسعار والمدونة.

- إنشاء مكون صفحة الأسعار لعرض تفاصيل الخطط ومقارنة الميزات.

- تطوير مكون صفحة المدونة لعرض منشورات المدونة مع روابط للمقالات الفردية.

- تقديم مكون تقييم الأدوات لتلقي ملاحظات المستخدمين حول الأدوات، بما في ذلك التقييم بالنجوم والتعليقات الاختيارية.

- تفعيل وظيفة useToolRating لجلب وعرض تقييمات الأدوات.

- تحديث أدوات تحسين محركات البحث لتضمين بيانات التقييم في البيانات المنظمة للأدوات.

- تحسين ملفات i18n بترجمات للميزات والصفحات الجديدة.

- دمج إدارة الموافقة على ملفات تعريف الارتباط لتتبع التحليلات.
This commit is contained in:
Your Name
2026-03-10 15:16:28 +02:00
parent 75e11df5fb
commit a14c31c594
25 changed files with 2194 additions and 16 deletions

View File

@@ -6,6 +6,8 @@ from flask import Flask
from config import config
from app.extensions import cors, limiter, talisman, init_celery
from app.services.account_service import init_account_db
from app.services.rating_service import init_ratings_db
from app.services.ai_cost_service import init_ai_cost_db
def create_app(config_name=None):
@@ -73,6 +75,8 @@ def create_app(config_name=None):
with app.app_context():
init_account_db()
init_ratings_db()
init_ai_cost_db()
# Register blueprints
from app.routes.health import health_bp
@@ -98,6 +102,7 @@ def create_app(config_name=None):
from app.routes.qrcode import qrcode_bp
from app.routes.html_to_pdf import html_to_pdf_bp
from app.routes.pdf_ai import pdf_ai_bp
from app.routes.rating import rating_bp
app.register_blueprint(health_bp, url_prefix="/api")
app.register_blueprint(auth_bp, url_prefix="/api/auth")
@@ -122,5 +127,6 @@ def create_app(config_name=None):
app.register_blueprint(qrcode_bp, url_prefix="/api/qrcode")
app.register_blueprint(html_to_pdf_bp, url_prefix="/api/convert")
app.register_blueprint(pdf_ai_bp, url_prefix="/api/pdf-ai")
app.register_blueprint(rating_bp, url_prefix="/api/ratings")
return app

View File

@@ -3,6 +3,7 @@ from flask import Blueprint, current_app, jsonify, request
from app.extensions import limiter
from app.services.account_service import get_user_by_id, update_user_plan
from app.services.ai_cost_service import get_monthly_spend
admin_bp = Blueprint("admin", __name__)
@@ -37,3 +38,14 @@ def update_plan_route(user_id: int):
return jsonify({"error": str(exc)}), 400
return jsonify({"message": "Plan updated.", "user": updated}), 200
@admin_bp.route("/ai-cost", methods=["GET"])
@limiter.limit("60/hour")
def ai_cost_dashboard():
"""Return the current month's AI spending summary."""
if not _check_admin_secret():
return jsonify({"error": "Unauthorized."}), 401
spend = get_monthly_spend()
return jsonify(spend), 200

View File

@@ -0,0 +1,76 @@
"""Tool ratings routes — collect and serve user feedback per tool."""
from flask import Blueprint, request, jsonify
from app.extensions import limiter
from app.services.rating_service import (
submit_rating,
get_tool_rating_summary,
get_all_ratings_summary,
)
rating_bp = Blueprint("rating", __name__)
@rating_bp.route("/submit", methods=["POST"])
@limiter.limit("30/hour")
def submit_rating_route():
"""
Submit a rating for a tool.
Accepts JSON:
- tool (str): tool slug e.g. "compress-pdf"
- rating (int): 1-5 stars
- feedback (str, optional): short text feedback
- tag (str, optional): predefined tag like "fast", "accurate", "issue"
"""
data = request.get_json(silent=True) or {}
tool = (data.get("tool") or "").strip()
rating = data.get("rating")
feedback = (data.get("feedback") or "").strip()[:500] # max 500 chars
tag = (data.get("tag") or "").strip()[:50]
if not tool:
return jsonify({"error": "Tool slug is required."}), 400
if not isinstance(rating, (int, float)) or not (1 <= int(rating) <= 5):
return jsonify({"error": "Rating must be an integer between 1 and 5."}), 400
rating = int(rating)
fingerprint = _get_fingerprint(request)
submit_rating(
tool=tool,
rating=rating,
feedback=feedback,
tag=tag,
fingerprint=fingerprint,
)
return jsonify({"message": "Thank you for your feedback!"}), 201
@rating_bp.route("/tool/<tool_slug>", methods=["GET"])
@limiter.limit("60/minute")
def get_tool_rating(tool_slug: str):
"""Return the aggregate rating summary for one tool."""
summary = get_tool_rating_summary(tool_slug)
return jsonify(summary)
@rating_bp.route("/all", methods=["GET"])
@limiter.limit("20/minute")
def get_all_ratings():
"""Return rating summaries for all tools."""
summaries = get_all_ratings_summary()
return jsonify({"tools": summaries})
def _get_fingerprint(req) -> str:
"""Build a simple fingerprint from IP + User-Agent to limit duplicate ratings."""
import hashlib
ip = req.remote_addr or "unknown"
ua = req.headers.get("User-Agent", "unknown")
raw = f"{ip}:{ua}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]

View File

@@ -86,6 +86,19 @@ def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict:
if not reply:
reply = "I couldn't generate a response. Please try again."
# Log usage
try:
from app.services.ai_cost_service import log_ai_usage
usage = data.get("usage", {})
log_ai_usage(
tool="flowchart_chat",
model=OPENROUTER_MODEL,
input_tokens=usage.get("prompt_tokens", max(1, len(message) // 4)),
output_tokens=usage.get("completion_tokens", max(1, len(reply) // 4)),
)
except Exception:
pass
return {"reply": reply, "updated_flow": None}
except requests.exceptions.Timeout:

View File

@@ -0,0 +1,131 @@
"""AI cost tracking service — monitors and limits AI API spending."""
import logging
import os
import sqlite3
from datetime import datetime, timezone
from flask import current_app
logger = logging.getLogger(__name__)
# Monthly budget in USD — set via environment variable, default $50
AI_MONTHLY_BUDGET = float(os.getenv("AI_MONTHLY_BUDGET", "50.0"))
# Estimated cost per 1K tokens (adjust based on your model)
COST_PER_1K_INPUT_TOKENS = float(os.getenv("AI_COST_PER_1K_INPUT", "0.0"))
COST_PER_1K_OUTPUT_TOKENS = float(os.getenv("AI_COST_PER_1K_OUTPUT", "0.0"))
def _connect() -> sqlite3.Connection:
db_path = current_app.config["DATABASE_PATH"]
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
return connection
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _current_month() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m")
def init_ai_cost_db():
"""Create AI cost tracking table if not exists."""
with _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS ai_cost_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL DEFAULT 0.0,
period_month TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_cost_period
ON ai_cost_log(period_month);
"""
)
def log_ai_usage(
tool: str,
model: str,
input_tokens: int = 0,
output_tokens: int = 0,
) -> None:
"""Log an AI API call with token usage."""
estimated_cost = (
(input_tokens / 1000.0) * COST_PER_1K_INPUT_TOKENS
+ (output_tokens / 1000.0) * COST_PER_1K_OUTPUT_TOKENS
)
with _connect() as conn:
conn.execute(
"""INSERT INTO ai_cost_log
(tool, model, input_tokens, output_tokens, estimated_cost_usd, period_month, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(tool, model, input_tokens, output_tokens, estimated_cost, _current_month(), _utc_now()),
)
logger.info(
"AI usage: tool=%s model=%s in=%d out=%d cost=$%.4f",
tool, model, input_tokens, output_tokens, estimated_cost,
)
def get_monthly_spend() -> dict:
"""Get the current month's AI spending summary."""
month = _current_month()
with _connect() as conn:
row = conn.execute(
"""SELECT
COUNT(*) as total_calls,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(estimated_cost_usd), 0.0) as total_cost
FROM ai_cost_log
WHERE period_month = ?""",
(month,),
).fetchone()
return {
"period": month,
"total_calls": row["total_calls"],
"total_input_tokens": row["total_input_tokens"],
"total_output_tokens": row["total_output_tokens"],
"total_cost_usd": round(row["total_cost"], 4),
"budget_usd": AI_MONTHLY_BUDGET,
"budget_remaining_usd": round(AI_MONTHLY_BUDGET - row["total_cost"], 4),
"budget_used_percent": round(
(row["total_cost"] / AI_MONTHLY_BUDGET * 100) if AI_MONTHLY_BUDGET > 0 else 0, 1
),
}
def is_budget_exceeded() -> bool:
"""Check if the monthly AI budget has been exceeded."""
spend = get_monthly_spend()
return spend["total_cost_usd"] >= AI_MONTHLY_BUDGET
def check_ai_budget() -> None:
"""Raise an error if AI budget is exceeded. Call before making AI requests."""
if is_budget_exceeded():
raise AiBudgetExceededError(
"Monthly AI processing budget has been reached. Please try again next month."
)
class AiBudgetExceededError(Exception):
"""Raised when the monthly AI budget is exceeded."""
pass

View File

@@ -20,6 +20,11 @@ class PdfAiError(Exception):
pass
def _estimate_tokens(text: str) -> int:
"""Rough token estimate: ~4 chars per token for English."""
return max(1, len(text) // 4)
def _extract_text_from_pdf(input_path: str, max_pages: int = 50) -> str:
"""Extract text content from a PDF file."""
try:
@@ -37,8 +42,22 @@ def _extract_text_from_pdf(input_path: str, max_pages: int = 50) -> str:
raise PdfAiError(f"Failed to extract text from PDF: {str(e)}")
def _call_openrouter(system_prompt: str, user_message: str, max_tokens: int = 1000) -> str:
def _call_openrouter(
system_prompt: str,
user_message: str,
max_tokens: int = 1000,
tool_name: str = "pdf_ai",
) -> str:
"""Send a request to OpenRouter API and return the reply."""
# Budget guard
try:
from app.services.ai_cost_service import check_ai_budget, AiBudgetExceededError
check_ai_budget()
except AiBudgetExceededError:
raise PdfAiError("Monthly AI processing budget has been reached. Please try again next month.")
except Exception:
pass # Don't block if cost service unavailable
if not OPENROUTER_API_KEY:
raise PdfAiError(
"AI service is not configured. Set OPENROUTER_API_KEY environment variable."
@@ -77,6 +96,19 @@ def _call_openrouter(system_prompt: str, user_message: str, max_tokens: int = 10
if not reply:
raise PdfAiError("AI returned an empty response. Please try again.")
# Log usage
try:
from app.services.ai_cost_service import log_ai_usage
usage = data.get("usage", {})
log_ai_usage(
tool=tool_name,
model=OPENROUTER_MODEL,
input_tokens=usage.get("prompt_tokens", _estimate_tokens(user_message)),
output_tokens=usage.get("completion_tokens", _estimate_tokens(reply)),
)
except Exception:
pass # Don't fail the request if logging fails
return reply
except requests.exceptions.Timeout:
@@ -119,7 +151,7 @@ def chat_with_pdf(input_path: str, question: str) -> dict:
)
user_msg = f"Document content:\n{truncated}\n\nQuestion: {question}"
reply = _call_openrouter(system_prompt, user_msg, max_tokens=800)
reply = _call_openrouter(system_prompt, user_msg, max_tokens=800, tool_name="pdf_chat")
page_count = text.count("[Page ")
return {"reply": reply, "pages_analyzed": page_count}
@@ -159,7 +191,7 @@ def summarize_pdf(input_path: str, length: str = "medium") -> dict:
)
user_msg = f"{length_instruction}\n\nDocument content:\n{truncated}"
summary = _call_openrouter(system_prompt, user_msg, max_tokens=1000)
summary = _call_openrouter(system_prompt, user_msg, max_tokens=1000, tool_name="pdf_summarize")
page_count = text.count("[Page ")
return {"summary": summary, "pages_analyzed": page_count}
@@ -195,7 +227,7 @@ def translate_pdf(input_path: str, target_language: str) -> dict:
f"structure as much as possible. Only output the translation, nothing else."
)
translation = _call_openrouter(system_prompt, truncated, max_tokens=2000)
translation = _call_openrouter(system_prompt, truncated, max_tokens=2000, tool_name="pdf_translate")
page_count = text.count("[Page ")
return {

View File

@@ -0,0 +1,137 @@
"""Rating service — stores and aggregates user ratings per tool."""
import logging
import os
import sqlite3
from datetime import datetime, timezone
from flask import current_app
logger = logging.getLogger(__name__)
def _connect() -> sqlite3.Connection:
"""Create a SQLite connection."""
db_path = current_app.config["DATABASE_PATH"]
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
return connection
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_ratings_db():
"""Create ratings table if it does not exist."""
with _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS tool_ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL,
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
feedback TEXT DEFAULT '',
tag TEXT DEFAULT '',
fingerprint TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tool_ratings_tool
ON tool_ratings(tool);
CREATE INDEX IF NOT EXISTS idx_tool_ratings_fingerprint_tool
ON tool_ratings(fingerprint, tool);
"""
)
def submit_rating(
tool: str,
rating: int,
feedback: str = "",
tag: str = "",
fingerprint: str = "",
) -> None:
"""Store a rating. Limits one rating per fingerprint per tool per day."""
now = _utc_now()
today = now[:10] # YYYY-MM-DD
with _connect() as conn:
# Check for duplicate rating from same fingerprint today
existing = conn.execute(
"""SELECT id FROM tool_ratings
WHERE fingerprint = ? AND tool = ? AND created_at LIKE ?
LIMIT 1""",
(fingerprint, tool, f"{today}%"),
).fetchone()
if existing:
# Update existing rating instead of creating duplicate
conn.execute(
"""UPDATE tool_ratings
SET rating = ?, feedback = ?, tag = ?, created_at = ?
WHERE id = ?""",
(rating, feedback, tag, now, existing["id"]),
)
else:
conn.execute(
"""INSERT INTO tool_ratings (tool, rating, feedback, tag, fingerprint, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(tool, rating, feedback, tag, fingerprint, now),
)
def get_tool_rating_summary(tool: str) -> dict:
"""Return aggregate rating data for one tool."""
with _connect() as conn:
row = conn.execute(
"""SELECT
COUNT(*) as count,
COALESCE(AVG(rating), 0) as average,
COALESCE(SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END), 0) as star5,
COALESCE(SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END), 0) as star4,
COALESCE(SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END), 0) as star3,
COALESCE(SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END), 0) as star2,
COALESCE(SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END), 0) as star1
FROM tool_ratings WHERE tool = ?""",
(tool,),
).fetchone()
return {
"tool": tool,
"count": row["count"],
"average": round(row["average"], 1),
"distribution": {
"5": row["star5"],
"4": row["star4"],
"3": row["star3"],
"2": row["star2"],
"1": row["star1"],
},
}
def get_all_ratings_summary() -> list[dict]:
"""Return aggregated ratings for all tools that have at least one rating."""
with _connect() as conn:
rows = conn.execute(
"""SELECT
tool,
COUNT(*) as count,
COALESCE(AVG(rating), 0) as average
FROM tool_ratings
GROUP BY tool
ORDER BY count DESC"""
).fetchall()
return [
{
"tool": row["tool"],
"count": row["count"],
"average": round(row["average"], 1),
}
for row in rows
]

Binary file not shown.

View File

@@ -6,6 +6,8 @@ import pytest
from unittest.mock import patch, MagicMock
from app import create_app
from app.services.account_service import init_account_db
from app.services.rating_service import init_ratings_db
from app.services.ai_cost_service import init_ai_cost_db
@pytest.fixture
@@ -29,6 +31,8 @@ def app():
})
with app.app_context():
init_account_db()
init_ratings_db()
init_ai_cost_db()
# Create temp directories
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

View File

@@ -0,0 +1,89 @@
"""Tests for the rating API endpoints."""
import json
import pytest
class TestRatingRoutes:
"""Tests for /api/ratings endpoints."""
def test_submit_rating_success(self, client):
"""POST /api/ratings/submit — valid rating."""
resp = client.post(
"/api/ratings/submit",
json={"tool": "compress-pdf", "rating": 5, "tag": "fast"},
)
assert resp.status_code == 201
data = resp.get_json()
assert "message" in data
def test_submit_rating_missing_tool(self, client):
"""POST /api/ratings/submit — missing tool."""
resp = client.post(
"/api/ratings/submit",
json={"rating": 4},
)
assert resp.status_code == 400
def test_submit_rating_invalid_score(self, client):
"""POST /api/ratings/submit — score out of range."""
resp = client.post(
"/api/ratings/submit",
json={"tool": "merge-pdf", "rating": 0},
)
assert resp.status_code == 400
def test_submit_rating_score_too_high(self, client):
"""POST /api/ratings/submit — score > 5."""
resp = client.post(
"/api/ratings/submit",
json={"tool": "merge-pdf", "rating": 6},
)
assert resp.status_code == 400
def test_get_tool_rating(self, client):
"""GET /api/ratings/tool/<slug> — returns summary."""
# Submit a rating first
client.post(
"/api/ratings/submit",
json={"tool": "split-pdf", "rating": 4},
)
resp = client.get("/api/ratings/tool/split-pdf")
assert resp.status_code == 200
data = resp.get_json()
assert data["tool"] == "split-pdf"
assert data["count"] >= 1
assert 1 <= data["average"] <= 5
def test_get_all_ratings(self, client):
"""GET /api/ratings/all — returns all tool summaries."""
client.post(
"/api/ratings/submit",
json={"tool": "ocr", "rating": 5},
)
resp = client.get("/api/ratings/all")
assert resp.status_code == 200
data = resp.get_json()
assert "tools" in data
assert len(data["tools"]) >= 1
def test_get_tool_rating_no_data(self, client):
"""GET /api/ratings/tool/<slug> — tool with no ratings."""
resp = client.get("/api/ratings/tool/nonexistent-tool")
assert resp.status_code == 200
data = resp.get_json()
assert data["count"] == 0
assert data["average"] == 0
def test_submit_rating_with_feedback(self, client):
"""POST /api/ratings/submit — with text feedback."""
resp = client.post(
"/api/ratings/submit",
json={
"tool": "pdf-editor",
"rating": 3,
"tag": "issue",
"feedback": "The editor was a bit slow but worked.",
},
)
assert resp.status_code == 201