Files
Siro/backend/serviceapp/complaint_ai_suggest.php
T

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");
}