HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Services/Segment/SegmentInsightService.php
<?php

namespace App\Services\Segment;

use App\Library\Cache\AppCache;
use App\Model\Field;
use App\Model\MailList;
use App\Model\Segment;
use App\Model\SegmentCondition;
use Illuminate\Support\Collection;
use Throwable;

/**
 * F4.2 — read-side single source of truth for the 4 Segment Coach tools
 * (`segment.explain_operator` / `segment.preview_count` / `segment.test_rules` /
 * `segment.build_from_nl`). Every method is read / preview-only — no DB
 * mutation. Apply happens in the legacy wizard at
 * `/rui/lists/{uid}/segments/create` after the user reviews the agent's
 * proposal (DEC-F4.2-1).
 *
 * Design (P11):
 *   - `previewCount()` wraps `Segment::subscribersCount()` + AppCache to give
 *     the agent a fast read with cache_hit telemetry.
 *   - `testRules()` instantiates an UNSAVED `Segment` + `SegmentCondition`
 *     models, wires their relations in-memory, and calls the canonical
 *     `Segment::subscribers()` pipeline so the agent's count uses the EXACT
 *     SQL the wizard's apply path will use.
 *   - `buildFromNaturalLanguage()` is a deterministic heuristic translator —
 *     phrase tokens → operator-catalog matches → conditions tree. NO nested
 *     LLM call (F4.2-S3 invariant).
 *   - `explainOperator()` is a wrapper around `SegmentOperatorCatalog::get()`.
 */
class SegmentInsightService
{
    /** Cap on `testRules` sample size (F4.2-S4). */
    private const SAMPLE_LIMIT = 5;

    /** Cap on `buildFromNaturalLanguage` input length to bound regex work. */
    private const NL_INPUT_MAX = 1000;

    /**
     * Look up an operator's metadata. Returns the same shape the
     * `segment.explain_operator` tool surfaces verbatim.
     *
     * @return array{operator:string, family:string, description:string, accepts:string, applies_to:list<string>, examples:list<string>}|null
     */
    public function explainOperator(string $operatorCode, ?string $fieldType = null): ?array
    {
        $meta = SegmentOperatorCatalog::get($operatorCode);
        if ($meta === null) {
            return null;
        }

        if ($fieldType !== null && $fieldType !== '' && ! in_array($fieldType, $meta['applies_to'], true)) {
            // Caller asked about a specific field type; surface mismatch as a
            // soft note rather than a hard fail. Tool can echo the warning.
            $meta['mismatch_warning'] = sprintf(
                "Operator `%s` does not normally apply to field type `%s`. Allowed: %s.",
                $operatorCode,
                $fieldType,
                implode(', ', $meta['applies_to']),
            );
        }

        return [
            'operator' => $operatorCode,
            'family' => $meta['family'],
            'description' => $meta['description'],
            'accepts' => $meta['accepts'],
            'applies_to' => $meta['applies_to'],
            'examples' => $meta['examples'],
        ] + (isset($meta['mismatch_warning']) ? ['mismatch_warning' => $meta['mismatch_warning']] : []);
    }

    /**
     * Count + last-updated for a saved segment. Reads from `AppCache` first
     * (cache_hit=true) and falls back to a live SQL count.
     *
     * @return array{segment_uid:string, name:string, list_uid:?string, count:int, last_updated:?string, cache_hit:bool}
     */
    public function previewCount(Segment $segment): array
    {
        $cacheHit = false;
        $count = null;

        try {
            $cached = AppCache::for($segment)->read('SubscriberCount');
            if (is_int($cached)) {
                $count = $cached;
                $cacheHit = true;
            } elseif (is_numeric($cached)) {
                $count = (int) $cached;
                $cacheHit = true;
            }
        } catch (Throwable) {
            // Cache miss / read failure — drop to live query.
        }

        if ($count === null) {
            try {
                $count = (int) $segment->subscribersCount(false);
            } catch (Throwable) {
                $count = 0;
            }
        }

        $listUid = null;
        try {
            // Use getRelationValue() to avoid PHPStan magic-property complaints
            // — Eloquent BelongsTo doesn't surface as a typed property.
            $list = $segment->getRelationValue('mailList');
            if ($list instanceof MailList) {
                $listUid = (string) $list->uid;
            }
        } catch (Throwable) {
            // Relation may be unloaded; surface null.
        }

        return [
            'segment_uid' => (string) $segment->uid,
            'name' => (string) ($segment->name ?? ''),
            'list_uid' => $listUid,
            'count' => $count,
            'last_updated' => $segment->updated_at?->toIso8601String(),
            'cache_hit' => $cacheHit,
        ];
    }

    /**
     * Run an ad-hoc rules tree against a list WITHOUT persisting. Mirrors the
     * wizard's apply path exactly — same SQL pipeline, same field resolution,
     * same match-mode semantics.
     *
     * @param  list<array{field_id?:string,operator:string,value?:mixed}>  $conditions
     * @return array{count:int, sample:list<array{uid:string,email:string,first_name:?string,signup_date:?string}>, applies_in_ms:int, validation_warnings:list<string>}
     */
    public function testRules(MailList $list, array $conditions, string $matching = 'all'): array
    {
        $matching = in_array($matching, ['all', 'any'], true) ? $matching : 'all';
        $warnings = [];

        $segment = new Segment();
        $segment->mail_list_id = $list->id;
        $segment->customer_id = $list->customer_id;
        $segment->matching = $matching;
        $segment->setRelation('mailList', $list);

        $hydrated = collect();
        foreach ($conditions as $idx => $raw) {
            if (! is_array($raw)) {
                $warnings[] = sprintf('Condition #%d skipped: not an object.', $idx);
                continue;
            }
            $operator = is_string($raw['operator'] ?? null) ? trim((string) $raw['operator']) : '';
            if ($operator === '' || SegmentOperatorCatalog::get($operator) === null) {
                $warnings[] = sprintf('Condition #%d skipped: unknown operator `%s`.', $idx, $operator);
                continue;
            }

            $cond = new SegmentCondition();
            $cond->customer_id = $list->customer_id;
            $cond->operator = $operator;

            $rawValue = $raw['value'] ?? null;
            if (is_array($rawValue) || is_object($rawValue)) {
                $warnings[] = sprintf('Condition #%d value coerced to string: arrays/objects are not supported.', $idx);
                $rawValue = json_encode($rawValue);
            }
            $cond->value = $rawValue === null ? '' : (string) $rawValue;

            $rawField = is_string($raw['field_id'] ?? null) ? trim((string) $raw['field_id']) : '';
            if ($rawField !== '') {
                $field = $this->resolveField($rawField, $list);
                if ($field !== null) {
                    $cond->field_id = $field->id;
                    $cond->setRelation('field', $field);
                } else {
                    // Built-in field key (status / created_at / tags / etc.) —
                    // mirrors `MailListController::segmentStore` fallback.
                    $cond->field_id = null;
                }
            }

            $hydrated->push($cond);
        }

        $segment->setRelation('segmentConditions', $hydrated);

        if ($hydrated->isEmpty()) {
            return [
                'count' => 0,
                'sample' => [],
                'applies_in_ms' => 0,
                'validation_warnings' => array_values(array_unique(array_merge($warnings, ['No valid conditions provided — count is zero by definition.']))),
            ];
        }

        $start = microtime(true);

        try {
            $query = $segment->subscribers();
            /** @var \Illuminate\Database\Eloquent\Builder $query */
            $count = (int) $query->count();

            /** @var array{conditions: string}|null $conds */
            $conds = $segment->getSubscribersConditions();
            $whereSql = is_array($conds) && isset($conds['conditions']) ? (string) $conds['conditions'] : '';
            $sampleRows = $list->subscribers()
                ->select('subscribers.*')
                ->whereRaw('('.$whereSql.')')
                ->orderByDesc('subscribers.id')
                ->limit(self::SAMPLE_LIMIT)
                ->get();
        } catch (Throwable $e) {
            return [
                'count' => 0,
                'sample' => [],
                'applies_in_ms' => (int) ((microtime(true) - $start) * 1000),
                'validation_warnings' => array_merge($warnings, [
                    'SQL execution failed: '.$this->scrubException($e),
                ]),
            ];
        }

        $sample = $sampleRows->map(function ($s) {
            return [
                'uid' => (string) ($s->uid ?? ''),
                'email' => (string) ($s->email ?? ''),
                'first_name' => $s->first_name !== null ? (string) $s->first_name : null,
                'signup_date' => $s->created_at?->toIso8601String(),
            ];
        })->all();

        return [
            'count' => $count,
            'sample' => array_values($sample),
            'applies_in_ms' => (int) ((microtime(true) - $start) * 1000),
            'validation_warnings' => array_values(array_unique($warnings)),
        ];
    }

    /**
     * Heuristic NL → conditions tree translator. Deterministic, no LLM call.
     * Returns a partial tree + warnings + unrecognised phrases. Caller (the
     * model) decides whether to refine or apply.
     *
     * Recognised patterns (English-first, Vietnamese fallback regex on a few
     * common verbs):
     *   - "opened in last N days" / "active" / "engaged"
     *   - "haven't opened in N days" / "dormant" / "inactive"
     *   - "clicked in last N days"
     *   - "signed up in last N days" / "joined recently"
     *   - "has tag X" / "tagged X" / "with tag X"
     *   - "no tag X" / "without tag X"
     *   - "verified" / "unverified" / "disposable"
     *   - "subscribed" / "unsubscribed" / "active"
     *   - "first name starts with X" / "email ends @X"
     *   - "country is X" / "from X"
     *
     * @return array{conditions:list<array{field_id:?string,operator:string,value:string}>, matching:string, warnings:list<string>, unrecognized_phrases:list<string>}
     */
    public function buildFromNaturalLanguage(MailList $list, string $nl): array
    {
        $nl = trim(mb_substr($nl, 0, self::NL_INPUT_MAX));
        if ($nl === '') {
            return [
                'conditions' => [],
                'matching' => 'all',
                'warnings' => ['Empty natural-language input.'],
                'unrecognized_phrases' => [],
            ];
        }

        $lc = mb_strtolower($nl);
        $warnings = [];
        $conditions = [];
        $consumed = [];

        // Engagement — open
        if (preg_match('/(?:opened|open|engaged|active)\s+(?:in\s+)?(?:the\s+)?last\s+(\d+)\s+(?:day|days|d)/u', $lc, $m)
            || preg_match('/(?:đã\s+mở|mở)\s+trong\s+(\d+)\s+(?:ngày|d)/u', $lc, $m)) {
            $conditions[] = ['field_id' => 'last_open_email', 'operator' => 'last_open_email_less_than_days', 'value' => (string) (int) $m[1]];
            $consumed[] = $m[0];
        }
        if (preg_match('/(?:haven\'?t\s+opened|not\s+opened|dormant|inactive|cold)\s+(?:in\s+)?(?:the\s+)?last\s+(\d+)\s+(?:day|days|d)/u', $lc, $m)
            || preg_match('/(?:chưa\s+mở|không\s+mở)\s+trong\s+(\d+)\s+(?:ngày|d)/u', $lc, $m)) {
            $conditions[] = ['field_id' => 'last_open_email', 'operator' => 'last_open_email_greater_than_days', 'value' => (string) (int) $m[1]];
            $consumed[] = $m[0];
        }

        // Engagement — click
        if (preg_match('/(?:clicked|click)\s+(?:in\s+)?(?:the\s+)?last\s+(\d+)\s+(?:day|days|d)/u', $lc, $m)
            || preg_match('/(?:đã\s+click|click)\s+trong\s+(\d+)\s+(?:ngày|d)/u', $lc, $m)) {
            $conditions[] = ['field_id' => 'last_link_click', 'operator' => 'last_link_click_less_than_days', 'value' => (string) (int) $m[1]];
            $consumed[] = $m[0];
        }
        if (preg_match('/(?:haven\'?t\s+clicked|not\s+clicked)\s+(?:in\s+)?(?:the\s+)?last\s+(\d+)\s+(?:day|days|d)/u', $lc, $m)) {
            $conditions[] = ['field_id' => 'last_link_click', 'operator' => 'last_link_click_greater_than_days', 'value' => (string) (int) $m[1]];
            $consumed[] = $m[0];
        }

        // Sign-up date
        if (preg_match('/(?:signed\s+up|joined|registered|subscribed)\s+(?:in\s+)?(?:the\s+)?last\s+(\d+)\s+(?:day|days|d)/u', $lc, $m)
            || preg_match('/(?:đăng\s+ký|tham\s+gia)\s+trong\s+(\d+)\s+(?:ngày|d)/u', $lc, $m)) {
            $conditions[] = ['field_id' => 'created_at', 'operator' => 'created_date_last_x_days', 'value' => (string) (int) $m[1]];
            $consumed[] = $m[0];
        }

        // Tags
        if (preg_match_all('/(?:has\s+tag|tagged|with\s+tag|có\s+tag)\s+["\']?([\w\-\s]+?)["\']?(?:\s+(?:and|or|,|$))/u', $lc.' ', $matches)) {
            foreach ($matches[1] as $tag) {
                $conditions[] = ['field_id' => null, 'operator' => 'tag_contains', 'value' => trim($tag)];
            }
            $consumed = array_merge($consumed, $matches[0]);
        }
        if (preg_match_all('/(?:no\s+tag|without\s+tag|không\s+có\s+tag)\s+["\']?([\w\-\s]+?)["\']?(?:\s+(?:and|or|,|$))/u', $lc.' ', $matches)) {
            foreach ($matches[1] as $tag) {
                $conditions[] = ['field_id' => null, 'operator' => 'tag_not_contains', 'value' => trim($tag)];
            }
            $consumed = array_merge($consumed, $matches[0]);
        }

        // Verification
        if (preg_match('/\bverified\b/u', $lc)) {
            $conditions[] = ['field_id' => null, 'operator' => 'verification_equal', 'value' => 'verified'];
            $consumed[] = 'verified';
        }
        if (preg_match('/\b(?:unverified|not\s+verified)\b/u', $lc)) {
            $conditions[] = ['field_id' => null, 'operator' => 'verification_equal', 'value' => 'unverified'];
            $consumed[] = 'unverified';
        }
        if (preg_match('/\b(?:disposable|throwaway)\b/u', $lc)) {
            $conditions[] = ['field_id' => null, 'operator' => 'verification_not_equal', 'value' => 'disposable'];
            $consumed[] = 'disposable';
            $warnings[] = '`disposable` translated to `verification_not_equal` (excludes disposable addresses); flip to `verification_equal` if the user wants to FIND them.';
        }

        // Email domain
        if (preg_match('/email\s+ends?\s+(?:with\s+)?["\']?(@[\w\.\-]+)["\']?/u', $lc, $m)) {
            $field = $this->findFieldByLabel($list, ['email']);
            if ($field !== null) {
                $conditions[] = ['field_id' => $field->uid, 'operator' => 'ends', 'value' => $m[1]];
                $consumed[] = $m[0];
            } else {
                $warnings[] = 'Detected email-domain phrase but the list has no `email` field with a uid; skipped.';
            }
        }

        $matching = preg_match('/\bany\s+of\b|\bor\b/u', $lc) ? 'any' : 'all';

        // Heuristic for unrecognised content: collapse the consumed phrases
        // and surface anything substantive that's left.
        $remaining = $lc;
        foreach ($consumed as $phrase) {
            $remaining = str_replace($phrase, '', $remaining);
        }
        $remaining = trim(preg_replace('/[\s,;]+(and|or)?[\s,;]+/u', ' ', $remaining) ?? '');
        $unrecognized = [];
        if ($remaining !== '' && mb_strlen($remaining) > 4) {
            $unrecognized[] = $remaining;
            $warnings[] = 'Some phrases could not be translated heuristically; refine the rules manually or call `segment.explain_operator` to pick an operator.';
        }

        return [
            'conditions' => array_values($conditions),
            'matching' => $matching,
            'warnings' => array_values(array_unique($warnings)),
            'unrecognized_phrases' => array_values(array_unique($unrecognized)),
        ];
    }

    /**
     * Resolve a field reference to a Field model. Tries:
     *   1. uid lookup via `Field::findByUid()`
     *   2. case-insensitive label match within the list
     */
    private function resolveField(string $ref, MailList $list): ?Field
    {
        try {
            $byUid = Field::findByUid($ref);
            if ($byUid !== null) {
                return $byUid;
            }
        } catch (Throwable) {
            // continue to label match
        }

        return $this->findFieldByLabel($list, [$ref]);
    }

    /**
     * @param  list<string>  $labels
     */
    private function findFieldByLabel(MailList $list, array $labels): ?Field
    {
        try {
            $fields = $list->getFields ?? collect();
        } catch (Throwable) {
            return null;
        }
        if (! $fields instanceof Collection) {
            return null;
        }

        foreach ($labels as $label) {
            $needle = mb_strtolower(trim($label));
            $hit = $fields->first(fn ($f) => $f instanceof Field && mb_strtolower((string) $f->label) === $needle);
            if ($hit instanceof Field) {
                return $hit;
            }
        }

        return null;
    }

    /**
     * Strip credential-like fragments out of an exception message before it
     * lands in the chat surface. Mirrors F4.1's defensive sanitisation.
     */
    private function scrubException(Throwable $e): string
    {
        $msg = $e->getMessage();
        $msg = preg_replace('/(password|secret|token|key)\s*=\s*\S+/i', '$1=[redacted]', $msg) ?? $msg;

        return mb_substr($msg, 0, 280);
    }
}