feat: 每日热点问题共享库(定时生成+管理员手动更新+点击埋点)
数据库: - daily_topics表:每天每场景一条热点问题记录 - sample_clicks表:记录用户点击示例问题行为 后端: - DailyTopicStore: CRUD + 埋点 + 点击统计 - generate_daily_topics: AI生成4场景各25-30个问题 - 定时任务:每天凌晨3点自动生成(后台线程轮询) - GET /topics/daily: 获取当天热点(公开) - POST /topics/click: 点击埋点 - GET /admin/daily-topics: 管理端查看 - POST /admin/daily-topics: 管理员手动更新 - POST /admin/daily-topics/generate: 手动触发AI生成 - GET /admin/sample-clicks: 点击统计 前端: - ChatPanel挂载时取当天热点,无则用硬编码兜底 - 点击示例问题时记录埋点 - AdminDailyTopics管理页面:查看/编辑/手动生成 - 路由 /admin/daily-topics + 导航配置 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -3170,6 +3170,211 @@ def build_profile_context(profile: UserProfile) -> str:
|
||||
return profile.to_context()
|
||||
|
||||
|
||||
# ===== 每日热点问题库 =====
|
||||
|
||||
class DailyTopicStore:
|
||||
"""每日热点问题库:每天 AI 生成或管理员手动更新。
|
||||
|
||||
每个场景(legal/decision/life/emotion)每天一条记录,
|
||||
questions 为 JSONB 数组,前端取当天问题展示给用户。
|
||||
"""
|
||||
|
||||
SCENES = ["legal", "decision", "life", "emotion"]
|
||||
|
||||
def __init__(self, dsn: str | None = None):
|
||||
self.dsn = dsn or os.getenv(
|
||||
"DATABASE_URL", "host=localhost port=5432 dbname=mcp user=postgres"
|
||||
)
|
||||
|
||||
def _conn(self):
|
||||
return psycopg2.connect(self.dsn)
|
||||
|
||||
def get_today(self, scene: str) -> list[str]:
|
||||
"""获取当天热点问题,无记录返回空列表。"""
|
||||
from datetime import date
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT questions FROM daily_topics "
|
||||
"WHERE date = %s AND scene = %s",
|
||||
(date.today(), scene),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
return r[0] if r else []
|
||||
|
||||
def get_by_date(self, date_str: str) -> list[dict]:
|
||||
"""获取指定日期所有场景的热点问题(管理端用)。"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, date, scene, questions, source, created_at "
|
||||
"FROM daily_topics WHERE date = %s ORDER BY scene",
|
||||
(date_str,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{"id": r[0], "date": str(r[1]), "scene": r[2],
|
||||
"questions": r[3], "source": r[4], "created_at": str(r[5])}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def upsert(self, scene: str, questions: list[str],
|
||||
source: str = "ai", date_str: str | None = None) -> dict:
|
||||
"""插入或更新当天(或指定日期)的热点问题。
|
||||
|
||||
Args:
|
||||
scene: 场景 legal/decision/life/emotion
|
||||
questions: 问题列表
|
||||
source: ai(自动生成) / admin(管理员手动)
|
||||
date_str: 指定日期(YYYY-MM-DD),默认今天
|
||||
|
||||
Returns:
|
||||
更新后的记录 dict
|
||||
"""
|
||||
from datetime import date
|
||||
import json
|
||||
target_date = date_str if date_str else str(date.today())
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO daily_topics (date, scene, questions, source) "
|
||||
"VALUES (%s, %s, %s, %s) "
|
||||
"ON CONFLICT (date, scene) DO UPDATE "
|
||||
"SET questions = EXCLUDED.questions, source = EXCLUDED.source, "
|
||||
"created_at = now() "
|
||||
"RETURNING id, date, scene, questions, source, created_at",
|
||||
(target_date, scene, json.dumps(questions), source),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
return {"id": r[0], "date": str(r[1]), "scene": r[2],
|
||||
"questions": r[3], "source": r[4], "created_at": str(r[5])}
|
||||
|
||||
def record_click(self, scene: str, question: str,
|
||||
user_phone: str | None = None) -> bool:
|
||||
"""记录用户点击示例问题(埋点)。"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO sample_clicks (user_phone, scene, question) "
|
||||
"VALUES (%s, %s, %s)",
|
||||
(user_phone, scene, question),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
def click_stats(self, days: int = 14) -> list[dict]:
|
||||
"""获取近 N 天示例问题点击统计(管理端用)。"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT scene, question, COUNT(*) as clicks "
|
||||
"FROM sample_clicks "
|
||||
"WHERE clicked_at >= now() - interval '%s days' "
|
||||
"GROUP BY scene, question ORDER BY clicks DESC LIMIT 100",
|
||||
(days,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{"scene": r[0], "question": r[1], "clicks": r[2]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def generate_daily_topics(kimi_client, store: DailyTopicStore) -> dict:
|
||||
"""AI 生成当天 4 个场景的热点问题并存库。
|
||||
|
||||
每个场景生成 20-30 个问题,聚焦老板焦虑和关注点。
|
||||
AI 不可用时跳过(保留已有数据)。
|
||||
|
||||
Returns:
|
||||
各场景生成结果 {scene: {count, source}}
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
scene_desc = {
|
||||
"legal": "法律/法务问题,聚焦中小企业老板的焦虑:劳动用工风险、合同纠纷、债权债务、股东纠纷、税务风险、诉讼仲裁、婚姻财产分割、股权分配、知识产权、行政处罚等",
|
||||
"decision": "经营决策问题,聚焦中小企业老板的焦虑:扩张收缩、项目承接、定价策略、招人裁员、融资合伙、业务转型、现金流管理、竞争应对、投资决策等",
|
||||
"life": "个人生活问题,聚焦中小企业老板的焦虑:家庭与事业平衡、子女教育规划、父母养老、健康风险、睡眠压力、时间管理、财富传承等",
|
||||
"emotion": "情绪与心理问题,聚焦中小企业老板的焦虑:创业压力、合伙人冲突、孤独感、决策焦虑、拖延症、自我怀疑、危机应对等",
|
||||
}
|
||||
|
||||
# 获取当前日期用于 prompt
|
||||
today_str = datetime.now().strftime("%Y年%m月%d日")
|
||||
# 获取当前热点事件提示(简单版:用日期季节推断)
|
||||
month = datetime.now().month
|
||||
season_hints = {
|
||||
1: "年初:年度规划、去年总结、新政策生效、春节前后用工",
|
||||
2: "春节后:招工难、复工、年度目标分解",
|
||||
3: "两会政策、年报截止、个税汇算",
|
||||
4: "Q1复盘、社保基数调整预告、春招",
|
||||
5: "劳动节、用工合规检查、年中冲刺",
|
||||
6: "年中复盘、半年报、高温补贴、用电高峰",
|
||||
7: "下半年规划、社保基数调整、学生毕业季",
|
||||
8: "秋招准备、Q3冲刺、税务自查",
|
||||
9: "Q3复盘、中秋、国庆备货、年度预算",
|
||||
10: "国庆后冲刺、年终规划、个税专项附加扣除",
|
||||
11: "年底冲刺、年终奖规划、来年预算、欠薪风险",
|
||||
12: "年终总结、欠薪清查、税务清算、年度复盘、春节前用工",
|
||||
}
|
||||
season = season_hints.get(month, "")
|
||||
|
||||
results = {}
|
||||
for scene, desc in scene_desc.items():
|
||||
prompt = f"""你是左右(BossAi)的内容编辑,负责为中小企业老板生成今日热点问题。
|
||||
|
||||
当前日期:{today_str}
|
||||
季节性背景:{season}
|
||||
|
||||
场景:{desc}
|
||||
|
||||
请生成25-30个该场景下老板最焦虑、最关注的热点问题。
|
||||
|
||||
要求:
|
||||
1. 问题必须从老板/雇主视角出发(BossAi 用户是企业老板)
|
||||
2. 结合当前季节性和时事热点(如政策变化、节日前后、经济形势)
|
||||
3. 问题要具体、有场景感、能直接开问,不要空泛
|
||||
4. 涵盖该场景下不同方向,不要集中在某一类
|
||||
5. 问题长度 10-30 字
|
||||
6. 返回JSON数组格式:["问题1","问题2",...]
|
||||
7. 只返回JSON,不要其他文字"""
|
||||
|
||||
try:
|
||||
answer = kimi_client.chat(
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0.8, timeout=90,
|
||||
)
|
||||
answer = answer.strip()
|
||||
if answer.startswith("```"):
|
||||
answer = answer.split("\n", 1)[1] if "\n" in answer else answer[3:]
|
||||
if answer.endswith("```"):
|
||||
answer = answer[:-3]
|
||||
answer = answer.strip().replace("\u201c", '"').replace("\u201d", '"')
|
||||
start = answer.find("[")
|
||||
end = answer.rfind("]")
|
||||
if start == -1 or end == -1:
|
||||
results[scene] = {"count": 0, "source": "ai", "error": "JSON解析失败"}
|
||||
continue
|
||||
questions = json.loads(answer[start:end + 1])
|
||||
# 过滤和去重
|
||||
valid = []
|
||||
seen = set()
|
||||
for q in questions:
|
||||
q = str(q).strip()
|
||||
if q and q not in seen and len(q) <= 50:
|
||||
valid.append(q)
|
||||
seen.add(q)
|
||||
if len(valid) < 10:
|
||||
results[scene] = {"count": 0, "source": "ai", "error": "有效问题不足10个"}
|
||||
continue
|
||||
store.upsert(scene, valid[:30], source="ai")
|
||||
results[scene] = {"count": len(valid[:30]), "source": "ai"}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"generate_daily_topics[{scene}] 失败: {type(e).__name__}: {e}")
|
||||
results[scene] = {"count": 0, "source": "ai", "error": str(e)}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ===== 对话历史落库 =====
|
||||
|
||||
class CryptoUtil:
|
||||
|
||||
Reference in New Issue
Block a user