涵盖(每条对应已存的审计记录): - AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生) audit: 2026-04-20__ai-module-complete.md - admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager audit: 2026-04-21__admin-web-ai-management-suite.md - App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance) audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md - App2 prewarm 全过滤器 + AI 触发器 cron reschedule audit: 2026-04-21__app2-finance-prewarm-all-filters.md migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql - AppType 联合类型对齐 + adminAiAppTypes.test.ts audit: 2026-04-30__admin_web_ai_app_type_alignment.md - DashScope tokens_used 提取修复 audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md - App3 线索完整详情 prompt audit: 2026-05-01__backend_app3_full_detail_prompt.md - Runtime Context 沙箱(5-1~5-2 主线): - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts - migration: 20260501__runtime_context_sandbox.sql - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py - database/changes: 7 份 sandbox_* 验证报告 - 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整 + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py) 合规: - .gitignore 启用 tmp/ 排除 - 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留) 待验证清单: - docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md 每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
395 lines
13 KiB
Python
395 lines
13 KiB
Python
"""
|
||
备注服务
|
||
|
||
负责备注 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))
|
||
|
||
# 触发 AI 备注分析链(App6 → App8)
|
||
# target_type='member' 时 target_id 即 member_id;'assistant' 时不触发(AI 只分析会员备注)
|
||
if target_type == "member":
|
||
try:
|
||
from app.services.trigger_scheduler import fire_event
|
||
fire_event(
|
||
"ai_note_created",
|
||
{
|
||
"site_id": site_id,
|
||
"member_id": target_id,
|
||
"note_content": content,
|
||
"noted_by_name": note.get("recorded_by_name")
|
||
or note.get("user_nickname") or "",
|
||
"noted_by_created_at": note.get("created_at") or "",
|
||
},
|
||
)
|
||
except Exception:
|
||
logger.exception(
|
||
"触发 ai_note_created 事件失败: note_id=%s member_id=%s",
|
||
note["id"], target_id,
|
||
)
|
||
|
||
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()
|