218 lines
9.0 KiB
Python
218 lines
9.0 KiB
Python
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 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", []))
|
|
return hit_any(user_input, signals)
|
|
|
|
|
|
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())
|