declare(strict_types=1);
namespace App\Modules\Products\Projection\Discovery;
use PDO;
final readonly class TropeInferer
{
public function __construct(
private PDO $pdo,
) {}
public function inferForProduct(int $productId): array
{
$sql = 'WITH
product_target AS (
SELECT :product_id AS product_id
),
haystack AS (
SELECT
pt.product_id,
'title' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(p.title, ''), ' ')) AS content
FROM product_target pt
JOIN products p ON p.id = pt.product_id
UNION ALL
SELECT
pt.product_id,
'subtitle' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(p.subtitle, ''), ' ')) AS content
FROM product_target pt
JOIN products p ON p.id = pt.product_id
UNION ALL
SELECT
pt.product_id,
CASE WHEN ps.is_main_subject = 1 THEN 'main_subject' ELSE 'subject' END AS source,
ps.is_main_subject,
LOWER(CONCAT(' ', COALESCE(s.heading_text, ''), ' ')) AS content
FROM product_target pt
JOIN product_subjects ps ON ps.product_id = pt.product_id
JOIN subjects s ON s.id = ps.subject_id
UNION ALL
SELECT
pt.product_id,
'text_11' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(ptx.content, ''), ' ')) AS content
FROM product_target pt
JOIN product_texts ptx ON ptx.product_id = pt.product_id
WHERE ptx.text_type = '11'
UNION ALL
SELECT
pt.product_id,
'text_03' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(ptx.content, ''), ' ')) AS content
FROM product_target pt
JOIN product_texts ptx ON ptx.product_id = pt.product_id
WHERE ptx.text_type = '03'
UNION ALL
SELECT
pt.product_id,
'product_description' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(p.description, ''), ' ')) AS content
FROM product_target pt
JOIN products p ON p.id = pt.product_id
UNION ALL
SELECT
pt.product_id,
'product_marketing_text' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(p.marketing_text, ''), ' ')) AS content
FROM product_target pt
JOIN products p ON p.id = pt.product_id
UNION ALL
SELECT
pt.product_id,
'collection' AS source,
0 AS is_main_subject,
LOWER(CONCAT(' ', COALESCE(c.title, ''), ' ', COALESCE(c.subtitle, ''), ' ')) AS content
FROM product_target pt
JOIN product_collections pc ON pc.product_id = pt.product_id
JOIN collections c ON c.id = pc.collection_id
),
normalized_haystack AS (
SELECT
product_id,
source,
is_main_subject,
CONCAT(
' ',
TRIM(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
content,
'<', ' '), '>', ' '), '/', ' '), '-', ' '), '_', ' '), '&', ' '), ':', ' '), ';', ' '), ',', ' '), '.', ' '),
'(', ' '), ')', ' '), '[', ' '), ']', ' '), '{', ' '), '}', ' '), '"', ' '), '\'', ' '), '?', ' '), '!', ' ')
),
' '
) AS norm_content
FROM haystack
),
source_rank AS (
SELECT 'main_subject' AS source_type, 1 AS rank_order UNION ALL
SELECT 'subject', 2 UNION ALL
SELECT 'text_11', 3 UNION ALL
SELECT 'title', 4 UNION ALL
SELECT 'subtitle', 5 UNION ALL
SELECT 'collection', 6 UNION ALL
SELECT 'text_03', 7 UNION ALL
SELECT 'product_description', 8 UNION ALL
SELECT 'product_marketing_text', 9
),
source_weights AS (
SELECT 'primary' AS relevance, 30 AS points
UNION ALL
SELECT 'secondary', 15
),
direct_trope_terms AS (
SELECT
tr.id AS trope_id,
tr.slug AS trope_slug,
tr.name AS trope_name,
'trope_name' AS match_type,
tr.name AS match_term
FROM discovery_tropes tr
WHERE tr.is_active = 1
UNION ALL
SELECT
tr.id,
tr.slug,
tr.name,
'trope_alias' AS match_type,
dta.alias AS match_term
FROM discovery_trope_aliases dta
JOIN discovery_tropes tr ON tr.id = dta.trope_id
WHERE dta.is_active = 1
AND tr.is_active = 1
),
normalized_direct_trope_terms AS (
SELECT
trope_id,
trope_slug,
trope_name,
match_type,
match_term,
CONCAT(
' ',
TRIM(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
LOWER(match_term),
'<', ' '), '>', ' '), '/', ' '), '-', ' '), '_', ' '), '&', ' '), ':', ' '), ';', ' '), ',', ' '), '.', ' '),
'(', ' '), ')', ' '), '[', ' '), ']', ' '), '{', ' '), '}', ' '), '"', ' '), '\'', ' '), '?', ' '), '!', ' ')
),
' '
) AS norm_term
FROM direct_trope_terms
),
direct_matches_raw AS (
SELECT
ndt.trope_id,
ndt.trope_slug,
ndt.trope_name,
nh.source,
ndt.match_type,
ndt.match_term,
sr.rank_order
FROM normalized_haystack nh
JOIN normalized_direct_trope_terms ndt
ON nh.norm_content LIKE CONCAT('%', ndt.norm_term, '%')
LEFT JOIN source_rank sr
ON sr.source_type = nh.source
WHERE LENGTH(TRIM(ndt.norm_term)) >= 4
),
direct_matches AS (
SELECT
trope_id,
trope_slug,
trope_name,
CASE
WHEN SUM(match_type = 'trope_name') > 0 THEN 'trope_name'
ELSE 'trope_alias'
END AS direct_match_type,
1 AS direct_match_count,
SUBSTRING_INDEX(
GROUP_CONCAT(
DISTINCT source
ORDER BY rank_order ASC
SEPARATOR '||'
),
'||',
1
) AS direct_sources
FROM direct_matches_raw
GROUP BY
trope_id,
trope_slug,
trope_name
),
normalized_tags AS (
SELECT
dt.id AS tag_id,
dt.slug AS tag_slug,
dt.name AS tag_name,
CONCAT(' ', TRIM(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
LOWER(dt.slug),
'<',' '), '>',' '), '/',' '), '-',' '), '_',' '), '&',' '), ':',' '), ';',' '), ',',' '), '.',' '),
'(',' '), ')',' '), '[',' '), ']',' '), '{',' '), '}',' '), '"',' '), '\'', ' '), '?',' '), '!',' ')
), ' ') AS norm_slug,
CONCAT(' ', TRIM(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
LOWER(dt.name),
'<',' '), '>',' '), '/',' '), '-',' '), '_',' '), '&',' '), ':',' '), ';',' '), ',',' '), '.',' '),
'(',' '), ')',' '), '[',' '), ']',' '), '{',' '), '}',' '), '"',' '), '\'', ' '), '?',' '), '!',' ')
), ' ') AS norm_name
FROM discovery_tags dt
WHERE dt.is_active = 1
AND NOT EXISTS (
SELECT 1
FROM discovery_genres dg
WHERE dg.is_active = 1
AND (
LOWER(dg.slug) = LOWER(dt.slug)
OR LOWER(dg.name) = LOWER(dt.name)
OR LOWER(dg.slug) = LOWER(dt.name)
OR LOWER(dg.name) = LOWER(dt.slug)
)
)
AND NOT EXISTS (
SELECT 1
FROM genre_aliases ga
WHERE LOWER(ga.alias) = LOWER(dt.name)
OR LOWER(ga.alias) = LOWER(dt.slug)
)
),
tag_hits_raw AS (
SELECT
dtt.trope_id,
tr.slug AS trope_slug,
tr.name AS trope_name,
nt.tag_id,
nt.tag_slug,
nt.tag_name,
dtt.relevance,
dtg.id AS group_id,
dtg.slug AS group_slug,
dtg.name AS group_name,
nh.source,
sw.points,
COALESCE(sr.rank_order, 99) AS rank_order
FROM normalized_haystack nh
JOIN normalized_tags nt
ON (
nh.norm_content LIKE CONCAT('%', nt.norm_slug, '%')
OR nh.norm_content LIKE CONCAT('%', nt.norm_name, '%')
)
JOIN discovery_trope_tags dtt
ON dtt.tag_id = nt.tag_id
AND dtt.active = 1
JOIN discovery_tropes tr
ON tr.id = dtt.trope_id
AND tr.is_active = 1
JOIN source_weights sw
ON sw.relevance = dtt.relevance
LEFT JOIN normalized_direct_trope_terms ndt_exclude
ON ndt_exclude.trope_id = dtt.trope_id
AND (
nt.norm_slug = ndt_exclude.norm_term
OR nt.norm_name = ndt_exclude.norm_term
)
LEFT JOIN source_rank sr
ON sr.source_type = nh.source
LEFT JOIN discovery_tag_group_tags dgt
ON dgt.tag_id = nt.tag_id
LEFT JOIN discovery_tag_groups dtg
ON dtg.id = dgt.group_id
WHERE
(
LENGTH(TRIM(nt.norm_slug)) >= 4
OR LENGTH(TRIM(nt.norm_name)) >= 4
)
AND ndt_exclude.trope_id IS NULL
),
best_tag_hits AS (
SELECT
trope_id,
trope_slug,
trope_name,
tag_id,
tag_slug,
tag_name,
relevance,
MAX(points) AS points,
MIN(rank_order) AS best_rank,
SUBSTRING_INDEX(
GROUP_CONCAT(source ORDER BY points DESC, rank_order ASC SEPARATOR '||'),
'||',
1
) AS best_source
FROM tag_hits_raw
WHERE points > 0
GROUP BY
trope_id,
trope_slug,
trope_name,
tag_id,
tag_slug,
tag_name,
relevance
),
best_group_hits AS (
SELECT
bth.trope_id,
bth.trope_slug,
bth.trope_name,
dgt.group_id,
dtg.slug AS group_slug,
dtg.name AS group_name,
MAX(bth.points) AS group_points,
SUBSTRING_INDEX(
GROUP_CONCAT(
CONCAT(
bth.tag_name,
' (',
bth.relevance,
' • ',
bth.best_source,
' • ',
bth.points,
' Punkte)'
)
ORDER BY bth.points DESC, bth.best_rank ASC
SEPARATOR '||'
),
'||',
1
) AS group_evidence
FROM best_tag_hits bth
JOIN discovery_tag_group_tags dgt
ON dgt.tag_id = bth.tag_id
JOIN discovery_tag_groups dtg
ON dtg.id = dgt.group_id
AND dtg.is_active = 1
GROUP BY
bth.trope_id,
bth.trope_slug,
bth.trope_name,
dgt.group_id,
dtg.slug,
dtg.name
),
trope_group_max AS (
SELECT
dtt.trope_id,
dgt.group_id,
SUM(dtt.relevance = 'primary') AS primary_tag_count,
SUM(dtt.relevance = 'secondary') AS secondary_tag_count,
(
SUM(dtt.relevance = 'primary')
+ SUM(dtt.relevance = 'secondary') * 0.25
) AS core_score,
MAX(
CASE dtt.relevance
WHEN 'primary' THEN 30
ELSE 15
END
) AS max_group_points
FROM discovery_trope_tags dtt
JOIN discovery_tag_group_tags dgt
ON dgt.tag_id = dtt.tag_id
JOIN discovery_tag_groups dtg
ON dtg.id = dgt.group_id
AND dtg.is_active = 1
AND dtg.slug NOT IN ('genres', 'marketing')
WHERE dtt.active = 1
GROUP BY
dtt.trope_id,
dgt.group_id
),
trope_group_roles AS (
SELECT
tgm.*,
CASE
WHEN tgm.primary_tag_count >= 2 THEN 'core'
WHEN tgm.primary_tag_count >= 1 AND tgm.core_score >= 1.25 THEN 'core'
WHEN tgm.primary_tag_count >= 1 THEN 'support'
WHEN tgm.secondary_tag_count >= 2 THEN 'support'
ELSE 'optional'
END AS group_role,
CASE
WHEN tgm.primary_tag_count >= 2 THEN 1.00
WHEN tgm.primary_tag_count >= 1 AND tgm.core_score >= 1.25 THEN 1.00
WHEN tgm.primary_tag_count >= 1 THEN 0.50
WHEN tgm.secondary_tag_count >= 2 THEN 0.50
ELSE 0.00
END AS denominator_weight
FROM trope_group_max tgm
),
trope_group_score AS (
SELECT
tgr.trope_id,
COUNT(*) AS trope_group_count,
SUM(tgr.max_group_points * tgr.denominator_weight) AS maximum_possible_score,
COUNT(bgh.group_id) AS matched_group_count,
COALESCE(
SUM(
COALESCE(bgh.group_points, 0)
* tgr.denominator_weight
),
0
) AS evidence_score,
ROUND(
100 * COUNT(bgh.group_id) / NULLIF(COUNT(*), 0),
1
) AS coverage_percent,
ROUND(
100 * COALESCE(
SUM(
COALESCE(bgh.group_points, 0)
* tgr.denominator_weight
),
0
)
/ NULLIF(
SUM(tgr.max_group_points * tgr.denominator_weight),
0
),
1
) AS confidence_percent,
GROUP_CONCAT(
CONCAT(
bgh.group_evidence,
' [',
tgr.group_role,
']'
)
ORDER BY bgh.group_points DESC
SEPARATOR ' | '
) AS tag_evidence
FROM trope_group_roles tgr
LEFT JOIN best_group_hits bgh
ON bgh.trope_id = tgr.trope_id
AND bgh.group_id = tgr.group_id
GROUP BY
tgr.trope_id
),
tag_trope_scores AS (
SELECT
trope_id,
COUNT(DISTINCT tag_id) AS unique_tags,
SUM(relevance = 'primary') AS primary_hits,
SUM(relevance = 'secondary') AS secondary_hits
FROM best_tag_hits
GROUP BY trope_id
),
final_ranking AS (
SELECT
tr.id AS trope_id,
tr.slug AS trope_slug,
tr.name AS trope_name,
COALESCE(tgs.evidence_score,0) AS evidence_score,
CASE
WHEN dm.trope_id IS NOT NULL THEN 1
ELSE 0
END AS has_direct_match,
dm.direct_match_type AS direct_match_type,
COALESCE(ts.unique_tags,0) AS unique_tags,
COALESCE(ts.primary_hits,0) AS primary_hits,
COALESCE(ts.secondary_hits,0) AS secondary_hits,
COALESCE(dm.direct_match_count,0) AS direct_match_count,
tgs.maximum_possible_score,
COALESCE(tgs.trope_group_count, 0) AS trope_group_count,
COALESCE(tgs.matched_group_count, 0) AS matched_group_count,
COALESCE(tgs.coverage_percent, 0) AS coverage_percent,
COALESCE(tgs.confidence_percent, 0) AS confidence_percent,
dm.direct_sources AS direct_sources,
tgs.tag_evidence AS tag_evidence
FROM discovery_tropes tr
LEFT JOIN direct_matches dm
ON dm.trope_id = tr.id
LEFT JOIN tag_trope_scores ts
ON ts.trope_id = tr.id
LEFT JOIN trope_group_score tgs
ON tgs.trope_id = tr.id
WHERE
dm.trope_id IS NOT NULL
OR tgs.trope_id IS NOT NULL
)
SELECT
pt.product_id,
fr.trope_id,
0 AS is_primary,
0 AS sort_order,
CASE
WHEN fr.has_direct_match = 1 THEN 'direct_match'
WHEN fr.primary_hits >= 3
AND fr.matched_group_count >= 3
AND fr.coverage_percent >= 60 THEN 'inferred_strong'
WHEN fr.primary_hits >= 2
AND fr.matched_group_count >= 2
AND fr.coverage_percent >= 50 THEN 'inferred_probable'
WHEN fr.primary_hits >= 2
AND fr.matched_group_count >= 2
AND fr.confidence_percent >= 35 THEN 'inferred_possible'
ELSE 'inferred_weak'
END AS source,
CASE
WHEN fr.has_direct_match = 1 THEN 1.00
ELSE ROUND(fr.confidence_percent / 100, 2)
END AS confidence,
JSON_OBJECT(
'trope_name', fr.trope_name,
'confidence_percent', fr.confidence_percent,
'evidence_score', fr.evidence_score,
'coverage_percent', fr.coverage_percent,
'matched_group_count', fr.matched_group_count,
'trope_group_count', fr.trope_group_count,
'unique_tags', fr.unique_tags,
'primary_hits', fr.primary_hits,
'secondary_hits', fr.secondary_hits,
'has_direct_match', fr.has_direct_match,
'direct_match_type', fr.direct_match_type,
'direct_sources', fr.direct_sources,
'tag_evidence', fr.tag_evidence
) AS evidence
FROM final_ranking fr
CROSS JOIN product_target pt
WHERE
fr.has_direct_match = 1
OR (
fr.primary_hits >= 3
AND fr.matched_group_count >= 3
AND fr.coverage_percent >= 60
)
OR (
fr.primary_hits >= 2
AND fr.matched_group_count >= 2
AND fr.coverage_percent >= 50
)
OR (
fr.primary_hits >= 2
AND fr.matched_group_count >= 2
AND fr.confidence_percent >= 35
);';
$statement = $this->pdo->prepare($sql);
$statement->execute([
'product_id' => $productId,
]);
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
}
Fatal error: Uncaught Error: Class "App\Modules\Products\Projection\Discovery\TropeInferer" not found in /home/u504091607/domains/romancemagazin.de/public_html/app/Providers/ProductsPersistenceServiceProvider.php:677
Stack trace:
#0 /home/u504091607/domains/romancemagazin.de/public_html/app/bootstrap.php(62): App\Providers\ProductsPersistenceServiceProvider->register()
#1 /home/u504091607/domains/romancemagazin.de/public_html/wp-content/plugins/product-catalog/product-catalog.php(38): require_once('/home/u50409160...')
#2 /home/u504091607/domains/romancemagazin.de/public_html/wp-settings.php(589): include_once('/home/u50409160...')
#3 /home/u504091607/domains/romancemagazin.de/public_html/wp-config.php(109): require_once('/home/u50409160...')
#4 /home/u504091607/domains/romancemagazin.de/public_html/wp-load.php(50): require_once('/home/u50409160...')
#5 /home/u504091607/domains/romancemagazin.de/public_html/wp-blog-header.php(13): require_once('/home/u50409160...')
#6 /home/u504091607/domains/romancemagazin.de/public_html/index.php(17): require('/home/u50409160...')
#7 {main}
thrown in /home/u504091607/domains/romancemagazin.de/public_html/app/Providers/ProductsPersistenceServiceProvider.php on line 677