Zeige die echten Produktwerte aus der Trefferliste und vermeide doppelte RAM/Speicher-Schluessel.
Some checks failed
Deploy Development / deploy (push) Successful in 2m1s
Test Suite / pytest-backend (push) Failing after 14s
Test Suite / smoke-dev (push) Successful in 1s
Test Suite / frontend-checks (push) Successful in 26s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-10-04 15:18:48 +02:00
parent 33336bcb6e
commit e6d53e0eeb
6 changed files with 142 additions and 34 deletions

View File

@ -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))

View File

@ -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 [
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": row.raw_name or row.attribute_key,
"raw_value": row.raw_value,
"normalized_value": row.normalized_value,
"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,
}
for row in rows
]
)
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,
)
)

View File

@ -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:

View File

@ -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"})

View File

@ -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 = {

View File

@ -32,11 +32,15 @@ export function CandidateDetailPage() {
</div>
<div className="card">
<h2>Attribute</h2>
{(Array.isArray(item.current_dynamic_attributes) ? item.current_dynamic_attributes : []).map((attr) => (
{(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 (
<p key={attr.raw_name}>
{attr.raw_name}: {String(attr.normalized_value ?? "")} {attr.unit || ""}
{attr.raw_name}: {String(value)} {attr.unit || ""}
</p>
))}
);
})}
</div>
<div className="card">
<h2>Historie</h2>