feat(api): Add human feedback API (#6) + async_utils module
Phase 6.6 人類回饋 API:
- PUT /api/v1/incidents/{id}/feedback endpoint
- effectiveness_score (1-5), human_feedback, learning_notes fields
- Sync to Redis (Working Memory) + PostgreSQL (Episodic Memory)
- For stats aggregation at /api/v1/stats/feedback/summary
async_utils module:
- fire_and_forget() for safe background tasks
- Prevents swallowed exceptions in asyncio.create_task()
- Addresses P2 #8 tech debt
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 狀態更新
|
# DEBUG: 測試 Incident 狀態更新
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
54
apps/api/src/core/async_utils.py
Normal file
54
apps/api/src/core/async_utils.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user