الميزات: إضافة صفحات الأسعار والمدونة، وتفعيل ميزة تقييم الأدوات
- إضافة روابط جديدة في تذييل صفحات الأسعار والمدونة. - إنشاء مكون صفحة الأسعار لعرض تفاصيل الخطط ومقارنة الميزات. - تطوير مكون صفحة المدونة لعرض منشورات المدونة مع روابط للمقالات الفردية. - تقديم مكون تقييم الأدوات لتلقي ملاحظات المستخدمين حول الأدوات، بما في ذلك التقييم بالنجوم والتعليقات الاختيارية. - تفعيل وظيفة useToolRating لجلب وعرض تقييمات الأدوات. - تحديث أدوات تحسين محركات البحث لتضمين بيانات التقييم في البيانات المنظمة للأدوات. - تحسين ملفات i18n بترجمات للميزات والصفحات الجديدة. - دمج إدارة الموافقة على ملفات تعريف الارتباط لتتبع التحليلات.
This commit is contained in:
@@ -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:
|
||||
|
||||
131
backend/app/services/ai_cost_service.py
Normal file
131
backend/app/services/ai_cost_service.py
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
137
backend/app/services/rating_service.py
Normal file
137
backend/app/services/rating_service.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user