feat: expose explicit source conflicts
This commit is contained in:
@@ -80,6 +80,7 @@ def activity_rows(
|
||||
sources=coordinate_sources,
|
||||
coordinate_precision=coordinate_precision,
|
||||
coordinate_sources=coordinate_sources,
|
||||
source_conflicts=_source_conflicts(items),
|
||||
))
|
||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||
|
||||
@@ -95,6 +96,16 @@ def _source_system(report: CatchReport) -> str:
|
||||
return "manual-import"
|
||||
|
||||
|
||||
def _source_conflicts(reports: list[CatchReport]) -> list[str]:
|
||||
conflicts: set[str] = set()
|
||||
for report in reports:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
values = provenance.get("conflicts", []) if isinstance(provenance, dict) else []
|
||||
if isinstance(values, list):
|
||||
conflicts.update(str(value).strip() for value in values if str(value).strip())
|
||||
return sorted(conflicts)
|
||||
|
||||
|
||||
def _coordinate_precision(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from ..activity import activity_rows
|
||||
from ..activity import _source_conflicts, activity_rows
|
||||
from ..config import settings
|
||||
from ..dependencies import Db
|
||||
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
|
||||
@@ -76,7 +76,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||
precisions = [item.get("coordinate_precision") for item in provenance]
|
||||
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
|
||||
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
|
||||
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources)
|
||||
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources, source_conflicts=_source_conflicts(reports))
|
||||
|
||||
|
||||
def _report_source(report: CatchReport) -> str:
|
||||
|
||||
@@ -102,6 +102,7 @@ class ActivityOut(BaseModel):
|
||||
sources: list[str]
|
||||
coordinate_precision: str
|
||||
coordinate_sources: list[str]
|
||||
source_conflicts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PaginatedActivityOut(BaseModel):
|
||||
@@ -160,6 +161,7 @@ class SpotOut(BaseModel):
|
||||
top_baits: list[str]
|
||||
coordinate_precision: str
|
||||
coordinate_sources: list[str]
|
||||
source_conflicts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OfficialRecordOut(BaseModel):
|
||||
|
||||
@@ -55,6 +55,20 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
assert payload["items"][0]["unique_players"] == 3
|
||||
assert "3 свежих улова" in payload["items"][0]["explanation"]
|
||||
assert payload["items"][0]["sources"] == ["manual-import"]
|
||||
assert payload["items"][0]["source_conflicts"] == []
|
||||
|
||||
|
||||
def test_explicit_source_conflict_is_exposed_on_activity_and_spot() -> None:
|
||||
with Session(engine) as db:
|
||||
report = db.scalar(select(CatchReport).where(CatchReport.waterbody.has(slug="test-lake")))
|
||||
assert report is not None
|
||||
report.raw_payload = {"provenance": {"conflicts": ["координаты: 10:20 · 11:21"]}}
|
||||
spot_id = report.spot_id
|
||||
db.commit()
|
||||
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=6").json()
|
||||
assert activity["items"][0]["source_conflicts"] == ["координаты: 10:20 · 11:21"]
|
||||
spot = client.get(f"/api/v1/spots/{spot_id}").json()
|
||||
assert spot["source_conflicts"] == ["координаты: 10:20 · 11:21"]
|
||||
|
||||
|
||||
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
|
||||
|
||||
@@ -18,7 +18,7 @@ const precision = { exact: "точные", approximate: "приблизител
|
||||
<FishSilhouette name={item.fish}/>
|
||||
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
|
||||
<p class="data-note">{item.explanation}</p>
|
||||
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} status={limited ? "insufficient" : "verified"}/>
|
||||
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} sourceConflicts={item.source_conflicts} status={limited ? "insufficient" : "verified"}/>
|
||||
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>{limited ? "Нужно ещё подтверждений" : item.best_bait ? "Работает сейчас" : "Наживка не указана"}</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
|
||||
<span class="card-cta">Открыть точку <span aria-hidden="true">→</span></span>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
import { ago, freshnessStatus } from "../lib/api";
|
||||
type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" };
|
||||
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, status = "verified" } = Astro.props as Props;
|
||||
type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; sourceConflicts?: string[]; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" };
|
||||
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, sourceConflicts = [], status = "verified" } = Astro.props as Props;
|
||||
const freshness = freshnessStatus(observedAt);
|
||||
const displayStatus = status === "verified" && freshness === "stale" ? "stale" : status;
|
||||
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", stale: "Данные устарели" };
|
||||
const displayStatus = sourceConflicts.length ? "conflict" : status === "verified" && freshness === "stale" ? "stale" : status;
|
||||
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" };
|
||||
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
|
||||
const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" };
|
||||
---
|
||||
<section class="data-passport" aria-label="Паспорт данных">
|
||||
<header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header>
|
||||
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
|
||||
{sourceConflicts.length > 0 && <p class="data-passport__conflict"><strong>Источники расходятся:</strong> {sourceConflicts.join(" · ")}</p>}
|
||||
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{precisionLabels[coordinatePrecision]}</dd></div>}</dl>
|
||||
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,7 @@ export type Activity = {
|
||||
unique_players: number; average_weight_g: number; max_weight_g: number;
|
||||
last_confirmed_at: string; activity_score: number; confidence_score: number;
|
||||
explanation: string; sources: string[];
|
||||
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[];
|
||||
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[];
|
||||
};
|
||||
|
||||
export type PaginatedActivity = {
|
||||
@@ -14,7 +14,7 @@ export type PaginatedActivity = {
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[] };
|
||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[] };
|
||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
|
||||
|
||||
@@ -44,7 +44,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||||
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
|
||||
</div>
|
||||
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
|
||||
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} sourceConflicts={activity.source_conflicts} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
|
||||
</section>
|
||||
</>}
|
||||
</Layout>
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"],.data-passport header strong[data-passport-status="insufficient"],.data-passport header strong[data-passport-status="blocked"],.data-passport header strong[data-passport-status="stale"]{background:var(--warning-soft);color:var(--warning)}
|
||||
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
|
||||
.data-passport__minimum{margin:12px 0 0;color:var(--warning);font-size:11px;line-height:1.4}
|
||||
.data-passport__conflict{margin:12px 0 0;padding:9px 10px;border-left:3px solid var(--warning);background:var(--warning-soft);color:var(--warning);font-size:11px;line-height:1.4}.data-passport__conflict strong{font-weight:750}
|
||||
.detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af}
|
||||
@media(max-width:720px){.data-passport dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@
|
||||
|
||||
- [ ] **U01 · UX-контракт и scorecard.** Контракт маршрутов, смысловой порядок ответа, словарь статусов и целевые метрики зафиксированы в [ux-contract.md](ux-contract.md); для home, spot, waterbody, plan и tackle описаны первый ответ и обязательное объяснение. Осталось провести ручной task-based review и собрать completion rate на пилоте.
|
||||
- [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Осталось добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры.
|
||||
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, а ограниченный источник не смешивается с неполными полями. Targeted E2E и unit-тест проверяют публичные пути, период и 48-часовой порог. Осталось добавить конфликт источников.
|
||||
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников.
|
||||
- [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу.
|
||||
- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт теперь проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; остаётся расширить сравнение подтверждёнными полями метода/риска.
|
||||
- [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Осталось провести ручной review на 5 ключевых маршрутах.
|
||||
|
||||
@@ -39,6 +39,7 @@ production-метрики.
|
||||
| `temporary_error` | Данные временно недоступны | Не заменять последнюю дату свежей; дать понятный retry/следующий шаг |
|
||||
| `verified` | Учтено | Есть разрешённый источник и достаточный контекст |
|
||||
| `incomplete` | Неполные данные | Перечислить отсутствующие поля, а не скрывать карточку за цветом |
|
||||
| `source_conflict` | Источники расходятся | Показать текст provenance-конфликта и не скрывать его за агрегированным рейтингом |
|
||||
|
||||
## Scorecard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user