scoping.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. """Scope candidate generation, normalization, and linking."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from typing import Any, Protocol
  6. from core.llm import chat_json as default_chat_json
  7. from core.prompts import load_prompt
  8. from core.text_limits import DIRECTIVE_CONTEXT_MAX_CHARS, clip_text
  9. from decode_content.contracts import REUSE_THRESHOLD, SCOPE_TYPE_CN, SkillContract, load_contract
  10. from decode_content.gates import ChatJsonFn
  11. NORMALIZE = load_prompt("normalize_scope")
  12. SRC2CN = SCOPE_TYPE_CN
  13. STRIP_VERBS = ("寻找", "定位", "推导", "核验", "提取", "挖掘", "捕捉", "识别", "梳理", "归纳", "判断", "验证", "确认", "复盘")
  14. SCOPE_CONN = re.compile(r"\s*[和与、,,//&]\s*")
  15. class ScopeLinker(Protocol):
  16. def link(self, value: str, *, source_type: str, top_k: int = 3) -> list[dict[str, Any]]:
  17. ...
  18. def slim_knowledges(knowledges: list[dict[str, Any]]) -> list[dict[str, Any]]:
  19. slim: list[dict[str, Any]] = []
  20. for knowledge in knowledges:
  21. item = {"id": knowledge.get("id"), "type": knowledge.get("type"), "title": knowledge.get("title")}
  22. if knowledge.get("type") == "how":
  23. item["steps"] = [
  24. {
  25. "id": step.get("id"),
  26. "input": step.get("input"),
  27. "directive": clip_text(step.get("directive") or "", DIRECTIVE_CONTEXT_MAX_CHARS),
  28. "output": step.get("output"),
  29. }
  30. for step in knowledge.get("steps", [])
  31. ]
  32. else:
  33. item["内容"] = {
  34. key: knowledge.get(key)
  35. for key in ("概要", "维度拆分规则", "body", "阐述")
  36. if knowledge.get(key)
  37. }
  38. slim.append(item)
  39. return slim
  40. def scope_candidates(
  41. knowledges: list[dict[str, Any]],
  42. *,
  43. contract: SkillContract | None = None,
  44. chat_json_fn: ChatJsonFn = default_chat_json,
  45. ) -> list[dict[str, Any]]:
  46. contract = contract or load_contract()
  47. user = (
  48. "给下面每颗知识标作用域候选(how 逐步:每个 step 一组;what/why 颗级:整颗一组)。\n"
  49. "每类平铺列出所有相关值(同类可多条、全部对等、不选主次);先脑内摊正交、收敛近义;意图通常 1 个。\n"
  50. "只输出 JSON:{\"scopes\":[{\"knowledge_id\":\"k1\",\"step_id\":\"s1\",\"items\":["
  51. "{\"scope_type\":\"substance\",\"value\":\"实质值1\"},{\"scope_type\":\"substance\",\"value\":\"实质值2\"},"
  52. "{\"scope_type\":\"form\",\"value\":\"形式值1\"},{\"scope_type\":\"intent\",\"value\":\"意图值\"}]},"
  53. "{\"knowledge_id\":\"k2\",\"step_id\":null,\"items\":[...]}]}\n\n"
  54. + json.dumps(slim_knowledges(knowledges), ensure_ascii=False)
  55. )
  56. return chat_json_fn(contract.phase2, user, timeout=120).get("scopes") or []
  57. def strip_verb_tail(value: str) -> str:
  58. if not value or len(value) < 3:
  59. return value
  60. original = value
  61. for verb in STRIP_VERBS:
  62. if value.startswith(verb) and len(value) - len(verb) >= 2:
  63. value = value[len(verb):]
  64. break
  65. for verb in STRIP_VERBS:
  66. if value.endswith(verb) and len(value) - len(verb) >= 2:
  67. value = value[: -len(verb)]
  68. break
  69. return value or original
  70. def nounify_scopes(
  71. scopes: list[dict[str, Any]],
  72. *,
  73. chat_json_fn: ChatJsonFn = default_chat_json,
  74. ) -> list[dict[str, Any]]:
  75. vals = sorted(
  76. {
  77. item["value"]
  78. for scope in scopes
  79. for item in (scope.get("items") or [])
  80. if item.get("value") and item.get("scope_type") != "intent"
  81. }
  82. )
  83. mapping: dict[str, str] = {}
  84. if vals:
  85. try:
  86. mapping = chat_json_fn(NORMALIZE, json.dumps(vals, ensure_ascii=False), timeout=90).get("映射") or {}
  87. except Exception:
  88. mapping = {}
  89. for scope in scopes:
  90. for item in scope.get("items") or []:
  91. value = item.get("value")
  92. if not value or item.get("scope_type") == "intent":
  93. continue
  94. item["value"] = strip_verb_tail(mapping.get(value) or value)
  95. return scopes
  96. def link_scope(linker: ScopeLinker, scope_type: str, value: str) -> dict[str, Any]:
  97. try:
  98. hits = linker.link(value, source_type=SRC2CN.get(scope_type, scope_type), top_k=3)
  99. except Exception:
  100. hits = []
  101. top = hits[0] if hits else {}
  102. score = float(top.get("score", 0.0) or 0.0)
  103. reuse = score >= REUSE_THRESHOLD and top.get("name")
  104. return {
  105. "scope_type": scope_type,
  106. "value": top["name"] if reuse else value,
  107. "candidate": value,
  108. "link": "复用" if reuse else "新建",
  109. "score": round(score, 4),
  110. "top": [
  111. {"name": hit["name"], "score": hit["score"], "path": hit.get("path", "")}
  112. for hit in hits
  113. if hit.get("name") is not None
  114. ],
  115. }
  116. def split_scope_items(items: list[dict[str, Any]] | None) -> list[dict[str, str]]:
  117. out: list[dict[str, str]] = []
  118. for item in items or []:
  119. scope_type = item.get("scope_type")
  120. value = item.get("value")
  121. if not (scope_type and value):
  122. continue
  123. parts = [part.strip() for part in SCOPE_CONN.split(value) if part.strip()]
  124. for part in (parts if len(parts) > 1 else [value]):
  125. out.append({"scope_type": scope_type, "value": part})
  126. return out
  127. def apply_scopes(
  128. knowledges: list[dict[str, Any]],
  129. scopes: list[dict[str, Any]],
  130. linker: ScopeLinker | None = None,
  131. ) -> None:
  132. by_k = {knowledge.get("id"): knowledge for knowledge in knowledges}
  133. for scope in scopes:
  134. knowledge = by_k.get(scope.get("knowledge_id"))
  135. if not knowledge:
  136. continue
  137. if linker is None:
  138. linked = [
  139. {"scope_type": item["scope_type"], "value": item["value"], "candidate": item["value"], "link": "候选"}
  140. for item in split_scope_items(scope.get("items"))
  141. ]
  142. else:
  143. linked = [
  144. link_scope(linker, item["scope_type"], item["value"])
  145. for item in split_scope_items(scope.get("items"))
  146. ]
  147. if knowledge.get("type") == "how" and scope.get("step_id"):
  148. for step in knowledge.get("steps", []):
  149. if step.get("id") == scope["step_id"]:
  150. step["作用域"] = linked
  151. else:
  152. knowledge["作用域"] = (knowledge.get("作用域") or []) + linked