243 lines
9.8 KiB
Python
243 lines
9.8 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 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())
|