Zeige die echten Produktwerte aus der Trefferliste und vermeide doppelte RAM/Speicher-Schluessel.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
33336bcb6e
commit
e6d53e0eeb
51
backend/app/discovery/attributes.py
Normal file
51
backend/app/discovery/attributes.py
Normal 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))
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.clock import now_utc
|
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.definitions import get_definition, get_definition_by_key, upsert_definition
|
||||||
from app.discovery.diff import diff_run, next_lifecycle
|
from app.discovery.diff import diff_run, next_lifecycle
|
||||||
from app.discovery.enums import (
|
from app.discovery.enums import (
|
||||||
|
|
@ -1447,7 +1448,7 @@ def _attribute_map(session: Session, observation_id: UUID) -> dict[str, Any]:
|
||||||
key = row.attribute_key
|
key = row.attribute_key
|
||||||
if definition is not None and definition.scoring_attribute_key:
|
if definition is not None and definition.scoring_attribute_key:
|
||||||
key = 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
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1457,16 +1458,29 @@ def _dynamic_attributes(session: Session, observation_id: UUID) -> list[dict[str
|
||||||
ProductObservationAttributeRecord.observation_id == observation_id
|
ProductObservationAttributeRecord.observation_id == observation_id
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return [
|
present = {(row.raw_name or row.attribute_key or "").casefold() for row in rows}
|
||||||
{
|
items: list[dict[str, Any]] = []
|
||||||
"criterion_definition_id": row.criterion_definition_id,
|
for row in rows:
|
||||||
"raw_name": row.raw_name or row.attribute_key,
|
name = row.raw_name or row.attribute_key or ""
|
||||||
"raw_value": row.raw_value,
|
if is_derived_duplicate(row.attribute_key or "", present) or is_derived_duplicate(name, present):
|
||||||
"normalized_value": row.normalized_value,
|
continue
|
||||||
"unit": row.unit,
|
raw = row.raw_value
|
||||||
}
|
normalized = row.normalized_value
|
||||||
for row in rows
|
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(
|
def _store_observation_attributes(
|
||||||
|
|
@ -1475,30 +1489,34 @@ def _store_observation_attributes(
|
||||||
raw_attributes: dict[str, Any],
|
raw_attributes: dict[str, Any],
|
||||||
normalized_attributes: dict[str, Any],
|
normalized_attributes: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
keys = set(raw_attributes) | set(normalized_attributes)
|
definitions = list(session.scalars(select(CriterionDefinitionRecord)))
|
||||||
definitions = {
|
by_score = {row.scoring_attribute_key: row for row in definitions if row.scoring_attribute_key}
|
||||||
row.scoring_attribute_key or row.key: row
|
by_key = {row.key: row for row in definitions}
|
||||||
for row in session.scalars(select(CriterionDefinitionRecord)).all()
|
by_label = {row.label.casefold(): row for row in definitions if row.label}
|
||||||
if row.scoring_attribute_key or row.key
|
|
||||||
}
|
|
||||||
attr_maps = {
|
attr_maps = {
|
||||||
row.raw_name: row
|
row.raw_name.casefold(): row
|
||||||
for row in session.scalars(select(SourceAttributeMappingRecord)).all()
|
for row in session.scalars(select(SourceAttributeMappingRecord)).all()
|
||||||
}
|
}
|
||||||
for key in keys:
|
for item in merge_observation_attributes(raw_attributes, normalized_attributes):
|
||||||
mapping = attr_maps.get(key)
|
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)
|
definition = None if mapping is None else session.get(CriterionDefinitionRecord, mapping.criterion_definition_id)
|
||||||
if definition is None:
|
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(
|
session.add(
|
||||||
ProductObservationAttributeRecord(
|
ProductObservationAttributeRecord(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
observation_id=observation_id,
|
observation_id=observation_id,
|
||||||
criterion_definition_id=None if definition is None else definition.id,
|
criterion_definition_id=None if definition is None else definition.id,
|
||||||
attribute_key=key,
|
attribute_key=attribute_key,
|
||||||
raw_name=key,
|
raw_name=raw_name,
|
||||||
raw_value=raw_attributes.get(key),
|
raw_value=item["raw_value"],
|
||||||
normalized_value=normalized_attributes.get(key),
|
normalized_value=item["normalized_value"],
|
||||||
unit=None if definition is None else definition.unit,
|
unit=None if definition is None else definition.unit,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -168,9 +168,11 @@ def _definition_list(node: Any) -> dict[str, str]:
|
||||||
if isinstance(child, str):
|
if isinstance(child, str):
|
||||||
continue
|
continue
|
||||||
if child.tag == "dt":
|
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:
|
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
|
pending = None
|
||||||
return
|
return
|
||||||
for child in current.children:
|
for child in current.children:
|
||||||
|
|
|
||||||
28
backend/tests/unit/test_observation_attributes.py
Normal file
28
backend/tests/unit/test_observation_attributes.py
Normal 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"})
|
||||||
|
|
@ -141,7 +141,12 @@ export type Candidate = {
|
||||||
source_url?: string | null;
|
source_url?: string | null;
|
||||||
current_best_price?: string | null;
|
current_best_price?: string | null;
|
||||||
currency?: 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_score?: number | null;
|
||||||
product_eligibility?: string | null;
|
product_eligibility?: string | null;
|
||||||
ranking_status: string;
|
ranking_status: string;
|
||||||
|
|
@ -160,7 +165,7 @@ export type Observation = {
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
availability?: string | null;
|
availability?: string | null;
|
||||||
seller_count?: number | 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 Source = { id: string; name: string };
|
||||||
export type SourceCategory = {
|
export type SourceCategory = {
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,15 @@ export function CandidateDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Attribute</h2>
|
<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) => {
|
||||||
<p key={attr.raw_name}>
|
const value = attr.raw_value ?? attr.normalized_value;
|
||||||
{attr.raw_name}: {String(attr.normalized_value ?? "")} {attr.unit || ""}
|
if (value == null || value === "") return null;
|
||||||
</p>
|
return (
|
||||||
))}
|
<p key={attr.raw_name}>
|
||||||
|
{attr.raw_name}: {String(value)} {attr.unit || ""}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Historie</h2>
|
<h2>Historie</h2>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user