mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 07:20:44 +08:00
361 lines
12 KiB
Python
361 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def _project_root() -> Path:
|
|
return Path(__file__).resolve().parents[4]
|
|
|
|
|
|
def _load_config() -> dict[str, Any]:
|
|
cfg_path = _project_root() / "oclaw.json"
|
|
if not cfg_path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _resolve_wiki_entry(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
plugins = cfg.get("plugins") if isinstance(cfg, dict) else {}
|
|
entries = plugins.get("entries") if isinstance(plugins, dict) else {}
|
|
entry = entries.get("memory-wiki") if isinstance(entries, dict) else {}
|
|
return entry if isinstance(entry, dict) else {}
|
|
|
|
|
|
def _resolve_runtime(entry: dict[str, Any]) -> tuple[Path, int, int, bool, int, bool]:
|
|
root_cfg = str(entry.get("wiki_root") or "docs/memory-system/wiki").strip()
|
|
root = Path(root_cfg)
|
|
if not root.is_absolute():
|
|
root = (_project_root() / root).resolve()
|
|
auto = entry.get("auto") if isinstance(entry.get("auto"), dict) else {}
|
|
inject = auto.get("inject") if isinstance(auto, dict) else {}
|
|
max_chars = int(inject.get("max_chars") or 1800)
|
|
top_k = int(inject.get("top_k") or 6)
|
|
ultra_saver_enabled = bool(inject.get("ultra_saver_enabled", False))
|
|
min_query_chars = int(inject.get("min_query_chars") or 20)
|
|
require_topic_hint = bool(inject.get("require_topic_hint", True))
|
|
return (
|
|
root,
|
|
max(500, min(max_chars, 8000)),
|
|
max(1, min(top_k, 20)),
|
|
ultra_saver_enabled,
|
|
max(1, min(min_query_chars, 500)),
|
|
require_topic_hint,
|
|
)
|
|
|
|
|
|
def _enabled(entry: dict[str, Any]) -> bool:
|
|
auto = entry.get("auto") if isinstance(entry.get("auto"), dict) else {}
|
|
return bool(auto.get("enabled", False))
|
|
|
|
|
|
def _default_topic_rules() -> list[dict[str, Any]]:
|
|
return [
|
|
{"topic": "network", "keywords": ["vlan", "router", "switch", "network", "dns", "gateway"]},
|
|
{"topic": "devops", "keywords": ["deploy", "k8s", "kubernetes", "docker", "ci", "ops"]},
|
|
{"topic": "engineering", "keywords": ["bug", "fix", "todo", "feature", "refactor", "test"]},
|
|
]
|
|
|
|
|
|
def _resolve_topic_rules(entry: dict[str, Any]) -> list[dict[str, Any]]:
|
|
auto = entry.get("auto") if isinstance(entry.get("auto"), dict) else {}
|
|
routing = auto.get("topic_routing") if isinstance(auto.get("topic_routing"), dict) else {}
|
|
rules = routing.get("rules")
|
|
if not isinstance(rules, list):
|
|
return _default_topic_rules()
|
|
out: list[dict[str, Any]] = []
|
|
for rule in rules:
|
|
if not isinstance(rule, dict):
|
|
continue
|
|
topic = str(rule.get("topic") or "").strip().lower()
|
|
kws = rule.get("keywords")
|
|
if not topic or not isinstance(kws, list):
|
|
continue
|
|
keywords = [str(k).strip().lower() for k in kws if str(k).strip()]
|
|
if not keywords:
|
|
continue
|
|
out.append({"topic": topic, "keywords": keywords})
|
|
return out or _default_topic_rules()
|
|
|
|
|
|
def _query_terms(query: str) -> list[str]:
|
|
return [t for t in re.split(r"\s+", query.strip().lower()) if len(t) >= 2]
|
|
|
|
|
|
def _score_line(*, query: str, terms: list[str], text: str) -> float:
|
|
low = text.lower()
|
|
if not low:
|
|
return 0.0
|
|
score = 0.0
|
|
if query and query in low:
|
|
score += 4.0
|
|
hit_terms = 0
|
|
for term in terms:
|
|
if term in low:
|
|
hit_terms += 1
|
|
score += 1.2
|
|
if hit_terms > 1:
|
|
score += 0.8
|
|
return score
|
|
|
|
|
|
def _line_snippet(lines: list[str], idx: int) -> str:
|
|
parts: list[str] = []
|
|
start = max(0, idx - 1)
|
|
end = min(len(lines), idx + 2)
|
|
for i in range(start, end):
|
|
line = str(lines[i] or "").strip()
|
|
if not line:
|
|
continue
|
|
parts.append(line)
|
|
return " | ".join(parts)
|
|
|
|
|
|
def _load_index_file_set(wiki_root: Path) -> set[str]:
|
|
idx = wiki_root / ".oclaw" / "index.json"
|
|
if not idx.exists():
|
|
return set()
|
|
try:
|
|
obj = json.loads(idx.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return set()
|
|
files = obj.get("files") if isinstance(obj, dict) else None
|
|
if not isinstance(files, list):
|
|
return set()
|
|
out: set[str] = set()
|
|
for one in files:
|
|
rel = str(one or "").replace("\\", "/").strip().lstrip("/")
|
|
if rel:
|
|
out.add(rel)
|
|
return out
|
|
|
|
|
|
def _load_topic_index(wiki_root: Path) -> dict[str, str]:
|
|
p = wiki_root / ".oclaw" / "topic-index.json"
|
|
if not p.exists():
|
|
return {}
|
|
try:
|
|
obj = json.loads(p.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
topics = obj.get("topics") if isinstance(obj, dict) else None
|
|
if not isinstance(topics, dict):
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for topic, meta in topics.items():
|
|
if not isinstance(topic, str) or not isinstance(meta, dict):
|
|
continue
|
|
rel = str(meta.get("path") or "").replace("\\", "/").strip().lstrip("/")
|
|
if rel:
|
|
out[topic.lower()] = rel
|
|
return out
|
|
|
|
|
|
def _query_topic_hints(query: str, rules: list[dict[str, Any]]) -> list[str]:
|
|
low = str(query or "").lower()
|
|
hints: list[str] = []
|
|
for rule in rules:
|
|
topic = str(rule.get("topic") or "").strip().lower()
|
|
kws = rule.get("keywords") if isinstance(rule.get("keywords"), list) else []
|
|
if not topic:
|
|
continue
|
|
if any(str(k).lower() in low for k in kws):
|
|
hints.append(topic)
|
|
return hints
|
|
|
|
|
|
def _candidate_files(wiki_root: Path) -> list[Path]:
|
|
preferred: list[Path] = []
|
|
merged = wiki_root / "inbox" / "merged-turns.md"
|
|
if merged.exists() and merged.is_file():
|
|
preferred.append(merged)
|
|
index_set = _load_index_file_set(wiki_root)
|
|
files = sorted([p for p in wiki_root.rglob("*.md") if p.is_file()])
|
|
if not files:
|
|
return preferred
|
|
if not index_set:
|
|
return preferred + [p for p in files if p not in preferred]
|
|
prioritized = []
|
|
fallback = []
|
|
for p in files:
|
|
if p in preferred:
|
|
continue
|
|
rel = str(p.relative_to(wiki_root)).replace("\\", "/")
|
|
if rel in index_set:
|
|
prioritized.append(p)
|
|
else:
|
|
fallback.append(p)
|
|
return preferred + prioritized + fallback
|
|
|
|
|
|
def _candidate_files_for_query(wiki_root: Path, query: str, topic_rules: list[dict[str, Any]]) -> list[Path]:
|
|
preferred: list[Path] = []
|
|
topic_map = _load_topic_index(wiki_root)
|
|
for hint in _query_topic_hints(query, topic_rules):
|
|
rel = topic_map.get(hint)
|
|
if not rel:
|
|
continue
|
|
p = wiki_root / rel
|
|
if p.exists() and p.is_file() and p not in preferred:
|
|
preferred.append(p)
|
|
merged = wiki_root / "inbox" / "merged-turns.md"
|
|
if merged.exists() and merged.is_file() and merged not in preferred:
|
|
preferred.append(merged)
|
|
rest = _candidate_files(wiki_root)
|
|
return preferred + [p for p in rest if p not in preferred]
|
|
|
|
|
|
def _collect_snippets(
|
|
wiki_root: Path,
|
|
query: str,
|
|
max_chars: int,
|
|
top_k: int,
|
|
topic_rules: list[dict[str, Any]] | None = None,
|
|
) -> tuple[str, list[dict[str, Any]]]:
|
|
if not wiki_root.exists():
|
|
return "", []
|
|
q = query.strip().lower()
|
|
if not q:
|
|
return "", []
|
|
terms = _query_terms(query)
|
|
if not terms and len(query) < 2:
|
|
return "", []
|
|
rules = topic_rules or _default_topic_rules()
|
|
candidates: list[tuple[float, int, str, int, str]] = []
|
|
for source_rank, fp in enumerate(_candidate_files_for_query(wiki_root, query, rules)):
|
|
rel = str(fp.relative_to(wiki_root)).replace("\\", "/")
|
|
try:
|
|
lines = fp.read_text(encoding="utf-8").splitlines()
|
|
except Exception:
|
|
continue
|
|
for idx, line in enumerate(lines, start=1):
|
|
txt = line.strip()
|
|
if not txt:
|
|
continue
|
|
score = _score_line(query=q, terms=terms, text=txt)
|
|
if score <= 0:
|
|
continue
|
|
snippet = _line_snippet(lines, idx - 1)
|
|
one = f"- {rel}:{idx} {snippet}"
|
|
candidates.append((score, int(source_rank), rel, idx, one))
|
|
if not candidates:
|
|
return "", []
|
|
candidates.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
|
blocks: list[str] = []
|
|
meta: list[dict[str, Any]] = []
|
|
total = 0
|
|
truncated = False
|
|
for score, _rank, _rel, _idx, one in candidates[: top_k * 10]:
|
|
if any(one == exist for exist in blocks):
|
|
continue
|
|
if total + len(one) + 1 > max_chars:
|
|
truncated = True
|
|
break
|
|
blocks.append(one)
|
|
meta.append({"source": _rel, "line": int(_idx), "score": round(float(score), 3)})
|
|
total += len(one) + 1
|
|
if len(blocks) >= top_k:
|
|
truncated = len(candidates) > len(meta)
|
|
break
|
|
if truncated:
|
|
for item in meta:
|
|
item["truncated"] = True
|
|
return "\n".join(blocks).strip(), meta
|
|
|
|
|
|
def handle(event: Any) -> None:
|
|
if getattr(event, "type", None) != "llm" or getattr(event, "action", None) != "before_prompt_build":
|
|
return
|
|
ctx = getattr(event, "context", None)
|
|
if not isinstance(ctx, dict):
|
|
return
|
|
memory_mode = str(ctx.get("memory_mode") or "default").strip().lower()
|
|
if memory_mode == "store_only":
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": "store_only",
|
|
"skip_reason": "memory_mode_store_only",
|
|
}
|
|
return
|
|
manager_gate = ctx.get("need_wiki_inject")
|
|
if isinstance(manager_gate, bool) and not manager_gate:
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": memory_mode,
|
|
"skip_reason": "manager_gate_off",
|
|
}
|
|
return
|
|
manager_query = str(ctx.get("wiki_query") or "").strip()
|
|
if isinstance(manager_gate, bool) and manager_gate and not manager_query:
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": memory_mode,
|
|
"skip_reason": "manager_wiki_query_missing",
|
|
}
|
|
return
|
|
cfg = _load_config()
|
|
entry = _resolve_wiki_entry(cfg)
|
|
if not _enabled(entry):
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": memory_mode,
|
|
"skip_reason": "auto_disabled",
|
|
}
|
|
return
|
|
wiki_root, max_chars, top_k, ultra_saver_enabled, min_query_chars, require_topic_hint = _resolve_runtime(entry)
|
|
topic_rules = _resolve_topic_rules(entry)
|
|
query = str(ctx.get("userText") or "").strip()
|
|
if ultra_saver_enabled and not isinstance(manager_gate, bool) and len(query) < min_query_chars:
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": memory_mode,
|
|
"ultra_saver_enabled": True,
|
|
"skip_reason": "short_query",
|
|
"min_query_chars": int(min_query_chars),
|
|
"query_len": int(len(query)),
|
|
}
|
|
return
|
|
if ultra_saver_enabled and not isinstance(manager_gate, bool) and require_topic_hint and not _query_topic_hints(query, topic_rules):
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": memory_mode,
|
|
"ultra_saver_enabled": True,
|
|
"skip_reason": "no_topic_hint",
|
|
}
|
|
return
|
|
snippets, inject_meta = _collect_snippets(
|
|
wiki_root,
|
|
query,
|
|
max_chars=max_chars,
|
|
top_k=top_k,
|
|
topic_rules=topic_rules,
|
|
)
|
|
if not snippets:
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": False,
|
|
"memory_mode": memory_mode,
|
|
"top_k": int(top_k),
|
|
"max_chars": int(max_chars),
|
|
"ultra_saver_enabled": bool(ultra_saver_enabled),
|
|
"skip_reason": "no_snippets",
|
|
}
|
|
return
|
|
ctx["wiki_inject_meta"] = {
|
|
"enabled": True,
|
|
"memory_mode": memory_mode,
|
|
"top_k": int(top_k),
|
|
"max_chars": int(max_chars),
|
|
"ultra_saver_enabled": bool(ultra_saver_enabled),
|
|
"hits": inject_meta,
|
|
}
|
|
ctx["prepend_system_context"] = (
|
|
"## Wiki Context (Auto Inject)\n"
|
|
"Use as background context only; prefer latest user request when conflicts exist.\n\n"
|
|
f"{snippets}"
|
|
)
|