diff --git a/AGENTS.md b/AGENTS.md index 623363c..cf04cae 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,10 @@ - ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元) ## 每日 AI 分析(backend FastAPI,15:10 自动触发) -- 报告结构约定:正文直接从 `## 一、市场总览` 开始,无"今日定调"摘要(已移除:prompt 不生成、前端无高亮框);`summary` 字段仅为正文前200字截断,供管理列表展示 +- 报告结构约定:正文以 `# {trade_date} A股收盘分析报告` 开头后直接进入第一章,无"今日定调"摘要(已移除:prompt 不生成、前端无高亮框);`summary` 为去掉标题行与 Markdown 标记后的正文前200字,供管理列表展示 +- 八章固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、明日展望|八、风险提示;分段归属:part1=一+二、part2=三+四、part3=五~八(原"四、关注方向/五、下个交易日建议"内容重复,已合并为"七、明日展望",板块资金流从第二章挪出独立成第四章) +- prompt 工程约定(改模板必读):①取数指令放 prompt 最前(要求同一轮并行调用 4 个核心工具,代码用 `REQUIRED_TOOLS` 校验,缺则补一轮并在 user 消息里点名缺哪些);②环比快照紧随其后、前日报告全文放最后(避免长文本把取数指令挤出注意力区);③凡写数字的规则必须同时写"找不到就写 —(今日无数据)",否则模型会用常识补全;④涨跌幅/环比/净额一律带 + 或 -,这是前端 `colorizeText` 红涨绿跌的着色依据;⑤模板用 `.format()` 渲染,正文中出现裸 `{}` 会直接报错 +- 分段生成必须回灌前文(`messages.append({"role":"assistant","content":part_content})`):否则后段看不到前段,会重复铺同一段数据、数字口径打架;生成失败的占位段不回灌 - 环比数据:`collect_ai_analysis` 每次运行先采集当日盘面快照(指数+涨跌统计 JSON)写入 `daily_market_stats` 表(UNIQUE trade_date,upsert),再读上一交易日快照格式化为 prompt 中的"环比数据段";首跑无昨日数据时 AI 须如实标注"暂无昨日基准" - 连板梯队:`get_market_dashboard` 返回 `limitLadder` 字段(完整版,2连板以上全量+首板前8),涨停原因/封单金额来自涨停池按 thscode 匹配;看板 events 里的天梯仍只取每层前2(保持 UI 精简),两处用途不同不要合并 - tokens 口径:`ai_reports.tokens_used` 只记"当次生成"消耗(约7万/份);同日重新生成走 UPSERT,`generation_count` 自增、`updated_at` 刷新、`created_at` 保留首次生成时间;表有 UNIQUE(trade_date, report_type),禁止改回 INSERT OR REPLACE 之外还要注意别用 lastrowid(UPSERT 更新时不可靠,须回查 id) diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py index cdb7d37..b8236cd 100644 --- a/backend/services/ai_service.py +++ b/backend/services/ai_service.py @@ -9,6 +9,7 @@ import asyncio import json +import re import traceback import httpx @@ -20,77 +21,69 @@ from database import get_connection _CST = timezone(timedelta(hours=8)) -SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。 +SYSTEM_PROMPT = """你是一位资深A股复盘分析师,为收盘后的每日复盘报告供稿。读者是有一定经验、但没时间盯盘的短线与波段投资者。 -你的分析风格: -- 数据驱动,基于真实数据而非主观臆断 -- 逻辑清晰,先总后分,层层递进 -- 观点明确,给出具体的操作建议 -- 风险提示,每次推荐都需说明风险点 +工作原则: +1. 事实优先:所有数字、股票名称与代码、涨停原因、封单金额必须能在工具返回中找到出处;工具没有的写「—(今日无数据)」,严禁凭记忆、常识或推理补全。 +2. 时间锚定:只分析给定交易日当天及之前的真实数据,禁止引用训练记忆中的行情、政策或题材。 +3. 判断必须挂数字:每个结论后面要有价格/涨跌幅/金额/家数支撑,不做无数据的定性判断。 +4. 说人话:结论先行、短句、少形容词;禁用「整体来看」「值得注意的是」「情绪有所回暖」这类无信息量的套话。 -可用工具: -- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报) -- get_theme_history: 获取指定日期的题材涨幅排行 -- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材) -- get_stock_quote: 获取个股实时行情 -- get_fund_flow: 获取个股资金流向 -- get_news: 获取财经快讯(新浪7x24,用于重要消息面) +输出格式(前端按 ## 二级标题切卡片渲染,红涨绿跌依赖数字符号): +- 章节标题严格用 `## 一、xxx` 形式,标题不超过 12 字 +- 涨跌幅、环比变化、资金净额等有方向的数字必须带 + 或 - 号(如 +2.31%、-15.6亿);不带符号的数字不会被着色 +- 多维度对比一律用 Markdown 表格,表头标注单位""" -重要规则: -1. 你必须先调用工具获取数据,然后基于数据进行分析 -2. 不要凭空编造数据,所有数据必须来自工具返回 -3. 如果工具返回空数据,如实说明数据不可用 -4. 分析完成后给出明确的结论和建议""" +# 一次工具轮就必须拿全的核心工具:模型常只调 1-2 个就开写,缺失会让对应章节数据空洞 +REQUIRED_TOOLS = ("get_market_dashboard", "get_theme_history", + "get_active_core_stocks", "get_news") -DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。 +DAILY_ANALYSIS_PROMPT = """请对 {trade_date}(A股交易日)做收盘复盘,生成一份完整报告。 + +■ 第一步 · 取数(必须一次并行完成) +在同一次回复中并行发起以下 4 个工具调用,不要拆成多轮、不要只调其中一部分: +1. get_market_dashboard() —— 指数、涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外指数与国内期货 globalMarkets +2. get_theme_history(date="{trade_date}") —— 今日题材涨幅榜 +3. get_active_core_stocks() —— 核心股 10 日涨幅矩阵与所属题材 +4. get_news(limit=30) —— 今日财经快讯 +只有在需要核实某只具体个股时,才额外调用 get_stock_quote / get_fund_flow,合计不超过 2 次。 + +■ 第二步 · 写作(以下规约对每一部分都生效) +1. 事实约束:所有数字、个股代码与名称、涨停原因、封单金额必须出自工具返回,找不到就写「—(今日无数据)」,禁止推测或用常识填补;禁止用「预计/有望/大概率」代替数据。 +2. 环比约束:下方快照中出现过的指标(成交额、涨跌家数、涨停/跌停/炸板、市场温度、两融余额、主要指数),首次提到时必须写成「今日值(较昨日 ±变化)」;快照缺失时写「暂无昨日基准」,不得编造环比。 +3. 单位口径(全文统一,表头标注单位):成交额→亿元(≥1万亿时写 x.xx 万亿);资金净额→亿元;封单金额→万元(limitLadder 的 sealWan 字段);指数→点。 +4. 符号规范:涨跌幅、环比变化、净流入/流出一律带 + 或 -,这是前端红涨绿跌的着色依据。 +5. 连板梯队:limitLadder 中 2 连板及以上全部列出(层级/股票/涨停原因/封单),首板只点评 3-5 只人气股。 +6. 板块资金:引用 sectorFundFlow 的行业净流入 TOP3、净流出 TOP3、概念净流入 TOP3(亿元,带符号),并结合题材涨跌说明资金是否印证了盘面。 +7. 消息面:基于 get_news 挑 5-8 条对次日影响最大的快讯,每条格式为「【分类】一句话新闻 —— 一句影响解读」(分类限:宏观/政策/行业/公司/海外);快讯里没有的方向如实说明「今日无重要消息」,严禁编造。 +8. 不荐股:禁止给出「买入/卖出/加仓/减仓/止损位/几成仓」等指令性表述;机会与风险一律写成条件句(如「若次日××,则××假设被强化/破坏」)。 + +报告共八章,固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、明日展望|八、风险提示。 +同一段数据只允许在它归属的那一章出现:指数与量能在第一章、题材涨幅在第二章、连板与核心股在第三章、资金流与两融在第四章,其他章节只做结论引用,不重复铺数据。 +系统会把八章拆成三次生成,每次只写被指定的部分。 -{prev_report_section} {prev_snapshot_section} - -请先调用以下工具获取数据: -1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外与期货 globalMarkets) -2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅 -3. get_active_core_stocks - 获取核心股数据 -4. get_news(limit=30) - 获取今日财经快讯 - -生成报告时必须遵守以下格式规则: - -1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为: -> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)> - -2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、两融余额、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。 - -3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。 - -4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。 - -5. 海外市场与国内期货必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。 - -6. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。 - -7. 适当使用表格展示数据对比。 - -然后基于数据生成报告。报告共八章,将由系统分三次调用完成,每次调用只负责其中一部分,具体写作指令由后续消息给出。 - -请用 Markdown 格式输出,适当使用表格展示数据对比。""" +{prev_report_section} +""" # 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断, -# 故拆为三段(每段约1200-1600字),各自独立调用后拼接 +# 故拆为三段(每段约1500-2000字),各自独立调用后拼接 REPORT_PARTS = [ - """现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明: -1. 以 `# {title} A股收盘分析报告` 一级标题开头 -2. 写 `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)与 `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3) -全文控制在1600字以内。""", - """现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容: -- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;核心股表现;龙头辨识) -- `## 四、关注方向`(明日题材方向、潜在交易机会) -全文控制在1300字以内。""", - """现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容: -- `## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向) -- `## 六、重要消息面`(5-8条,格式【分类】新闻——影响解读) -- `## 七、海外市场与国内期货`(点评对次日A股的影响) -- `## 八、风险提示` -全文控制在1900字以内。""", + """现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,禁止「好的/以下是」之类开场白: +1. 以 `# {title} A股收盘分析报告` 一级标题开头;标题后不要写定调摘要、不要写引言,直接进入第一章 +2. `## 一、市场总览`:主要指数表格(指数/收盘/涨跌幅)+量能与情绪(成交额、涨跌家数、涨停/跌停/炸板率、市场温度),全部按规约写成「今日值(较昨日 ±变化)」 +3. `## 二、题材热点分析`:题材涨幅 TOP5 表(题材/涨幅/龙头/连续活跃天数)、持续活跃与新兴热点、退潮警示 +全文控制在 1700 字以内。""", + """前文(第1部分)已在上下文中,现在续写【第2部分】,直接输出 Markdown,不要复述前文、不要写过渡句: +- `## 三、核心股追踪`:limitLadder 完整连板梯队表(层级/股票/涨停原因/封单万元),2 连板以上全列,首板挑 3-5 只人气股点评;再写核心股 10 日表现与龙头辨识 +- `## 四、资金与筹码`:sectorFundFlow 的行业净流入 TOP3 / 净流出 TOP3 / 概念净流入 TOP3(亿元,带符号)、两融余额与变化、事件情报中值得注意的筹码信号 +全文控制在 1500 字以内。""", + """前两部分已在上下文中,现在续写【第3部分】,直接输出 Markdown,不要复述前文、不要写过渡句: +- `## 五、重要消息面`:5-8 条,格式为【分类】一句话新闻 —— 一句影响解读 +- `## 六、海外市场与国内期货`:从 globalMarkets 挑与 A 股关联度高的品种(股指期货、原油、贵金属、黑色系),逐一点评对次日的影响;数据缺失如实说明 +- `## 七、明日展望`:明日主线方向、需要验证的观察点、节奏与规避方向(用条件句,不给操作指令) +- `## 八、风险提示`:3-5 条,每条写清「什么情况出现 → 说明什么判断失效」 +全文控制在 2000 字以内。""", ] @@ -230,6 +223,7 @@ async def collect_ai_analysis(trade_date: str) -> dict: llm_calls = 0 was_truncated = False max_rounds = 8 + tool_rounds = 0 for i in range(max_rounds): response = await call_llm(messages, tools=TOOLS) @@ -244,6 +238,7 @@ async def collect_ai_analysis(trade_date: str) -> dict: f"tool_calls={len(message.get('tool_calls') or [])}") if finish_reason == "tool_calls" and message.get("tool_calls"): + tool_rounds += 1 messages.append(message) for tool_call in message["tool_calls"]: func_name = tool_call["function"]["name"] @@ -255,7 +250,21 @@ async def collect_ai_analysis(trade_date: str) -> dict: "tool_call_id": tool_call["id"], "content": result }) - break # 工具已齐,立即进入分段写作(再问一轮模型只会空转120s) + # 核心工具齐了就立即进分段写作(再问一轮只会空转120s); + # 不齐则最多再补一轮,否则对应章节会无数据可写 + if all(t in tools_used for t in REQUIRED_TOOLS): + break + if tool_rounds >= 2: + print(f"[ai-service] 核心工具仍不齐(已取:{sorted(set(tools_used))}),直接进入写作") + break + print(f"[ai-service] 核心工具不全,补一轮取数({tool_rounds}/2)") + missing = [t for t in REQUIRED_TOOLS if t not in tools_used] + messages.append({ + "role": "user", + "content": f"还缺以下数据,请立即调用后停止取数:{', '.join(missing)}" + f"(get_theme_history 的 date 参数为 {trade_date})" + }) + continue # 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作; # 一轮工具都没拿到则重试 @@ -292,6 +301,9 @@ async def collect_ai_analysis(trade_date: str) -> dict: was_truncated = True print(f"[ai-service] 警告:part {part_idx + 1} 三次尝试均失败") part_content = f"\n\n> (第{part_idx + 1}部分生成失败,请稍后重新生成)\n" + else: + # 回灌前文:后段看不到前段内容会导致重复铺数据、数字口径打架 + messages.append({"role": "assistant", "content": part_content}) final_content += (final_content and "\n\n" or "") + part_content summary = _extract_summary(final_content) @@ -302,8 +314,13 @@ async def collect_ai_analysis(trade_date: str) -> dict: def _extract_summary(content: str) -> str: - """摘要:正文截断前200字(供管理列表展示)""" - return (content or "")[:200].replace("\n", " ").strip() + """摘要:去掉标题行与 Markdown 标记后截断 200 字(供管理列表展示) + + 正文首行是 `# XXXX A股收盘分析报告`,直接截断会让它占掉摘要大半。 + """ + text = re.sub(r"^#.*$", "", content or "", flags=re.MULTILINE) + text = re.sub(r"[>#*`|]", "", text) + return re.sub(r"\s+", " ", text).strip()[:200] def _num(v) -> str: @@ -471,7 +488,11 @@ def _get_prev_report_section(trade_date: str) -> str: return "" prev_date = row["trade_date"] prev_content = row["content"] or "" - return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析: + return f"""以下是前一个交易日({prev_date})的分析报告,仅用于保持分析连续性: + +- 今日数据优先:两者冲突时以今日工具数据为准,并在正文点出变化(如"昨日提示的××今日××") +- 禁止照搬昨日结论、禁止复制昨日段落 +- 重点核对昨日"明日展望"与"风险提示"里提到的方向是否兑现;未兑现的要在正文中说明 {prev_content}