[V10.278] 補市場情報 AI 摘要 preflight 與競價摘要快取 | market_intel, competitor_intel
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
OoO
2026-05-19 21:54:58 +08:00
parent 91ebb88ac4
commit 482bfbdb64
3 changed files with 433 additions and 4 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.278 補 PChome 競價摘要 30 分鐘共享快取與 feeder/backfill 主動清除,並新增市場情報 `candidate_queue_review_ai_summary_preflight` 預覽 gateAPI 只檢查未來摘要輸入與 Ollama-first/Gemini-backup-only policy不呼叫 LLM、不派 Telegram、不寫 DB、不掛 scheduler。
- V10.276 修正 ElephantAlpha 價格類 Hermes prefetch timeout`price_drop` / `market_opportunity` trigger 直接把 SQL 命中的 MOMO / PChome 價差實證轉成 HITL action lines完整 Hermes LLM prefetch 預設關閉;無 DB 實證仍只記 suppressed telemetry / cooldown不寫 `human_review`、不發空 Telegram。
- V10.266 強化核心 MOMO/PChome 比價鏈路:新增 `marketplace_product_matcher.py` 身份比對、只讓 `identity_v2` 且分數 ≥ 0.76 的高信心配對進 Dashboard/AI/Excel/Daily/Growth/PPT並建立 `competitor_intel_repository.py` 統一圖表與簡報資料出口;同品牌但不同型號、不同組數、套組/單品或多品項不一致會進待審,不進正式比價。
- V10.267 專業化 ElephantAlpha `resource_optimization` 告警:不再讓 LLM 生成「48 小時預期效益 / 已執行」敘事,改由程式量測 action queue、P1/P2、pending_review、逾時項目與 CPU load單純 backlog 不發 Telegram只有可行動資源壓力才寫 `ai_insights(resource_pressure)` 並發送量測型告警。

View File

@@ -11,6 +11,9 @@ from routes.market_intel_review_routes import (
market_intel_review_bp,
)
from services.market_intel import MarketIntelService
from services.market_intel.candidate_queue_review_ai_summary_preflight import (
build_candidate_queue_review_ai_summary_preflight,
)
from services.market_intel.candidate_queue_review_archive_summary import (
build_candidate_queue_review_archive_summary,
)
@@ -106,6 +109,37 @@ def _build_review_completion_archive_stack(
return transaction, receipt, closeout, inventory, archive
def _build_review_archive_summary_stack(
*,
service,
sample_result,
payload_error,
operator_evidence,
writer_output,
smoke_result,
limit,
execute_requested,
):
transaction, receipt, closeout, inventory, archive = (
_build_review_completion_archive_stack(
service=service,
sample_result=sample_result,
payload_error=payload_error,
operator_evidence=operator_evidence,
writer_output=writer_output,
smoke_result=smoke_result,
limit=limit,
execute_requested=execute_requested,
)
)
archive_summary = build_candidate_queue_review_archive_summary(
review_completion_archive=archive,
operator_evidence=operator_evidence,
execute_requested=execute_requested,
)
return transaction, receipt, closeout, inventory, archive, archive_summary
@market_intel_review_bp.route(
"/api/market_intel/manual_sample_review/"
"candidate_queue_review_decision_post_closeout_inventory",
@@ -145,8 +179,8 @@ def market_intel_manual_sample_candidate_queue_review_archive_summary():
sample_result, operator_evidence, writer_output, smoke_result, payload_error, limit = (
_extract_run_payload()
)
transaction, receipt, closeout, inventory, archive = (
_build_review_completion_archive_stack(
transaction, receipt, closeout, inventory, archive, archive_summary = (
_build_review_archive_summary_stack(
service=service,
sample_result=sample_result,
payload_error=payload_error,
@@ -157,8 +191,37 @@ def market_intel_manual_sample_candidate_queue_review_archive_summary():
execute_requested=execute_requested,
)
)
data = build_candidate_queue_review_archive_summary(
review_completion_archive=archive,
data = archive_summary
data["phase"] = service.phase
return jsonify(data), 400 if payload_error else 200
@market_intel_review_bp.route(
"/api/market_intel/manual_sample_review/"
"candidate_queue_review_ai_summary_preflight",
methods=["POST"],
)
@login_required
def market_intel_manual_sample_candidate_queue_review_ai_summary_preflight():
service = MarketIntelService()
execute_requested = request.args.get("execute", "false").lower() == "true"
sample_result, operator_evidence, writer_output, smoke_result, payload_error, limit = (
_extract_run_payload()
)
transaction, receipt, closeout, inventory, archive, archive_summary = (
_build_review_archive_summary_stack(
service=service,
sample_result=sample_result,
payload_error=payload_error,
operator_evidence=operator_evidence,
writer_output=writer_output,
smoke_result=smoke_result,
limit=limit,
execute_requested=execute_requested,
)
)
data = build_candidate_queue_review_ai_summary_preflight(
archive_summary=archive_summary,
operator_evidence=operator_evidence,
execute_requested=execute_requested,
)

View File

@@ -0,0 +1,365 @@
"""候選審核 queue AI summary preflight 預覽。
本模組只檢查 archive summary input 是否足夠進入未來的 Ollama-first 摘要流程;
不呼叫 LLM、不派送 Telegram、不讀 approval token、不執行 CLI、不寫檔、
不寫 DB、不更新 review_state、不 commit、不掛 scheduler。
"""
FORBIDDEN_TOKEN_KEYWORDS = (
"approval_token",
"approval-token",
"market_intel_queue_write_approval",
)
SAFE_TOKEN_METADATA_KEYS = {
"approval_token_present",
"approval_token_valid",
"approval_token_secret_configured",
}
SAFE_APPROVAL_ENV_VAR = "MARKET_INTEL_QUEUE_WRITE_APPROVAL"
TARGET_TABLE = "market_alert_review_queue"
OLLAMA_CASCADE = (
{"key": "gcp_a", "label": "GCP-A", "host": "34.143.170.20:11434"},
{"key": "gcp_b", "label": "GCP-B", "host": "34.21.145.224:11434"},
{"key": "lan_111", "label": "111", "host": "192.168.0.111:11434"},
)
def _as_dict(value):
return value if isinstance(value, dict) else {}
def _as_list(value):
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return list(value)
return [value]
def _contains_forbidden_token_key(value):
if isinstance(value, dict):
for key, nested in value.items():
normalized_key = str(key).lower()
if normalized_key in SAFE_TOKEN_METADATA_KEYS and isinstance(nested, bool):
continue
if normalized_key == "approval_env_var" and nested == SAFE_APPROVAL_ENV_VAR:
continue
if any(token_key in normalized_key for token_key in FORBIDDEN_TOKEN_KEYWORDS):
return True
if _contains_forbidden_token_key(nested):
return True
elif isinstance(value, list):
return any(_contains_forbidden_token_key(item) for item in value)
return False
def _safe_text(value, limit=300):
if value is None:
return None
text = str(value).strip()
return text[:limit] if text else None
def _operator_summary(operator_evidence):
operator_evidence = _as_dict(operator_evidence)
return {
"provided_keys": sorted(operator_evidence.keys()),
"operator_confirmed_ai_summary_preflight": bool(
operator_evidence.get("operator_confirmed_ai_summary_preflight")
or operator_evidence.get("operator_confirmed_ai_summary_review")
),
"operator_confirmed_ollama_first": bool(
operator_evidence.get("operator_confirmed_ollama_first")
or operator_evidence.get("operator_confirmed_ollama_cascade")
),
"operator_confirmed_gemini_backup_only": bool(
operator_evidence.get("operator_confirmed_gemini_backup_only")
or operator_evidence.get("operator_confirmed_gemini_fallback_only")
),
"operator_confirmed_no_llm_call": bool(
operator_evidence.get("operator_confirmed_no_llm_call")
),
"operator_confirmed_no_telegram_dispatch": bool(
operator_evidence.get("operator_confirmed_no_telegram_dispatch")
),
"operator_confirmed_no_scheduler_attach": bool(
operator_evidence.get("operator_confirmed_no_scheduler_attach")
),
"operator_confirmed_no_api_db_write": bool(
operator_evidence.get("operator_confirmed_no_api_db_write")
),
"ai_summary_preflight_artifact_path": _safe_text(
operator_evidence.get("ai_summary_preflight_artifact_path")
or operator_evidence.get("summary_preflight_artifact_path")
),
"approval_token_submitted_to_api": _contains_forbidden_token_key(
operator_evidence
),
}
def _summary_payload(archive_summary):
summary = _as_dict(archive_summary)
sections = _as_list(summary.get("summary_sections"))
artifact_paths = _as_dict(summary.get("artifact_paths"))
expected_keys = sorted(str(key) for key in _as_list(summary.get("expected_dedupe_keys")))
found_keys = sorted(str(key) for key in _as_list(summary.get("found_dedupe_keys")))
missing_keys = sorted(str(key) for key in _as_list(summary.get("missing_dedupe_keys")))
return {
"provided": bool(summary),
"mode": summary.get("mode"),
"archive_summary_ready": bool(summary.get("archive_summary_ready")),
"summary_input_ready": bool(summary.get("summary_input_ready")),
"ready_for_ai_summary_review": bool(summary.get("ready_for_ai_summary_review")),
"expected_dedupe_keys": expected_keys,
"found_dedupe_keys": found_keys,
"missing_dedupe_keys": missing_keys,
"state_mismatches": _as_list(summary.get("state_mismatches")),
"row_summary_count": int(summary.get("row_summary_count") or 0),
"summary_sections": sections,
"summary_section_keys": [
_as_dict(section).get("key")
for section in sections
if _as_dict(section).get("key")
],
"artifact_paths": artifact_paths,
"read_only_query_executed": bool(summary.get("read_only_query_executed")),
"database_connection_opened": bool(summary.get("database_connection_opened")),
"database_write_executed": bool(summary.get("database_write_executed")),
"database_commit_executed": bool(summary.get("database_commit_executed")),
"review_state_update_executed": bool(summary.get("review_state_update_executed")),
"scheduler_attached": bool(summary.get("scheduler_attached")),
"llm_call_executed": bool(summary.get("llm_call_executed")),
"telegram_dispatched": bool(summary.get("telegram_dispatched")),
"ai_summary_generated": bool(summary.get("ai_summary_generated")),
"blocked_reasons": _as_list(summary.get("blocked_reasons")),
}
def _side_effects_clear(summary):
blocked_keys = (
"api_executes_cli",
"api_writes_file",
"api_writes_database",
"api_updates_review_state",
"summary_file_written",
"summary_record_written",
"summary_manifest_written",
"database_write_executed",
"database_commit_executed",
"review_state_update_executed",
"llm_call_executed",
"ollama_call_executed",
"gemini_call_executed",
"telegram_dispatched",
"ai_summary_generated",
"external_network_executed",
"scheduler_attached",
"writes_executed",
"would_write_database",
)
return all(not _as_dict(summary).get(key) for key in blocked_keys)
def _model_route_policy():
return {
"primary_policy": "ollama_first",
"primary_cascade": list(OLLAMA_CASCADE),
"fallback_policy": "gemini_backup_only_after_ollama_cascade_failure",
"app_host_forbidden_as_ollama_node": "192.168.0.188",
"api_executes_llm": False,
"api_dispatches_telegram": False,
"expected_future_caller": "market_intel_review_ai_summary",
}
def _preflight_gates(*, summary, operator, route_policy):
required_sections = {
"review_scope",
"review_state_result",
"evidence_artifacts",
"execution_safety",
}
section_keys = set(summary["summary_section_keys"])
artifact_paths = summary["artifact_paths"]
return [
{
"key": "archive_summary_preview_provided",
"label": "必須提供 archive summary preview",
"passed": bool(
summary["provided"]
and summary["mode"] == "candidate_queue_review_archive_summary_preview"
),
},
{
"key": "archive_summary_ready",
"label": "archive summary input 必須已通過",
"passed": bool(
summary["archive_summary_ready"]
and summary["summary_input_ready"]
and summary["ready_for_ai_summary_review"]
),
},
{
"key": "summary_sections_complete",
"label": "摘要輸入必須包含必要 section",
"passed": required_sections.issubset(section_keys),
},
{
"key": "summary_dedupe_scope_complete",
"label": "summary dedupe key 必須完整且無缺漏",
"passed": bool(
summary["expected_dedupe_keys"]
and summary["expected_dedupe_keys"] == summary["found_dedupe_keys"]
and not summary["missing_dedupe_keys"]
),
},
{
"key": "summary_state_clean",
"label": "AI 摘要前不可存在 review_state mismatch",
"passed": bool(summary["row_summary_count"] > 0 and not summary["state_mismatches"]),
},
{
"key": "summary_artifact_paths_present",
"label": "摘要前必須保留上游 artifact path",
"passed": all(artifact_paths.values()),
},
{
"key": "ollama_first_policy_locked",
"label": "未來摘要路由必須 Ollama-first 且 Gemini 只能備援",
"passed": bool(
route_policy["primary_policy"] == "ollama_first"
and len(route_policy["primary_cascade"]) == 3
and route_policy["fallback_policy"]
== "gemini_backup_only_after_ollama_cascade_failure"
and route_policy["app_host_forbidden_as_ollama_node"]
== "192.168.0.188"
),
},
{
"key": "operator_confirmed_ai_summary_preflight",
"label": "操作員確認本步不呼叫模型且後續須 Ollama-first",
"passed": bool(
operator["operator_confirmed_ai_summary_preflight"]
and operator["operator_confirmed_ollama_first"]
and operator["operator_confirmed_gemini_backup_only"]
and operator["operator_confirmed_no_llm_call"]
and operator["operator_confirmed_no_telegram_dispatch"]
and operator["operator_confirmed_no_api_db_write"]
and operator["operator_confirmed_no_scheduler_attach"]
),
},
{
"key": "ai_summary_preflight_artifact_path_recorded",
"label": "preflight artifact path 必須由操作員提供",
"passed": bool(operator["ai_summary_preflight_artifact_path"]),
},
{
"key": "ai_summary_preflight_no_approval_token_submitted_to_api",
"label": "payload 不得包含一次性 approval token key",
"passed": not operator["approval_token_submitted_to_api"],
},
{
"key": "ai_summary_preflight_has_no_side_effects",
"label": "preflight 不得呼叫 LLM、派送 Telegram、寫檔、寫 DB 或掛 scheduler",
"passed": _side_effects_clear(summary),
},
]
def build_candidate_queue_review_ai_summary_preflight(
*,
archive_summary,
operator_evidence=None,
execute_requested=False,
):
"""建立 AI summary preflight 預覽;不執行 LLM、檔案、網路或 DB 副作用。"""
summary = _summary_payload(archive_summary)
operator = _operator_summary(operator_evidence)
route_policy = _model_route_policy()
gates = _preflight_gates(
summary=summary,
operator=operator,
route_policy=route_policy,
)
blocked_reasons = [gate["key"] for gate in gates if not gate["passed"]]
preflight_ready = bool(not blocked_reasons)
return {
"mode": "candidate_queue_review_ai_summary_preflight_preview",
"target_table": TARGET_TABLE,
"target_operation": "preflight_ollama_first_ai_summary",
"execute_requested": bool(execute_requested),
"ai_summary_preflight_ready": preflight_ready,
"ready_for_manual_ollama_summary_run": preflight_ready,
"ready_for_next_manual_phase": preflight_ready,
"ready_for_ai_summary_generation": False,
"ready_for_llm_call": False,
"ready_for_telegram_dispatch": False,
"ready_for_api_review_state_update": False,
"ready_for_api_database_write": False,
"ready_for_scheduler_attach": False,
"api_executes_cli": False,
"api_reads_approval_token": False,
"api_writes_file": False,
"api_writes_database": False,
"api_updates_review_state": False,
"approval_record_written": False,
"decision_record_written": False,
"ai_summary_generated": False,
"llm_call_executed": False,
"ollama_call_executed": False,
"gemini_call_executed": False,
"telegram_dispatched": False,
"summary_file_written": False,
"summary_record_written": False,
"summary_manifest_written": False,
"review_state_update_executed": False,
"read_only_query_executed": summary["read_only_query_executed"],
"database_connection_opened": summary["database_connection_opened"],
"database_session_created": False,
"explicit_transaction_opened": False,
"transaction_opened": False,
"transaction_committed": False,
"database_write_executed": False,
"database_commit_executed": False,
"database_rollback_executed": False,
"external_network_executed": False,
"scheduler_attached": False,
"writes_executed": False,
"would_write_database": False,
"expected_dedupe_keys": summary["expected_dedupe_keys"],
"found_dedupe_keys": summary["found_dedupe_keys"],
"missing_dedupe_keys": summary["missing_dedupe_keys"],
"state_mismatches": summary["state_mismatches"],
"row_summary_count": summary["row_summary_count"],
"summary_section_keys": summary["summary_section_keys"],
"model_route_policy": route_policy,
"operator_ai_summary_preflight": operator,
"blocked_reasons": blocked_reasons,
"gates": gates,
"next_operator_steps": [
"確認 archive summary input 已通過且 artifact path 完整",
"後續若要產生 AI 摘要,只能另以 OllamaService 三主機級聯執行",
"Gemini 只能在 Ollama cascade 全失敗後作為備援或 ADR 鎖定場景",
"preflight 本身不得呼叫模型、不得派送 Telegram、不得寫 DB",
],
"safe_boundaries": [
"do_not_call_llm_from_ai_summary_preflight",
"do_not_dispatch_telegram_from_ai_summary_preflight",
"do_not_write_summary_file_from_ai_summary_preflight",
"do_not_insert_summary_record_from_ai_summary_preflight",
"do_not_update_review_state_from_ai_summary_preflight",
"do_not_read_approval_token_from_ai_summary_preflight",
"do_not_execute_cli_from_ai_summary_preflight",
"do_not_attach_scheduler_from_ai_summary_preflight",
"ollama_first_required_for_future_summary",
"ollama_cascade_must_use_gcp_a_then_gcp_b_then_111",
"gemini_backup_only_after_ollama_cascade_failure",
"host_188_forbidden_as_ollama_node",
"ai_summary_preflight_preview_only",
"no_remove_orphans",
"no_momo_db_lifecycle_change",
],
}