"""Scope candidate generation, normalization, and linking.""" from __future__ import annotations import json import re from typing import Any, Protocol from core.llm import chat_json as default_chat_json from core.prompts import load_prompt from decode_content.contracts import REUSE_THRESHOLD, SCOPE_TYPE_CN, SkillContract, load_contract from decode_content.gates import ChatJsonFn NORMALIZE = load_prompt("normalize_scope") SRC2CN = SCOPE_TYPE_CN STRIP_VERBS = ("寻找", "定位", "推导", "核验", "提取", "挖掘", "捕捉", "识别", "梳理", "归纳", "判断", "验证", "确认", "复盘") SCOPE_CONN = re.compile(r"\s*[和与、,,//&]\s*") class ScopeLinker(Protocol): def link(self, value: str, *, source_type: str, top_k: int = 3) -> list[dict[str, Any]]: ... def slim_knowledges(knowledges: list[dict[str, Any]]) -> list[dict[str, Any]]: slim: list[dict[str, Any]] = [] for knowledge in knowledges: item = {"id": knowledge.get("id"), "type": knowledge.get("type"), "title": knowledge.get("title")} if knowledge.get("type") == "how": item["steps"] = [ { "id": step.get("id"), "input": step.get("input"), "directive": (step.get("directive") or "")[:500], "output": step.get("output"), } for step in knowledge.get("steps", []) ] else: item["内容"] = { key: knowledge.get(key) for key in ("概要", "维度拆分规则", "body", "阐述") if knowledge.get(key) } slim.append(item) return slim def scope_candidates( knowledges: list[dict[str, Any]], *, contract: SkillContract | None = None, chat_json_fn: ChatJsonFn = default_chat_json, ) -> list[dict[str, Any]]: contract = contract or load_contract() user = ( "给下面每颗知识标作用域候选(how 逐步:每个 step 一组;what/why 颗级:整颗一组)。\n" "每类平铺列出所有相关值(同类可多条、全部对等、不选主次);先脑内摊正交、收敛近义;意图通常 1 个。\n" "只输出 JSON:{\"scopes\":[{\"knowledge_id\":\"k1\",\"step_id\":\"s1\",\"items\":[" "{\"scope_type\":\"substance\",\"value\":\"实质值1\"},{\"scope_type\":\"substance\",\"value\":\"实质值2\"}," "{\"scope_type\":\"form\",\"value\":\"形式值1\"},{\"scope_type\":\"intent\",\"value\":\"意图值\"}]}," "{\"knowledge_id\":\"k2\",\"step_id\":null,\"items\":[...]}]}\n\n" + json.dumps(slim_knowledges(knowledges), ensure_ascii=False) ) return chat_json_fn(contract.phase2, user, timeout=120).get("scopes") or [] def strip_verb_tail(value: str) -> str: if not value or len(value) < 3: return value original = value for verb in STRIP_VERBS: if value.startswith(verb) and len(value) - len(verb) >= 2: value = value[len(verb):] break for verb in STRIP_VERBS: if value.endswith(verb) and len(value) - len(verb) >= 2: value = value[: -len(verb)] break return value or original def nounify_scopes( scopes: list[dict[str, Any]], *, chat_json_fn: ChatJsonFn = default_chat_json, ) -> list[dict[str, Any]]: vals = sorted( { item["value"] for scope in scopes for item in (scope.get("items") or []) if item.get("value") and item.get("scope_type") != "intent" } ) mapping: dict[str, str] = {} if vals: try: mapping = chat_json_fn(NORMALIZE, json.dumps(vals, ensure_ascii=False), timeout=90).get("映射") or {} except Exception: mapping = {} for scope in scopes: for item in scope.get("items") or []: value = item.get("value") if not value or item.get("scope_type") == "intent": continue item["value"] = strip_verb_tail(mapping.get(value) or value) return scopes def link_scope(linker: ScopeLinker, scope_type: str, value: str) -> dict[str, Any]: try: hits = linker.link(value, source_type=SRC2CN.get(scope_type, scope_type), top_k=3) except Exception: hits = [] top = hits[0] if hits else {} score = float(top.get("score", 0.0) or 0.0) reuse = score >= REUSE_THRESHOLD and top.get("name") return { "scope_type": scope_type, "value": top["name"] if reuse else value, "candidate": value, "link": "复用" if reuse else "新建", "score": round(score, 4), "top": [ {"name": hit["name"], "score": hit["score"], "path": hit.get("path", "")} for hit in hits if hit.get("name") is not None ], } def split_scope_items(items: list[dict[str, Any]] | None) -> list[dict[str, str]]: out: list[dict[str, str]] = [] for item in items or []: scope_type = item.get("scope_type") value = item.get("value") if not (scope_type and value): continue parts = [part.strip() for part in SCOPE_CONN.split(value) if part.strip()] for part in (parts if len(parts) > 1 else [value]): out.append({"scope_type": scope_type, "value": part}) return out def apply_scopes( knowledges: list[dict[str, Any]], scopes: list[dict[str, Any]], linker: ScopeLinker | None = None, ) -> None: by_k = {knowledge.get("id"): knowledge for knowledge in knowledges} for scope in scopes: knowledge = by_k.get(scope.get("knowledge_id")) if not knowledge: continue if linker is None: linked = [ {"scope_type": item["scope_type"], "value": item["value"], "candidate": item["value"], "link": "候选"} for item in split_scope_items(scope.get("items")) ] else: linked = [ link_scope(linker, item["scope_type"], item["value"]) for item in split_scope_items(scope.get("items")) ] if knowledge.get("type") == "how" and scope.get("step_id"): for step in knowledge.get("steps", []): if step.get("id") == scope["step_id"]: step["作用域"] = linked else: knowledge["作用域"] = (knowledge.get("作用域") or []) + linked