γκρ δεν κερδίζει τίποτα ); /** * Ουδέτερα σε -μα όπου η ρίζα "-ματ-" ΔΕΝ φαίνεται στην ονομαστική * ενικού — καμία κατάληξη δεν λύνει "σώμα"→"σωματ" γιατί το "σωμα" * ΕΙΝΑΙ ήδη η πλήρης ονομαστική. Λίστα μικρή, ρητή, μόνο συχνές * λέξεις e-shop — ΔΕΝ επιχειρούμε γενικό κανόνα για ανώμαλα. * * Κλειδιά σε normalized μορφή (πεζά, χωρίς τόνους/τελικό ς) — έτσι * φτάνουν στο stem_word() μετά το normalize() του stems(). * * @var array */ private static $stem_irregulars = array( 'σωμα' => 'σωματ', // σώμα/σώματος/σώματα/σωμάτων 'ονομα' => 'ονοματ', // όνομα/ονόματος/ονόματα/ονομάτων 'χρωμα' => 'χρωματ', // χρώμα/χρώματος/χρώματα/χρωμάτων 'δερμα' => 'δερματ', // δέρμα/δέρματος/δέρματα/δερμάτων 'στομα' => 'στοματ', // στόμα/στόματος/στόματα/στομάτων 'αιμα' => 'αιματ', // αίμα/αίματος/αίματα/αιμάτων 'κυμα' => 'κυματ', // κύμα/κύματος/κύματα/κυμάτων ); /** * Ανώτατο πλήθος παραλλαγών ΑΝΑ ΛΕΞΗ στο greeklish_variants() (Γύρος 4). * Μία λέξη σπάνια έχει πάνω από 2-3 σημεία εναλλαγής (μπ/ντ/γκ/αυ/ευ/ * ει/οι/υ/ξ/χ) — 8 πλήρεις καρτεσιανοί συνδυασμοί ανά λέξη είναι άφθονοι. */ const MAX_VARIANTS_PER_WORD = 8; /** * Συνολικό ανώτατο πλήθος tokens που επιστρέφει το greeklish_variants() * για ολόκληρο το κείμενο — δικλείδα για παθολογικά μεγάλα κείμενα με * πολλές λέξεις, όχι το κύριο σημείο ελέγχου (αυτό είναι το ανά-λέξη * όριο). Ανέβηκε από 60 σε 120 στον Γύρο 5, γιατί το * Addsynapse_Search_Indexer::build_document() πλέον περνάει εδώ τίτλο+κατηγορίες+ * brand+περιγραφή μαζί, όχι μόνο τον τίτλο — βλ. σχόλιο Γύρου 5 στην * κορυφή του αρχείου. */ const MAX_GREEKLISH_TOKENS = 120; /** * Δίψηφα με ΠΑΝΩ ΑΠΟ ΜΙΑ εύλογη λατινική απόδοση, για το * greeklish_variants(). Το πρώτο στοιχείο κάθε λίστας είναι ΠΑΝΤΑ * αυτό που βγάζει η αυτούσια greeklish() (η προεπιλογή) — τα υπόλοιπα * είναι εναλλακτικές γραφές. * * @var array */ private static $variant_digraphs = array( 'μπ' => array( 'mp', 'b' ), 'ντ' => array( 'nt', 'd' ), 'γκ' => array( 'gk', 'g' ), 'αυ' => array( 'au', 'av', 'af' ), 'ευ' => array( 'eu', 'ev', 'ef' ), 'ει' => array( 'ei', 'i' ), 'οι' => array( 'oi', 'i' ), ); /** * Μονά γράμματα με πάνω από μία εύλογη λατινική απόδοση. Ίδια σύμβαση: * πρώτο στοιχείο = ό,τι βγάζει η greeklish(). * * @var array */ private static $variant_singles = array( // 'y' προστέθηκε στον Γύρο 3: ο πιο διαδεδομένος τρόπος γραφής // του "υ" στα greeklish ("Mystiko", "Kyria", "glyko") έλειπε. 'υ' => array( 'u', 'i', 'y' ), 'ξ' => array( 'ks', 'x' ), 'χ' => array( 'h', 'ch' ), ); /** * Καταλήξεις προς αφαίρεση στο stems(), ΗΔΗ σε πεζά και με κανονικό * σίγμα (σ, όχι τελικό ς) — γιατί το stems() περνά πρώτα από το * normalize(), που μετατρέπει κάθε τελικό ς σε σ. Ταξινομημένες από * τη μεγαλύτερη στη μικρότερη: δοκιμάζουμε πάντα την πιο μεγάλη * κατάληξη που ταιριάζει, ώστε "γυναικειοσ"/"γυναικεια" (επίθετο σε * -ειος/-εια) να μην συγκρουστούν με τα γενικά -ος/-ια των ουσιαστικών. * * 🔴 ΣΚΟΠΙΜΑ ΔΕΝ υπάρχουν εδώ "ματοσ"/"ματα"/"ματων" σαν σύνθετες * καταλήξεις (παρότι η ανάθεση τις περιέγραφε έτσι): για τα ουδέτερα * σε -μα, η ρίζα "-ματ-" πρέπει να ΜΕΙΝΕΙ στη ρίζα, όχι να κοπεί μαζί * με την κατάληξη πτώσης — αλλιώς "όνομα/ονόματος" έδιναν "ονο" αντί * "ονοματ" (βρέθηκε με πραγματικό τρέξιμο, όχι με το χέρι: για μικρές * λέξεις σαν "σώμα" το μακρύ "ματοσ" απορρίπτεται από το ελάχιστο * μήκος ρίζας και πέφτει σωστά στο κοντό "οσ"· για μεγαλύτερες σαν * "όνομα" το ίδιο "ματοσ" περνούσε "με ασφάλεια" και έκοβε λάθος). * Οι κοντές καταλήξεις πτώσης ("οσ","α","ων" παρακάτω) αρκούν ΜΟΝΕΣ * τους και είναι συνεπείς σε όλες τις -μα λέξεις — δες * $stem_irregulars για την ονομαστική ενικού, που δεν λύνεται με * καμία κατάληξη. * * @var string[] */ private static $stem_suffixes = array( // 4 χαρακτήρες 'ικοσ', 'σεισ', 'ειοσ', // 3 χαρακτήρες 'εων', 'ιων', 'ιου', 'σησ', 'ουσ', 'ικη', 'ικο', 'ικα', 'εια', 'ειο', // 2 χαρακτήρες 'οσ', 'ου', 'οι', 'ων', 'ησ', 'εσ', 'ασ', 'ια', 'μα', 'ση', // 1 χαρακτήρας 'ο', 'η', 'α', 'ι', ); /** * Πεζά, αφαίρεση τόνων/διαλυτικών, τελικό σίγμα ς→σ, συμπίεση κενών. * * Ο πίνακας τόνων είναι ο ίδιος με το αποδεδειγμένο * `Catalog::normalise()` (SalesAgent, fscommerce) — εκεί λύθηκε ήδη * το πρόβλημα «πρωτεΐνες δεν ταιριάζει με ΠΡΩΤΕΪΝΕΣ». * * @param string $s * @return string */ public static function normalize( $s ) { $s = (string) $s; if ( '' === $s ) { return ''; } $s = mb_strtolower( $s, 'UTF-8' ); $s = strtr( $s, array( 'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω', 'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ', 'ς' => 'σ', ) ); $s = preg_replace( '/\s+/u', ' ', $s ); $s = null === $s ? '' : trim( $s ); return $s; } /** * Ελληνικός τίτλος → λατινικοί χαρακτήρες («κρεατίνη» → «kreatini»). * * ΑΥΤΟΥΣΙΟ από `Product::greek_to_greeglish()` (fscommerce). Η * αντιστοίχιση γίνεται με ένα strtr() πάνω σε πίνακα χαρακτήρα-προς- * χαρακτήρα (πλην "ου"→"ou", που είναι το μόνο πραγματικό δίψηφο στον * πίνακα) — τα υπόλοιπα «δίψηφα» της γλωσσικής περιγραφής (μπ→b, * ντ→d, γκ→g, αυ→af/av, ευ→ef/ev) ΔΕΝ υπάρχουν σε αυτή την υλοποίηση: * προκύπτουν μόνο κατά σύμπτωση όταν ταυτίζονται με τη συνένωση των * μονών γραμμάτων (π.χ. τσ→"t"+"s"="ts", τζ→"t"+"z"="tz", αι/ει/οι * ταυτίζονται φυσικά). Για μπ/ντ/γκ/αυ/ευ το πραγματικό αποτέλεσμα * είναι "mp"/"nt"/"gk"/"au"/"eu", ΟΧΙ "b"/"d"/"g"/"af"/"ev" — δες * σημείωση στο report, ΔΕΝ το διορθώσαμε γιατί είναι δοκιμασμένο σε * παραγωγή. * * @param string $string * @return string */ public static function greeklish( $string ) { $string = (string) $string; $g2l = array(); $g2l['ου'] = 'ou'; $g2l['Ου'] = 'Ou'; $g2l['ού'] = 'ou'; $g2l['Ού'] = 'Ou'; $g2l['α'] = 'a'; $g2l['ά'] = 'a'; $g2l['Α'] = 'A'; $g2l['Ά'] = 'A'; $g2l['β'] = 'v'; $g2l['Β'] = 'V'; $g2l['γ'] = 'g'; $g2l['Γ'] = 'G'; $g2l['δ'] = 'd'; $g2l['Δ'] = 'D'; $g2l['ε'] = 'e'; $g2l['έ'] = 'e'; $g2l['Ε'] = 'E'; $g2l['Έ'] = 'E'; $g2l['ζ'] = 'z'; $g2l['Ζ'] = 'Z'; $g2l['η'] = 'i'; $g2l['ή'] = 'i'; $g2l['Η'] = 'I'; $g2l['Ή'] = 'I'; $g2l['θ'] = 'th'; $g2l['Θ'] = 'Th'; $g2l['ι'] = 'i'; $g2l['ί'] = 'i'; $g2l['ϊ'] = 'i'; $g2l['ΐ'] = 'i'; $g2l['Ι'] = 'I'; $g2l['Ί'] = 'I'; $g2l['Ϊ'] = 'I'; $g2l['κ'] = 'k'; $g2l['Κ'] = 'K'; $g2l['λ'] = 'l'; $g2l['Λ'] = 'L'; $g2l['μ'] = 'm'; $g2l['Μ'] = 'M'; $g2l['ν'] = 'n'; $g2l['Ν'] = 'N'; $g2l['ξ'] = 'ks'; $g2l['Ξ'] = 'Ks'; $g2l['ο'] = 'o'; $g2l['ό'] = 'o'; $g2l['Ο'] = 'O'; $g2l['Ό'] = 'O'; $g2l['π'] = 'p'; $g2l['Π'] = 'P'; $g2l['ρ'] = 'r'; $g2l['Ρ'] = 'R'; $g2l['σ'] = 's'; $g2l['ς'] = 's'; $g2l['Σ'] = 'S'; $g2l['τ'] = 't'; $g2l['Τ'] = 'T'; $g2l['υ'] = 'u'; $g2l['ύ'] = 'u'; $g2l['ϋ'] = 'u'; $g2l['ΰ'] = 'u'; $g2l['Υ'] = 'U'; $g2l['Ύ'] = 'U'; $g2l['Ϋ'] = 'I'; $g2l['φ'] = 'f'; $g2l['Φ'] = 'F'; $g2l['χ'] = 'h'; $g2l['Χ'] = 'H'; $g2l['ψ'] = 'ps'; $g2l['Ψ'] = 'Ps'; $g2l['ω'] = 'o'; $g2l['ώ'] = 'o'; $g2l['Ω'] = 'O'; $g2l['Ώ'] = 'O'; $g2l['-'] = '_'; $g2l['.'] = '_'; $g2l['/'] = '_'; return strtr( $string, $g2l ); } /** * Λατινικός τίτλος → ελληνικοί χαρακτήρες, φωνητικά («Scitec» κ.λπ.). * * ΑΥΤΟΥΣΙΟ από `Product::latinToGreek()` (fscommerce): δίψηφα πρώτα * (th, ch, ph, ps, ou, ai, ei, oi, mp, nt, gk, ts, tz, sh, ck), μετά * μονά γράμματα. * * ΓΥΡΟΣ 2 — ΔΙΟΡΘΩΘΗΚΕ: το ίδιο το docblock του fscommerce έδινε ως * παράδειγμα-στόχο «Scitec» → «σαϊτεκ», αλλά η αυτούσια υλοποίηση δεν * το πετύχαινε (καμία απόδοση για "sc"/"ci" → έβγαζε «σκιτεκ»). Αυτό * ΔΕΝ είναι arbitrary βελτίωση — είναι διόρθωση σφάλματος πάνω στον * ρητά δηλωμένο στόχο του πρωτοτύπου. Προστέθηκαν τρία στοιχεία στον * ΙΔΙΟ πίνακα δίψηφων (η strtr() προτιμάει πάντα το μακρύτερο ταίριασμα, * οπότε "sci" προηγείται του "ci"): * - 'sci' → 'σαϊ' (αγγλική φωνητική, όπως "science": Scitec→σαϊτεκ) * - 'ce' → 'σε' (λατινικό c πριν από e ακούγεται /s/: Cetler→σετλερ) * - 'ci' → 'σι' (το ίδιο πριν από i) * Όλα τα υπόλοιπα δίψηφα/μονά γράμματα ΔΕΝ πειράχτηκαν. * * @param string $text * @return string */ public static function greek_spelling( $text ) { $text = (string) $text; $digraphs = array( 'th' => 'θ', 'ch' => 'χ', 'ph' => 'φ', 'ps' => 'ψ', 'ou' => 'ου', 'ai' => 'αι', 'ei' => 'ει', 'oi' => 'οι', 'mp' => 'μπ', 'nt' => 'ντ', 'gk' => 'γκ', 'ts' => 'τσ', 'tz' => 'τζ', 'sh' => 'σ', 'ck' => 'κ', 'sci' => 'σαϊ', 'ce' => 'σε', 'ci' => 'σι', ); $letters = array( 'a' => 'α', 'b' => 'μπ', 'c' => 'κ', 'd' => 'ντ', 'e' => 'ε', 'f' => 'φ', 'g' => 'γ', 'h' => 'χ', 'i' => 'ι', 'j' => 'τζ', 'k' => 'κ', 'l' => 'λ', 'm' => 'μ', 'n' => 'ν', 'o' => 'ο', 'p' => 'π', 'q' => 'κ', 'r' => 'ρ', 's' => 'σ', 't' => 'τ', 'u' => 'ου', 'v' => 'β', 'w' => 'ου', 'x' => 'ξ', 'y' => 'υ', 'z' => 'ζ', ); $lower = mb_strtolower( $text, 'UTF-8' ); return trim( strtr( strtr( $lower, $digraphs ), $letters ) ); } /** * Παραλλαγές του greeklish() — ΝΕΑ μέθοδος, ΔΕΝ αλλάζει το verbatim * greeklish(). Ο κόσμος γράφει "μπάρα" άλλοτε "mpara" άλλοτε "bara", * "αυγό" άλλοτε "augo" άλλοτε "avgo"/"afgo" κ.ο.κ. — το verbatim * greeklish() βγάζει ΜΟΝΟ μία από αυτές. * * ΓΥΡΟΣ 4 — ΑΛΛΑΓΗ ΣΧΕΔΙΑΣΜΟΥ: παράγει tokens ΑΝΑ ΛΕΞΗ, όχι παραλλαγές * ολόκληρης φράσης. Ο Γύρος 3 έφτιαχνε πλήρεις εναλλακτικούς τίτλους * με ένα-σημείο-τη-φορά flip πάνω σε ΟΛΟΚΛΗΡΟ το κείμενο, με σκληρό * όριο 8 στο σύνολο — σε τίτλο με πολλά δίψηφα οι πρώτες λέξεις * εξαντλούσαν τις 8 θέσεις και οι τελευταίες (π.χ. "γκάμα", "ευτυχία") * δεν έπαιρναν ΚΑΜΙΑ παραλλαγή. Λάθος σχεδιασμός, όχι λάθος σταθερά. * * Το Meilisearch ταιριάζει σε επίπεδο token μέσα στο searchable array * — δεν χρειάζεται ποτέ ολόκληρη τη φράση σε greeklish. Άρα: σπάμε σε * λέξεις, κάθε λέξη παίρνει ΤΟΝ ΠΛΗΡΗ καρτεσιανό συνδυασμό των δικών * της εναλλακτικών (όριο MAX_VARIANTS_PER_WORD ανά λέξη — μια λέξη * σπάνια έχει πάνω από 2-3 σημεία εναλλαγής, οπότε αυτό το όριο σχεδόν * ποτέ δεν πιάνεται στην πράξη), ενώνουμε, αφαιρούμε διπλότυπα, και * κόβουμε στο τέλος με MAX_GREEKLISH_TOKENS (60) ως δικλείδα για * παθολογικά μεγάλους τίτλους — όχι ως το κύριο σημείο ελέγχου. * * Για κάθε λέξη, το ΠΡΩΤΟ token της είναι ΠΑΝΤΑ αυτό που βγάζει η * verbatim greeklish() για εκείνη τη λέξη. Ντετερμινιστικό: ίδιο * input, ίδια σειρά, ίδιο αποτέλεσμα πάντα. * * @param string $s * @return string[] Μοναδικά tokens, ΠΟΤΕ πάνω από MAX_GREEKLISH_TOKENS. */ public static function greeklish_variants( $s ) { $s = (string) $s; $normalized = self::normalize( $s ); if ( '' === $normalized ) { return array( self::greeklish( $s ) ); } preg_match_all( '/[\p{L}\p{N}]+/u', $normalized, $matches ); $words = $matches[0]; if ( empty( $words ) ) { // Κείμενο χωρίς καμία λέξη (π.χ. μόνο σημεία στίξης/emoji) — // fallback στο verbatim αποτέλεσμα, ποτέ κενό. return array( self::greeklish( $s ) ); } $tokens = array(); foreach ( $words as $word ) { $segments = self::greeklish_segments( $word ); $combos = self::expand_word_variants( $segments, self::MAX_VARIANTS_PER_WORD ); foreach ( $combos as $token ) { if ( ! in_array( $token, $tokens, true ) ) { $tokens[] = $token; } } } if ( count( $tokens ) > self::MAX_GREEKLISH_TOKENS ) { $tokens = array_slice( $tokens, 0, self::MAX_GREEKLISH_TOKENS ); } return $tokens; } /** * Σπάει ένα ήδη normalized (πεζό, άτονο) κείμενο σε "segments": για * κάθε θέση, ένα δίψηφο-με-εναλλακτικές (μπ/ντ/γκ/αυ/ευ/ει/οι), ένα * μονό γράμμα-με-εναλλακτικές (υ/ξ/χ), ή — για όλα τα άλλα — ένα * μονό segment με τη μοναδική τιμή που θα έβγαζε η greeklish() για * εκείνον τον χαρακτήρα (καμία διπλή συντήρηση του βασικού πίνακα). * * @param string $text * @return array */ private static function greeklish_segments( $text ) { $chars = mb_str_split( $text, 1, 'UTF-8' ); $length = count( $chars ); $segments = array(); $i = 0; while ( $i < $length ) { if ( $i + 1 < $length ) { $pair = $chars[ $i ] . $chars[ $i + 1 ]; if ( isset( self::$variant_digraphs[ $pair ] ) ) { $segments[] = self::$variant_digraphs[ $pair ]; $i += 2; continue; } } $char = $chars[ $i ]; if ( isset( self::$variant_singles[ $char ] ) ) { $segments[] = self::$variant_singles[ $char ]; } else { $segments[] = array( self::greeklish( $char ) ); } $i++; } return $segments; } /** * Πλήρης καρτεσιανός συνδυασμός των εναλλακτικών ΜΙΑΣ λέξης (Γύρος 4). * Ασφαλές γιατί λειτουργεί ανά λέξη — μια λέξη έχει σχεδόν πάντα το * πολύ 2-3 μεταβλητά σημεία, άρα ο συνδυασμός μένει μικρός. Κόβουμε * ΣΕ ΚΑΘΕ βήμα (όχι μόνο στο τέλος) στο $max, ώστε ένα ακραίο/ * παθολογικό word να μην φουσκώσει προσωρινά στη μνήμη πριν το cap. * * Ντετερμινιστικό: η πρώτη τιμή του αποτελέσματος είναι πάντα ο * συνδυασμός-προεπιλογή (index 0 κάθε segment) = ό,τι βγάζει η * verbatim greeklish() για αυτή τη λέξη. * * @param array $segments * @param int $max * @return string[] */ private static function expand_word_variants( array $segments, $max ) { $combos = array( '' ); foreach ( $segments as $segment ) { $next = array(); foreach ( $combos as $prefix ) { foreach ( $segment as $piece ) { $next[] = $prefix . $piece; } } if ( count( $next ) > $max ) { $next = array_slice( $next, 0, $max ); } $combos = $next; } return $combos; } /** * Ελαφρύ ελληνικό stemmer/lemmatiser — ΤΟ ΝΕΟ ΚΟΜΜΑΤΙ (δεν υπήρχε * πουθενά). Κόβει συνήθεις καταλήξεις ουσιαστικών/επιθέτων ώστε * "πρωτεΐνης"/"πρωτεΐνες"/"πρωτεΐνη" να καταλήξουν στην ίδια ρίζα. * * Τρέχει σε χρόνο δεικτοδότησης πάνω σε ολόκληρο τίτλο/κείμενο: το * σπάει σε λέξεις, βγάζει τη ρίζα της κάθε μιας, επιστρέφει ΠΙΝΑΚΑ * μοναδικών ριζών (ΟΧΙ φράση). * * Κανόνες ασφαλείας: * - ελάχιστο μήκος ρίζας 3 χαρακτήρες (MIN_ROOT_LENGTH)· * - λίστα εξαιρέσεων για άκλιτες λέξεις ($stem_exceptions)· * - λέξεις εκτός ελληνικού αλφαβήτου (λατινικά, αριθμοί) περνούν * ΑΝΕΠΕΞΕΡΓΑΣΤΕΣ — ο stemmer είναι καθαρά ελληνικός· * - ποτέ δεν επιστρέφεται κενή ρίζα για λέξη που υπήρχε· αν καμία * κατάληξη δεν ταιριάζει με ασφάλεια, μένει η ίδια η λέξη. * * @param string $s * @return string[] Μοναδικές ρίζες. */ public static function stems( $s ) { $normalized = self::normalize( $s ); if ( '' === $normalized ) { return array(); } preg_match_all( '/[\p{L}\p{N}]+/u', $normalized, $matches ); $words = $matches[0]; $roots = array(); foreach ( $words as $word ) { $root = self::stem_word( $word ); if ( '' !== $root ) { $roots[] = $root; } } $roots = array_values( array_unique( $roots ) ); if ( empty( $roots ) ) { // Δεν βρέθηκε καμία λέξη (π.χ. μόνο emoji/σημεία στίξης) αλλά // το αρχικό κείμενο δεν ήταν κενό — κρατάμε κάτι αντί για // τίποτα, ώστε το πεδίο του δείκτη να μην αδειάσει σιωπηλά. return array( $normalized ); } return $roots; } /** * Ρίζα μιας μεμονωμένης, ήδη κανονικοποιημένης λέξης. * * @param string $word * @return string */ private static function stem_word( $word ) { if ( '' === $word ) { return ''; } if ( isset( self::$stem_irregulars[ $word ] ) ) { return self::$stem_irregulars[ $word ]; } if ( in_array( $word, self::$stem_exceptions, true ) ) { return $word; } // Ο stemmer αφορά μόνο ελληνικές λέξεις· λατινικά brand names // (Scitec, Nike…) και κωδικοί περνάνε ως έχουν. if ( ! preg_match( '/\p{Greek}/u', $word ) ) { return $word; } $word_length = mb_strlen( $word, 'UTF-8' ); foreach ( self::$stem_suffixes as $suffix ) { $suffix_length = mb_strlen( $suffix, 'UTF-8' ); if ( $suffix_length >= $word_length ) { continue; } if ( mb_substr( $word, -$suffix_length, null, 'UTF-8' ) !== $suffix ) { continue; } $root_length = $word_length - $suffix_length; if ( $root_length >= self::MIN_ROOT_LENGTH ) { return mb_substr( $word, 0, $root_length, 'UTF-8' ); } } // Καμία κατάληξη δεν ταίριαξε με ασφάλεια — η λέξη μένει ως έχει. return $word; } }