diff --git a/apps/api/src/api/v1/incidents.py b/apps/api/src/api/v1/incidents.py index 004a5396..b6cce60a 100644 --- a/apps/api/src/api/v1/incidents.py +++ b/apps/api/src/api/v1/incidents.py @@ -342,6 +342,180 @@ async def generate_proposal(incident_id: str) -> ProposalGenerateResponse: ) +# ============================================================================= +# PUT /api/v1/incidents/{incident_id}/feedback - Phase 6.6 人類回饋 API +# ============================================================================= + + +class FeedbackRequest(BaseModel): + """人類回饋請求""" + + effectiveness_score: int | None = Field( + None, + ge=1, + le=5, + description="有效性評分 (1-5 分, 5 最有效)", + ) + human_feedback: str | None = Field( + None, + max_length=1000, + description="文字回饋 (如 '這個建議很準' 或 '下次應該先檢查 X')", + ) + learning_notes: str | None = Field( + None, + max_length=2000, + description="給未來 AI 的學習筆記", + ) + should_remember: bool = Field( + default=True, + description="是否納入長期記憶 (Episodic Memory)", + ) + + +class FeedbackResponse(BaseModel): + """人類回饋回應""" + + success: bool + message: str + incident_id: str + outcome: dict[str, Any] | None = None + + +@router.put( + "/{incident_id}/feedback", + response_model=FeedbackResponse, + summary="提交人類回饋", + description=""" + Phase 6.6: 人類回饋 API + + 用途: + - 收集人類對 AI 建議的有效性評分 + - 記錄文字回饋與學習筆記 + - 作為 AI 改進的訓練數據 + + 工作流程: + 1. 更新 Incident 的 outcome 欄位 + 2. 同步到 Redis (Working Memory) 和 PostgreSQL (Episodic Memory) + 3. 供統計分析 API (/api/v1/stats/feedback/summary) 聚合查詢 + """, +) +async def submit_feedback( + incident_id: str, + request: FeedbackRequest, +) -> FeedbackResponse: + """ + 提交人類對 AI 建議的回饋 + + Args: + incident_id: 事件 ID + request: 回饋內容 + + Returns: + FeedbackResponse: 更新結果 + + Raises: + HTTPException: 404 事件不存在 + """ + from datetime import datetime + + from sqlalchemy import select + + from src.db.base import get_db_context + from src.db.models import IncidentRecord + from src.models.incident import IncidentOutcome + + redis_client = get_redis() + redis_key = f"incident:{incident_id}" + + # 1. 取得現有 Incident + try: + data = await redis_client.get(redis_key) + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Incident not found: {incident_id}", + ) + incident = Incident.model_validate_json(data) + except HTTPException: + raise + except Exception as e: + logger.exception("feedback_redis_read_error", incident_id=incident_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to read incident: {str(e)}", + ) from e + + # 2. 更新或建立 outcome + if incident.outcome is None: + incident.outcome = IncidentOutcome() + + if request.effectiveness_score is not None: + incident.outcome.effectiveness_score = request.effectiveness_score + if request.human_feedback is not None: + incident.outcome.human_feedback = request.human_feedback + if request.learning_notes is not None: + incident.outcome.learning_notes = request.learning_notes + incident.outcome.should_remember = request.should_remember + incident.updated_at = datetime.now(UTC) + + # 3. 寫入 Redis + try: + await redis_client.set( + redis_key, + incident.model_dump_json(), + ex=604800, # 7 天 TTL + ) + logger.info( + "feedback_redis_updated", + incident_id=incident_id, + effectiveness_score=request.effectiveness_score, + ) + except Exception as e: + logger.exception("feedback_redis_write_error", incident_id=incident_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update Redis: {str(e)}", + ) from e + + # 4. 同步到 PostgreSQL (Episodic Memory) + try: + async with get_db_context() as db: + stmt = select(IncidentRecord).where( + IncidentRecord.incident_id == incident_id + ) + result = await db.execute(stmt) + record = result.scalar_one_or_none() + + if record: + record.outcome = incident.outcome.model_dump(mode="json") + record.updated_at = datetime.now(UTC) + await db.commit() + logger.info( + "feedback_db_updated", + incident_id=incident_id, + ) + else: + logger.warning( + "feedback_db_record_not_found", + incident_id=incident_id, + message="將在下次 memory 同步時建立", + ) + except Exception as e: + logger.warning( + "feedback_db_write_error", + incident_id=incident_id, + error=str(e), + ) + # DB 寫入失敗不阻止回應 (Redis 已更新) + + return FeedbackResponse( + success=True, + message="Feedback submitted successfully", + incident_id=incident_id, + outcome=incident.outcome.model_dump(mode="json"), + ) + + # ============================================================================= # DEBUG: 測試 Incident 狀態更新 # ============================================================================= diff --git a/apps/api/src/core/async_utils.py b/apps/api/src/core/async_utils.py new file mode 100644 index 00000000..f4ffc674 --- /dev/null +++ b/apps/api/src/core/async_utils.py @@ -0,0 +1,54 @@ +""" +Async Utilities +=============== +Safe async patterns for production use. + +ADR-013: 此模組包含關鍵異步模式,修改前請先讀懂註解! +""" + +import asyncio +from collections.abc import Coroutine +from typing import Any + +from src.core.logging import get_logger + +logger = get_logger("awoooi.async_utils") + + +def fire_and_forget(coro: Coroutine[Any, Any, Any], name: str | None = None) -> asyncio.Task[Any]: + """ + 安全的 fire-and-forget 模式。 + + 🔴 重要:不使用此函數的 asyncio.create_task() 會吞掉例外! + + Args: + coro: 要執行的協程 + name: 任務名稱 (用於日誌) + + Returns: + 已建立的任務 (可忽略) + + Example: + # ✅ 正確用法 + fire_and_forget(send_notification(user_id), name="notification") + + # ❌ 錯誤用法 (例外會被吞掉) + asyncio.create_task(send_notification(user_id)) + """ + task = asyncio.create_task(coro, name=name) + + def _handle_exception(t: asyncio.Task[Any]) -> None: + try: + exc = t.exception() + if exc is not None: + logger.error( + "fire_and_forget_exception", + task_name=name or "unnamed", + error=str(exc), + error_type=type(exc).__name__, + ) + except asyncio.CancelledError: + logger.debug("fire_and_forget_cancelled", task_name=name or "unnamed") + + task.add_done_callback(_handle_exception) + return task