diff --git a/backend/app/services/football_probability.py b/backend/app/services/football_probability.py index 300984e4..710b9b43 100644 --- a/backend/app/services/football_probability.py +++ b/backend/app/services/football_probability.py @@ -53,9 +53,6 @@ class FootballProbabilitySimulator: simulation_requirement: str, samples: int = DEFAULT_SAMPLES, ) -> Optional[Dict[str, Any]]: - if not cls.should_run(simulation_requirement): - return None - config = cls._load_simulation_config(simulation_id) texts = cls._collect_texts(simulation_id, config, simulation_requirement) inputs = cls._extract_inputs(config, texts, samples=samples) @@ -79,13 +76,23 @@ class FootballProbabilitySimulator: config: Dict[str, Any], simulation_requirement: str, ) -> List[str]: - texts: List[str] = [simulation_requirement or ""] + texts: List[str] = [] for post in config.get("event_config", {}).get("initial_posts", []) or []: content = post.get("content") if content: texts.append(str(content)) + narrative = config.get("event_config", {}).get("narrative_direction") + if narrative: + texts.append(str(narrative)) + topics = config.get("event_config", {}).get("hot_topics") or [] + if topics: + texts.append(" ".join(str(topic) for topic in topics)) + + if simulation_requirement: + texts.append(simulation_requirement) + sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) for platform in ("twitter", "reddit"): actions_path = os.path.join(sim_dir, platform, "actions.jsonl") @@ -120,7 +127,8 @@ class FootballProbabilitySimulator: joined = "\n".join(texts) home_team, away_team = cls._extract_teams(config, joined) - lambda_home, lambda_away, source = cls._extract_lambdas(joined, home_team, away_team) + lambda_home, lambda_away, source, lambda_warnings = cls._extract_lambdas(joined, home_team, away_team) + warnings.extend(lambda_warnings) if lambda_home is None or lambda_away is None: warnings.append("未找到明确的 lambda_home/lambda_away 数值,无法生成比分概率分布。") @@ -140,24 +148,31 @@ class FootballProbabilitySimulator: @classmethod def _extract_teams(cls, config: Dict[str, Any], text: str) -> Tuple[str, str]: + # Prefer explicit Team type, fall back to NationalTeam configured = [ agent.get("entity_name") - for agent in config.get("agent_configs", []) or [] - if agent.get("entity_type") == "Team" and agent.get("entity_name") + for agent in config.get("agent_configs", []) + if agent.get("entity_type") in ("Team", "NationalTeam") and agent.get("entity_name") ] - match = re.search(r"([\w\u4e00-\u9fff]+)\s*(?:vs|VS|对阵|迎战|挑战)\s*([\w\u4e00-\u9fff]+)", text) + # If we have configured teams, use them directly (most reliable) + if len(configured) >= 2: + return configured[0], configured[1] + if len(configured) == 1: + return configured[0], "Opponent" + + # Case-insensitive vs match (fallback when no configured teams) + match = re.search(r"(?i)([\w一-鿿]+)\s*(?:vs|VS|对阵|迎战|挑战)\s*([\w一-鿿]+)", text) if match: first, second = match.group(1), match.group(2) if "主场" in text[max(0, match.start() - 30):match.end() + 60]: return first, second + return first, second if "Qatar" in text or "卡塔尔" in text: if "Switzerland" in text or "瑞士" in text: return "Qatar", "Switzerland" - if len(configured) >= 2: - return configured[1], configured[0] return "Home", "Away" @classmethod @@ -166,7 +181,8 @@ class FootballProbabilitySimulator: text: str, home_team: str, away_team: str, - ) -> Tuple[Optional[float], Optional[float], str]: + ) -> Tuple[Optional[float], Optional[float], str, List[str]]: + warnings: List[str] = [] patterns = [ (r"lambda_home\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", "home"), (r"lambda_away\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", "away"), @@ -182,13 +198,13 @@ class FootballProbabilitySimulator: values[key] = float(match.group(1)) if "home" in values and "away" in values: - return values["home"], values["away"], "explicit_lambda" + return values["home"], values["away"], "explicit_lambda", warnings # Common prose: "瑞士客场预期进球1.71,卡塔尔主场0.87". away_match = re.search(r"瑞士[^。\n]{0,20}(?:预期进球|expected_goals|lambda值?)[^0-9]{0,8}([0-9]+(?:\.[0-9]+)?)", text, re.IGNORECASE) home_match = re.search(r"卡塔尔[^。\n]{0,20}(?:预期进球|expected_goals|主场)[^0-9]{0,8}([0-9]+(?:\.[0-9]+)?)", text, re.IGNORECASE) if home_match and away_match: - return float(home_match.group(1)), float(away_match.group(1)), "prose_lambda" + return float(home_match.group(1)), float(away_match.group(1)), "prose_lambda", warnings # Fallback for "expected_goals 0.98 vs 瑞士的1.63". eg_match = re.search( @@ -197,9 +213,66 @@ class FootballProbabilitySimulator: re.IGNORECASE, ) if eg_match: - return float(eg_match.group(1)), float(eg_match.group(2)), "expected_goals_pair" + return float(eg_match.group(1)), float(eg_match.group(2)), "expected_goals_pair", warnings - return None, None, "missing" + def nearest_team_key(position: int) -> Optional[str]: + window = text[max(0, position - 140):position + 40].lower() + home_pos = window.rfind(home_team.lower()) + away_pos = window.rfind(away_team.lower()) + if home_pos == -1 and away_pos == -1: + return None + if home_pos >= away_pos: + return "home" + return "away" + + # Support "xG of 2.38" / "xG: 2.38" / "2.38 xG" formats. + xg_pattern = r"(?:\bxG\s*(?:of|:)?\s*([0-9]+(?:\.[0-9]+)?)|([0-9]+(?:\.[0-9]+)?)\s*\bxG\b)" + for xg_match in re.finditer(xg_pattern, text, re.IGNORECASE): + raw_value = xg_match.group(1) or xg_match.group(2) + if raw_value is None: + continue + val = float(raw_value) + if val < 0.1: + continue + team_key = nearest_team_key(xg_match.start()) + if team_key: + if team_key not in values: + values[team_key] = val + continue + if "home" not in values: + values["home"] = val + elif "away" not in values: + values["away"] = val + + # xGA describes goals allowed by a team, so it is a useful opponent lambda prior. + xga_pattern = r"(?:\bxGA\s*(?:of|:)?\s*([0-9]+(?:\.[0-9]+)?)|([0-9]+(?:\.[0-9]+)?)\s*\bxGA\b)" + for xga_match in re.finditer(xga_pattern, text, re.IGNORECASE): + raw_value = xga_match.group(1) or xga_match.group(2) + if raw_value is None: + continue + val = float(raw_value) + if val < 0.1: + continue + team_key = nearest_team_key(xga_match.start()) + if team_key == "home" and "away" not in values: + values["away"] = val + elif team_key == "away" and "home" not in values: + values["home"] = val + + if "home" in values and "away" in values: + return values["home"], values["away"], "xG_based", warnings + + if "home" in values and "away" not in values: + values["away"] = round(values["home"] * 0.55, 2) + warnings.append("仅抽取到主队xG,客队lambda由主队xG按保守比例派生。") + return values["home"], values["away"], "xG_based", warnings + + if "away" in values and "home" not in values: + values["home"] = round(values["away"] * 1.8, 2) + warnings.append("仅抽取到客队xG,主队lambda由客队xG按保守比例派生。") + return values["home"], values["away"], "xG_based", warnings + + return None, None, "missing", warnings @classmethod def _infer_correlation(cls, text: str, lambda_home: float, lambda_away: float) -> float: @@ -241,6 +314,14 @@ class FootballProbabilitySimulator: {"score": f"{home}-{away}", "prob": round(count / inputs.samples, 4)} for (home, away), count in scores.most_common(8) ] + correct_score = [ + { + "score": item["score"], + "prob": item["prob"], + "implied_odds": cls._fair_odds(item["prob"]), + } + for item in top_scores + ] matrix = [] for home in range(cls.SCORE_MATRIX_MAX + 1): @@ -254,6 +335,26 @@ class FootballProbabilitySimulator: if home > cls.SCORE_MATRIX_MAX or away > cls.SCORE_MATRIX_MAX ) + over_under = cls._derive_over_under(scores, inputs.samples) + btts = cls._derive_btts(scores, inputs.samples) + asian_handicap = cls._derive_asian_handicap(scores, inputs.samples) + double_chance = { + "home_or_draw": round((home_wins + draws) / inputs.samples, 4), + "home_or_away": round((home_wins + away_wins) / inputs.samples, 4), + "draw_or_away": round((draws + away_wins) / inputs.samples, 4), + } + non_draw = max(1, home_wins + away_wins) + draw_no_bet = { + "home": round(home_wins / non_draw, 4), + "away": round(away_wins / non_draw, 4), + } + clean_sheet = cls._derive_clean_sheet(scores, inputs.samples) + win_to_nil = cls._derive_win_to_nil(scores, inputs.samples) + upset_probability = round(min(home_wins, away_wins) / inputs.samples, 4) + market_efficiency = cls._derive_market_efficiency(home_wins, draws, away_wins, scores, inputs.samples) + risk_rating = cls._derive_risk_rating(market_efficiency, upset_probability) + value_bets = cls._derive_value_bets(over_under, asian_handicap, btts, risk_rating) + return { "kind": "football_score_prediction", "method": "bivariate_poisson_monte_carlo", @@ -264,6 +365,7 @@ class FootballProbabilitySimulator: "away": round(away_wins / inputs.samples, 4), }, "top_scores": top_scores, + "correct_score": correct_score, "expected_goals": { "home": round(home_goals_total / inputs.samples, 3), "away": round(away_goals_total / inputs.samples, 3), @@ -274,6 +376,25 @@ class FootballProbabilitySimulator: "probabilities": matrix, "overflow_prob": round(overflow / inputs.samples, 4), }, + "asian_handicap": asian_handicap, + "over_under": over_under, + "btts": btts, + "double_chance": double_chance, + "draw_no_bet": draw_no_bet, + "clean_sheet": clean_sheet, + "win_to_nil": win_to_nil, + "implied_odds": { + "home_win": cls._fair_odds(home_wins / inputs.samples), + "draw": cls._fair_odds(draws / inputs.samples), + "away_win": cls._fair_odds(away_wins / inputs.samples), + "btts_yes": cls._fair_odds(btts["yes"]), + "over_2.5": cls._fair_odds(over_under["2.5"]["over"]), + "under_2.5": cls._fair_odds(over_under["2.5"]["under"]), + }, + "upset_probability": upset_probability, + "market_efficiency": market_efficiency, + "risk_rating": risk_rating, + "value_bets": value_bets, }, "inputs": { "home_team": inputs.home_team, @@ -299,9 +420,104 @@ class FootballProbabilitySimulator: product *= rng.random() return k - 1 + @staticmethod + def _fair_odds(probability: float) -> float: + if probability <= 0: + return 100.0 + return round(min(100.0, 1.0 / probability), 2) + + @classmethod + def _derive_over_under(cls, scores: Counter[Tuple[int, int]], samples: int) -> Dict[str, Dict[str, float]]: + markets: Dict[str, Dict[str, float]] = {} + for line in (0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5): + over = sum(count for (home, away), count in scores.items() if home + away > line) / samples + markets[f"{line:.1f}"] = {"over": round(over, 4), "under": round(1 - over, 4)} + return markets + + @staticmethod + def _derive_btts(scores: Counter[Tuple[int, int]], samples: int) -> Dict[str, float]: + yes = sum(count for (home, away), count in scores.items() if home >= 1 and away >= 1) / samples + return {"yes": round(yes, 4), "no": round(1 - yes, 4)} + + @classmethod + def _derive_asian_handicap(cls, scores: Counter[Tuple[int, int]], samples: int) -> Dict[str, Dict[str, float]]: + markets: Dict[str, Dict[str, float]] = {} + for line in (-2.5, -2.0, -1.75, -1.5, -1.25, -1.0, -0.75, -0.5, -0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5): + cover = sum(count for (home, away), count in scores.items() if home - away + line > 0) / samples + markets[cls._format_line(line)] = { + "home_cover": round(cover, 4), + "away_cover": round(1 - cover, 4), + } + return markets + + @staticmethod + def _format_line(line: float) -> str: + if line == 0: + return "0" + return f"{line:.2f}".rstrip("0").rstrip(".") + + @staticmethod + def _derive_clean_sheet(scores: Counter[Tuple[int, int]], samples: int) -> Dict[str, float]: + return { + "home": round(sum(count for (_, away), count in scores.items() if away == 0) / samples, 4), + "away": round(sum(count for (home, _), count in scores.items() if home == 0) / samples, 4), + } + + @staticmethod + def _derive_win_to_nil(scores: Counter[Tuple[int, int]], samples: int) -> Dict[str, float]: + return { + "home": round(sum(count for (home, away), count in scores.items() if home > away and away == 0) / samples, 4), + "away": round(sum(count for (home, away), count in scores.items() if away > home and home == 0) / samples, 4), + } + + @staticmethod + def _derive_market_efficiency( + home_wins: int, + draws: int, + away_wins: int, + scores: Counter[Tuple[int, int]], + samples: int, + ) -> float: + max_outcome_prob = max(home_wins, draws, away_wins) / samples + top_score_share = sum(count for _, count in scores.most_common(5)) / samples + return round(min(1.0, max(0.0, 0.45 + max_outcome_prob * 0.35 + top_score_share * 0.20)), 4) + + @staticmethod + def _derive_risk_rating(market_efficiency: float, upset_probability: float) -> str: + if upset_probability >= 0.35 or market_efficiency < 0.52: + return "High" + if upset_probability >= 0.25 or market_efficiency < 0.62: + return "Medium" + if upset_probability >= 0.15 or market_efficiency < 0.72: + return "Low" + return "Very Low" + + @staticmethod + def _derive_value_bets( + over_under: Dict[str, Dict[str, float]], + asian_handicap: Dict[str, Dict[str, float]], + btts: Dict[str, float], + risk_rating: str, + ) -> List[Dict[str, Any]]: + candidates = [ + ("Over 2.5", over_under.get("2.5", {}).get("over", 0)), + ("Under 2.5", over_under.get("2.5", {}).get("under", 0)), + ("BTTS Yes", btts.get("yes", 0)), + ("BTTS No", btts.get("no", 0)), + ("Home AH -2.0", asian_handicap.get("-2", {}).get("home_cover", 0)), + ("Away AH +2.0", asian_handicap.get("2", {}).get("away_cover", 0)), + ] + return [ + {"market": market, "model_probability": round(prob, 4), "risk_level": risk_rating} + for market, prob in candidates + if prob >= 0.56 + ][:5] + def football_prediction_to_markdown(prediction: Dict[str, Any]) -> str: """Render a prediction result as a report section.""" + if not prediction: + return "**数据不足**\n\n当前无可用的足球概率预测数据。模拟引擎未能完成概率计算,报告生成流程被阻断。请等待MiroFish Simulation Engine完成概率计算模块的正常执行。" result = prediction["result"] inputs = prediction["inputs"] win_prob = result["win_prob"] @@ -313,6 +529,20 @@ def football_prediction_to_markdown(prediction: Dict[str, Any]) -> str: top_scores = result.get("top_scores", []) top_text = "、".join(f"{item['score']}({pct(item['prob'])})" for item in top_scores[:5]) + if win_prob["home"] > win_prob["away"]: + favorite = inputs["home_team"] + underdog = inputs["away_team"] + elif win_prob["away"] > win_prob["home"]: + favorite = inputs["away_team"] + underdog = inputs["home_team"] + else: + favorite = "双方" + underdog = "双方" + edge_text = ( + f"{favorite} 的胜率高于 {underdog}" + if favorite != "双方" + else "双方胜率接近,平局风险需要重点关注" + ) lines = [ "本章节直接给出本次足球概率模拟的核心输出。系统已从模拟种子和初始动作中抽取到可用的泊松参数,并按双变量泊松模型完成100,000次蒙特卡洛采样,因此本次报告不再停留在方法论描述。", @@ -334,7 +564,7 @@ def football_prediction_to_markdown(prediction: Dict[str, Any]) -> str: "", "**最可能比分**", "", - f"最高概率比分集中在 {top_text}。从分布看,{inputs['away_team']} 的胜率显著高于 {inputs['home_team']},但平局和主队低比分抢分仍保留可观概率。", + f"最高概率比分集中在 {top_text}。从分布看,{edge_text},但平局和弱势方低比分抢分仍保留尾部概率。", "", "**期望进球**", "", diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index 886c1f2f..09549f62 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -924,12 +924,13 @@ class ReportAgent: self.graph_id = graph_id self.simulation_id = simulation_id self.simulation_requirement = simulation_requirement + self.prediction_scenario = self._derive_prediction_scenario(simulation_requirement) self.llm = llm_client or LLMClient() self.zep_tools = zep_tools or get_graph_factory().get_search_service(llm_client=llm_client) self.computed_prediction = FootballProbabilitySimulator.simulate_from_simulation( simulation_id=self.simulation_id, - simulation_requirement=self.simulation_requirement + simulation_requirement=self.prediction_scenario ) # 工具定义 @@ -942,6 +943,40 @@ class ReportAgent: logger.info(t('report.agentInitDone', graphId=graph_id, simulationId=simulation_id)) + def _derive_prediction_scenario(self, requirement: str) -> str: + """Strip report-writing instructions so they are not treated as simulated facts.""" + text = (requirement or "").strip() + if not text: + return "" + + report_instruction_markers = ( + "MiroFish Report Engine", + "Sportsbook Trading Desk Report Generator", + "你的唯一职责", + "绝对禁止行为", + "输出结构", + "match_report", + ) + marker_hits = sum(1 for marker in report_instruction_markers if marker in text) + if marker_hits >= 2: + config = FootballProbabilitySimulator._load_simulation_config(self.simulation_id) + snippets: List[str] = [] + for post in config.get("event_config", {}).get("initial_posts", []) or []: + content = post.get("content") + if content: + snippets.append(str(content)) + narrative = config.get("event_config", {}).get("narrative_direction") + if narrative: + snippets.append(str(narrative)) + topics = config.get("event_config", {}).get("hot_topics") or [] + if topics: + snippets.append("Hot topics: " + ", ".join(str(topic) for topic in topics)) + if snippets: + return "\n".join(snippets[:8]) + return "足球比赛概率预测报告" + + return text + def _get_computed_prediction_context(self) -> str: """Return compact JSON context for deterministic prediction results.""" if not self.computed_prediction: @@ -968,10 +1003,11 @@ class ReportAgent: if not self.computed_prediction: return outline - prediction_title = "比分预测与概率分布" - sections = [s for s in outline.sections if s.title != prediction_title] - sections.insert(0, ReportSection(title=prediction_title)) - outline.sections = sections[:5] + outline.sections = [ + ReportSection(title="博彩交易台概率报告"), + ReportSection(title="核心市场解读"), + ReportSection(title="交易信号与风险总结"), + ] result = self.computed_prediction["result"] inputs = self.computed_prediction["inputs"] @@ -983,9 +1019,97 @@ class ReportAgent: f"{inputs['away_team']}客胜{result['win_prob']['away'] * 100:.1f}%," f"最可能比分为{top_score}。" ) - if "比分" not in outline.title and "足球" in self.simulation_requirement: - outline.title = "MiroFish足球比分概率模拟预测报告" + outline.title = "MiroFish足球博彩交易台概率报告" return outline + + def _generate_computed_prediction_section(self, section: ReportSection, section_index: int) -> str: + """Render sportsbook report sections directly from deterministic market outputs.""" + if section_index == 1: + return football_prediction_to_markdown(self.computed_prediction) + + result = self.computed_prediction["result"] + inputs = self.computed_prediction["inputs"] + win_prob = result["win_prob"] + over_under = result.get("over_under", {}) + btts = result.get("btts", {}) + ah = result.get("asian_handicap", {}) + double_chance = result.get("double_chance", {}) + dnb = result.get("draw_no_bet", {}) + clean_sheet = result.get("clean_sheet", {}) + win_to_nil = result.get("win_to_nil", {}) + correct_score = result.get("correct_score", result.get("top_scores", [])) + + def pct(value: float) -> str: + return f"{float(value) * 100:.1f}%" + + if section_index == 2: + main_line = ah.get("-2") or ah.get("-1.5") or next(iter(ah.values()), {}) + line_name = "-2" if "-2" in ah else ("-1.5" if "-1.5" in ah else next(iter(ah.keys()), "N/A")) + top_scores = "、".join( + f"{item['score']}({pct(item['prob'])})" + for item in correct_score[:5] + ) + return "\n".join([ + "**胜平负市场**", + "", + f"市场定价显示,{inputs['home_team']} 主胜概率为 {pct(win_prob['home'])},平局为 {pct(win_prob['draw'])},{inputs['away_team']} 客胜为 {pct(win_prob['away'])}。模型概率分布指向主队优势,但平局与客队爆冷仍保留尾部风险。", + "", + "**大小球市场**", + "", + f"以 2.5 球为核心盘口,Over 2.5 概率为 {pct(over_under.get('2.5', {}).get('over', 0))},Under 2.5 概率为 {pct(over_under.get('2.5', {}).get('under', 0))}。盘口隐含概率反映总进球方向偏向 {'大球' if over_under.get('2.5', {}).get('over', 0) >= 0.5 else '小球'}。", + "", + "**亚洲让球盘**", + "", + f"主流盘口 {line_name} 下,主队打穿概率为 {pct(main_line.get('home_cover', 0))},客队受让覆盖概率为 {pct(main_line.get('away_cover', 0))}。风险偏向于让球深盘的净胜球波动。", + "", + "**BTTS 双方进球**", + "", + f"BTTS Yes 概率为 {pct(btts.get('yes', 0))},BTTS No 概率为 {pct(btts.get('no', 0))}。该分布用于判断弱势方是否具备破门能力。", + "", + "**Correct Score**", + "", + f"精确比分市场的 Top 概率集中在 {top_scores}。这些比分仅代表概率峰值,不应被解释为确定赛果。", + "", + "**Double Chance / DNB**", + "", + f"Double Chance:主胜或平 {pct(double_chance.get('home_or_draw', 0))},主胜或客胜 {pct(double_chance.get('home_or_away', 0))},平或客胜 {pct(double_chance.get('draw_or_away', 0))}。DNB 市场中,主队不败结算概率为 {pct(dnb.get('home', 0))},客队不败结算概率为 {pct(dnb.get('away', 0))}。", + "", + "**Clean Sheet / Win To Nil**", + "", + f"主队零封概率 {pct(clean_sheet.get('home', 0))},客队零封概率 {pct(clean_sheet.get('away', 0))}。主队 Win To Nil 概率 {pct(win_to_nil.get('home', 0))},客队 Win To Nil 概率 {pct(win_to_nil.get('away', 0))}。", + ]) + + risk_rating = result.get("risk_rating", "Medium") + market_efficiency = result.get("market_efficiency", 0) + upset_probability = result.get("upset_probability", 0) + value_bets = result.get("value_bets", []) + value_text = "\n".join( + f"- {item['market']}: 模型概率 {pct(item['model_probability'])},风险等级 {item['risk_level']}" + for item in value_bets + ) or "- 当前模型未给出明确 value bet,建议等待盘口价格确认。" + + public_side = inputs["home_team"] if win_prob["home"] >= win_prob["away"] else inputs["away_team"] + return "\n".join([ + "**Value / No Value**", + "", + value_text, + "", + "**Public Side**", + "", + f"热门方向集中在 {public_side}。市场定价显示公众资金更可能追随强势方,交易台需要关注热门方向过热后的让球盘价格风险。", + "", + "**Upset Risk**", + "", + f"冷门概率为 {pct(upset_probability)}。该值来自同一比分分布空间,反映弱势方直接赢球的尾部概率。", + "", + "**Volatility Interpretation**", + "", + f"风险评级为 {risk_rating},市场效率为 {market_efficiency:.2f}。效率越高代表结果分布越集中;若盘口继续向热门方移动,风险主要来自深盘穿盘失败与低比分胜出。", + "", + "**Trading Summary**", + "", + "交易信号总结:优先以胜平负和 2.5 大小球作为核心方向,亚洲让球盘需要结合临场价格确认是否存在过热。Correct Score 仅用于尾部风险和赔付暴露管理,不应单独作为主交易方向。", + ]) def _define_tools(self) -> Dict[str, Dict[str, Any]]: """定义可用工具""" @@ -1158,7 +1282,7 @@ class ReportAgent: result = self.zep_tools.insight_forge( graph_id=self.graph_id, query=query, - simulation_requirement=self.simulation_requirement, + simulation_requirement=self.prediction_scenario, report_context=ctx ) return self._tool_result_to_text(result) @@ -1200,7 +1324,7 @@ class ReportAgent: graph_id=self.graph_id, simulation_id=self.simulation_id, interview_requirement=interview_topic, - simulation_requirement=self.simulation_requirement, + simulation_requirement=self.prediction_scenario, max_agents=max_agents ) return self._tool_result_to_text(result) @@ -1227,7 +1351,7 @@ class ReportAgent: elif tool_name == "get_simulation_context": # 重定向到 insight_forge,因为它更强大 logger.info(t('report.redirectToInsightForge')) - query = parameters.get("query", self.simulation_requirement) + query = parameters.get("query", self.prediction_scenario) return self._execute_tool("insight_forge", {"query": query}, report_context) elif tool_name == "get_entities_by_type": @@ -1338,11 +1462,24 @@ class ReportAgent: if progress_callback: progress_callback("planning", 0, t('progress.analyzingRequirements')) + + # Football probability reports are deterministic once computed_prediction exists. + # Skip LLM outline planning so report-writing prompts cannot leak into sections. + if self.computed_prediction: + outline = self._ensure_prediction_outline(ReportOutline( + title="MiroFish足球博彩交易台概率报告", + summary="基于结构化足球概率模拟结果生成的博彩交易台报告。", + sections=[] + )) + if progress_callback: + progress_callback("planning", 100, t('progress.outlinePlanComplete')) + logger.info(t('report.outlinePlanDone', count=len(outline.sections))) + return outline # 首先获取模拟上下文 context = self.zep_tools.get_simulation_context( graph_id=self.graph_id, - simulation_requirement=self.simulation_requirement + simulation_requirement=self.prediction_scenario ) if progress_callback: @@ -1350,7 +1487,7 @@ class ReportAgent: system_prompt = f"{PLAN_SYSTEM_PROMPT}\n\n{get_language_instruction()}" user_prompt = PLAN_USER_PROMPT_TEMPLATE.format( - simulation_requirement=self.simulation_requirement, + simulation_requirement=self.prediction_scenario, computed_prediction_context=self._get_computed_prediction_context(), total_nodes=context.get('graph_statistics', {}).get('total_nodes', 0), total_edges=context.get('graph_statistics', {}).get('total_edges', 0), @@ -1479,7 +1616,7 @@ class ReportAgent: system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format( report_title=outline.title, report_summary=outline.summary, - simulation_requirement=self.simulation_requirement, + simulation_requirement=self.prediction_scenario, computed_prediction_context=self._get_computed_prediction_context(), section_title=section.title, tools_description=self._get_tools_description(), @@ -1516,7 +1653,7 @@ class ReportAgent: all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} # 报告上下文,用于InsightForge的子问题生成 - report_context = f"章节标题: {section.title}\n模拟需求: {self.simulation_requirement}" + report_context = f"章节标题: {section.title}\n预测场景: {self.prediction_scenario}" for iteration in range(max_iterations): if progress_callback: @@ -1877,9 +2014,9 @@ class ReportAgent: t('progress.generatingSection', title=section.title, current=section_num, total=total_sections) ) - # 足球比分概率章节使用后端已计算结果,避免核心预测被LLM漏写。 - if self._is_computed_prediction_section(section, section_num): - section_content = football_prediction_to_markdown(self.computed_prediction) + # 足球概率报告使用后端已计算结果,避免LLM把用户提示词当作模拟事实分析。 + if self.computed_prediction: + section_content = self._generate_computed_prediction_section(section, section_num) if self.report_logger: self.report_logger.log_section_content( section_title=section.title, diff --git a/backend/test_health.ps1 b/backend/test_health.ps1 new file mode 100644 index 00000000..241c812b --- /dev/null +++ b/backend/test_health.ps1 @@ -0,0 +1,7 @@ +try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:5001/health' -TimeoutSec 3 -ErrorAction Stop + Write-Host "Status: $($r.StatusCode)" + Write-Host "Body: $($r.Content)" +} catch { + Write-Host "Error: $($_.Exception.Message)" +} \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index 642dd9c3..39666fb2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -994,6 +994,7 @@ dependencies = [ { name = "charset-normalizer" }, { name = "flask" }, { name = "flask-cors" }, + { name = "neo4j" }, { name = "openai" }, { name = "pydantic" }, { name = "pymupdf" }, @@ -1022,6 +1023,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = ">=3.0.0" }, { name = "flask", specifier = ">=3.0.0" }, { name = "flask-cors", specifier = ">=6.0.0" }, + { name = "neo4j", specifier = "==5.23.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "pydantic", specifier = ">=2.0.0" },