補齊比價覆核狀態分流
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s

This commit is contained in:
OoO
2026-05-20 09:18:49 +08:00
parent c9a5478fef
commit 3090b18fa4
8 changed files with 160 additions and 19 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.300 補商品看板比價覆核狀態分流:`filter=pchome_review` 新增全部、需單位價、身份否決、低信心、價格過期、找不到同款 segmented 篩選與分頁保留參數,讓 6,000+ 筆覆核隊列能依 matcher 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。
- V10.299 補市場情報 candidate queue review AI summary persistence run closeout新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout` 與 UI 按鈕,在 receipt 通過後收尾 metadata_json persistence gate確認 closeout artifact、操作員確認與後續 Telegram dispatch 必須另開 gateAPI/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。
- V10.298 補市場情報 candidate queue review AI summary persistence run receipt新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt` 與 UI 按鈕,審核操作員貼回的 metadata_json CLI writer output、post-write smoke、dedupe key、summary payload hash、artifact path 與 token 外洩風險API/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。
- V10.297 將 PChome 單位價覆核隊列接回商品看板第一屏KPI 顯示待處理/需單位價覆核數,焦點區列出候選 PChome 商品、候選價、match score 與人工動作;新增 `filter=pchome_review` 的比價覆核隊列,讓使用者可直接進入待處理 SKU不再只在 daily/growth/PPT 摘要看到統計。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.299"
SYSTEM_VERSION = "V10.300"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-20 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 僅備援 / 鎖定場景
> **適用版本**: V10.297
> **適用版本**: V10.300
---
@@ -56,7 +56,7 @@ SQL漏斗(~300筆)
- 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。
- 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。
- PChome / MOMO 競價摘要出口 `services/competitor_intel_repository.py` 使用 30 分鐘共享快取(`COMPETITOR_INTEL_CACHE_TTL_SECONDS` 可調),避免 `/growth_analysis``/daily_sales`、PPT/AI 報表每次請求重跑昂貴覆蓋率與價差趨勢查詢;`run_competitor_price_feeder_task` 與 PChome backfill 完成後會主動清除快取。快取只包摘要輸出,不改 matcher 的高信心門檻與 identity_v2 準確性規則。
- 商品看板第一屏:`/` 的 V2 看板直接以 `products``price_records``competitor_prices``competitor_match_attempts``ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU並以 DB 分頁支援 search/category 後的完整隊列,不得只截前 50 筆。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要與人工動作。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
- 商品看板第一屏:`/` 的 V2 看板直接以 `products``price_records``competitor_prices``competitor_match_attempts``ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要與人工動作。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -41,6 +41,30 @@ dashboard_bp = Blueprint('dashboard', __name__)
PRODUCT_PICK_LIST_LIMIT = 50
PCHOME_MATCH_SCORE_FLOOR = 0.76
REVIEW_STATUS_OPTIONS = [
{
'key': 'all',
'label': '全部',
'statuses': (
'unit_comparable',
'refresh_unit_comparable',
'identity_veto',
'low_score',
'expired_match',
'refresh_no_result',
'no_result',
),
},
{
'key': 'unit_comparable',
'label': '需單位價',
'statuses': ('unit_comparable', 'refresh_unit_comparable'),
},
{'key': 'identity_veto', 'label': '身份否決', 'statuses': ('identity_veto',)},
{'key': 'low_score', 'label': '低信心', 'statuses': ('low_score',)},
{'key': 'expired_match', 'label': '價格過期', 'statuses': ('expired_match',)},
{'key': 'no_result', 'label': '找不到同款', 'statuses': ('no_result', 'refresh_no_result')},
]
def _build_pchome_product_url(product_id):
@@ -501,9 +525,17 @@ def _load_competitor_review_context(session, limit=12):
def _merge_competitor_review_context(overview, review_context):
coverage = review_context.get('coverage') or {}
review_queue = review_context.get('review_queue') or []
attempt_status = coverage.get('attempt_status') or {}
review_status_counts = {}
for option in REVIEW_STATUS_OPTIONS:
review_status_counts[option['key']] = sum(
int(attempt_status.get(status) or 0)
for status in option['statuses']
)
overview.update({
'review_queue_count': int(coverage.get('actionable_review_count') or len(review_queue) or 0),
'unit_comparable_count': int(coverage.get('unit_comparable_count') or 0),
'review_status_counts': review_status_counts,
'review_queue': review_queue[:3],
})
return overview
@@ -517,18 +549,45 @@ def _normalize_dashboard_category_filter(category_filter):
return category_filter
def _load_competitor_review_page(session, page=1, per_page=50, search_query='', category_filter='all'):
def _normalize_review_status_filter(review_status):
review_status = (review_status or '').strip()
valid_keys = {option['key'] for option in REVIEW_STATUS_OPTIONS}
return review_status if review_status in valid_keys else 'all'
def _build_review_status_options(overview):
counts = (overview or {}).get('review_status_counts') or {}
return [
{
'key': option['key'],
'label': option['label'],
'count': int(counts.get(option['key']) or 0),
}
for option in REVIEW_STATUS_OPTIONS
]
def _load_competitor_review_page(
session,
page=1,
per_page=50,
search_query='',
category_filter='all',
review_status='all',
):
try:
from services.competitor_intel_repository import fetch_competitor_review_queue_page
engine = _get_session_engine(session)
if not engine:
return {'items': [], 'total': 0, 'page': page, 'per_page': per_page}
review_status = _normalize_review_status_filter(review_status)
return fetch_competitor_review_queue_page(
engine,
page=page,
per_page=per_page,
search_query=search_query,
category=_normalize_dashboard_category_filter(category_filter),
status_filter='' if review_status == 'all' else review_status,
)
except Exception as exc:
sys_log.warning(f"[Dashboard] PChome 覆核隊列分頁讀取略過: {exc}")
@@ -1676,6 +1735,7 @@ def index():
sort_by = request.args.get('sort_by', 'timestamp')
filter_type = request.args.get('filter', 'all')
order = request.args.get('order', 'desc')
review_status = _normalize_review_status_filter(request.args.get('review_status', 'all'))
search_query = request.args.get('q', '').strip()
per_page = 50
@@ -1749,6 +1809,7 @@ def index():
per_page=per_page,
search_query=search_query,
category_filter=category_filter,
review_status=review_status,
)
review_queue = review_page.get('items') or []
review_queue_total = int(review_page.get('total') or len(review_queue))
@@ -1915,6 +1976,7 @@ def index():
data['competitor_overview'] = competitor_overview
_DASHBOARD_DATA_CACHE['full_data'] = data
_write_shared_full_dashboard_cache(data)
review_status_options = _build_review_status_options(competitor_overview)
template_name = 'dashboard_v2.html'
return render_template(template_name,
@@ -1936,6 +1998,8 @@ def index():
public_url=public_url,
current_category=category_filter,
current_filter=filter_type,
current_review_status=review_status,
review_status_options=review_status_options,
search_query=search_query,
current_sort=sort_by,
current_order=order,

View File

@@ -32,6 +32,13 @@ ACTIONABLE_ATTEMPT_STATUSES = {
"refresh_no_result",
"no_result",
}
REVIEW_STATUS_FILTER_GROUPS = {
"unit_comparable": ("unit_comparable", "refresh_unit_comparable"),
"identity_veto": ("identity_veto",),
"low_score": ("low_score",),
"expired_match": ("expired_match",),
"no_result": ("no_result", "refresh_no_result"),
}
ATTEMPT_STATUS_LABELS = {
"unit_comparable": "需單位價比較",
"refresh_unit_comparable": "需單位價比較",
@@ -506,15 +513,20 @@ def fetch_competitor_review_queue_page(
per_page: int = 50,
search_query: str = "",
category: str = "",
status_filter: str = "",
) -> dict:
"""Paginated PChome review queue for operator-facing Dashboard pages."""
page = max(1, int(page or 1))
per_page = max(1, min(int(per_page or 50), 100))
search_query = (search_query or "").strip()
category = (category or "").strip()
status_filter = (status_filter or "").strip()
if status_filter not in REVIEW_STATUS_FILTER_GROUPS:
status_filter = ""
cache_key = (
"review_queue_page:v1:"
f"page={page}:per={per_page}:q={search_query.lower()}:cat={category}:"
f"status={status_filter}:"
f"floor={PCHOME_MATCH_SCORE_FLOOR}"
)
return _cached_payload(
@@ -525,25 +537,25 @@ def fetch_competitor_review_queue_page(
per_page=per_page,
search_query=search_query,
category=category,
status_filter=status_filter,
),
ttl_seconds=min(COMPETITOR_INTEL_CACHE_TTL_SECONDS, 300),
)
def _review_queue_cte_and_filter(search_query: str = "", category: str = "") -> tuple[str, dict[str, Any]]:
def _review_queue_cte_and_filter(
search_query: str = "",
category: str = "",
status_filter: str = "",
) -> tuple[str, dict[str, Any]]:
params: dict[str, Any] = {}
status_filter = (status_filter or "").strip()
status_values = REVIEW_STATUS_FILTER_GROUPS.get(status_filter) or tuple(ACTIONABLE_ATTEMPT_STATUSES)
status_sql = ", ".join(f"'{status}'" for status in status_values)
filters = [
"lm.rn = 1",
"vc.sku IS NULL",
"""la.attempt_status IN (
'unit_comparable',
'refresh_unit_comparable',
'identity_veto',
'low_score',
'expired_match',
'refresh_no_result',
'no_result'
)""",
f"la.attempt_status IN ({status_sql})",
]
if search_query:
params["search_like"] = f"%{search_query.lower()}%"
@@ -629,6 +641,7 @@ def _fetch_competitor_review_queue_page_uncached(
per_page: int = 50,
search_query: str = "",
category: str = "",
status_filter: str = "",
) -> dict:
inspector = inspect(engine)
if not (
@@ -637,11 +650,21 @@ def _fetch_competitor_review_queue_page_uncached(
and inspector.has_table("competitor_prices")
and inspector.has_table("competitor_match_attempts")
):
return {"items": [], "total": 0, "page": max(1, int(page or 1)), "per_page": per_page}
return {
"items": [],
"total": 0,
"page": max(1, int(page or 1)),
"per_page": per_page,
"status_filter": status_filter,
}
page = max(1, int(page or 1))
per_page = max(1, min(int(per_page or 50), 100))
cte, params = _review_queue_cte_and_filter(search_query=search_query, category=category)
cte, params = _review_queue_cte_and_filter(
search_query=search_query,
category=category,
status_filter=status_filter,
)
page_params = {
**params,
"limit": per_page,
@@ -668,6 +691,7 @@ def _fetch_competitor_review_queue_page_uncached(
"total": total,
"page": page,
"per_page": per_page,
"status_filter": status_filter,
}

View File

@@ -231,6 +231,7 @@
{% endfor %}
</select>
<input type="hidden" name="filter" value="{{ current_filter }}">
<input type="hidden" name="review_status" value="{{ current_review_status }}">
<input type="hidden" name="sort_by" value="{{ current_sort }}">
<input type="hidden" name="order" value="{{ current_order }}">
<button class="dashboard-action-button" type="submit">
@@ -240,7 +241,7 @@
<div class="dashboard-segmented">
<a class="{% if current_filter == 'all' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='all', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">全部</a>
<a class="{% if current_filter == 'ai_picks' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">AI挑品</a>
<a class="{% if current_filter == 'pchome_review' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">比價覆核</a>
<a class="{% if current_filter == 'pchome_review' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='pchome_review', review_status='all', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">比價覆核</a>
<a class="{% if current_filter == 'new' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='new', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">新上架</a>
<a class="{% if current_filter == 'increase' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='increase', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">漲價</a>
<a class="{% if current_filter == 'decrease' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='decrease', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">降價</a>
@@ -332,6 +333,17 @@
</div>
{% endif %}
{% if current_filter == 'pchome_review' %}
<div class="dashboard-review-segments">
{% for option in review_status_options %}
<a class="{% if current_review_status == option.key %}is-active{% endif %}" href="{{ url_for('dashboard.index', page=1, filter='pchome_review', review_status=option.key, category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">
<span>{{ option.label }}</span>
<span class="momo-mono">{{ option.count | number_format }}</span>
</a>
{% endfor %}
</div>
{% endif %}
<div class="dashboard-table-wrap">
<table class="dashboard-table {% if current_filter == 'ai_picks' %}is-ai-picks{% elif current_filter == 'pchome_review' %}is-review{% endif %}">
<thead>
@@ -560,11 +572,11 @@
{% if total_pages > 1 %}
<div class="dashboard-pagination">
{% if current_page > 1 %}
<a class="dashboard-action-link" href="{{ url_for('dashboard.index', page=current_page - 1, category=current_category, filter=current_filter, q=search_query, sort_by=current_sort, order=current_order) }}">上一頁</a>
<a class="dashboard-action-link" href="{{ url_for('dashboard.index', page=current_page - 1, category=current_category, filter=current_filter, review_status=current_review_status, q=search_query, sort_by=current_sort, order=current_order) }}">上一頁</a>
{% endif %}
<span class="dashboard-table-meta momo-mono">第 {{ current_page }} / {{ total_pages }} 頁</span>
{% if current_page < total_pages %}
<a class="dashboard-action-link" href="{{ url_for('dashboard.index', page=current_page + 1, category=current_category, filter=current_filter, q=search_query, sort_by=current_sort, order=current_order) }}">下一頁</a>
<a class="dashboard-action-link" href="{{ url_for('dashboard.index', page=current_page + 1, category=current_category, filter=current_filter, review_status=current_review_status, q=search_query, sort_by=current_sort, order=current_order) }}">下一頁</a>
{% endif %}
</div>
{% endif %}

View File

@@ -142,6 +142,8 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "unit_comparable_count" in route_source
assert "filter_type == 'pchome_review'" in route_source
assert "total_items = review_queue_total" in route_source
assert "REVIEW_STATUS_OPTIONS" in route_source
assert "current_review_status" in route_source
assert "MockRecord" not in route_source
assert "{% for item in items %}" in dashboard
assert "比價監控總覽" in dashboard
@@ -154,6 +156,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "需單位價覆核" in dashboard
assert "filter='ai_picks'" in dashboard
assert "filter='pchome_review'" in dashboard
assert "review_status=option.key" in dashboard
assert "需單位價" in dashboard
assert "dashboard-review-segments" in dashboard
assert "AI 挑品清單" in dashboard
assert "比價覆核隊列" in dashboard
assert "覆核動作" in dashboard

View File

@@ -371,6 +371,41 @@
background: var(--momo-ink);
}
.dashboard-review-segments {
display: flex;
gap: 8px;
padding: 12px 20px;
overflow-x: auto;
border-bottom: 1px solid var(--momo-border-light);
-webkit-overflow-scrolling: touch;
}
.dashboard-review-segments a {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
min-height: 30px;
padding: 6px 10px;
color: var(--momo-text-secondary);
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
border-radius: 4px;
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.dashboard-review-segments a.is-active {
color: var(--momo-text-inverse);
background: var(--momo-ink);
border-color: var(--momo-ink);
}
.dashboard-review-segments a.is-active .momo-mono {
color: rgba(250, 247, 240, 0.72);
}
.dashboard-action-link,
.dashboard-action-button {
display: inline-flex;