Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作: - 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId - 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设) - xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离 - chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡) - reference_card KPI 富卡接入 SSE 路径,db 真写入 - 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字 数据库: - public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id - biz.ai_run_logs 加 assistant_id + 复合索引 - chk_ai_cache_type CHECK 约束 8 类应用名 - cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation) - 历史 emoji 抽取脚本 44/44 成功 后端 silent failure 修: - cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效) - _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema) - task_manager talkingPoints 改 app5_tactics + tactics 字段 - task_manager aiSuggestion 改取 one_line_summary - cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area - WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞) - internal_ai token 改 hmac.compare_digest 工具/文档: - main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤 - 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则) - 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错) - audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务 - backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线 实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
714 lines
24 KiB
Python
714 lines
24 KiB
Python
"""页面上下文文本化模块(应用 1 专用)。
|
||
|
||
根据 contextType 从数据库获取对应页面数据,
|
||
格式化为结构化中文文本(≤ 2000 字符),供 AI 理解当前场景。
|
||
不传入 member_phone 等断档敏感字段。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from functools import partial
|
||
from typing import Any
|
||
|
||
from app.database import get_connection, get_etl_readonly_connection
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MAX_PAGE_CONTEXT_LENGTH = 2000
|
||
FDW_QUERY_TIMEOUT_SEC = 5
|
||
|
||
# 支持的 10 种页面类型
|
||
SUPPORTED_PAGE_TYPES = {
|
||
"task-detail",
|
||
"customer-detail",
|
||
"coach-detail",
|
||
"board-finance",
|
||
"board-customer",
|
||
"board-coach",
|
||
"performance",
|
||
"my-profile",
|
||
"task-list",
|
||
"customer-service-records",
|
||
}
|
||
|
||
|
||
async def build_page_text(
|
||
source_page: str,
|
||
context_id: int | str | None,
|
||
site_id: int,
|
||
filters: dict | None = None,
|
||
) -> str:
|
||
"""将页面数据转换为 AI 可读的结构化中文文本。
|
||
|
||
Args:
|
||
source_page: 页面类型(contextType)
|
||
context_id: 实体 ID(contextId)
|
||
site_id: 门店 ID
|
||
filters: 看板类页面的筛选参数
|
||
|
||
Returns:
|
||
结构化中文文本(≤ 2000 字符),失败时返回降级文本
|
||
"""
|
||
if not source_page or source_page not in SUPPORTED_PAGE_TYPES:
|
||
return ""
|
||
|
||
try:
|
||
loop = asyncio.get_event_loop()
|
||
text = await loop.run_in_executor(
|
||
None,
|
||
partial(_build_page_text_sync, source_page, context_id, site_id, filters or {}),
|
||
)
|
||
# 截断保护
|
||
if len(text) > MAX_PAGE_CONTEXT_LENGTH:
|
||
text = text[:MAX_PAGE_CONTEXT_LENGTH - 20] + "\n…(上下文已截断)"
|
||
return text
|
||
except Exception:
|
||
logger.exception("页面上下文获取失败: source_page=%s", source_page)
|
||
return "页面上下文获取失败,请直接描述您的问题"
|
||
|
||
|
||
def _build_page_text_sync(
|
||
source_page: str,
|
||
context_id: int | str | None,
|
||
site_id: int,
|
||
filters: dict,
|
||
) -> str:
|
||
"""同步路由到对应页面文本化函数。"""
|
||
handlers = {
|
||
"task-detail": _text_task_detail,
|
||
"customer-detail": _text_customer_detail,
|
||
"coach-detail": _text_coach_detail,
|
||
"board-finance": _text_board_finance,
|
||
"board-customer": _text_board_customer,
|
||
"board-coach": _text_board_coach,
|
||
"performance": _text_performance,
|
||
"my-profile": _text_my_profile,
|
||
"task-list": _text_task_list,
|
||
"customer-service-records": _text_customer_service_records,
|
||
}
|
||
handler = handlers.get(source_page)
|
||
if not handler:
|
||
return ""
|
||
return handler(context_id, site_id, filters)
|
||
|
||
|
||
# ── 详情类页面 ──────────────────────────────────────────────────
|
||
|
||
|
||
def _text_task_detail(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""任务详情页文本化。"""
|
||
if not context_id:
|
||
return ""
|
||
task_id = int(context_id)
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 任务信息
|
||
cur.execute(
|
||
"""
|
||
SELECT ct.task_type, ct.status, ct.deadline,
|
||
ct.member_id, ct.assistant_id,
|
||
dm.nickname AS member_nickname,
|
||
da.nickname AS assistant_nickname
|
||
FROM biz.coach_tasks ct
|
||
LEFT JOIN biz.coach_tasks_member_view dm
|
||
ON dm.member_id = ct.member_id AND dm.site_id = ct.site_id
|
||
LEFT JOIN biz.coach_tasks_assistant_view da
|
||
ON da.assistant_id = ct.assistant_id AND da.site_id = ct.site_id
|
||
WHERE ct.id = %s AND ct.site_id = %s
|
||
""",
|
||
(task_id, site_id),
|
||
)
|
||
task = cur.fetchone()
|
||
if not task:
|
||
return f"任务 {task_id} 不存在"
|
||
|
||
task_type, status, deadline, member_id, assistant_id, member_nick, asst_nick = task
|
||
|
||
# 最近备注(最多 3 条)
|
||
cur.execute(
|
||
"""
|
||
SELECT content, created_at
|
||
FROM biz.notes
|
||
WHERE task_id = %s AND site_id = %s
|
||
ORDER BY created_at DESC LIMIT 3
|
||
""",
|
||
(task_id, site_id),
|
||
)
|
||
notes = cur.fetchall()
|
||
|
||
# AI 缓存(最新分析)
|
||
cur.execute(
|
||
"""
|
||
SELECT result_json, created_at
|
||
FROM biz.ai_cache
|
||
WHERE cache_type = 'app4_analysis'
|
||
AND site_id = %s
|
||
AND target_id = %s
|
||
ORDER BY created_at DESC LIMIT 1
|
||
""",
|
||
(site_id, f"{assistant_id}_{member_id}"),
|
||
)
|
||
ai_row = cur.fetchone()
|
||
|
||
lines = [
|
||
"【任务详情】",
|
||
f" 任务类型:{task_type or '未知'}",
|
||
f" 状态:{status or '未知'}",
|
||
f" 截止日期:{_fmt_date(deadline)}",
|
||
f" 客户:{member_nick or f'ID:{member_id}'}",
|
||
f" 助教:{asst_nick or f'ID:{assistant_id}'}",
|
||
]
|
||
if notes:
|
||
lines.append("【最近备注】")
|
||
for content, created_at in notes:
|
||
short = (content or "")[:100]
|
||
lines.append(f" {_fmt_date(created_at)} {short}")
|
||
if ai_row:
|
||
lines.append(f"【AI 分析】最近更新于 {_fmt_date(ai_row[1])}")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _text_customer_detail(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""客户详情页文本化。"""
|
||
if not context_id:
|
||
return ""
|
||
member_id = int(context_id)
|
||
|
||
# 复用 member_data 的同步查询(避免循环导入,直接查询)
|
||
etl_conn = None
|
||
biz_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 进入新事务,RLS 视图 current_setting
|
||
# ('app.current_site_id') 拿到空串导致 bigint 转换失败。每次 cursor 必须
|
||
# 重新 SET 当前事务的 GUC(对齐 member_data.py / assistant_data.py 模式)。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
# CHANGE 2026-03-23 | Prompt: FDW 迁移——fdw_etl.* → app.* 直连 ETL 库
|
||
# 会员信息
|
||
cur.execute(
|
||
"""
|
||
SELECT nickname
|
||
FROM app.v_dim_member
|
||
WHERE member_id = %s AND scd2_is_current = 1
|
||
""",
|
||
(member_id,),
|
||
)
|
||
m_row = cur.fetchone()
|
||
nickname = m_row[0] if m_row else f"ID:{member_id}"
|
||
|
||
# 最近 5 条消费
|
||
# W1-AI-CLOSURE 复盘修正:列名错位 — v_dwd_settlement_head 实际列是
|
||
# pay_time / settle_name(无 settle_date / room_name);items_sum 按
|
||
# DWD-DOC 强制规则 #1 用合成表达式(consume_money 禁止直接计算)。
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
pay_time,
|
||
(COALESCE(table_charge_money, 0)
|
||
+ COALESCE(goods_money, 0)
|
||
+ COALESCE(assistant_pd_money, 0)
|
||
+ COALESCE(assistant_cx_money, 0)
|
||
+ COALESCE(electricity_money, 0)) AS items_sum,
|
||
settle_name
|
||
FROM app.v_dwd_settlement_head
|
||
WHERE member_id = %s AND settle_type IN (1, 3)
|
||
ORDER BY pay_time DESC LIMIT 5
|
||
""",
|
||
(member_id,),
|
||
)
|
||
recent = cur.fetchall()
|
||
|
||
# 余额
|
||
# W1-AI-CLOSURE 复盘修正:列名 balance_amount 不存在,实际列是
|
||
# cash_card_balance / gift_card_balance / total_card_balance;
|
||
# total_card_balance 是合计储值(对齐 customer_service.balance 字段)。
|
||
cur.execute(
|
||
"""
|
||
SELECT total_card_balance
|
||
FROM app.v_dws_member_consumption_summary
|
||
WHERE member_id = %s LIMIT 1
|
||
""",
|
||
(member_id,),
|
||
)
|
||
bal_row = cur.fetchone()
|
||
etl_conn.commit()
|
||
|
||
# 维客线索(W1-AI-CLOSURE 复盘修正:列名 created_at 不存在,实际是 recorded_at;
|
||
# 表加 schema 前缀 public 防 search_path 漂移;补 is_hidden 过滤)
|
||
biz_conn = get_connection()
|
||
with biz_conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT summary FROM public.member_retention_clue
|
||
WHERE member_id = %s AND site_id = %s AND is_hidden = false
|
||
ORDER BY recorded_at DESC LIMIT 5
|
||
""",
|
||
(member_id, site_id),
|
||
)
|
||
clues = cur.fetchall()
|
||
|
||
lines = [
|
||
"【客户详情】",
|
||
f" 昵称:{nickname}",
|
||
f" 储值余额:{_fmt_decimal(bal_row[0]) if bal_row else '未知'}",
|
||
]
|
||
if recent:
|
||
lines.append("【近期消费】")
|
||
for sd, amt, room in recent:
|
||
lines.append(f" {_fmt_date(sd)} ¥{_fmt_decimal(amt)} {room or ''}")
|
||
if clues:
|
||
lines.append("【维客线索】")
|
||
for (summary,) in clues:
|
||
lines.append(f" {summary}")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
if biz_conn:
|
||
biz_conn.close()
|
||
|
||
|
||
def _text_coach_detail(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""助教详情页文本化。"""
|
||
if not context_id:
|
||
return ""
|
||
assistant_id = int(context_id)
|
||
|
||
etl_conn = None
|
||
biz_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 在新事务中 RLS 视图查不到 site_id。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
cur.execute(
|
||
"""
|
||
SELECT nickname, level, hire_date
|
||
FROM app.v_dim_assistant
|
||
WHERE assistant_id = %s LIMIT 1
|
||
""",
|
||
(assistant_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
etl_conn.commit()
|
||
|
||
if not row:
|
||
return f"助教 {assistant_id} 不存在"
|
||
|
||
nickname, level, hire_date = row
|
||
|
||
biz_conn = get_connection()
|
||
with biz_conn.cursor() as cur:
|
||
# 任务统计
|
||
cur.execute(
|
||
"""
|
||
SELECT status, COUNT(*)
|
||
FROM biz.coach_tasks
|
||
WHERE assistant_id = %s AND site_id = %s
|
||
GROUP BY status
|
||
""",
|
||
(assistant_id, site_id),
|
||
)
|
||
task_stats = cur.fetchall()
|
||
|
||
lines = [
|
||
"【助教详情】",
|
||
f" 花名:{nickname or ''}",
|
||
f" 级别:{level or ''}",
|
||
f" 入职日期:{_fmt_date(hire_date)}",
|
||
]
|
||
if task_stats:
|
||
lines.append("【任务统计】")
|
||
for status, cnt in task_stats:
|
||
lines.append(f" {status}: {cnt} 个")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
if biz_conn:
|
||
biz_conn.close()
|
||
|
||
|
||
# ── 看板类页面 ──────────────────────────────────────────────────
|
||
|
||
|
||
def _text_board_finance(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""财务看板文本化。"""
|
||
time_dim = filters.get("timeDimension", "this_month")
|
||
area = filters.get("areaFilter", "")
|
||
|
||
etl_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 在新事务中 RLS 视图查不到 site_id。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
# 简化查询:获取汇总数据(CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE)
|
||
from app.services.runtime_context import as_runtime_today_param
|
||
_ref_date = as_runtime_today_param(site_id)
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
COUNT(*) AS settle_count,
|
||
COALESCE(SUM(items_sum), 0) AS total_revenue,
|
||
COALESCE(AVG(items_sum), 0) AS avg_revenue
|
||
FROM app.v_dwd_settlement_head
|
||
WHERE settle_type IN (1, 3)
|
||
AND settle_date >= (%s::date - INTERVAL '1 month')
|
||
AND settle_date <= %s::date
|
||
""",
|
||
(_ref_date, _ref_date),
|
||
)
|
||
row = cur.fetchone()
|
||
etl_conn.commit()
|
||
|
||
lines = [
|
||
"【财务看板】",
|
||
f" 时间维度:{time_dim}",
|
||
]
|
||
if area:
|
||
lines.append(f" 区域筛选:{area}")
|
||
if row:
|
||
lines.append(f" 结算笔数:{row[0]}")
|
||
lines.append(f" 总营收:¥{_fmt_decimal(row[1])}")
|
||
lines.append(f" 笔均:¥{_fmt_decimal(row[2])}")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
|
||
|
||
def _text_board_customer(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""客户看板文本化。"""
|
||
dimension = filters.get("dimension", "consumption")
|
||
type_filter = filters.get("typeFilter", "")
|
||
|
||
etl_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 在新事务中 RLS 视图查不到 site_id。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
# Top 10 客户(CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE)
|
||
from app.services.runtime_context import as_runtime_today_param
|
||
_ref_date = as_runtime_today_param(site_id)
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
dm.nickname,
|
||
COALESCE(SUM(sh.items_sum), 0) AS total_consumption
|
||
FROM app.v_dwd_settlement_head sh
|
||
JOIN app.v_dim_member dm
|
||
ON dm.member_id = sh.member_id AND dm.scd2_is_current = 1
|
||
WHERE sh.settle_type IN (1, 3)
|
||
AND sh.member_id > 0
|
||
AND sh.settle_date >= (%s::date - INTERVAL '1 month')
|
||
AND sh.settle_date <= %s::date
|
||
GROUP BY dm.nickname
|
||
ORDER BY total_consumption DESC
|
||
LIMIT 10
|
||
""",
|
||
(_ref_date, _ref_date),
|
||
)
|
||
rows = cur.fetchall()
|
||
etl_conn.commit()
|
||
|
||
lines = [
|
||
"【客户看板】",
|
||
f" 排序维度:{dimension}",
|
||
]
|
||
if type_filter:
|
||
lines.append(f" 类型筛选:{type_filter}")
|
||
if rows:
|
||
lines.append(" Top 10 客户:")
|
||
for i, (nick, amt) in enumerate(rows, 1):
|
||
lines.append(f" {i}. {nick or '未知'} ¥{_fmt_decimal(amt)}")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
|
||
|
||
def _text_board_coach(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""助教看板文本化。"""
|
||
dimension = filters.get("dimension", "service_count")
|
||
project = filters.get("projectFilter", "")
|
||
time_dim = filters.get("timeDimension", "this_month")
|
||
|
||
etl_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 在新事务中 RLS 视图查不到 site_id。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE
|
||
from app.services.runtime_context import as_runtime_today_param
|
||
_ref_date = as_runtime_today_param(site_id)
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
da.nickname,
|
||
COUNT(*) AS service_count,
|
||
COALESCE(SUM(sl.ledger_amount), 0) AS total_revenue
|
||
FROM app.v_dwd_assistant_service_log sl
|
||
JOIN app.v_dim_assistant da
|
||
ON da.assistant_id = sl.site_assistant_id
|
||
WHERE sl.is_delete = 0
|
||
AND sl.create_time >= (%s::date - INTERVAL '1 month')
|
||
AND sl.create_time < (%s::date + INTERVAL '1 day')
|
||
GROUP BY da.nickname
|
||
ORDER BY service_count DESC
|
||
LIMIT 10
|
||
""",
|
||
(_ref_date, _ref_date),
|
||
)
|
||
rows = cur.fetchall()
|
||
etl_conn.commit()
|
||
|
||
lines = [
|
||
"【助教看板】",
|
||
f" 排序维度:{dimension}",
|
||
f" 时间维度:{time_dim}",
|
||
]
|
||
if project:
|
||
lines.append(f" 技能筛选:{project}")
|
||
if rows:
|
||
lines.append(" Top 10 助教:")
|
||
for i, (nick, cnt, amt) in enumerate(rows, 1):
|
||
lines.append(f" {i}. {nick or '未知'} 服务{cnt}次 ¥{_fmt_decimal(amt)}")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
|
||
|
||
# ── 其他页面 ──────────────────────────────────────────────────
|
||
|
||
|
||
def _text_performance(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""绩效页面文本化。"""
|
||
time_dim = filters.get("timeDimension", "this_month")
|
||
|
||
etl_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 在新事务中 RLS 视图查不到 site_id。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
da.nickname,
|
||
sc.performance_tier,
|
||
sc.monthly_customers
|
||
FROM app.v_dws_assistant_salary_calc sc
|
||
JOIN app.v_dim_assistant da
|
||
ON da.assistant_id = sc.assistant_id
|
||
ORDER BY sc.calc_month DESC, sc.monthly_customers DESC
|
||
LIMIT 10
|
||
""",
|
||
)
|
||
rows = cur.fetchall()
|
||
etl_conn.commit()
|
||
|
||
lines = [
|
||
"【绩效数据】",
|
||
f" 时间维度:{time_dim}",
|
||
]
|
||
if rows:
|
||
for nick, tier, customers in rows:
|
||
lines.append(f" {nick or '未知'} {tier or ''} 服务{customers or 0}人")
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
|
||
|
||
def _text_my_profile(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""个人信息页文本化。"""
|
||
return "【个人信息】\n 当前为个人信息页面,可查询个人绩效和任务情况。"
|
||
|
||
|
||
def _text_task_list(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""任务列表页文本化。"""
|
||
if not context_id:
|
||
# 无特定任务,返回概要
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT status, COUNT(*)
|
||
FROM biz.coach_tasks
|
||
WHERE site_id = %s
|
||
GROUP BY status
|
||
""",
|
||
(site_id,),
|
||
)
|
||
stats = cur.fetchall()
|
||
|
||
lines = ["【任务列表】"]
|
||
for status, cnt in stats:
|
||
lines.append(f" {status}: {cnt} 个")
|
||
return "\n".join(lines)
|
||
finally:
|
||
conn.close()
|
||
|
||
# 有特定任务 ID,复用 task-detail
|
||
return _text_task_detail(context_id, site_id, filters)
|
||
|
||
|
||
def _text_customer_service_records(
|
||
context_id: int | str | None, site_id: int, filters: dict
|
||
) -> str:
|
||
"""客户服务记录页文本化。"""
|
||
if not context_id:
|
||
return ""
|
||
member_id = int(context_id)
|
||
|
||
etl_conn = None
|
||
try:
|
||
etl_conn = get_etl_readonly_connection(site_id)
|
||
with etl_conn.cursor() as cur:
|
||
# W1-AI-CLOSURE 复盘修正:get_etl_readonly_connection 在 SET LOCAL 后
|
||
# commit(LOCAL 失效),后续 cursor 在新事务中 RLS 视图查不到 site_id。
|
||
cur.execute(
|
||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||
)
|
||
cur.execute(
|
||
"SET LOCAL statement_timeout = %s",
|
||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||
)
|
||
# CHANGE 2026-05-02 | 仅取业务日及之前的服务记录,沙箱不读「未来」
|
||
from app.services.runtime_context import as_runtime_today_param
|
||
_ref_date = as_runtime_today_param(site_id)
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
create_time,
|
||
real_use_seconds / 60 AS duration_minutes,
|
||
ledger_amount,
|
||
site_table_id
|
||
FROM app.v_dwd_assistant_service_log
|
||
WHERE tenant_member_id = %s AND is_delete = 0
|
||
AND create_time < (%s::date + INTERVAL '1 day')
|
||
ORDER BY create_time DESC
|
||
LIMIT 10
|
||
""",
|
||
(member_id, _ref_date),
|
||
)
|
||
rows = cur.fetchall()
|
||
etl_conn.commit()
|
||
|
||
lines = ["【服务记录】"]
|
||
if not rows:
|
||
lines.append(" 暂无服务记录")
|
||
else:
|
||
for sd, dur, amt, room in rows:
|
||
lines.append(
|
||
f" {_fmt_date(sd)} {dur or 0}分钟 ¥{_fmt_decimal(amt)} {room or ''}"
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
finally:
|
||
if etl_conn:
|
||
etl_conn.close()
|
||
|
||
|
||
# ── 工具函数 ──────────────────────────────────────────────────
|
||
|
||
|
||
def _fmt_date(val: Any) -> str:
|
||
"""格式化日期值。"""
|
||
if isinstance(val, datetime):
|
||
return val.strftime("%Y-%m-%d %H:%M")
|
||
if isinstance(val, date):
|
||
return val.isoformat()
|
||
return str(val) if val else "未知"
|
||
|
||
|
||
def _fmt_decimal(val: Any) -> str:
|
||
"""格式化金额值。"""
|
||
if val is None:
|
||
return "0.00"
|
||
if isinstance(val, Decimal):
|
||
return f"{val:.2f}"
|
||
if isinstance(val, float):
|
||
return f"{val:.2f}"
|
||
return str(val)
|