Files
Neo-ZQYY/apps/backend/app/ai/data_fetchers/page_context.py
Neo caf179a5da feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录):
- 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 逐一处理
2026-05-04 02:30:19 +08:00

663 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""页面上下文文本化模块(应用 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: 实体 IDcontextId
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:
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 条消费
cur.execute(
"""
SELECT settle_date, items_sum, room_name
FROM app.v_dwd_settlement_head
WHERE member_id = %s AND settle_type IN (1, 3)
ORDER BY settle_date DESC LIMIT 5
""",
(member_id,),
)
recent = cur.fetchall()
# 余额
cur.execute(
"""
SELECT balance_amount
FROM app.v_dws_member_consumption_summary
WHERE member_id = %s LIMIT 1
""",
(member_id,),
)
bal_row = cur.fetchone()
etl_conn.commit()
# 维客线索
biz_conn = get_connection()
with biz_conn.cursor() as cur:
cur.execute(
"""
SELECT summary FROM member_retention_clue
WHERE member_id = %s AND site_id = %s
ORDER BY created_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:
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:
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:
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:
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:
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:
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)