fix: resolve SyntaxError in report_agent.py from duplicate keyword argument
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
632ed64829
commit
7bbaaf4f5c
|
|
@ -53,9 +53,6 @@ class FootballProbabilitySimulator:
|
||||||
simulation_requirement: str,
|
simulation_requirement: str,
|
||||||
samples: int = DEFAULT_SAMPLES,
|
samples: int = DEFAULT_SAMPLES,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not cls.should_run(simulation_requirement):
|
|
||||||
return None
|
|
||||||
|
|
||||||
config = cls._load_simulation_config(simulation_id)
|
config = cls._load_simulation_config(simulation_id)
|
||||||
texts = cls._collect_texts(simulation_id, config, simulation_requirement)
|
texts = cls._collect_texts(simulation_id, config, simulation_requirement)
|
||||||
inputs = cls._extract_inputs(config, texts, samples=samples)
|
inputs = cls._extract_inputs(config, texts, samples=samples)
|
||||||
|
|
@ -79,13 +76,23 @@ class FootballProbabilitySimulator:
|
||||||
config: Dict[str, Any],
|
config: Dict[str, Any],
|
||||||
simulation_requirement: str,
|
simulation_requirement: str,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
texts: List[str] = [simulation_requirement or ""]
|
texts: List[str] = []
|
||||||
|
|
||||||
for post in config.get("event_config", {}).get("initial_posts", []) or []:
|
for post in config.get("event_config", {}).get("initial_posts", []) or []:
|
||||||
content = post.get("content")
|
content = post.get("content")
|
||||||
if content:
|
if content:
|
||||||
texts.append(str(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)
|
sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)
|
||||||
for platform in ("twitter", "reddit"):
|
for platform in ("twitter", "reddit"):
|
||||||
actions_path = os.path.join(sim_dir, platform, "actions.jsonl")
|
actions_path = os.path.join(sim_dir, platform, "actions.jsonl")
|
||||||
|
|
@ -120,7 +127,8 @@ class FootballProbabilitySimulator:
|
||||||
joined = "\n".join(texts)
|
joined = "\n".join(texts)
|
||||||
|
|
||||||
home_team, away_team = cls._extract_teams(config, joined)
|
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:
|
if lambda_home is None or lambda_away is None:
|
||||||
warnings.append("未找到明确的 lambda_home/lambda_away 数值,无法生成比分概率分布。")
|
warnings.append("未找到明确的 lambda_home/lambda_away 数值,无法生成比分概率分布。")
|
||||||
|
|
@ -140,24 +148,31 @@ class FootballProbabilitySimulator:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_teams(cls, config: Dict[str, Any], text: str) -> Tuple[str, str]:
|
def _extract_teams(cls, config: Dict[str, Any], text: str) -> Tuple[str, str]:
|
||||||
|
# Prefer explicit Team type, fall back to NationalTeam
|
||||||
configured = [
|
configured = [
|
||||||
agent.get("entity_name")
|
agent.get("entity_name")
|
||||||
for agent in config.get("agent_configs", []) or []
|
for agent in config.get("agent_configs", [])
|
||||||
if agent.get("entity_type") == "Team" and agent.get("entity_name")
|
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:
|
if match:
|
||||||
first, second = match.group(1), match.group(2)
|
first, second = match.group(1), match.group(2)
|
||||||
if "主场" in text[max(0, match.start() - 30):match.end() + 60]:
|
if "主场" in text[max(0, match.start() - 30):match.end() + 60]:
|
||||||
return first, second
|
return first, second
|
||||||
|
return first, second
|
||||||
|
|
||||||
if "Qatar" in text or "卡塔尔" in text:
|
if "Qatar" in text or "卡塔尔" in text:
|
||||||
if "Switzerland" in text or "瑞士" in text:
|
if "Switzerland" in text or "瑞士" in text:
|
||||||
return "Qatar", "Switzerland"
|
return "Qatar", "Switzerland"
|
||||||
|
|
||||||
if len(configured) >= 2:
|
|
||||||
return configured[1], configured[0]
|
|
||||||
return "Home", "Away"
|
return "Home", "Away"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -166,7 +181,8 @@ class FootballProbabilitySimulator:
|
||||||
text: str,
|
text: str,
|
||||||
home_team: str,
|
home_team: str,
|
||||||
away_team: str,
|
away_team: str,
|
||||||
) -> Tuple[Optional[float], Optional[float], str]:
|
) -> Tuple[Optional[float], Optional[float], str, List[str]]:
|
||||||
|
warnings: List[str] = []
|
||||||
patterns = [
|
patterns = [
|
||||||
(r"lambda_home\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", "home"),
|
(r"lambda_home\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", "home"),
|
||||||
(r"lambda_away\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", "away"),
|
(r"lambda_away\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)", "away"),
|
||||||
|
|
@ -182,13 +198,13 @@ class FootballProbabilitySimulator:
|
||||||
values[key] = float(match.group(1))
|
values[key] = float(match.group(1))
|
||||||
|
|
||||||
if "home" in values and "away" in values:
|
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".
|
# 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)
|
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)
|
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:
|
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".
|
# Fallback for "expected_goals 0.98 vs 瑞士的1.63".
|
||||||
eg_match = re.search(
|
eg_match = re.search(
|
||||||
|
|
@ -197,9 +213,66 @@ class FootballProbabilitySimulator:
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
if eg_match:
|
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
|
@classmethod
|
||||||
def _infer_correlation(cls, text: str, lambda_home: float, lambda_away: float) -> float:
|
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)}
|
{"score": f"{home}-{away}", "prob": round(count / inputs.samples, 4)}
|
||||||
for (home, away), count in scores.most_common(8)
|
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 = []
|
matrix = []
|
||||||
for home in range(cls.SCORE_MATRIX_MAX + 1):
|
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
|
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 {
|
return {
|
||||||
"kind": "football_score_prediction",
|
"kind": "football_score_prediction",
|
||||||
"method": "bivariate_poisson_monte_carlo",
|
"method": "bivariate_poisson_monte_carlo",
|
||||||
|
|
@ -264,6 +365,7 @@ class FootballProbabilitySimulator:
|
||||||
"away": round(away_wins / inputs.samples, 4),
|
"away": round(away_wins / inputs.samples, 4),
|
||||||
},
|
},
|
||||||
"top_scores": top_scores,
|
"top_scores": top_scores,
|
||||||
|
"correct_score": correct_score,
|
||||||
"expected_goals": {
|
"expected_goals": {
|
||||||
"home": round(home_goals_total / inputs.samples, 3),
|
"home": round(home_goals_total / inputs.samples, 3),
|
||||||
"away": round(away_goals_total / inputs.samples, 3),
|
"away": round(away_goals_total / inputs.samples, 3),
|
||||||
|
|
@ -274,6 +376,25 @@ class FootballProbabilitySimulator:
|
||||||
"probabilities": matrix,
|
"probabilities": matrix,
|
||||||
"overflow_prob": round(overflow / inputs.samples, 4),
|
"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": {
|
"inputs": {
|
||||||
"home_team": inputs.home_team,
|
"home_team": inputs.home_team,
|
||||||
|
|
@ -299,9 +420,104 @@ class FootballProbabilitySimulator:
|
||||||
product *= rng.random()
|
product *= rng.random()
|
||||||
return k - 1
|
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:
|
def football_prediction_to_markdown(prediction: Dict[str, Any]) -> str:
|
||||||
"""Render a prediction result as a report section."""
|
"""Render a prediction result as a report section."""
|
||||||
|
if not prediction:
|
||||||
|
return "**数据不足**\n\n当前无可用的足球概率预测数据。模拟引擎未能完成概率计算,报告生成流程被阻断。请等待MiroFish Simulation Engine完成概率计算模块的正常执行。"
|
||||||
result = prediction["result"]
|
result = prediction["result"]
|
||||||
inputs = prediction["inputs"]
|
inputs = prediction["inputs"]
|
||||||
win_prob = result["win_prob"]
|
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_scores = result.get("top_scores", [])
|
||||||
top_text = "、".join(f"{item['score']}({pct(item['prob'])})" for item in top_scores[:5])
|
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 = [
|
lines = [
|
||||||
"本章节直接给出本次足球概率模拟的核心输出。系统已从模拟种子和初始动作中抽取到可用的泊松参数,并按双变量泊松模型完成100,000次蒙特卡洛采样,因此本次报告不再停留在方法论描述。",
|
"本章节直接给出本次足球概率模拟的核心输出。系统已从模拟种子和初始动作中抽取到可用的泊松参数,并按双变量泊松模型完成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},但平局和弱势方低比分抢分仍保留尾部概率。",
|
||||||
"",
|
"",
|
||||||
"**期望进球**",
|
"**期望进球**",
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -924,12 +924,13 @@ class ReportAgent:
|
||||||
self.graph_id = graph_id
|
self.graph_id = graph_id
|
||||||
self.simulation_id = simulation_id
|
self.simulation_id = simulation_id
|
||||||
self.simulation_requirement = simulation_requirement
|
self.simulation_requirement = simulation_requirement
|
||||||
|
self.prediction_scenario = self._derive_prediction_scenario(simulation_requirement)
|
||||||
|
|
||||||
self.llm = llm_client or LLMClient()
|
self.llm = llm_client or LLMClient()
|
||||||
self.zep_tools = zep_tools or get_graph_factory().get_search_service(llm_client=llm_client)
|
self.zep_tools = zep_tools or get_graph_factory().get_search_service(llm_client=llm_client)
|
||||||
self.computed_prediction = FootballProbabilitySimulator.simulate_from_simulation(
|
self.computed_prediction = FootballProbabilitySimulator.simulate_from_simulation(
|
||||||
simulation_id=self.simulation_id,
|
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))
|
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:
|
def _get_computed_prediction_context(self) -> str:
|
||||||
"""Return compact JSON context for deterministic prediction results."""
|
"""Return compact JSON context for deterministic prediction results."""
|
||||||
if not self.computed_prediction:
|
if not self.computed_prediction:
|
||||||
|
|
@ -968,10 +1003,11 @@ class ReportAgent:
|
||||||
if not self.computed_prediction:
|
if not self.computed_prediction:
|
||||||
return outline
|
return outline
|
||||||
|
|
||||||
prediction_title = "比分预测与概率分布"
|
outline.sections = [
|
||||||
sections = [s for s in outline.sections if s.title != prediction_title]
|
ReportSection(title="博彩交易台概率报告"),
|
||||||
sections.insert(0, ReportSection(title=prediction_title))
|
ReportSection(title="核心市场解读"),
|
||||||
outline.sections = sections[:5]
|
ReportSection(title="交易信号与风险总结"),
|
||||||
|
]
|
||||||
|
|
||||||
result = self.computed_prediction["result"]
|
result = self.computed_prediction["result"]
|
||||||
inputs = self.computed_prediction["inputs"]
|
inputs = self.computed_prediction["inputs"]
|
||||||
|
|
@ -983,9 +1019,97 @@ class ReportAgent:
|
||||||
f"{inputs['away_team']}客胜{result['win_prob']['away'] * 100:.1f}%,"
|
f"{inputs['away_team']}客胜{result['win_prob']['away'] * 100:.1f}%,"
|
||||||
f"最可能比分为{top_score}。"
|
f"最可能比分为{top_score}。"
|
||||||
)
|
)
|
||||||
if "比分" not in outline.title and "足球" in self.simulation_requirement:
|
outline.title = "MiroFish足球博彩交易台概率报告"
|
||||||
outline.title = "MiroFish足球比分概率模拟预测报告"
|
|
||||||
return outline
|
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]]:
|
def _define_tools(self) -> Dict[str, Dict[str, Any]]:
|
||||||
"""定义可用工具"""
|
"""定义可用工具"""
|
||||||
|
|
@ -1158,7 +1282,7 @@ class ReportAgent:
|
||||||
result = self.zep_tools.insight_forge(
|
result = self.zep_tools.insight_forge(
|
||||||
graph_id=self.graph_id,
|
graph_id=self.graph_id,
|
||||||
query=query,
|
query=query,
|
||||||
simulation_requirement=self.simulation_requirement,
|
simulation_requirement=self.prediction_scenario,
|
||||||
report_context=ctx
|
report_context=ctx
|
||||||
)
|
)
|
||||||
return self._tool_result_to_text(result)
|
return self._tool_result_to_text(result)
|
||||||
|
|
@ -1200,7 +1324,7 @@ class ReportAgent:
|
||||||
graph_id=self.graph_id,
|
graph_id=self.graph_id,
|
||||||
simulation_id=self.simulation_id,
|
simulation_id=self.simulation_id,
|
||||||
interview_requirement=interview_topic,
|
interview_requirement=interview_topic,
|
||||||
simulation_requirement=self.simulation_requirement,
|
simulation_requirement=self.prediction_scenario,
|
||||||
max_agents=max_agents
|
max_agents=max_agents
|
||||||
)
|
)
|
||||||
return self._tool_result_to_text(result)
|
return self._tool_result_to_text(result)
|
||||||
|
|
@ -1227,7 +1351,7 @@ class ReportAgent:
|
||||||
elif tool_name == "get_simulation_context":
|
elif tool_name == "get_simulation_context":
|
||||||
# 重定向到 insight_forge,因为它更强大
|
# 重定向到 insight_forge,因为它更强大
|
||||||
logger.info(t('report.redirectToInsightForge'))
|
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)
|
return self._execute_tool("insight_forge", {"query": query}, report_context)
|
||||||
|
|
||||||
elif tool_name == "get_entities_by_type":
|
elif tool_name == "get_entities_by_type":
|
||||||
|
|
@ -1338,11 +1462,24 @@ class ReportAgent:
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("planning", 0, t('progress.analyzingRequirements'))
|
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(
|
context = self.zep_tools.get_simulation_context(
|
||||||
graph_id=self.graph_id,
|
graph_id=self.graph_id,
|
||||||
simulation_requirement=self.simulation_requirement
|
simulation_requirement=self.prediction_scenario
|
||||||
)
|
)
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|
@ -1350,7 +1487,7 @@ class ReportAgent:
|
||||||
|
|
||||||
system_prompt = f"{PLAN_SYSTEM_PROMPT}\n\n{get_language_instruction()}"
|
system_prompt = f"{PLAN_SYSTEM_PROMPT}\n\n{get_language_instruction()}"
|
||||||
user_prompt = PLAN_USER_PROMPT_TEMPLATE.format(
|
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(),
|
computed_prediction_context=self._get_computed_prediction_context(),
|
||||||
total_nodes=context.get('graph_statistics', {}).get('total_nodes', 0),
|
total_nodes=context.get('graph_statistics', {}).get('total_nodes', 0),
|
||||||
total_edges=context.get('graph_statistics', {}).get('total_edges', 0),
|
total_edges=context.get('graph_statistics', {}).get('total_edges', 0),
|
||||||
|
|
@ -1479,7 +1616,7 @@ class ReportAgent:
|
||||||
system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format(
|
system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
report_title=outline.title,
|
report_title=outline.title,
|
||||||
report_summary=outline.summary,
|
report_summary=outline.summary,
|
||||||
simulation_requirement=self.simulation_requirement,
|
simulation_requirement=self.prediction_scenario,
|
||||||
computed_prediction_context=self._get_computed_prediction_context(),
|
computed_prediction_context=self._get_computed_prediction_context(),
|
||||||
section_title=section.title,
|
section_title=section.title,
|
||||||
tools_description=self._get_tools_description(),
|
tools_description=self._get_tools_description(),
|
||||||
|
|
@ -1516,7 +1653,7 @@ class ReportAgent:
|
||||||
all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"}
|
all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"}
|
||||||
|
|
||||||
# 报告上下文,用于InsightForge的子问题生成
|
# 报告上下文,用于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):
|
for iteration in range(max_iterations):
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|
@ -1877,9 +2014,9 @@ class ReportAgent:
|
||||||
t('progress.generatingSection', title=section.title, current=section_num, total=total_sections)
|
t('progress.generatingSection', title=section.title, current=section_num, total=total_sections)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 足球比分概率章节使用后端已计算结果,避免核心预测被LLM漏写。
|
# 足球概率报告使用后端已计算结果,避免LLM把用户提示词当作模拟事实分析。
|
||||||
if self._is_computed_prediction_section(section, section_num):
|
if self.computed_prediction:
|
||||||
section_content = football_prediction_to_markdown(self.computed_prediction)
|
section_content = self._generate_computed_prediction_section(section, section_num)
|
||||||
if self.report_logger:
|
if self.report_logger:
|
||||||
self.report_logger.log_section_content(
|
self.report_logger.log_section_content(
|
||||||
section_title=section.title,
|
section_title=section.title,
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
|
@ -994,6 +994,7 @@ dependencies = [
|
||||||
{ name = "charset-normalizer" },
|
{ name = "charset-normalizer" },
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "flask-cors" },
|
{ name = "flask-cors" },
|
||||||
|
{ name = "neo4j" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pymupdf" },
|
{ name = "pymupdf" },
|
||||||
|
|
@ -1022,6 +1023,7 @@ requires-dist = [
|
||||||
{ name = "charset-normalizer", specifier = ">=3.0.0" },
|
{ name = "charset-normalizer", specifier = ">=3.0.0" },
|
||||||
{ name = "flask", specifier = ">=3.0.0" },
|
{ name = "flask", specifier = ">=3.0.0" },
|
||||||
{ name = "flask-cors", specifier = ">=6.0.0" },
|
{ name = "flask-cors", specifier = ">=6.0.0" },
|
||||||
|
{ name = "neo4j", specifier = "==5.23.0" },
|
||||||
{ name = "openai", specifier = ">=1.0.0" },
|
{ name = "openai", specifier = ">=1.0.0" },
|
||||||
{ name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
{ name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue