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