import json import sys from pathlib import Path def read_json(path): return json.loads(Path(path).read_text(encoding="utf-8")) def load_models(root): return [read_json(path) for path in sorted((root / "models").glob("*.model.json"))] def load_selector_rules(root): return read_json(root / "selector" / "selector_rules.json") def rules_by_model(selector_rules): return {item["model_id"]: item for item in selector_rules.get("models", [])} def hit_any(user_input, signals): return [signal for signal in signals if signal in user_input] def instruction_segment(user_input): segments = [user_input] for delimiter in (":", ":", "\n"): if delimiter in user_input: segments.append(user_input.split(delimiter, 1)[0]) return min(segments, key=len) def has_analysis_override(user_input, global_rules): return bool(hit_any(user_input, global_rules.get("explicit_analysis_override_phrases", []))) def hard_no_call_hits(user_input, global_rules): signals = list(global_rules.get("hard_no_call_signals", [])) signals.extend(global_rules.get("direct_execution_no_call_signals", [])) hits = hit_any(user_input, signals) instruction_hits = instruction_task_no_call_hits( user_input, global_rules.get("instruction_task_no_call_signals", []), ) return hits + instruction_hits def instruction_task_no_call_hits(user_input, signals): instruction = instruction_segment(user_input) if instruction != user_input: return hit_any(instruction, signals) stripped = user_input.strip() return [ signal for signal in signals if 0 <= stripped.find(signal) <= 4 ] def depth_limited_qpi_override_hits(user_input, global_rules): depth_hits = hit_any(user_input, global_rules.get("depth_limiting_no_call_signals", [])) override_hits = hit_any(user_input, global_rules.get("qpi_limited_analysis_override_phrases", [])) return depth_hits, override_hits def model_hard_exclusion_hits(user_input, model_rule): return hit_any(user_input, model_rule.get("hard_exclusion_triggers", [])) def score_model(model, model_rule, global_rules, user_input, task_type="", pipeline_position=""): model_id = model.get("model_id") weights = global_rules.get("weights", {}) score = float(model_rule.get("base_score", 0)) / 100 reasons = [] penalties = [] negative_hits = hit_any(user_input, model_rule.get("negative_triggers", [])) + hit_any(user_input, model.get("negative_triggers", [])) depth_hits, qpi_limited_hits = depth_limited_qpi_override_hits(user_input, global_rules) suppress_qpi_depth_penalty = model_id == "qpi" and depth_hits and qpi_limited_hits if negative_hits and not has_analysis_override(user_input, global_rules) and not suppress_qpi_depth_penalty: penalty_key = "model_negative_penalty_qpi" if model_id == "qpi" else "model_negative_penalty_default" score -= weights.get(penalty_key, 0.5) penalties.append("negative trigger: " + "、".join(sorted(set(negative_hits))[:4])) trigger_hits = hit_any(user_input, model_rule.get("trigger_keywords", [])) + hit_any(user_input, model.get("trigger_keywords", [])) trigger_hits = sorted(set(trigger_hits), key=trigger_hits.index) if trigger_hits: score += weights.get("trigger_keyword", 0.25) reasons.append("trigger keyword matched: " + "、".join(trigger_hits[:4])) input_type_matches = model_rule.get("input_type_matches", []) + model.get("input_types", []) input_hits = [input_type for input_type in input_type_matches if input_type in user_input or input_type == task_type] if input_hits: score += weights.get("input_type", 0.15) reasons.append("input type matched: " + "、".join(input_hits[:3])) if pipeline_position and model.get("pipeline_position") == pipeline_position: score += weights.get("pipeline_position", 0.2) reasons.append("pipeline_position matched") complexity_hits = hit_any(user_input, global_rules.get("complexity_signals", [])) if complexity_hits: score += weights.get("complexity_signal", 0.15) reasons.append("complexity signal: " + "、".join(complexity_hits[:4])) priority = model.get("selection_priority") if isinstance(priority, int): score += max(0, min(priority, 10)) * weights.get("selection_priority_factor", 0.01) reasons.append(f"selection_priority={priority}") qpi_gate_hits = hit_any(user_input, global_rules.get("qpi_gate_signals", [])) if model_id == "qpi" and qpi_gate_hits: score += weights.get("qpi_gate_bonus", 0.25) reasons.append("QPI problem-definition gate matched") if model_id == "qpi": has_positive_signal = bool( trigger_hits or input_hits or complexity_hits or qpi_gate_hits or has_analysis_override(user_input, global_rules) or task_type in {"problem_definition", "question_analysis"} or pipeline_position in {"pre_analysis", "problem_definition"} ) if not has_positive_signal: score -= weights.get("qpi_missing_positive_signal_penalty", 0.25) penalties.append("QPI positive signal missing") if model_id == "qpi" and "QPI 已判断" in user_input: score -= weights.get("qpi_already_completed_penalty", 0.25) penalties.append("QPI already completed") if model_id == "intellectual_archaeology": heavy_hits = hit_any(user_input, global_rules.get("ia_heavy_signals", [])) if heavy_hits: score += weights.get("ia_heavy_bonus", 0.2) reasons.append("IA heavy-depth signal: " + "、".join(heavy_hits[:3])) if "QPI 已判断" in user_input: score += weights.get("ia_qpi_completed_bonus", 0.25) reasons.append("QPI-before-IA gate satisfied") if global_rules.get("qpi_before_ia_gate", True) and qpi_gate_hits and "QPI 已判断" not in user_input: score -= weights.get("ia_qpi_gate_penalty", 0.45) penalties.append("QPI-before-IA gate: " + "、".join(qpi_gate_hits[:3])) if not heavy_hits and "QPI 已判断" not in user_input: score -= weights.get("ia_no_heavy_gate_penalty", 0.2) penalties.append("no IA heavy-depth gate") score = min(max(score, 0.0), 1.0) return { "model_id": model_id, "score": round(score, 2), "reasons": reasons, "penalties": penalties } def hard_no_call_result(root, user_input, hits): rejected = [] for model in load_models(root): rejected.append({ "model_id": model.get("model_id"), "score": 0.0, "reasons": [], "penalties": ["hard no-call signal: " + "、".join(sorted(set(hits))[:4])], "decision": "rejected" }) return { "input": user_input, "selected_models": [], "rejected_models": rejected, "no_call": True, "routing_notes": "hard no-call gate matched; explicit analysis override was not present." } def recommend(root, user_input, task_type="", pipeline_position="", threshold=None): selector_rules = load_selector_rules(root) global_rules = selector_rules.get("global_rules", {}) threshold = global_rules.get("no_call_threshold", 0.35) if threshold is None else threshold no_call_hits = hard_no_call_hits(user_input, global_rules) depth_hits, qpi_limited_hits = depth_limited_qpi_override_hits(user_input, global_rules) has_depth_limited_qpi_override = bool(depth_hits and qpi_limited_hits) if ( global_rules.get("hard_no_call_first", True) and no_call_hits and not has_analysis_override(user_input, global_rules) and not has_depth_limited_qpi_override ): return hard_no_call_result(root, user_input, no_call_hits) model_rules = rules_by_model(selector_rules) scored = [] for model in load_models(root): model_id = model.get("model_id") model_rule = model_rules.get(model_id, {}) item = score_model(model, model_rule, global_rules, user_input, task_type, pipeline_position) exclusion_hits = model_hard_exclusion_hits(user_input, model_rule) if exclusion_hits: item["score"] = 0.0 item["hard_excluded"] = True item["penalties"].append("model hard exclusion signal: " + "、".join(sorted(set(exclusion_hits))[:4])) scored.append(item) no_call = all(item["score"] < threshold for item in scored) selected = [] rejected = [] for item in scored: output_item = { "model_id": item["model_id"], "score": item["score"], "reasons": item["reasons"], "penalties": item["penalties"], "decision": "selected" if item["score"] >= threshold and not no_call and not item.get("hard_excluded") else "rejected" } if output_item["decision"] == "selected": selected.append(output_item) else: rejected.append(output_item) selected.sort(key=lambda item: item["score"], reverse=True) rejected.sort(key=lambda item: item["score"], reverse=True) return { "input": user_input, "selected_models": selected, "rejected_models": rejected, "no_call": no_call, "routing_notes": "selector is rule-based and driven by selector/selector_rules.json; no LLM, no vector search, no answer generation." } def main(): root = Path(__file__).resolve().parents[1] example = "团队每次都说要长期主义,但一到季度 KPI 就回到短期动作,这到底是什么问题?" result = recommend(root, example, task_type="question_analysis", pipeline_position="pre_analysis") print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())