224 lines
11 KiB
PHP
224 lines
11 KiB
PHP
<?php
|
|
// ============================================================
|
|
// serviceapp/complaint_ai_suggest.php — مستشار الشكاوى
|
|
//
|
|
// يقرأ الشكوى وسياق رحلتها ويقترح على الموظف: تصنيف السبب، تحديد
|
|
// المسؤولية، مبلغ تعويض، ومسودة ردّ للعميل.
|
|
//
|
|
// ⚠️ اقتراح لا قرار. لا يُغلق شكوى ولا يصرف ديناراً — الموظف يراجع ثم
|
|
// يضغط في resolve_complaint.php. نموذج لغوي يقرّر صرف المال وحده هو
|
|
// أسرع طريق لكارثة تشغيلية، ولأن يتعلّم أحدهم صياغة شكوى تُخرج مالاً.
|
|
//
|
|
// يبني على core/Services/SiroGeminiService.php القائم (نفس المفتاح
|
|
// ونفس آلية النداء المستخدمة في محرك التسويق والتسعير).
|
|
// ============================================================
|
|
|
|
require_once __DIR__ . '/../connect.php';
|
|
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
|
require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
|
// نفس دوال السياق التي يستخدمها add_solve_all.php وقت تقديم الشكوى:
|
|
// تقييمات الطرفين، تعليقات من ركبوا معه/معها، وسلوك القيادة في الرحلة.
|
|
require_once __DIR__ . '/../ride/feedBack/complaint_context.php';
|
|
|
|
$complaintId = filterRequest("complaint_id", 'int');
|
|
|
|
if (!$complaintId) {
|
|
jsonError("Missing complaint_id");
|
|
}
|
|
|
|
try {
|
|
$stmt = $con->prepare("SELECT * FROM complaint WHERE id = ? LIMIT 1");
|
|
$stmt->execute([$complaintId]);
|
|
$complaint = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$complaint) {
|
|
jsonError("Complaint not found", 404);
|
|
}
|
|
|
|
$rideId = $complaint['ride_id'] ?? null;
|
|
|
|
// ── سياق الرحلة ─────────────────────────────────────────
|
|
// بدونه النموذج يخمّن. مع السعر والمسافة والحالة يصير الاقتراح مبنياً
|
|
// على وقائع.
|
|
$ride = [];
|
|
$currency = 'SYP';
|
|
$hardCap = 0.0;
|
|
|
|
if ($rideId) {
|
|
$stmtRide = $con->prepare("SELECT * FROM ride WHERE id = ? LIMIT 1");
|
|
$stmtRide->execute([$rideId]);
|
|
$ride = $stmtRide->fetch(PDO::FETCH_ASSOC) ?: [];
|
|
|
|
$kazan = getKazanForRide($con, $rideId);
|
|
$currency = !empty($kazan['currency'])
|
|
? (string) $kazan['currency']
|
|
: getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria'));
|
|
}
|
|
$hardCap = getMoneyHardCap($currency);
|
|
|
|
// ── سوابق الطرفين ───────────────────────────────────────
|
|
// شكوى أولى من راكب مختلفة عن الخامسة. والسائق الذي تكررت شكاواه
|
|
// مختلف عمّن يُشتكى عليه أول مرة.
|
|
$history = ['passenger_complaints' => 0, 'driver_complaints' => 0];
|
|
try {
|
|
if (!empty($complaint['passenger_id'])) {
|
|
$st = $con->prepare("SELECT COUNT(*) FROM complaint
|
|
WHERE passenger_id = ? AND id <> ?");
|
|
$st->execute([$complaint['passenger_id'], $complaintId]);
|
|
$history['passenger_complaints'] = (int) $st->fetchColumn();
|
|
}
|
|
if (!empty($complaint['driver_id'])) {
|
|
$st = $con->prepare("SELECT COUNT(*) FROM complaint
|
|
WHERE driver_id = ? AND id <> ?");
|
|
$st->execute([$complaint['driver_id'], $complaintId]);
|
|
$history['driver_complaints'] = (int) $st->fetchColumn();
|
|
}
|
|
} catch (PDOException $e) {
|
|
error_log("[complaint/ai] تعذّر جلب السوابق: " . $e->getMessage());
|
|
}
|
|
|
|
// ── بناء المُوجِّه ──────────────────────────────────────
|
|
// لا نمرّر أسماء ولا هواتف ولا بريداً: النموذج خدمة خارجية، وما يخرج
|
|
// من الخادم لا يعود. المعرّفات وحدها تكفي للتحليل.
|
|
// الملفان الكاملان: تقييم كل طرف، وتعليقات من تعاملوا معه سابقاً.
|
|
// هذا ما يجعل النموذج يعرف "من هؤلاء" لا الشكوى وحدها — راكب تكررت
|
|
// شكاواه الكيدية مختلف عن راكب أول شكوى له، والسائق كذلك.
|
|
$driverProfile = !empty($complaint['driver_id'])
|
|
? getDriverFullProfile($con, $encryptionHelper ?? null, $complaint['driver_id'])
|
|
: null;
|
|
$passengerProfile = !empty($complaint['passenger_id'])
|
|
? getPassengerFullProfile($con, $encryptionHelper ?? null, $complaint['passenger_id'])
|
|
: null;
|
|
$driverBehavior = ($rideId && !empty($complaint['driver_id']))
|
|
? getDriverBehavior($con, $rideId, $complaint['driver_id'])
|
|
: null;
|
|
|
|
// نزع الأسماء: الملفات تحمل first_name/last_name، والنموذج خدمة
|
|
// خارجية. التقييمات والتعليقات تكفي للتحليل بلا هوية.
|
|
foreach ([&$driverProfile, &$passengerProfile] as &$pr) {
|
|
if (is_array($pr) && isset($pr['info']) && is_array($pr['info'])) {
|
|
unset($pr['info']['first_name'], $pr['info']['last_name']);
|
|
}
|
|
}
|
|
unset($pr);
|
|
|
|
$context = [
|
|
'complaint' => [
|
|
'type' => $complaint['complaint_type'] ?? '',
|
|
'description' => mb_substr((string) ($complaint['description'] ?? ''), 0, 1500),
|
|
'passenger_report' => mb_substr((string) ($complaint['passenger_report'] ?? ''), 0, 800),
|
|
'driver_report' => mb_substr((string) ($complaint['driver_report'] ?? ''), 0, 800),
|
|
'prior_ai_solutions' => mb_substr((string) ($complaint['cs_solutions'] ?? ''), 0, 800),
|
|
'nature' => $complaint['complaint_nature'] ?? null,
|
|
'filed_at' => $complaint['date_filed'] ?? '',
|
|
],
|
|
'ride' => [
|
|
'status' => $ride['status'] ?? null,
|
|
'price' => $ride['price'] ?? null,
|
|
'distance_km' => $ride['distance'] ?? null,
|
|
'car_type' => $ride['carType'] ?? $ride['car_type'] ?? null,
|
|
],
|
|
'driver_profile' => $driverProfile,
|
|
'passenger_profile' => $passengerProfile,
|
|
'driver_behavior' => $driverBehavior,
|
|
'complaint_counts' => $history,
|
|
'currency' => $currency,
|
|
];
|
|
|
|
// التحويل قبل الـheredoc: استدعاء دالة داخله غير مسموح، و$this
|
|
// لا وجود له في سكربت إجرائي (خطأ قاتل لا يلتقطه php -l).
|
|
$contextJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
|
|
|
$reasons = implode(', ', [
|
|
'driver_behavior', 'passenger_behavior', 'overcharge', 'route_issue',
|
|
'no_show', 'vehicle_condition', 'app_issue', 'payment_issue', 'safety', 'other',
|
|
]);
|
|
|
|
$prompt = <<<PROMPT
|
|
أنت مساعد لموظف خدمة عملاء في تطبيق نقل ركّاب اسمه "سيرو".
|
|
مهمتك اقتراح تسوية لشكوى، لا اتخاذ القرار — الموظف هو من يقرّر.
|
|
|
|
بيانات الحالة (JSON):
|
|
{$contextJson}
|
|
|
|
قواعد ملزمة:
|
|
1. reason_code يجب أن يكون واحداً من: {$reasons}
|
|
2. المبلغ بعملة {$currency} ولا يتجاوز {$hardCap} إطلاقاً. هذا سقف صغير
|
|
عمداً: التعويض استرضاء لا تسوية نزاع. ما يستحق أكثر يُرفع للإدارة.
|
|
3. إن لم تكن المسؤولية واضحة من البيانات، اجعل المبلغ 0 واطلب توضيحاً.
|
|
وإن بدت الشكوى كيدية (سوابق الراكب وتقييماته تدل)، اجعله 0 وبيّن ذلك.
|
|
4. نوع التعويض: refund إن كان استرجاعاً لقيمة رحلة، أو goodwill إن كان استرضاءً.
|
|
5. الرد المقترَح للعميل بالعربية، مهذّب ومختصر، بلا وعود خارج ما اقترحته.
|
|
|
|
أجب بـ JSON فقط، بلا أي نص خارجه، بهذا الشكل:
|
|
{
|
|
"reason_code": "...",
|
|
"fault_determination": "driver | passenger | company | unclear",
|
|
"suggested_status": "In Progress | Resolved",
|
|
"compensation": {"amount": 0, "kind": "refund|goodwill", "beneficiary": "passenger|driver"},
|
|
"customer_reply": "...",
|
|
"internal_note": "...",
|
|
"confidence": 0.0
|
|
}
|
|
PROMPT;
|
|
|
|
// ── النداء ──────────────────────────────────────────────
|
|
$gemini = new SiroGeminiService();
|
|
$model = getenv('GEMINI_COMPLAINT_MODEL') ?: 'gemini-flash-lite-latest';
|
|
$raw = $gemini->callGemini($prompt, $model);
|
|
|
|
$text = $raw['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
|
|
|
if (!$text) {
|
|
// غياب الاقتراح لا يعطّل الموظف — الشاشة تعمل بدونه.
|
|
error_log("[complaint/ai] لا رد من النموذج للشكوى #$complaintId");
|
|
jsonSuccess(['available' => false], "AI suggestion unavailable");
|
|
}
|
|
|
|
// النموذج يغلّف JSON بأسوار ```json أحياناً رغم التعليمات.
|
|
$clean = trim(preg_replace('/^```(?:json)?|```$/mu', '', $text));
|
|
$parsed = json_decode($clean, true);
|
|
|
|
if (!is_array($parsed)) {
|
|
error_log("[complaint/ai] رد غير قابل للتحليل للشكوى #$complaintId: "
|
|
. mb_substr($clean, 0, 200));
|
|
jsonSuccess(['available' => false], "AI suggestion unparsable");
|
|
}
|
|
|
|
// ── تحصين المخرجات ─────────────────────────────────────
|
|
// النموذج قد يتجاهل القواعد. لا نمرّر رقماً منه إلى واجهة تصرف مالاً
|
|
// دون قصّه هنا أولاً.
|
|
$amount = (float) ($parsed['compensation']['amount'] ?? 0);
|
|
$amount = max(0.0, min($amount, $hardCap));
|
|
|
|
$kind = $parsed['compensation']['kind'] ?? 'goodwill';
|
|
if (!in_array($kind, ['refund', 'goodwill'], true)) $kind = 'goodwill';
|
|
|
|
$beneficiary = $parsed['compensation']['beneficiary'] ?? 'passenger';
|
|
if (!in_array($beneficiary, ['passenger', 'driver'], true)) $beneficiary = 'passenger';
|
|
|
|
jsonSuccess([
|
|
'available' => true,
|
|
'reason_code' => $parsed['reason_code'] ?? null,
|
|
'fault_determination' => $parsed['fault_determination'] ?? 'unclear',
|
|
'suggested_status' => $parsed['suggested_status'] ?? 'In Progress',
|
|
'compensation' => [
|
|
'amount' => $amount,
|
|
'kind' => $kind,
|
|
'beneficiary' => $beneficiary,
|
|
'currency' => $currency,
|
|
'hard_cap' => $hardCap,
|
|
],
|
|
'customer_reply' => $parsed['customer_reply'] ?? '',
|
|
'internal_note' => $parsed['internal_note'] ?? '',
|
|
'confidence' => (float) ($parsed['confidence'] ?? 0),
|
|
], "ok");
|
|
|
|
} catch (PDOException $e) {
|
|
error_log("[complaint/ai] " . $e->getMessage());
|
|
jsonError("DB Error", 500);
|
|
} catch (Throwable $e) {
|
|
error_log("[complaint/ai] " . $e->getMessage());
|
|
jsonSuccess(['available' => false], "AI suggestion unavailable");
|
|
}
|