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 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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
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;
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user