From e6d53e0eeb1d72be17ccf98c1a0821540785f8cd Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 4 Oct 2026 15:18:48 +0200 Subject: [PATCH] Zeige die echten Produktwerte aus der Trefferliste und vermeide doppelte RAM/Speicher-Schluessel. Co-authored-by: Cursor --- backend/app/discovery/attributes.py | 51 ++++++++++++++ backend/app/services/discovery_service.py | 68 ++++++++++++------- backend/app/sources/geizhals/result_parser.py | 6 +- .../tests/unit/test_observation_attributes.py | 28 ++++++++ frontend/src/api.ts | 9 ++- frontend/src/pages/CandidateDetailPage.tsx | 14 ++-- 6 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 backend/app/discovery/attributes.py create mode 100644 backend/tests/unit/test_observation_attributes.py diff --git a/backend/app/discovery/attributes.py b/backend/app/discovery/attributes.py new file mode 100644 index 0000000..90b200f --- /dev/null +++ b/backend/app/discovery/attributes.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any + +SPEC_ALIASES = { + "ram_gb": ("ram", "arbeitsspeicher"), + "storage_gb": ("speicher", "ssd", "festplatte"), + "weight_kg": ("gewicht",), +} + + +def merge_observation_attributes( + raw_attributes: dict[str, Any], + normalized_attributes: dict[str, Any], +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + used_norm: set[str] = set() + for name, raw_value in raw_attributes.items(): + if raw_value is None or str(raw_value).strip() == "": + continue + fold = str(name).casefold().rstrip(":") + norm_key = next((key for key, aliases in SPEC_ALIASES.items() if fold in aliases), None) + norm_value = normalized_attributes.get(norm_key) if norm_key else None + if norm_key: + used_norm.add(norm_key) + items.append( + { + "attribute_key": norm_key or fold.replace(" ", "_"), + "raw_name": str(name).rstrip(":"), + "raw_value": raw_value, + "normalized_value": norm_value, + } + ) + for key, value in normalized_attributes.items(): + if key in used_norm or value is None: + continue + items.append( + { + "attribute_key": key, + "raw_name": key, + "raw_value": None, + "normalized_value": value, + } + ) + return items + + +def is_derived_duplicate(name: str, present: set[str]) -> bool: + fold = name.casefold() + aliases = SPEC_ALIASES.get(fold) + return bool(aliases and present.intersection(aliases)) diff --git a/backend/app/services/discovery_service.py b/backend/app/services/discovery_service.py index 14957b1..934fa09 100644 --- a/backend/app/services/discovery_service.py +++ b/backend/app/services/discovery_service.py @@ -9,6 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.clock import now_utc +from app.discovery.attributes import is_derived_duplicate, merge_observation_attributes from app.discovery.definitions import get_definition, get_definition_by_key, upsert_definition from app.discovery.diff import diff_run, next_lifecycle from app.discovery.enums import ( @@ -1447,7 +1448,7 @@ def _attribute_map(session: Session, observation_id: UUID) -> dict[str, Any]: key = row.attribute_key if definition is not None and definition.scoring_attribute_key: key = definition.scoring_attribute_key - mapped[key] = row.normalized_value + mapped[key] = row.normalized_value if row.normalized_value is not None else row.raw_value return mapped @@ -1457,16 +1458,29 @@ def _dynamic_attributes(session: Session, observation_id: UUID) -> list[dict[str ProductObservationAttributeRecord.observation_id == observation_id ) ).all() - return [ - { - "criterion_definition_id": row.criterion_definition_id, - "raw_name": row.raw_name or row.attribute_key, - "raw_value": row.raw_value, - "normalized_value": row.normalized_value, - "unit": row.unit, - } - for row in rows - ] + present = {(row.raw_name or row.attribute_key or "").casefold() for row in rows} + items: list[dict[str, Any]] = [] + for row in rows: + name = row.raw_name or row.attribute_key or "" + if is_derived_duplicate(row.attribute_key or "", present) or is_derived_duplicate(name, present): + continue + raw = row.raw_value + normalized = row.normalized_value + if (raw is None or raw == "") and normalized is None: + continue + definition = ( + None if row.criterion_definition_id is None else session.get(CriterionDefinitionRecord, row.criterion_definition_id) + ) + items.append( + { + "criterion_definition_id": row.criterion_definition_id, + "raw_name": (definition.label if definition is not None else None) or name, + "raw_value": raw, + "normalized_value": normalized if normalized is not None else raw, + "unit": row.unit, + } + ) + return items def _store_observation_attributes( @@ -1475,30 +1489,34 @@ def _store_observation_attributes( raw_attributes: dict[str, Any], normalized_attributes: dict[str, Any], ) -> None: - keys = set(raw_attributes) | set(normalized_attributes) - definitions = { - row.scoring_attribute_key or row.key: row - for row in session.scalars(select(CriterionDefinitionRecord)).all() - if row.scoring_attribute_key or row.key - } + definitions = list(session.scalars(select(CriterionDefinitionRecord))) + by_score = {row.scoring_attribute_key: row for row in definitions if row.scoring_attribute_key} + by_key = {row.key: row for row in definitions} + by_label = {row.label.casefold(): row for row in definitions if row.label} attr_maps = { - row.raw_name: row + row.raw_name.casefold(): row for row in session.scalars(select(SourceAttributeMappingRecord)).all() } - for key in keys: - mapping = attr_maps.get(key) + for item in merge_observation_attributes(raw_attributes, normalized_attributes): + raw_name = str(item["raw_name"]) + attribute_key = str(item["attribute_key"]) + mapping = attr_maps.get(raw_name.casefold()) definition = None if mapping is None else session.get(CriterionDefinitionRecord, mapping.criterion_definition_id) if definition is None: - definition = definitions.get(key) + definition = ( + by_label.get(raw_name.casefold()) + or by_score.get(attribute_key) + or by_key.get(attribute_key) + ) session.add( ProductObservationAttributeRecord( id=uuid4(), observation_id=observation_id, criterion_definition_id=None if definition is None else definition.id, - attribute_key=key, - raw_name=key, - raw_value=raw_attributes.get(key), - normalized_value=normalized_attributes.get(key), + attribute_key=attribute_key, + raw_name=raw_name, + raw_value=item["raw_value"], + normalized_value=item["normalized_value"], unit=None if definition is None else definition.unit, ) ) diff --git a/backend/app/sources/geizhals/result_parser.py b/backend/app/sources/geizhals/result_parser.py index 4caefd5..7974f66 100644 --- a/backend/app/sources/geizhals/result_parser.py +++ b/backend/app/sources/geizhals/result_parser.py @@ -168,9 +168,11 @@ def _definition_list(node: Any) -> dict[str, str]: if isinstance(child, str): continue if child.tag == "dt": - pending = re.sub(r"\s+", " ", node_text(child)).strip() + pending = re.sub(r"\s+", " ", node_text(child)).strip().rstrip(":") elif child.tag == "dd" and pending: - pairs[pending] = re.sub(r"\s+", " ", node_text(child)).strip() + value = re.sub(r"\s+", " ", node_text(child)).strip() + if value: + pairs[pending] = value pending = None return for child in current.children: diff --git a/backend/tests/unit/test_observation_attributes.py b/backend/tests/unit/test_observation_attributes.py new file mode 100644 index 0000000..726b236 --- /dev/null +++ b/backend/tests/unit/test_observation_attributes.py @@ -0,0 +1,28 @@ +from app.discovery.attributes import is_derived_duplicate, merge_observation_attributes + + +def test_merge_keeps_product_values_and_drops_alias_duplicates() -> None: + items = merge_observation_attributes( + { + "CPU": "Apple M5 - 10 Core CPU", + "Display": '14.2" (3024x1964)', + "RAM": "16GB LPDDR5X", + "Speicher": "512GB SSD", + "Grafik": "", + }, + {"ram_gb": 16, "storage_gb": 512}, + ) + by_name = {item["raw_name"]: item for item in items} + assert "ram_gb" not in by_name + assert "storage_gb" not in by_name + assert by_name["CPU"]["raw_value"] == "Apple M5 - 10 Core CPU" + assert by_name["Display"]["raw_value"] == '14.2" (3024x1964)' + assert by_name["RAM"]["normalized_value"] == 16 + assert by_name["RAM"]["attribute_key"] == "ram_gb" + assert by_name["Speicher"]["normalized_value"] == 512 + assert "Grafik" not in by_name + + +def test_derived_duplicate_is_detected() -> None: + assert is_derived_duplicate("ram_gb", {"ram", "cpu"}) + assert not is_derived_duplicate("cpu", {"ram", "cpu"}) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 17c9e7c..9c2d011 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -141,7 +141,12 @@ export type Candidate = { source_url?: string | null; current_best_price?: string | null; currency?: string | null; - current_dynamic_attributes?: { raw_name: string; normalized_value: unknown; unit?: string | null }[]; + current_dynamic_attributes?: { + raw_name: string; + raw_value?: unknown; + normalized_value: unknown; + unit?: string | null; + }[]; product_score?: number | null; product_eligibility?: string | null; ranking_status: string; @@ -160,7 +165,7 @@ export type Observation = { currency?: string | null; availability?: string | null; seller_count?: number | null; - dynamic_attributes?: { raw_name: string; normalized_value: unknown }[]; + dynamic_attributes?: { raw_name: string; raw_value?: unknown; normalized_value: unknown }[]; }; export type Source = { id: string; name: string }; export type SourceCategory = { diff --git a/frontend/src/pages/CandidateDetailPage.tsx b/frontend/src/pages/CandidateDetailPage.tsx index 985fa09..3ae63b2 100644 --- a/frontend/src/pages/CandidateDetailPage.tsx +++ b/frontend/src/pages/CandidateDetailPage.tsx @@ -32,11 +32,15 @@ export function CandidateDetailPage() {

Attribute

- {(Array.isArray(item.current_dynamic_attributes) ? item.current_dynamic_attributes : []).map((attr) => ( -

- {attr.raw_name}: {String(attr.normalized_value ?? "")} {attr.unit || ""} -

- ))} + {(Array.isArray(item.current_dynamic_attributes) ? item.current_dynamic_attributes : []).map((attr) => { + const value = attr.raw_value ?? attr.normalized_value; + if (value == null || value === "") return null; + return ( +

+ {attr.raw_name}: {String(value)} {attr.unit || ""} +

+ ); + })}

Historie