This commit is contained in:
@@ -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 必須另開 gate;API/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 摘要看到統計。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user