""" 备注服务 负责备注 CRUD、星星评分存储与读取。 备注类型根据关联任务自动确定:follow_up_visit 任务 → follow_up,否则 normal。 星星评分不参与回访完成判定,不参与 AI 分析,仅存储。 """ import json import logging from fastapi import HTTPException from app.trace.decorators import trace_service logger = logging.getLogger(__name__) def _get_connection(): """延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。""" from app.database import get_connection return get_connection() def _record_history( cur, task_id: int, action: str, old_status: str | None = None, new_status: str | None = None, old_task_type: str | None = None, new_task_type: str | None = None, detail: dict | None = None, ) -> None: """在 coach_task_history 中记录变更(复用 task_manager 的模式)。""" cur.execute( """ INSERT INTO biz.coach_task_history (task_id, action, old_status, new_status, old_task_type, new_task_type, detail) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( task_id, action, old_status, new_status, old_task_type, new_task_type, json.dumps(detail) if detail else None, ), ) @trace_service(description_zh="ai_analyze_note", description_en="Ai Analyze Note") async def ai_analyze_note(note_id: int, site_id: int, member_id: int, content: str, user_name: str = "") -> int | None: """ AI 应用 6 备注分析:调用百炼 Application API 获取评分。 CHANGE 2026-03-27 | 打通 AI 应用 6 调用链 仅执行 App6 评分,不触发 App8 线索整合(后续统一处理)。 返回 score(1-10),失败返回 None。 """ try: from app.ai.config import AIConfig from app.ai.dashscope_client import DashScopeClient import json config = AIConfig.from_env() client = DashScopeClient(api_key=config.api_key, workspace_id=config.workspace_id) # 构建 prompt(简化版,直接传给百炼应用) prompt = json.dumps({ "site_id": site_id, "member_id": member_id, "note_content": content, "noted_by_name": user_name, }, ensure_ascii=False) result, tokens_used, _ = await client.call_app(config.app_id_6_note, prompt) score = result.get("score") if isinstance(result, dict) else None if score is not None: score = max(1, min(10, int(score))) logger.info("App6 备注评分完成: note_id=%d score=%d tokens=%d", note_id, score, tokens_used) return score except Exception: logger.warning("App6 备注评分失败: note_id=%d", note_id, exc_info=True) return None async def _async_ai_score(note_id: int, site_id: int, member_id: int, content: str) -> None: """后台异步执行 AI 评分,不阻塞 API 响应。""" try: ai_score_val = await ai_analyze_note( note_id=note_id, site_id=site_id, member_id=member_id, content=content, ) if ai_score_val is not None: conn = _get_connection() try: with conn.cursor() as cur: cur.execute( "UPDATE biz.notes SET ai_score = %s, updated_at = NOW() WHERE id = %s", (ai_score_val, note_id), ) conn.commit() logger.info("AI 评分已写入: note_id=%d ai_score=%d", note_id, ai_score_val) finally: conn.close() except Exception: logger.warning("后台 AI 评分失败: note_id=%d", note_id, exc_info=True) @trace_service("创建备注", "Create note") async def create_note( site_id: int, user_id: int, target_type: str, target_id: int, content: str, task_id: int | None = None, rating_service_willingness: int | None = None, rating_revisit_likelihood: int | None = None, score: int | None = None, ) -> dict: """ 创建备注。 1. 验证评分范围(1-5 或 NULL),不合法则 422 2. 确定 note type: - 若 task_id 关联的任务 task_type='follow_up_visit' → type='follow_up' - 否则 → type='normal' 3. INSERT INTO biz.notes 4. 若 type='follow_up': - 保留 AI 占位调用(P5 接入时调用链不变),返回值仅更新 ai_score - 不论 ai_score 如何,有备注即标记关联 active 回访任务 completed 5. 返回创建的备注记录 注意:星星评分不参与回访完成判定,不参与 AI 分析,仅存储。 """ # 验证评分范围 for label, val in [ ("再次服务意愿评分", rating_service_willingness), ("再来店可能性评分", rating_revisit_likelihood), ("备注星星评分", score), ]: if val is not None and (val < 1 or val > 5): raise HTTPException( status_code=422, detail=f"{label}必须在 1-5 范围内" ) conn = _get_connection() try: # 确定 note type note_type = "normal" task_info = None if task_id is not None: with conn.cursor() as cur: cur.execute( """ SELECT id, task_type, status, site_id FROM biz.coach_tasks WHERE id = %s """, (task_id,), ) row = cur.fetchone() if row is None: raise HTTPException(status_code=404, detail="关联任务不存在") task_info = { "id": row[0], "task_type": row[1], "status": row[2], "site_id": row[3], } if task_info["site_id"] != site_id: raise HTTPException(status_code=403, detail="权限不足") if task_info["task_type"] == "follow_up_visit": note_type = "follow_up" # INSERT 备注 with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ INSERT INTO biz.notes (site_id, user_id, target_type, target_id, type, content, rating_service_willingness, rating_revisit_likelihood, task_id, score) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, site_id, user_id, target_type, target_id, type, content, rating_service_willingness, rating_revisit_likelihood, task_id, ai_score, ai_analysis, created_at, updated_at, score """, ( site_id, user_id, target_type, target_id, note_type, content, rating_service_willingness, rating_revisit_likelihood, task_id, score, ), ) row = cur.fetchone() note = { "id": row[0], "site_id": row[1], "user_id": row[2], "target_type": row[3], "target_id": row[4], "type": row[5], "content": row[6], "rating_service_willingness": row[7], "rating_revisit_likelihood": row[8], "task_id": row[9], "ai_score": row[10], "ai_analysis": row[11], "created_at": row[12].isoformat() if row[12] else None, "updated_at": row[13].isoformat() if row[13] else None, "score": row[14], } # 若 type='follow_up',标记回访任务完成(不依赖 AI 评分) if note_type == "follow_up" and task_id is not None: if task_info and task_info["status"] == "active": cur.execute( """ UPDATE biz.coach_tasks SET status = 'completed', completed_at = NOW(), completed_task_type = task_type, updated_at = NOW() WHERE id = %s AND status = 'active' """, (task_id,), ) _record_history( cur, task_id, action="completed_by_note", old_status="active", new_status="completed", old_task_type=task_info["task_type"], new_task_type=task_info["task_type"], detail={"note_id": note["id"]}, ) conn.commit() # CHANGE 2026-03-27 | AI 评分:后台异步执行,不阻塞 API 响应 # 备注先返回给前端(aiScore=null),AI 评分完成后写入数据库 # 前端下次加载页面时自动获取最新 aiScore import asyncio asyncio.create_task(_async_ai_score(note["id"], site_id, target_id, content)) return note except HTTPException: conn.rollback() raise except Exception: conn.rollback() raise finally: conn.close() @trace_service("查询备注列表", "Get notes") async def get_notes( site_id: int, target_type: str, target_id: int ) -> list[dict]: """ 查询某目标的备注列表。 按 created_at DESC 排序,包含星星评分和 AI 评分。 """ conn = _get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT id, site_id, user_id, target_type, target_id, type, content, rating_service_willingness, rating_revisit_likelihood, task_id, ai_score, ai_analysis, created_at, updated_at FROM biz.notes WHERE site_id = %s AND target_type = %s AND target_id = %s ORDER BY created_at DESC """, (site_id, target_type, target_id), ) rows = cur.fetchall() conn.commit() return [ { "id": r[0], "site_id": r[1], "user_id": r[2], "target_type": r[3], "target_id": r[4], "type": r[5], "content": r[6], "rating_service_willingness": r[7], "rating_revisit_likelihood": r[8], "task_id": r[9], "ai_score": r[10], "ai_analysis": r[11], "created_at": r[12].isoformat() if r[12] else None, "updated_at": r[13].isoformat() if r[13] else None, } for r in rows ] finally: conn.close() @trace_service("删除备注", "Delete note") async def delete_note(note_id: int, user_id: int, site_id: int) -> dict: """ 删除备注。 验证备注归属(user_id + site_id)后执行硬删除。 - 不存在 → 404 - 不属于当前用户 → 403 """ conn = _get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT id, user_id, site_id FROM biz.notes WHERE id = %s """, (note_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="资源不存在") if row[2] != site_id or row[1] != user_id: raise HTTPException(status_code=403, detail="权限不足") with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( "DELETE FROM biz.notes WHERE id = %s", (note_id,), ) conn.commit() return {"id": note_id, "deleted": True} except HTTPException: conn.rollback() raise except Exception: conn.rollback() raise finally: conn.close()