inferenza automatica del titolo */ final class DomainEngine { private IdentifierClassifier $classifier; private ProviderRoutingTable $routingTable; public function __construct( IdentifierClassifier $classifier, ProviderRoutingTable $routingTable ) { $this->classifier = $classifier; $this->routingTable = $routingTable; } /** * Resolve domain and provider chain for $input. * * @param string $input Raw user query (ISBN, EAN, IMDb ID, title…) * @param string|null $mediaHint Optional explicit mediaType from operator UI * * @throws Exceptions\DiscoveryUnresolvedException When input is non-empty but unclassifiable */ public function resolve(string $input, ?string $mediaHint = null): DomainResolution { $input = trim($input); // ── 1. Explicit operator hint takes absolute priority ───────────────── // // Architectural contract: if the operator has already selected a domain // (Cinema, Libri, Musica, Giochi, Cibo…), that selection IS the domain. // We must NOT ask for confirmation again, even for free-text titles. // // Important runtime detail: the explicit domain must not erase identifier // classification. A barcode entered under Cinema/Musica/Food is still an // IDENTIFIER lookup; only its routing domain is fixed by the operator. // Losing IdentifierResolution here previously converted EAN/UPC searches // into TITLE searches and prevented domain providers from receiving the // barcode lookup they support. if ($mediaHint !== null) { $hintNorm = MediaType::normalize($mediaHint); if ($this->isConcreteMediaType($hintNorm)) { $chain = $this->routingTable->getChain($hintNorm); $identifierResolution = $this->classifier->classify($input, $hintNorm); return new DomainResolution( $hintNorm, $chain, $identifierResolution, /* requiresOperatorInput= */ false ); } } // ── 2. Classify the raw input (identifier path: Classe A / B) ───────── $resolution = $this->classifier->classify($input, $mediaHint); // ── 3a. Classe A/B: resolved identifier ─────────────────────────────── if ($resolution !== null) { $domain = $resolution->getDomain(); $chain = $this->routingTable->getChain($domain); return new DomainResolution($domain, $chain, $resolution); } // ── 3b. Classe C: free-text title, NO explicit operator hint ────────── // Per spec: title-only without a domain hint → require operator domain // selection. We do NOT throw here; we return a DomainResolution with // requiresOperatorInput=true so the caller can surface the UI selector. if ($this->looksLikeFreeText($input)) { return new DomainResolution( MediaType::UNKNOWN, new ProviderChain([]), null, /* requiresOperatorInput= */ true ); } // ── 3c. Non-empty but unclassifiable → hard error ───────────────────── throw new Exceptions\DiscoveryUnresolvedException( $this->sanitize($input) ); } // ---------------------------------------------------------------------- helpers /** * A "concrete" media type is any valid domain that is not AUTO or UNKNOWN. * We intentionally do NOT require the routing table to have a chain for it: * the operator's explicit selection is the authoritative domain decision. */ private function isConcreteMediaType(string $domain): bool { return $domain !== MediaType::AUTO && $domain !== MediaType::UNKNOWN; } /** * Heuristic: input is free text if it contains spaces or is longer * than 20 chars without matching any structured pattern. */ private function looksLikeFreeText(string $input): bool { return str_contains($input, ' ') || strlen($input) > 20; } private function sanitize(string $input): string { // Remove anything that is not printable ASCII before echoing to error messages return preg_replace('/[^\x20-\x7E]/', '', substr($input, 0, 100)) ?? ''; } }