fix(skill-addresses): 补提交被 gitignore 漏掉的 address_scan 引擎模块
skills/ 忽略规则误伤了 packages/harness/deerflow/skills/address_scan.py, 导致部署环境缺模块、启动报 ModuleNotFoundError: deerflow.skills.address_scan。 强制纳入跟踪。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6b480fdbe9
commit
f8228847ab
1 file changed
+255
@@ -0,0 +1,255 @@
|
||||
"""Scan skill text for URL / IP addresses and apply safe batch replacements.
|
||||
|
||||
Pure-logic layer for the 技能地址(URL/IP)批量替换 feature. This module knows
|
||||
**only about text** — it never walks the filesystem. The HTTP router
|
||||
(``app/gateway/routers/skill_addresses.py``) walks ``skills/{public,custom}``,
|
||||
reads each ``*.md`` / ``*.py`` file, and feeds the text here. Keeping the
|
||||
extraction / replacement logic here (no ``app.*`` import) makes it unit-testable
|
||||
in isolation and respects the harness/app boundary.
|
||||
|
||||
Two address kinds are supported (the feature scope):
|
||||
|
||||
* ``"url"`` — full ``http(s)://…`` URLs.
|
||||
* ``"ip"`` — dotted-quad IPv4, optionally ``:port``. Each octet is range
|
||||
checked (0–255) so version-number noise like ``1.2.3.400`` is not matched.
|
||||
|
||||
The single most important invariant is **substring-safe replacement**: we never
|
||||
``str.replace`` raw file text (that would rewrite ``1.2.3.4`` *inside*
|
||||
``11.2.3.40``). Instead we re-detect the exact token spans and only rewrite a
|
||||
span whose text is *exactly* one of the requested ``from`` values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"VALID_KINDS",
|
||||
"AddressSpan",
|
||||
"Occurrence",
|
||||
"normalize_kinds",
|
||||
"find_spans",
|
||||
"scan_occurrences",
|
||||
"count_matches",
|
||||
"apply_replacements",
|
||||
"is_valid_replacement",
|
||||
"infer_kind",
|
||||
]
|
||||
|
||||
# Supported address kinds (the configured feature scope: URL + IP).
|
||||
VALID_KINDS: tuple[str, ...] = ("url", "ip")
|
||||
|
||||
# A URL body stops at whitespace, quotes, brackets, angle brackets, backticks
|
||||
# and backslashes — everything that commonly *delimits* a URL in md / py. The
|
||||
# trailing punctuation that often clings to the end (a sentence period, a
|
||||
# closing paren already excluded, a Markdown comma) is stripped afterwards.
|
||||
_URL_RE = re.compile(r"https?://[^\s'\"`<>)\]}\\]+", re.IGNORECASE)
|
||||
|
||||
# Dotted-quad IPv4 (optionally :port). Guarded so it is not glued to a longer
|
||||
# number run (``(?<![\d.])`` / ``(?![\d.])``); octets are range-checked below.
|
||||
_IP_RE = re.compile(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?(?![\d.])")
|
||||
|
||||
# Punctuation (ASCII + CJK) that should not be considered part of a URL token
|
||||
# when it appears at the very end of the match.
|
||||
_URL_TRAILING = set(".,;:!?'\")]}>`" + ")。,;:!?】》、…")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AddressSpan:
|
||||
"""An address token located at an exact ``[start, end)`` offset in text."""
|
||||
|
||||
kind: str
|
||||
value: str
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Occurrence:
|
||||
"""A located address enriched with 1-based line/column + the source line."""
|
||||
|
||||
kind: str
|
||||
value: str
|
||||
line: int
|
||||
col: int
|
||||
snippet: str
|
||||
|
||||
|
||||
def normalize_kinds(kinds: object) -> tuple[str, ...]:
|
||||
"""Coerce arbitrary input into an ordered, de-duped subset of ``VALID_KINDS``.
|
||||
|
||||
``None`` / empty means "all kinds". Unknown kinds are dropped.
|
||||
"""
|
||||
|
||||
if kinds is None:
|
||||
return VALID_KINDS
|
||||
if isinstance(kinds, str):
|
||||
raw = [p.strip() for p in kinds.split(",")]
|
||||
else:
|
||||
try:
|
||||
raw = [str(p).strip() for p in kinds] # type: ignore[assignment]
|
||||
except TypeError:
|
||||
return VALID_KINDS
|
||||
seen: list[str] = []
|
||||
for k in raw:
|
||||
kl = k.lower()
|
||||
if kl in VALID_KINDS and kl not in seen:
|
||||
seen.append(kl)
|
||||
return tuple(seen) if seen else VALID_KINDS
|
||||
|
||||
|
||||
def _valid_ipv4(quad: str) -> bool:
|
||||
parts = quad.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
for p in parts:
|
||||
if not p.isdigit() or len(p) > 3:
|
||||
return False
|
||||
if int(p) > 255:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _valid_port(port: str | None) -> bool:
|
||||
if port is None:
|
||||
return True
|
||||
return port.isdigit() and 0 <= int(port) <= 65535
|
||||
|
||||
|
||||
def _url_spans(text: str) -> list[AddressSpan]:
|
||||
spans: list[AddressSpan] = []
|
||||
for m in _URL_RE.finditer(text):
|
||||
start, end = m.start(), m.end()
|
||||
value = m.group(0)
|
||||
# Strip clinging trailing punctuation.
|
||||
while value and value[-1] in _URL_TRAILING:
|
||||
value = value[:-1]
|
||||
end -= 1
|
||||
if value:
|
||||
spans.append(AddressSpan(kind="url", value=value, start=start, end=end))
|
||||
return spans
|
||||
|
||||
|
||||
def _ip_spans(text: str, exclude: list[AddressSpan]) -> list[AddressSpan]:
|
||||
spans: list[AddressSpan] = []
|
||||
for m in _IP_RE.finditer(text):
|
||||
quad = m.group(1)
|
||||
port = m.group(2)
|
||||
if not _valid_ipv4(quad) or not _valid_port(port):
|
||||
continue
|
||||
start, end = m.start(), m.end()
|
||||
# Skip an IP that lives *inside* a URL (e.g. http://1.2.3.4:8080/x) —
|
||||
# replacing just the IP there would corrupt the URL.
|
||||
if any(s.start <= start < s.end for s in exclude):
|
||||
continue
|
||||
spans.append(AddressSpan(kind="ip", value=m.group(0), start=start, end=end))
|
||||
return spans
|
||||
|
||||
|
||||
def find_spans(text: str, kinds: object = None) -> list[AddressSpan]:
|
||||
"""Return every address token span in *text*, ordered by position.
|
||||
|
||||
URL regions are always computed first so that IPs embedded inside a URL are
|
||||
not separately surfaced, even when only ``"ip"`` is requested.
|
||||
"""
|
||||
|
||||
wanted = normalize_kinds(kinds)
|
||||
url_spans = _url_spans(text)
|
||||
result: list[AddressSpan] = []
|
||||
if "url" in wanted:
|
||||
result.extend(url_spans)
|
||||
if "ip" in wanted:
|
||||
result.extend(_ip_spans(text, exclude=url_spans))
|
||||
result.sort(key=lambda s: s.start)
|
||||
return result
|
||||
|
||||
|
||||
def scan_occurrences(text: str, kinds: object = None, *, snippet_max: int = 240) -> list[Occurrence]:
|
||||
"""Locate addresses with 1-based line/column and a trimmed source line."""
|
||||
|
||||
spans = find_spans(text, kinds)
|
||||
if not spans:
|
||||
return []
|
||||
# Precompute line-start offsets for O(log n) line lookup.
|
||||
line_starts = [0]
|
||||
for i, ch in enumerate(text):
|
||||
if ch == "\n":
|
||||
line_starts.append(i + 1)
|
||||
|
||||
occ: list[Occurrence] = []
|
||||
import bisect
|
||||
|
||||
for s in spans:
|
||||
line_idx = bisect.bisect_right(line_starts, s.start) - 1
|
||||
line_no = line_idx + 1
|
||||
col = s.start - line_starts[line_idx] + 1
|
||||
line_end = line_starts[line_idx + 1] - 1 if line_idx + 1 < len(line_starts) else len(text)
|
||||
raw_line = text[line_starts[line_idx]:line_end].rstrip("\r")
|
||||
snippet = raw_line.strip()
|
||||
if len(snippet) > snippet_max:
|
||||
snippet = snippet[: snippet_max - 1] + "…"
|
||||
occ.append(Occurrence(kind=s.kind, value=s.value, line=line_no, col=col, snippet=snippet))
|
||||
return occ
|
||||
|
||||
|
||||
def count_matches(text: str, mapping: dict[str, str], kinds: object = None) -> int:
|
||||
"""How many address spans in *text* would be rewritten by *mapping*."""
|
||||
|
||||
if not mapping:
|
||||
return 0
|
||||
return sum(1 for s in find_spans(text, kinds) if s.value in mapping)
|
||||
|
||||
|
||||
def apply_replacements(text: str, mapping: dict[str, str], kinds: object = None) -> tuple[str, int]:
|
||||
"""Substring-safe batch replace.
|
||||
|
||||
Only spans whose text is *exactly* a key of *mapping* are rewritten, applied
|
||||
right-to-left so earlier offsets stay valid. Returns ``(new_text, count)``.
|
||||
A no-op mapping (or no hits) returns the original text and ``0``.
|
||||
"""
|
||||
|
||||
if not mapping:
|
||||
return text, 0
|
||||
spans = [s for s in find_spans(text, kinds) if s.value in mapping]
|
||||
if not spans:
|
||||
return text, 0
|
||||
out = text
|
||||
for s in sorted(spans, key=lambda s: s.start, reverse=True):
|
||||
out = out[: s.start] + mapping[s.value] + out[s.end:]
|
||||
return out, len(spans)
|
||||
|
||||
|
||||
def infer_kind(value: str) -> str | None:
|
||||
"""Return the address kind of *value* if it is a single, whole address.
|
||||
|
||||
Used to classify a user-supplied ``from`` value (so the matching ``to`` can
|
||||
be validated against the right rules). Returns ``None`` when *value* is not
|
||||
exactly one URL or IP token.
|
||||
"""
|
||||
|
||||
if not value:
|
||||
return None
|
||||
spans = find_spans(value, VALID_KINDS)
|
||||
if len(spans) == 1 and spans[0].value == value:
|
||||
return spans[0].kind
|
||||
return None
|
||||
|
||||
|
||||
def is_valid_replacement(kind: str, value: str) -> bool:
|
||||
"""Validate that a user-supplied replacement is well-formed for its kind.
|
||||
|
||||
Keeps an admin from turning a URL into garbage (e.g. dropping the scheme) or
|
||||
an IP into a non-IP. Empty replacements are rejected.
|
||||
"""
|
||||
|
||||
if not value or value.strip() != value or any(c.isspace() for c in value):
|
||||
return False
|
||||
if kind == "url":
|
||||
return bool(re.fullmatch(r"https?://[^\s'\"`<>]+", value, re.IGNORECASE))
|
||||
if kind == "ip":
|
||||
m = _IP_RE.fullmatch(value)
|
||||
if not m:
|
||||
return False
|
||||
return _valid_ipv4(m.group(1)) and _valid_port(m.group(2))
|
||||
return False
|
||||
Reference in new issue
Block a user