Uebernimm Enum-Listen und Filtergruppen in die Taxonomie, speichere Suchtreffer als Kandidaten und lasse CI das vorhandene API-Image nutzen.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2117ca6b54
commit
68a8e3d945
|
|
@ -47,7 +47,7 @@ jobs:
|
||||||
if ! grep -q '^BUYING_AGENT_FRONTEND_PORT=' .env; then
|
if ! grep -q '^BUYING_AGENT_FRONTEND_PORT=' .env; then
|
||||||
echo 'BUYING_AGENT_FRONTEND_PORT=5174' >> .env
|
echo 'BUYING_AGENT_FRONTEND_PORT=5174' >> .env
|
||||||
fi
|
fi
|
||||||
docker compose -f docker-compose.dev-env.yml build --pull=false api frontend
|
COMPOSE_BAKE=0 docker compose -f docker-compose.dev-env.yml build --pull=false api frontend
|
||||||
if ! ENGINE_BUILD="$ENGINE_BUILD" BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \
|
if ! ENGINE_BUILD="$ENGINE_BUILD" BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \
|
||||||
docker compose -f docker-compose.dev-env.yml up -d --wait; then
|
docker compose -f docker-compose.dev-env.yml up -d --wait; then
|
||||||
echo "compose up --wait failed — api logs:"
|
echo "compose up --wait failed — api logs:"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ jobs:
|
||||||
if ! grep -q '^BUYING_AGENT_FRONTEND_PORT=' .env; then
|
if ! grep -q '^BUYING_AGENT_FRONTEND_PORT=' .env; then
|
||||||
echo 'BUYING_AGENT_FRONTEND_PORT=4173' >> .env
|
echo 'BUYING_AGENT_FRONTEND_PORT=4173' >> .env
|
||||||
fi
|
fi
|
||||||
docker compose build --pull=false api frontend
|
COMPOSE_BAKE=0 docker compose build --pull=false api frontend
|
||||||
if ! ENGINE_BUILD="$ENGINE_BUILD" \
|
if ! ENGINE_BUILD="$ENGINE_BUILD" \
|
||||||
BUYING_AGENT_API_BIND="$BUYING_AGENT_API_BIND" \
|
BUYING_AGENT_API_BIND="$BUYING_AGENT_API_BIND" \
|
||||||
BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \
|
BUYING_AGENT_API_PORT="$BUYING_AGENT_API_PORT" \
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ FROM base AS runtime
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
RUN uv sync --frozen --extra live --no-dev \
|
RUN uv sync --frozen --extra live --extra test --no-dev \
|
||||||
&& playwright install --with-deps chromium \
|
&& playwright install --with-deps chromium \
|
||||||
&& sed -i 's/\r$//' /app/scripts/startup.sh \
|
&& sed -i 's/\r$//' /app/scripts/startup.sh \
|
||||||
&& chmod +x /app/scripts/startup.sh \
|
&& chmod +x /app/scripts/startup.sh \
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,7 @@ def _encode_mapping(mapping: dict[str, Any], criterion: dict[str, Any]) -> list[
|
||||||
if encoding.get("encoding_type") == "RANGE_TOKEN":
|
if encoding.get("encoding_type") == "RANGE_TOKEN":
|
||||||
return _encode_range_token(mapping, criterion)
|
return _encode_range_token(mapping, criterion)
|
||||||
if param == "xf":
|
if param == "xf":
|
||||||
filter_key = str(mapping["external_filter_key"])
|
return _encode_xf(mapping, criterion)
|
||||||
source_value = _xf_value(mapping, criterion)
|
|
||||||
return [("xf", f"{filter_key}_{_plus(source_value)}")]
|
|
||||||
if param in {"minp", "maxp", "prmin", "prmax", "asuch", "fs"}:
|
if param in {"minp", "maxp", "prmin", "prmax", "asuch", "fs"}:
|
||||||
return [(param, str(criterion.get("value")))]
|
return [(param, str(criterion.get("value")))]
|
||||||
template = encoding.get("value_template")
|
template = encoding.get("value_template")
|
||||||
|
|
@ -112,10 +110,30 @@ def _lookup_option(mapping: dict[str, Any], value: Any) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_xf(mapping: dict[str, Any], criterion: dict[str, Any]) -> list[tuple[str, str]]:
|
||||||
|
filter_key = str(mapping["external_filter_key"])
|
||||||
|
raw = criterion.get("value")
|
||||||
|
values = raw if isinstance(raw, list) else [raw]
|
||||||
|
fragments: list[tuple[str, str]] = []
|
||||||
|
for value in values:
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
source_value = _xf_value(mapping, {**criterion, "value": value})
|
||||||
|
fragments.append(("xf", f"{filter_key}_{_plus(source_value)}"))
|
||||||
|
if fragments:
|
||||||
|
return fragments
|
||||||
|
source_value = _xf_value(mapping, criterion)
|
||||||
|
return [("xf", f"{filter_key}_{_plus(source_value)}")]
|
||||||
|
|
||||||
|
|
||||||
def _xf_value(mapping: dict[str, Any], criterion: dict[str, Any]) -> str:
|
def _xf_value(mapping: dict[str, Any], criterion: dict[str, Any]) -> str:
|
||||||
looked_up = _lookup_option(mapping, criterion.get("value"))
|
looked_up = _lookup_option(mapping, criterion.get("value"))
|
||||||
if looked_up is not None:
|
if looked_up is not None:
|
||||||
return looked_up
|
return looked_up
|
||||||
|
options = (mapping.get("encoding") or {}).get("options") or {}
|
||||||
|
value = criterion.get("value")
|
||||||
|
if options and value is not None and value != "":
|
||||||
|
return str(value)
|
||||||
return str(mapping.get("source_value") or _threshold_value(mapping, criterion))
|
return str(mapping.get("source_value") or _threshold_value(mapping, criterion))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ class SourceFilterDefinitionRecord(Base):
|
||||||
source_category_id: Mapped[UUID] = mapped_column(ForeignKey("source_categories.id"), nullable=False)
|
source_category_id: Mapped[UUID] = mapped_column(ForeignKey("source_categories.id"), nullable=False)
|
||||||
external_filter_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
external_filter_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
label: Mapped[str] = mapped_column(String(256), nullable=False)
|
label: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
|
group_label: Mapped[str | None] = mapped_column(String(128))
|
||||||
filter_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
filter_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
canonical_unit: Mapped[str | None] = mapped_column(String(32))
|
canonical_unit: Mapped[str | None] = mapped_column(String(32))
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ class CatalogService:
|
||||||
filters = self.session.scalars(
|
filters = self.session.scalars(
|
||||||
select(SourceFilterDefinitionRecord)
|
select(SourceFilterDefinitionRecord)
|
||||||
.where(SourceFilterDefinitionRecord.source_category_id == source_category.id)
|
.where(SourceFilterDefinitionRecord.source_category_id == source_category.id)
|
||||||
.order_by(SourceFilterDefinitionRecord.label)
|
.order_by(SourceFilterDefinitionRecord.group_label, SourceFilterDefinitionRecord.label)
|
||||||
).all()
|
).all()
|
||||||
mappings = {
|
mappings = {
|
||||||
row.external_filter_key: row
|
row.external_filter_key: row
|
||||||
|
|
@ -173,7 +173,9 @@ class CatalogService:
|
||||||
)
|
)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
options = self.session.scalars(
|
options = self.session.scalars(
|
||||||
select(SourceFilterOptionRecord).where(SourceFilterOptionRecord.filter_id == filt.id)
|
select(SourceFilterOptionRecord)
|
||||||
|
.where(SourceFilterOptionRecord.filter_id == filt.id)
|
||||||
|
.order_by(SourceFilterOptionRecord.source_value)
|
||||||
).all()
|
).all()
|
||||||
rng = self.session.get(SourceFilterRangeRecord, filt.id)
|
rng = self.session.get(SourceFilterRangeRecord, filt.id)
|
||||||
criteria.append(
|
criteria.append(
|
||||||
|
|
@ -254,6 +256,7 @@ class CatalogService:
|
||||||
return {
|
return {
|
||||||
"criterion_definition_id": definition.id,
|
"criterion_definition_id": definition.id,
|
||||||
"label": definition.label,
|
"label": definition.label,
|
||||||
|
"group_label": None if filt is None else (filt.group_label or "Allgemein"),
|
||||||
"description": definition.description,
|
"description": definition.description,
|
||||||
"origin": definition.origin,
|
"origin": definition.origin,
|
||||||
"source": definition.source,
|
"source": definition.source,
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ class TaxonomyService:
|
||||||
source_category_id=category.id,
|
source_category_id=category.id,
|
||||||
external_filter_key=str(raw["external_filter_key"]),
|
external_filter_key=str(raw["external_filter_key"]),
|
||||||
label=str(raw.get("label") or raw["external_filter_key"]),
|
label=str(raw.get("label") or raw["external_filter_key"]),
|
||||||
|
group_label=raw.get("group_label") or "Allgemein",
|
||||||
filter_type=filter_type,
|
filter_type=filter_type,
|
||||||
canonical_unit=raw.get("canonical_unit"),
|
canonical_unit=raw.get("canonical_unit"),
|
||||||
status=MappingStatus.LEARNED_UNVERIFIED.value,
|
status=MappingStatus.LEARNED_UNVERIFIED.value,
|
||||||
|
|
@ -181,6 +182,7 @@ class TaxonomyService:
|
||||||
if filt.filter_type != filter_type:
|
if filt.filter_type != filter_type:
|
||||||
self._break_mappings(category.id, filt.external_filter_key)
|
self._break_mappings(category.id, filt.external_filter_key)
|
||||||
filt.label = str(raw.get("label") or filt.label)
|
filt.label = str(raw.get("label") or filt.label)
|
||||||
|
filt.group_label = raw.get("group_label") or filt.group_label or "Allgemein"
|
||||||
filt.filter_type = filter_type
|
filt.filter_type = filter_type
|
||||||
filt.canonical_unit = raw.get("canonical_unit")
|
filt.canonical_unit = raw.get("canonical_unit")
|
||||||
self._sync_options(filt, raw.get("options") or [])
|
self._sync_options(filt, raw.get("options") or [])
|
||||||
|
|
@ -223,12 +225,14 @@ class TaxonomyService:
|
||||||
rows = self.session.scalars(
|
rows = self.session.scalars(
|
||||||
select(SourceFilterDefinitionRecord)
|
select(SourceFilterDefinitionRecord)
|
||||||
.where(SourceFilterDefinitionRecord.source_category_id == category.id)
|
.where(SourceFilterDefinitionRecord.source_category_id == category.id)
|
||||||
.order_by(SourceFilterDefinitionRecord.external_filter_key)
|
.order_by(SourceFilterDefinitionRecord.group_label, SourceFilterDefinitionRecord.label)
|
||||||
).all()
|
).all()
|
||||||
filters = []
|
filters = []
|
||||||
for filt in rows:
|
for filt in rows:
|
||||||
options = self.session.scalars(
|
options = self.session.scalars(
|
||||||
select(SourceFilterOptionRecord).where(SourceFilterOptionRecord.filter_id == filt.id)
|
select(SourceFilterOptionRecord)
|
||||||
|
.where(SourceFilterOptionRecord.filter_id == filt.id)
|
||||||
|
.order_by(SourceFilterOptionRecord.source_value)
|
||||||
).all()
|
).all()
|
||||||
rng = self.session.get(SourceFilterRangeRecord, filt.id)
|
rng = self.session.get(SourceFilterRangeRecord, filt.id)
|
||||||
filters.append(
|
filters.append(
|
||||||
|
|
@ -236,6 +240,7 @@ class TaxonomyService:
|
||||||
"id": filt.id,
|
"id": filt.id,
|
||||||
"external_filter_key": filt.external_filter_key,
|
"external_filter_key": filt.external_filter_key,
|
||||||
"label": filt.label,
|
"label": filt.label,
|
||||||
|
"group_label": filt.group_label or "Allgemein",
|
||||||
"filter_type": filt.filter_type,
|
"filter_type": filt.filter_type,
|
||||||
"canonical_unit": filt.canonical_unit,
|
"canonical_unit": filt.canonical_unit,
|
||||||
"status": filt.status,
|
"status": filt.status,
|
||||||
|
|
@ -912,7 +917,7 @@ class SearchExecutionService:
|
||||||
pages: list[dict[str, Any]] = []
|
pages: list[dict[str, Any]] = []
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
pages, items, error_code = self._paginate(adapter, search)
|
pages, items, error_code, advertised = self._paginate(adapter, search)
|
||||||
except AppError as exc:
|
except AppError as exc:
|
||||||
run.status = RunStatus.FAILED.value
|
run.status = RunStatus.FAILED.value
|
||||||
run.error_code = exc.code
|
run.error_code = exc.code
|
||||||
|
|
@ -921,7 +926,7 @@ class SearchExecutionService:
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
return self.searches.get_run(run.id)
|
return self.searches.get_run(run.id)
|
||||||
run.page_count = len(pages)
|
run.page_count = len(pages)
|
||||||
run.result_count = len(items)
|
run.result_count = max(len(items), advertised)
|
||||||
run.http_status_summary = {"pages": pages}
|
run.http_status_summary = {"pages": pages}
|
||||||
run.error_code = error_code
|
run.error_code = error_code
|
||||||
run.finished_at = now_utc()
|
run.finished_at = now_utc()
|
||||||
|
|
@ -957,33 +962,38 @@ class SearchExecutionService:
|
||||||
self,
|
self,
|
||||||
adapter: DiscoverySourceAdapter,
|
adapter: DiscoverySourceAdapter,
|
||||||
search: DiscoverySearchRecord,
|
search: DiscoverySearchRecord,
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str | None]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str | None, int]:
|
||||||
query = {"url": search.generated_url}
|
query = {"url": search.generated_url}
|
||||||
collected: list[dict[str, Any]] = []
|
collected: list[dict[str, Any]] = []
|
||||||
pages: list[dict[str, Any]] = []
|
pages: list[dict[str, Any]] = []
|
||||||
page = 1
|
page = 1
|
||||||
expected_pages = 1
|
expected_pages = 1
|
||||||
|
advertised = 0
|
||||||
while page <= expected_pages and page <= MAX_PAGES:
|
while page <= expected_pages and page <= MAX_PAGES:
|
||||||
try:
|
try:
|
||||||
response = adapter.fetch_results(query, {"page": page})
|
response = adapter.fetch_results(query, {"page": page})
|
||||||
except AppError as exc:
|
except AppError as exc:
|
||||||
if exc.code == "FIXTURE_NOT_FOUND" and page > 1:
|
if exc.code == "FIXTURE_NOT_FOUND" and page > 1:
|
||||||
return pages, collected, "PARTIAL"
|
return pages, collected, "PARTIAL", advertised
|
||||||
raise
|
raise
|
||||||
status = int(response.get("http_status") or 200)
|
status = int(response.get("http_status") or 200)
|
||||||
pages.append({"page": page, "status": status})
|
pages.append({"page": page, "status": status, "result_count": response.get("result_count")})
|
||||||
if status == 429:
|
if status == 429:
|
||||||
return pages, collected, "BACKOFF"
|
return pages, collected, "BACKOFF", advertised
|
||||||
if status == 403 or response.get("challenge") == "captcha" or response.get("blocked"):
|
if status == 403 or response.get("challenge") == "captcha" or response.get("blocked"):
|
||||||
return pages, collected, "BLOCKED"
|
return pages, collected, "BLOCKED", advertised
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
return pages, collected, "PARTIAL"
|
return pages, collected, "PARTIAL", advertised
|
||||||
collected.extend(adapter.parse_results(response))
|
collected.extend(adapter.parse_results(response))
|
||||||
|
if response.get("result_count") is not None:
|
||||||
|
advertised = max(advertised, int(response["result_count"]))
|
||||||
|
if advertised > search.broad_result_threshold:
|
||||||
|
return pages, collected, None, advertised
|
||||||
expected_pages = int(response.get("page_count") or page)
|
expected_pages = int(response.get("page_count") or page)
|
||||||
page += 1
|
page += 1
|
||||||
if page > MAX_PAGES + 1 or (expected_pages > MAX_PAGES and len(pages) >= MAX_PAGES):
|
if page > MAX_PAGES + 1 or (expected_pages > MAX_PAGES and len(pages) >= MAX_PAGES):
|
||||||
return pages, collected, "PARTIAL"
|
return pages, collected, "PARTIAL", advertised
|
||||||
return pages, collected, None
|
return pages, collected, None, advertised
|
||||||
|
|
||||||
def _backoff(self, search_id: UUID) -> timedelta:
|
def _backoff(self, search_id: UUID) -> timedelta:
|
||||||
rows = self.session.scalars(
|
rows = self.session.scalars(
|
||||||
|
|
@ -1407,7 +1417,7 @@ class CandidateRankingService:
|
||||||
"source_url": None if ref is None else ref.source_url,
|
"source_url": None if ref is None else ref.source_url,
|
||||||
"current_best_price": None if obs is None else obs.best_price,
|
"current_best_price": None if obs is None else obs.best_price,
|
||||||
"currency": None if obs is None else obs.currency,
|
"currency": None if obs is None else obs.currency,
|
||||||
"current_dynamic_attributes": {} if obs is None else _dynamic_attributes(self.session, obs.id),
|
"current_dynamic_attributes": [] if obs is None else _dynamic_attributes(self.session, obs.id),
|
||||||
"product_score": row.product_score,
|
"product_score": row.product_score,
|
||||||
"product_eligibility": row.product_eligibility,
|
"product_eligibility": row.product_eligibility,
|
||||||
"ranking_status": ranking_status,
|
"ranking_status": ranking_status,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ EXPAND_LABELS = (
|
||||||
"Immer alle Filter anzeigen",
|
"Immer alle Filter anzeigen",
|
||||||
"weitere Filter anzeigen",
|
"weitere Filter anzeigen",
|
||||||
)
|
)
|
||||||
|
MORE_OPTION_SELECTOR = 'button:has-text("weitere"), a:has-text("weitere")'
|
||||||
|
|
||||||
|
|
||||||
def load_result_page(url: str, *, playwright: bool = True) -> tuple[str, str, int, bool]:
|
def load_result_page(url: str, *, playwright: bool = True) -> tuple[str, str, int, bool]:
|
||||||
|
|
@ -101,6 +102,7 @@ def _fetch_playwright(url: str, *, expand_filters: bool = False) -> tuple[int, s
|
||||||
continue
|
continue
|
||||||
if expand_filters:
|
if expand_filters:
|
||||||
_expand_filter_sections(page)
|
_expand_filter_sections(page)
|
||||||
|
_expand_more_options(page)
|
||||||
html = page.content()
|
html = page.content()
|
||||||
status = response.status if response is not None else 200
|
status = response.status if response is not None else 200
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
@ -119,6 +121,18 @@ def _expand_filter_sections(page: object) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_more_options(page: object) -> None:
|
||||||
|
locator = page.locator(MORE_OPTION_SELECTOR)
|
||||||
|
for _ in range(80):
|
||||||
|
if locator.count() == 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
locator.first.click(timeout=2000)
|
||||||
|
page.wait_for_timeout(350)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _blocked(status: int, html: str) -> bool:
|
def _blocked(status: int, html: str) -> bool:
|
||||||
if status in {401, 403, 429}:
|
if status in {401, 403, 429}:
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,26 @@ SKIP_OPTION_LABELS = {
|
||||||
"filter überspringen",
|
"filter überspringen",
|
||||||
"einfache filter",
|
"einfache filter",
|
||||||
"erweiterte filter",
|
"erweiterte filter",
|
||||||
|
"weitere",
|
||||||
|
"weniger",
|
||||||
|
"alle anzeigen",
|
||||||
|
"weniger anzeigen",
|
||||||
}
|
}
|
||||||
|
PRODUCT_COUNT_RE = re.compile(r"\(\d[\d.]*\s*(produkte)?\)\s*$", re.IGNORECASE)
|
||||||
|
OPTION_TAGS = {"a", "button", "label", "input", "span"}
|
||||||
|
GROUP_ORDER = (
|
||||||
|
"Allgemein",
|
||||||
|
"Display",
|
||||||
|
"CPU",
|
||||||
|
"RAM",
|
||||||
|
"RAM erweitert",
|
||||||
|
"Grafik",
|
||||||
|
"Anschlüsse",
|
||||||
|
"Wireless",
|
||||||
|
"Akku",
|
||||||
|
"Abmessungen",
|
||||||
|
"Sonstiges",
|
||||||
|
)
|
||||||
RANGE_MIN_NAMES = {"bpmin", "minp", "prmin"}
|
RANGE_MIN_NAMES = {"bpmin", "minp", "prmin"}
|
||||||
RANGE_MAX_NAMES = {"bpmax", "maxp", "prmax"}
|
RANGE_MAX_NAMES = {"bpmax", "maxp", "prmax"}
|
||||||
TEXT_NAMES = {"asuch", "fs"}
|
TEXT_NAMES = {"asuch", "fs"}
|
||||||
|
|
@ -117,7 +136,11 @@ def parse_taxonomy_html(html: str, *, category_ref: str, source_url: str) -> dic
|
||||||
for group in groups.values()
|
for group in groups.values()
|
||||||
if _usable_group(group)
|
if _usable_group(group)
|
||||||
]
|
]
|
||||||
filters.sort(key=lambda item: (item["label"].lower(), item["external_filter_key"]))
|
for group in filters:
|
||||||
|
section = group.get("group_label") or "Allgemein"
|
||||||
|
group["group_label"] = section
|
||||||
|
group["group_sort"] = _group_sort(section)
|
||||||
|
filters.sort(key=lambda item: (item["group_sort"], item["group_label"].lower(), item["label"].lower(), item["external_filter_key"]))
|
||||||
return {
|
return {
|
||||||
"source": "geizhals",
|
"source": "geizhals",
|
||||||
"external_category_key": category_ref,
|
"external_category_key": category_ref,
|
||||||
|
|
@ -148,6 +171,9 @@ def _collect_declared_filters(node: _Node, groups: dict[str, dict[str, Any]]) ->
|
||||||
source_type = node.attrs.get("data-filter-type") or ""
|
source_type = node.attrs.get("data-filter-type") or ""
|
||||||
label = _declared_label(node) or f"Filter {filter_id}"
|
label = _declared_label(node) or f"Filter {filter_id}"
|
||||||
group = groups.setdefault(filter_id, _empty_group(filter_id, label, source_type))
|
group = groups.setdefault(filter_id, _empty_group(filter_id, label, source_type))
|
||||||
|
section = _section_label(node)
|
||||||
|
if section:
|
||||||
|
group["group_label"] = section
|
||||||
if label and group["label"].startswith("Filter "):
|
if label and group["label"].startswith("Filter "):
|
||||||
group["label"] = label
|
group["label"] = label
|
||||||
if source_type:
|
if source_type:
|
||||||
|
|
@ -169,19 +195,21 @@ def _collect_xf_links(
|
||||||
required_key: str | None = None,
|
required_key: str | None = None,
|
||||||
allow_histogram: bool = False,
|
allow_histogram: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if node.tag == "a":
|
if node.tag in OPTION_TAGS:
|
||||||
href = node.attrs.get("href") or ""
|
label = node.attrs.get("aria-label") or node.attrs.get("title") or _node_text(node)
|
||||||
label = node.attrs.get("aria-label") or _node_text(node)
|
|
||||||
if _usable_option(node, label, allow_histogram=allow_histogram):
|
if _usable_option(node, label, allow_histogram=allow_histogram):
|
||||||
for key, query_value in _xf_tokens(href):
|
for key, query_value in _node_xf_tokens(node):
|
||||||
if required_key is not None and key != required_key:
|
if required_key is not None and key != required_key:
|
||||||
continue
|
continue
|
||||||
if required_key is None and not _has_structural_label(node):
|
if required_key is None and not _has_structural_label(node):
|
||||||
continue
|
continue
|
||||||
group_label = _declared_label(_filter_root(node)) or _nearest_group_label(node)
|
filter_label = _declared_label(_filter_root(node)) or _nearest_group_label(node)
|
||||||
group = groups.setdefault(key, _empty_group(key, group_label, None))
|
group = groups.setdefault(key, _empty_group(key, filter_label, None))
|
||||||
if group_label and (group["label"].startswith("Filter ") or group["label"] == key):
|
section = _section_label(node)
|
||||||
group["label"] = group_label
|
if section:
|
||||||
|
group["group_label"] = section
|
||||||
|
if filter_label and (group["label"].startswith("Filter ") or group["label"] == key):
|
||||||
|
group["label"] = filter_label
|
||||||
option = _option_from_link(label, query_value)
|
option = _option_from_link(label, query_value)
|
||||||
existing = {item["source_value"] for item in group["options"]}
|
existing = {item["source_value"] for item in group["options"]}
|
||||||
if option["source_value"] not in existing:
|
if option["source_value"] not in existing:
|
||||||
|
|
@ -207,11 +235,14 @@ def _collect_named_inputs(node: _Node, groups: dict[str, dict[str, Any]]) -> Non
|
||||||
min_name = next((name for name in RANGE_MIN_NAMES if name in names), "bpmin")
|
min_name = next((name for name in RANGE_MIN_NAMES if name in names), "bpmin")
|
||||||
max_name = next((name for name in RANGE_MAX_NAMES if name in names), "bpmax")
|
max_name = next((name for name in RANGE_MAX_NAMES if name in names), "bpmax")
|
||||||
label = _nearest_group_label(names.get(min_name) or names.get(max_name) or node) or "Preis"
|
label = _nearest_group_label(names.get(min_name) or names.get(max_name) or node) or "Preis"
|
||||||
|
section = _section_label(names.get(min_name) or names.get(max_name) or node) or "Allgemein"
|
||||||
groups["price"] = {
|
groups["price"] = {
|
||||||
"external_filter_key": "price",
|
"external_filter_key": "price",
|
||||||
"label": "Preis" if "preis" not in label.lower() else label,
|
"label": "Preis" if "preis" not in label.lower() else label,
|
||||||
"filter_type": FilterType.NUMERIC_RANGE.value,
|
"filter_type": FilterType.NUMERIC_RANGE.value,
|
||||||
"canonical_unit": "EUR",
|
"canonical_unit": "EUR",
|
||||||
|
"group_label": section,
|
||||||
|
"group_sort": _group_sort(section),
|
||||||
"options": [],
|
"options": [],
|
||||||
"range": {"minimum": 0, "maximum": None, "step": 1, "unit": "EUR"},
|
"range": {"minimum": 0, "maximum": None, "step": 1, "unit": "EUR"},
|
||||||
"encoding": {
|
"encoding": {
|
||||||
|
|
@ -225,11 +256,14 @@ def _collect_named_inputs(node: _Node, groups: dict[str, dict[str, Any]]) -> Non
|
||||||
if name not in names:
|
if name not in names:
|
||||||
continue
|
continue
|
||||||
label = _nearest_group_label(names[name]) or "Suchbegriffe"
|
label = _nearest_group_label(names[name]) or "Suchbegriffe"
|
||||||
|
section = _section_label(names[name]) or "Allgemein"
|
||||||
groups[name] = {
|
groups[name] = {
|
||||||
"external_filter_key": name,
|
"external_filter_key": name,
|
||||||
"label": label,
|
"label": label,
|
||||||
"filter_type": FilterType.TEXT.value,
|
"filter_type": FilterType.TEXT.value,
|
||||||
"canonical_unit": None,
|
"canonical_unit": None,
|
||||||
|
"group_label": section,
|
||||||
|
"group_sort": _group_sort(section),
|
||||||
"options": [],
|
"options": [],
|
||||||
"encoding": {"url_param": name, "encoding_type": "TEXT_PARAMETER", "value_template": "{value}"},
|
"encoding": {"url_param": name, "encoding_type": "TEXT_PARAMETER", "value_template": "{value}"},
|
||||||
}
|
}
|
||||||
|
|
@ -243,6 +277,30 @@ def _find_inputs(node: _Node, found: list[_Node]) -> None:
|
||||||
_find_inputs(child, found)
|
_find_inputs(child, found)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_xf_tokens(node: _Node) -> list[tuple[str, str]]:
|
||||||
|
tokens = _xf_tokens(node.attrs.get("href") or "")
|
||||||
|
if tokens:
|
||||||
|
return tokens
|
||||||
|
for attr in ("data-xf", "data-filter-value", "data-value", "value"):
|
||||||
|
raw = node.attrs.get(attr) or ""
|
||||||
|
if attr == "value" and node.attrs.get("name") not in {"xf", ""} and node.tag != "input":
|
||||||
|
continue
|
||||||
|
parsed = _xf_raw_token(raw)
|
||||||
|
if parsed:
|
||||||
|
return [parsed]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _xf_raw_token(raw: str) -> tuple[str, str] | None:
|
||||||
|
value = unquote(raw).replace("|", "~").split("~")[0]
|
||||||
|
if "_" not in value:
|
||||||
|
return None
|
||||||
|
key, token = value.split("_", 1)
|
||||||
|
if key.isdigit() and token:
|
||||||
|
return key, token.replace("+", " ")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _xf_tokens(href: str) -> list[tuple[str, str]]:
|
def _xf_tokens(href: str) -> list[tuple[str, str]]:
|
||||||
if not href or href.startswith("#") or href.startswith("javascript:"):
|
if not href or href.startswith("#") or href.startswith("javascript:"):
|
||||||
return []
|
return []
|
||||||
|
|
@ -261,8 +319,7 @@ def _xf_tokens(href: str) -> list[tuple[str, str]]:
|
||||||
|
|
||||||
|
|
||||||
def _option_from_link(label: str, query_value: str) -> dict[str, Any]:
|
def _option_from_link(label: str, query_value: str) -> dict[str, Any]:
|
||||||
cleaned = re.sub(r"\(\d[\d.]*\s*(produkte)?\)\s*$", "", label, flags=re.IGNORECASE).strip()
|
cleaned = PRODUCT_COUNT_RE.sub("", re.sub(r"\s+", " ", label)).strip()
|
||||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
||||||
operator = None
|
operator = None
|
||||||
if cleaned.lower().startswith("ab "):
|
if cleaned.lower().startswith("ab "):
|
||||||
operator = ">="
|
operator = ">="
|
||||||
|
|
@ -299,6 +356,8 @@ def _empty_group(key: str, label: str | None, source_type: str | None) -> dict[s
|
||||||
"label": label or f"Filter {key}",
|
"label": label or f"Filter {key}",
|
||||||
"filter_type": TYPE_MAP.get(source_type or "", FilterType.ENUM.value),
|
"filter_type": TYPE_MAP.get(source_type or "", FilterType.ENUM.value),
|
||||||
"canonical_unit": None,
|
"canonical_unit": None,
|
||||||
|
"group_label": None,
|
||||||
|
"group_sort": _group_sort("Allgemein"),
|
||||||
"options": [],
|
"options": [],
|
||||||
"encoding": {
|
"encoding": {
|
||||||
"url_param": "xf",
|
"url_param": "xf",
|
||||||
|
|
@ -375,18 +434,62 @@ def _usable_option(node: _Node, label: str, *, allow_histogram: bool) -> bool:
|
||||||
return False
|
return False
|
||||||
if "bucket" in classes and not allow_histogram:
|
if "bucket" in classes and not allow_histogram:
|
||||||
return False
|
return False
|
||||||
cleaned = re.sub(r"\s+", " ", label).strip()
|
cleaned = PRODUCT_COUNT_RE.sub("", re.sub(r"\s+", " ", label)).strip()
|
||||||
if not cleaned or _skip_option(cleaned):
|
if not cleaned or _skip_option(cleaned):
|
||||||
return False
|
return False
|
||||||
if "überspringen" in cleaned.lower():
|
if "überspringen" in cleaned.lower() or "aufklappen" in cleaned.lower() or "zuklappen" in cleaned.lower():
|
||||||
return False
|
return False
|
||||||
if not allow_histogram and "produkte" in cleaned.lower():
|
if cleaned.lower().startswith("weitere"):
|
||||||
return False
|
return False
|
||||||
if len(cleaned.split()) > 8:
|
if not allow_histogram and re.fullmatch(r"\d[\d.]*\s*produkte", cleaned, re.IGNORECASE):
|
||||||
|
return False
|
||||||
|
if len(cleaned.split()) > 12:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _section_label(node: _Node | None) -> str | None:
|
||||||
|
current = node
|
||||||
|
while current is not None:
|
||||||
|
classes = current.attrs.get("class", "").lower()
|
||||||
|
if "filter-row" in classes or current.attrs.get("data-filter-row-id"):
|
||||||
|
text = _strip_section_suffix(
|
||||||
|
current.attrs.get("data-filter-row-name")
|
||||||
|
or current.attrs.get("aria-label")
|
||||||
|
or _row_heading(current)
|
||||||
|
)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
labeled = current.attrs.get("aria-label") or ""
|
||||||
|
if re.search(r"(auf|zu)klappen", labeled, re.IGNORECASE):
|
||||||
|
text = _strip_section_suffix(labeled)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
current = current.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_heading(node: _Node) -> str:
|
||||||
|
for child in node.children:
|
||||||
|
if isinstance(child, _Node) and child.tag in {"button", "h2", "h3", "h4", "legend", "strong"}:
|
||||||
|
text = _clean_label(_node_text(child))
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return _clean_label(node.attrs.get("aria-label") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_section_suffix(text: str | None) -> str:
|
||||||
|
cleaned = _clean_label(text or "")
|
||||||
|
return re.sub(r"\s*(aufklappen|zuklappen|einklappen)$", "", cleaned, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _group_sort(label: str) -> int:
|
||||||
|
try:
|
||||||
|
return GROUP_ORDER.index(label)
|
||||||
|
except ValueError:
|
||||||
|
return len(GROUP_ORDER)
|
||||||
|
|
||||||
|
|
||||||
def _usable_group(group: dict[str, Any]) -> bool:
|
def _usable_group(group: dict[str, Any]) -> bool:
|
||||||
if group["filter_type"] == FilterType.TEXT.value:
|
if group["filter_type"] == FilterType.TEXT.value:
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,20 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Notebooks</h1>
|
<h1>Notebooks</h1>
|
||||||
|
<div class="filter-row" data-filter-row-id="allgemein">
|
||||||
|
<button aria-label="Allgemein aufklappen">Allgemein</button>
|
||||||
<div data-filter-type="text_range" data-filter-id="17023">
|
<div data-filter-type="text_range" data-filter-id="17023">
|
||||||
<span id="filter-element__title-label--17023">RAM</span>
|
<span id="filter-element__title-label--17023">RAM</span>
|
||||||
<a href="?cat=nb&xf=17023_ab+16GB&promode=true">ab 16GB</a>
|
<a href="?cat=nb&xf=17023_ab+16GB&promode=true">ab 16GB</a>
|
||||||
<a href="?cat=nb&xf=17023_ab+32GB&promode=true">ab 32GB</a>
|
<a href="?cat=nb&xf=17023_ab+32GB&promode=true">ab 32GB</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div data-filter-type="text" data-filter-id="1">
|
||||||
|
<span id="filter-element__title-label--1">Hersteller</span>
|
||||||
|
<a href="?cat=nb&xf=1_Apple" aria-label="Apple (234 Produkte)">Apple</a>
|
||||||
|
<a href="?cat=nb&xf=1_ASUS">ASUS</a>
|
||||||
|
<button type="button" data-xf="1_Lenovo">Lenovo</button>
|
||||||
|
<button type="button">weitere 12</button>
|
||||||
|
</div>
|
||||||
<form>
|
<form>
|
||||||
<label>Suchbegriffe</label>
|
<label>Suchbegriffe</label>
|
||||||
<input name="asuch" type="text">
|
<input name="asuch" type="text">
|
||||||
|
|
@ -17,15 +26,10 @@
|
||||||
<input name="bpmin" type="number" placeholder="Preis ab">
|
<input name="bpmin" type="number" placeholder="Preis ab">
|
||||||
<input name="bpmax" type="number" placeholder="Preis bis">
|
<input name="bpmax" type="number" placeholder="Preis bis">
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="filter-row" data-filter-row-id="display">
|
||||||
|
<button aria-label="Display aufklappen">Display</button>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
|
||||||
<th>Hersteller:</th>
|
|
||||||
<td>
|
|
||||||
<a href="?cat=nb&xf=1_Apple">Apple</a>
|
|
||||||
<a href="?cat=nb&xf=1_ASUS">ASUS</a>
|
|
||||||
<a href="?cat=nb&xf=1_Lenovo">Lenovo</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>Display-Größe:</th>
|
<th>Display-Größe:</th>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -128,5 +132,6 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
24
backend/migrations/versions/0007_filter_groups.py
Normal file
24
backend/migrations/versions/0007_filter_groups.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Persist source filter groups from the learned taxonomy.
|
||||||
|
|
||||||
|
Revision ID: 0007_filter_groups
|
||||||
|
Revises: 0006_source_category_readiness
|
||||||
|
Create Date: 2026-10-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0007_filter_groups"
|
||||||
|
down_revision: str | None = "0006_source_category_readiness"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("source_filter_definitions", sa.Column("group_label", sa.String(128)))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("source_filter_definitions", "group_label")
|
||||||
|
|
@ -95,6 +95,24 @@ def test_multiple_xf_filters_join_with_tilde():
|
||||||
assert "xf=12_16384~7436_8192" in url or "xf=7436_8192~12_16384" in url
|
assert "xf=12_16384~7436_8192" in url or "xf=7436_8192~12_16384" in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_uses_selected_option_not_first_mapping_value():
|
||||||
|
url, _ = generate_search_url(
|
||||||
|
category_url="https://geizhals.de/?cat=nb",
|
||||||
|
criteria_items=[{"criterion": "hersteller", "operator": "=", "value": "Lenovo"}],
|
||||||
|
mappings=[
|
||||||
|
{
|
||||||
|
"criterion_key": "hersteller",
|
||||||
|
"external_filter_key": "1",
|
||||||
|
"source_value": "Apple",
|
||||||
|
"encoding": {"url_param": "xf", "options": {"Apple": "Apple", "Lenovo": "Lenovo"}},
|
||||||
|
"status": "VERIFIED",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "xf=1_Lenovo" in url
|
||||||
|
assert "Apple" not in url
|
||||||
|
|
||||||
|
|
||||||
def test_roundtrip_verifies_xf_and_maxp():
|
def test_roundtrip_verifies_xf_and_maxp():
|
||||||
assert verify_roundtrip(
|
assert verify_roundtrip(
|
||||||
category_url="https://geizhals.de/?cat=nb",
|
category_url="https://geizhals.de/?cat=nb",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ def test_parser_reads_href_filters_and_price_inputs() -> None:
|
||||||
assert price["filter_type"] == "NUMERIC_RANGE"
|
assert price["filter_type"] == "NUMERIC_RANGE"
|
||||||
assert price["encoding"]["min_parameter"] == "bpmin"
|
assert price["encoding"]["min_parameter"] == "bpmin"
|
||||||
assert price["encoding"]["max_parameter"] == "bpmax"
|
assert price["encoding"]["max_parameter"] == "bpmax"
|
||||||
|
hersteller = next(item for item in payload["filters"] if item["external_filter_key"] == "1")
|
||||||
|
assert hersteller["label"] == "Hersteller"
|
||||||
|
assert hersteller["filter_type"] == "ENUM"
|
||||||
|
assert hersteller["group_label"] == "Allgemein"
|
||||||
|
assert {option["source_value"] for option in hersteller["options"]} >= {"Apple", "ASUS", "Lenovo"}
|
||||||
|
display = next(item for item in payload["filters"] if item["external_filter_key"] == "12128")
|
||||||
|
assert display["group_label"] == "Display"
|
||||||
|
|
||||||
|
|
||||||
def test_learned_ram_and_price_encode_observed_geizhals_query() -> None:
|
def test_learned_ram_and_price_encode_observed_geizhals_query() -> None:
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,9 @@ Watchtower bleibt aus.
|
||||||
| `deploy-prod.yml` | Push `main` | Deploy nach `~/docker/buyingagent`, Health `localhost:8006` |
|
| `deploy-prod.yml` | Push `main` | Deploy nach `~/docker/buyingagent`, Health `localhost:8006` |
|
||||||
| `test.yml` | nach Dev-Deploy bzw. Push `develop` | pytest auf `buyingagent_test` (Dev-Postgres, nicht Live-DB) und read-only Smoke gegen `/health/live` und `/health/ready` |
|
| `test.yml` | nach Dev-Deploy bzw. Push `develop` | pytest auf `buyingagent_test` (Dev-Postgres, nicht Live-DB) und read-only Smoke gegen `/health/live` und `/health/ready` |
|
||||||
|
|
||||||
Der API-Build nutzt `ghcr.io` (nicht Docker Hub). `docker compose build --pull=false` verwendet vorhandene Basis-Images und vermeidet IPv6-Timeouts gegen `auth.docker.io`.
|
Der API-Build nutzt `ghcr.io` (nicht Docker Hub). `COMPOSE_BAKE=0 docker compose build --pull=false` verwendet vorhandene Basis-Images und vermeidet Registry-Metadatenabrufe, die auf dem Pi per IPv6-DNS timeouten.
|
||||||
|
|
||||||
`test.yml` folgt Kanshō: pytest läuft in einem Einweg-Container (`api-test`, Compose-Profil `test`) gegen die wegwerfbare Datenbank `buyingagent_test` auf dem bereits laufenden Dev-Postgres. Die Live-Datenbank bleibt unangetastet. Es gibt kein Frontend, daher keinen Frontend-Build-Job.
|
`test.yml` folgt Kanshō: pytest läuft in einem Einweg-Container aus dem bereits deployedten `api`-Image gegen die wegwerfbare Datenbank `buyingagent_test` auf dem bereits laufenden Dev-Postgres. Die Live-Datenbank bleibt unangetastet. Es gibt kein Frontend, daher keinen Frontend-Build-Job.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export type ProjectCriterion = {
|
||||||
export type AvailableCriterion = {
|
export type AvailableCriterion = {
|
||||||
criterion_definition_id: string;
|
criterion_definition_id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
group_label?: string | null;
|
||||||
origin: string;
|
origin: string;
|
||||||
filter_type?: string | null;
|
filter_type?: string | null;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
|
|
@ -170,4 +171,11 @@ export type SourceCategory = {
|
||||||
product_category_id?: string | null;
|
product_category_id?: string | null;
|
||||||
source_url?: string | null;
|
source_url?: string | null;
|
||||||
};
|
};
|
||||||
export type SourceFilter = { label: string; external_filter_key: string; filter_type: string; status: string };
|
export type SourceFilter = {
|
||||||
|
label: string;
|
||||||
|
group_label?: string | null;
|
||||||
|
external_filter_key: string;
|
||||||
|
filter_type: string;
|
||||||
|
status: string;
|
||||||
|
options?: { source_value: string; normalized_value: unknown }[];
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,28 @@ test("renders numeric threshold without notebook-specific fields", () => {
|
||||||
expect(screen.queryByLabelText("RAM GB")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("RAM GB")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders enum field list from learned options", () => {
|
||||||
|
render(
|
||||||
|
<CriteriaControl
|
||||||
|
criterion={{
|
||||||
|
...base,
|
||||||
|
label: "Hersteller",
|
||||||
|
filter_type: "ENUM",
|
||||||
|
operators: ["="],
|
||||||
|
options: [
|
||||||
|
{ source_value: "Apple", normalized_value: null },
|
||||||
|
{ source_value: "Lenovo", normalized_value: null },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
operator="="
|
||||||
|
value=""
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("option", { name: "Apple" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "Lenovo" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("picks a single active source automatically", () => {
|
test("picks a single active source automatically", () => {
|
||||||
expect(pickSource([{ source: "geizhals", status: "ACTIVE" }])).toBe("geizhals");
|
expect(pickSource([{ source: "geizhals", status: "ACTIVE" }])).toBe("geizhals");
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ export function CriteriaControl({
|
||||||
onChange: (next: { operator: string; value: unknown }) => void;
|
onChange: (next: { operator: string; value: unknown }) => void;
|
||||||
}) {
|
}) {
|
||||||
const type = criterion.filter_type || "TEXT";
|
const type = criterion.filter_type || "TEXT";
|
||||||
|
const options = criterion.options || [];
|
||||||
|
const optionValue = (option: { source_value: string; normalized_value: unknown }) =>
|
||||||
|
String(option.normalized_value ?? option.source_value);
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -35,7 +38,30 @@ export function CriteriaControl({
|
||||||
<option value="false">nein</option>
|
<option value="false">nein</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
) : type === "ENUM" || type === "MULTI_ENUM" ? (
|
) : type === "MULTI_ENUM" ? (
|
||||||
|
<fieldset className="option-list">
|
||||||
|
<legend>Werte</legend>
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = Array.isArray(value) ? value.map(String).includes(optionValue(option)) : false;
|
||||||
|
return (
|
||||||
|
<label key={option.source_value} className="option-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(event) => {
|
||||||
|
const current = Array.isArray(value) ? value.map(String) : [];
|
||||||
|
const next = event.target.checked
|
||||||
|
? [...current, optionValue(option)]
|
||||||
|
: current.filter((item) => item !== optionValue(option));
|
||||||
|
onChange({ operator, value: next });
|
||||||
|
}}
|
||||||
|
/>{" "}
|
||||||
|
{option.source_value}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
) : options.length > 0 && (type === "ENUM" || type === "NUMERIC_THRESHOLD") ? (
|
||||||
<label>
|
<label>
|
||||||
Wert
|
Wert
|
||||||
<select
|
<select
|
||||||
|
|
@ -43,8 +69,8 @@ export function CriteriaControl({
|
||||||
onChange={(event) => onChange({ operator, value: event.target.value })}
|
onChange={(event) => onChange({ operator, value: event.target.value })}
|
||||||
>
|
>
|
||||||
<option value="">Bitte wählen</option>
|
<option value="">Bitte wählen</option>
|
||||||
{(criterion.options || []).map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.source_value} value={String(option.normalized_value ?? option.source_value)}>
|
<option key={option.source_value} value={optionValue(option)}>
|
||||||
{option.source_value}
|
{option.source_value}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -92,18 +92,35 @@ export function AdminPage() {
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>Gruppe</th>
|
||||||
<th>Label</th>
|
<th>Label</th>
|
||||||
<th>Filter</th>
|
<th>Filter</th>
|
||||||
<th>Typ</th>
|
<th>Typ</th>
|
||||||
|
<th>Werte</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filters.data.filters.map((item) => (
|
{filters.data.filters.map((item) => (
|
||||||
<tr key={item.external_filter_key}>
|
<tr key={item.external_filter_key}>
|
||||||
|
<td>{item.group_label || "–"}</td>
|
||||||
<td>{item.label}</td>
|
<td>{item.label}</td>
|
||||||
<td>{item.external_filter_key}</td>
|
<td>{item.external_filter_key}</td>
|
||||||
<td>{item.filter_type}</td>
|
<td>{item.filter_type}</td>
|
||||||
|
<td>
|
||||||
|
{(item.options || []).length ? (
|
||||||
|
<details>
|
||||||
|
<summary>{item.options?.length} Werte</summary>
|
||||||
|
<ul className="option-list">
|
||||||
|
{(item.options || []).map((option) => (
|
||||||
|
<li key={option.source_value}>{option.source_value}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
"–"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>{item.status}</td>
|
<td>{item.status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function CandidateDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Attribute</h2>
|
<h2>Attribute</h2>
|
||||||
{(item.current_dynamic_attributes || []).map((attr) => (
|
{(Array.isArray(item.current_dynamic_attributes) ? item.current_dynamic_attributes : []).map((attr) => (
|
||||||
<p key={attr.raw_name}>
|
<p key={attr.raw_name}>
|
||||||
{attr.raw_name}: {String(attr.normalized_value ?? "")} {attr.unit || ""}
|
{attr.raw_name}: {String(attr.normalized_value ?? "")} {attr.unit || ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -37,27 +37,87 @@ export function ProjectDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Overview({ projectId }: { projectId: string }) {
|
function Overview({ projectId }: { projectId: string }) {
|
||||||
|
const client = useQueryClient();
|
||||||
const searches = useQuery({ queryKey: ["searches", projectId], queryFn: () => api.searches(projectId) });
|
const searches = useQuery({ queryKey: ["searches", projectId], queryFn: () => api.searches(projectId) });
|
||||||
|
const searchId = searches.data?.items[0]?.id;
|
||||||
|
const runs = useQuery({
|
||||||
|
queryKey: ["runs", searchId],
|
||||||
|
queryFn: () => api.runs(searchId!),
|
||||||
|
enabled: Boolean(searchId),
|
||||||
|
});
|
||||||
|
const candidates = useQuery({
|
||||||
|
queryKey: ["candidates", projectId],
|
||||||
|
queryFn: () => api.candidates(projectId),
|
||||||
|
});
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: async (searchId: string) => {
|
mutationFn: async (id: string) => {
|
||||||
await api.generateQuery(searchId);
|
await api.generateQuery(id);
|
||||||
return api.runSearch(searchId);
|
return api.runSearch(id);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ["searches", projectId] });
|
||||||
|
client.invalidateQueries({ queryKey: ["runs", searchId] });
|
||||||
|
client.invalidateQueries({ queryKey: ["candidates", projectId] });
|
||||||
|
client.invalidateQueries({ queryKey: ["project", projectId] });
|
||||||
|
client.invalidateQueries({ queryKey: ["projects"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (searches.isLoading) return <LoadingState />;
|
if (searches.isLoading) return <LoadingState />;
|
||||||
const items = searches.data?.items || [];
|
const items = searches.data?.items || [];
|
||||||
if (!items.length) return <EmptyState>Noch kein SearchRun ausgeführt.</EmptyState>;
|
if (!items.length) return <EmptyState>Noch keine Suche angelegt.</EmptyState>;
|
||||||
|
const lastRun = runs.data?.items[0];
|
||||||
|
const found = candidates.data?.items || [];
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{items.map((search) => (
|
{items.map((search) => (
|
||||||
<div className="card" key={search.id}>
|
<div className="card grid" key={search.id}>
|
||||||
<p>Status: {search.status}</p>
|
<p>Status: {search.status}</p>
|
||||||
|
<p className="muted">URL: {search.generated_url || "–"}</p>
|
||||||
{search.status === "OUT_OF_SYNC" ? <p>Suchparameter wurden geändert. Die externe Suche muss aktualisiert werden.</p> : null}
|
{search.status === "OUT_OF_SYNC" ? <p>Suchparameter wurden geändert. Die externe Suche muss aktualisiert werden.</p> : null}
|
||||||
<button className="button" onClick={() => run.mutate(search.id)}>
|
{search.status === "TOO_BROAD" ? (
|
||||||
Suche aktualisieren
|
<p>Die Treffermenge ist zu groß. Die bereits geladenen Kandidaten bleiben gespeichert; die Suche enger fassen.</p>
|
||||||
|
) : null}
|
||||||
|
{lastRun ? (
|
||||||
|
<p>
|
||||||
|
Letzter Lauf: {lastRun.status} · {lastRun.result_count} Treffer · {lastRun.new_count} neu
|
||||||
|
{lastRun.error_code ? ` · ${lastRun.error_code}` : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="muted">Noch kein SearchRun ausgeführt.</p>
|
||||||
|
)}
|
||||||
|
{run.isError ? <ErrorState message={(run.error as Error).message} /> : null}
|
||||||
|
<button className="button" disabled={run.isPending} onClick={() => run.mutate(search.id)}>
|
||||||
|
{run.isPending ? "Sucht …" : "Suche aktualisieren"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div className="card grid">
|
||||||
|
<p>{found.length} Kandidaten aus der Suche</p>
|
||||||
|
{found.length ? (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Preis</th>
|
||||||
|
<th>Diff</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{found.slice(0, 12).map((item) => (
|
||||||
|
<tr key={item.candidate_id}>
|
||||||
|
<td>
|
||||||
|
<NavLink to={`/projects/${projectId}/candidates/${item.candidate_id}`}>{item.title || "Kandidat"}</NavLink>
|
||||||
|
</td>
|
||||||
|
<td>{item.current_best_price ? `${item.current_best_price} ${item.currency || ""}` : "–"}</td>
|
||||||
|
<td>{item.current_diff_status || "–"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<EmptyState>Noch keine Kandidaten übernommen.</EmptyState>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,19 @@ export function WizardPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const sourceRow = sources.data.items.find((item) => item.source === source) || sources.data.items[0];
|
const sourceRow = sources.data.items.find((item) => item.source === source) || sources.data.items[0];
|
||||||
await api.createSearch(project.id, {
|
const search = await api.createSearch(project.id, {
|
||||||
source_id: sourceRow.source,
|
source_id: sourceRow.source,
|
||||||
source_category_id: sourceRow.source_category_id,
|
source_category_id: sourceRow.source_category_id,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await api.generateQuery(search.id);
|
||||||
|
await api.runSearch(search.id);
|
||||||
|
} catch {
|
||||||
|
// Projekt und Suche bleiben erhalten; der Lauf kann auf der Projektseite wiederholt werden.
|
||||||
|
}
|
||||||
return project;
|
return project;
|
||||||
},
|
},
|
||||||
onSuccess: (project) => navigate(`/projects/${project.id}`),
|
onSuccess: (project) => navigate(`/projects/${project.id}/candidates`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourceItems = sources.data?.items || [];
|
const sourceItems = sources.data?.items || [];
|
||||||
|
|
@ -68,8 +74,21 @@ export function WizardPage() {
|
||||||
const items = criteria.data?.criteria || [];
|
const items = criteria.data?.criteria || [];
|
||||||
const query = criterionQuery.trim().toLowerCase();
|
const query = criterionQuery.trim().toLowerCase();
|
||||||
if (!query) return items;
|
if (!query) return items;
|
||||||
return items.filter((item) => item.label.toLowerCase().includes(query));
|
return items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.label.toLowerCase().includes(query) || (item.group_label || "").toLowerCase().includes(query),
|
||||||
|
);
|
||||||
}, [criteria.data, criterionQuery]);
|
}, [criteria.data, criterionQuery]);
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, AvailableCriterion[]>();
|
||||||
|
for (const item of visible) {
|
||||||
|
const key = item.group_label || "Weitere";
|
||||||
|
const bucket = map.get(key) || [];
|
||||||
|
bucket.push(item);
|
||||||
|
map.set(key, bucket);
|
||||||
|
}
|
||||||
|
return [...map.entries()];
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
if (categories.isLoading) return <LoadingState />;
|
if (categories.isLoading) return <LoadingState />;
|
||||||
if (categories.isError) return <ErrorState message={(categories.error as Error).message} />;
|
if (categories.isError) return <ErrorState message={(categories.error as Error).message} />;
|
||||||
|
|
@ -161,7 +180,10 @@ export function WizardPage() {
|
||||||
Kriterien suchen
|
Kriterien suchen
|
||||||
<input value={criterionQuery} onChange={(event) => setCriterionQuery(event.target.value)} />
|
<input value={criterionQuery} onChange={(event) => setCriterionQuery(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
{visible.map((item) => {
|
{grouped.map(([group, items]) => (
|
||||||
|
<fieldset key={group} className="criterion-group">
|
||||||
|
<legend>{group}</legend>
|
||||||
|
{items.map((item) => {
|
||||||
const on = selected.includes(item.criterion_definition_id);
|
const on = selected.includes(item.criterion_definition_id);
|
||||||
return (
|
return (
|
||||||
<div key={item.criterion_definition_id}>
|
<div key={item.criterion_definition_id}>
|
||||||
|
|
@ -179,12 +201,15 @@ export function WizardPage() {
|
||||||
...current,
|
...current,
|
||||||
[item.criterion_definition_id]: current[item.criterion_definition_id] || {
|
[item.criterion_definition_id]: current[item.criterion_definition_id] || {
|
||||||
operator: item.operators[0] || ">=",
|
operator: item.operators[0] || ">=",
|
||||||
value: "",
|
value: item.filter_type === "MULTI_ENUM" ? [] : "",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{(item.options || []).length ? (
|
||||||
|
<span className="muted"> · {(item.options || []).length} Werte</span>
|
||||||
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
{on ? (
|
{on ? (
|
||||||
<CriteriaControl
|
<CriteriaControl
|
||||||
|
|
@ -197,12 +222,14 @@ export function WizardPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{create.isError ? <ErrorState message={(create.error as Error).message} /> : null}
|
{create.isError ? <ErrorState message={(create.error as Error).message} /> : null}
|
||||||
<button className="button" disabled={!name || !categoryId || selected.length === 0 || create.isPending} onClick={() => create.mutate()}>
|
<button className="button" disabled={!name || !categoryId || selected.length === 0 || create.isPending} onClick={() => create.mutate()}>
|
||||||
Projekt speichern
|
{create.isPending ? "Speichert und sucht …" : "Projekt speichern und suchen"}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #2a2d36; }
|
||||||
.button.secondary { background: #2a2d36; }
|
.button.secondary { background: #2a2d36; }
|
||||||
input, select { background: #111318; color: #e8eaed; border: 1px solid #3a3f4b; border-radius: 8px; padding: 8px; width: 100%; }
|
input, select { background: #111318; color: #e8eaed; border: 1px solid #3a3f4b; border-radius: 8px; padding: 8px; width: 100%; }
|
||||||
label { display: grid; gap: 6px; font-size: 14px; }
|
label { display: grid; gap: 6px; font-size: 14px; }
|
||||||
|
.criterion-group { border: 1px solid #2a2d36; border-radius: 10px; padding: 12px; display: grid; gap: 10px; }
|
||||||
|
.criterion-group legend { padding: 0 6px; color: #c5cad3; }
|
||||||
|
.option-list { margin: 8px 0 0; padding-left: 18px; }
|
||||||
|
.option-item { display: flex; align-items: center; gap: 8px; }
|
||||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
.tabs a { padding: 6px 10px; border-radius: 8px; background: #181b22; }
|
.tabs a { padding: 6px 10px; border-radius: 8px; background: #181b22; }
|
||||||
.tabs a.active { background: #1d4ed8; }
|
.tabs a.active { background: #1d4ed8; }
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,10 @@ docker compose -f "$COMPOSE_FILE" exec -T postgres \
|
||||||
psql -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 \
|
psql -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 \
|
||||||
-c "CREATE DATABASE buyingagent_test OWNER ${PGUSER};"
|
-c "CREATE DATABASE buyingagent_test OWNER ${PGUSER};"
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" --profile test build --pull=false api-test
|
# Reuse the already-deployed api image. A fresh api-test build hits ghcr.io
|
||||||
|
# metadata even with --pull=false and fails on IPv6 DNS timeouts.
|
||||||
docker compose -f "$COMPOSE_FILE" --profile test run --rm --no-deps \
|
run_test() {
|
||||||
|
docker compose -f "$COMPOSE_FILE" run --rm --no-deps --entrypoint "" \
|
||||||
-v "${APP_DIR}:/src:ro" \
|
-v "${APP_DIR}:/src:ro" \
|
||||||
-w /src/backend \
|
-w /src/backend \
|
||||||
-e PYTHONPATH=/src/backend \
|
-e PYTHONPATH=/src/backend \
|
||||||
|
|
@ -51,14 +52,9 @@ docker compose -f "$COMPOSE_FILE" --profile test run --rm --no-deps \
|
||||||
-e ENVIRONMENT=test \
|
-e ENVIRONMENT=test \
|
||||||
-e ENGINE_BUILD=test \
|
-e ENGINE_BUILD=test \
|
||||||
-e CLOCK_NOW=2026-10-03T12:00:00Z \
|
-e CLOCK_NOW=2026-10-03T12:00:00Z \
|
||||||
api-test /app/.venv/bin/alembic upgrade head
|
-e GEIZHALS_USE_FIXTURES=1 \
|
||||||
|
api "$@"
|
||||||
|
}
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" --profile test run --rm --no-deps \
|
run_test /app/.venv/bin/alembic upgrade head
|
||||||
-v "${APP_DIR}:/src:ro" \
|
run_test /app/.venv/bin/pytest tests -m "not slow" -ra --tb=short
|
||||||
-w /src/backend \
|
|
||||||
-e PYTHONPATH=/src/backend \
|
|
||||||
-e DB_NAME=buyingagent_test \
|
|
||||||
-e ENVIRONMENT=test \
|
|
||||||
-e ENGINE_BUILD=test \
|
|
||||||
-e CLOCK_NOW=2026-10-03T12:00:00Z \
|
|
||||||
api-test /app/.venv/bin/pytest tests -m "not slow" -ra --tb=short
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user