loop-memory 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Per-page knowledge classification (security scope routing) and
|
|
2
|
+
default-scope derivation helpers.
|
|
3
|
+
|
|
4
|
+
These are intentionally lightweight (no third-party deps, no model
|
|
5
|
+
calls by default) so they can run on every wiki upsert without
|
|
6
|
+
blocking the request thread.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .classifier import Classification, classify_page, pattern_classify
|
|
11
|
+
from .scope import (
|
|
12
|
+
CLIENT_SCOPE_TOKENS,
|
|
13
|
+
DEFAULT_CLIENT_SCOPE,
|
|
14
|
+
VALID_SCOPE_TOKENS,
|
|
15
|
+
auto_scope_config,
|
|
16
|
+
build_scope_audit,
|
|
17
|
+
derive_default_scope,
|
|
18
|
+
derive_scope_from_sources,
|
|
19
|
+
normalise_scope,
|
|
20
|
+
parse_scope,
|
|
21
|
+
source_token,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Classification",
|
|
26
|
+
"classify_page",
|
|
27
|
+
"pattern_classify",
|
|
28
|
+
"CLIENT_SCOPE_TOKENS",
|
|
29
|
+
"DEFAULT_CLIENT_SCOPE",
|
|
30
|
+
"VALID_SCOPE_TOKENS",
|
|
31
|
+
"auto_scope_config",
|
|
32
|
+
"build_scope_audit",
|
|
33
|
+
"derive_default_scope",
|
|
34
|
+
"derive_scope_from_sources",
|
|
35
|
+
"normalise_scope",
|
|
36
|
+
"parse_scope",
|
|
37
|
+
"source_token",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
from .backfill import reclassify_legacy_pages
|
|
41
|
+
__all__.append("reclassify_legacy_pages")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""One-shot re-classify legacy wiki pages.
|
|
2
|
+
|
|
3
|
+
Existing pages created under the SCHEMA_VERSION<=7 rule "default to
|
|
4
|
+
global" kept ``scope='global'`` regardless of whether the content was
|
|
5
|
+
actually a cross-client universal security best practice. Now that the
|
|
6
|
+
auto-classifier is in place, those legacy rows are out of step with the
|
|
7
|
+
new contract ("security knowledge that's universally applicable →
|
|
8
|
+
global; everything else → per-source scope").
|
|
9
|
+
|
|
10
|
+
This module rewrites those legacy rows on demand. Pages whose content
|
|
11
|
+
fails to qualify as auto-global are downgraded to a per-source scope
|
|
12
|
+
derived from evidence memory sources (falling back to ``codex``), and
|
|
13
|
+
the original global scope + manual-override history are preserved in
|
|
14
|
+
``auto_classification.history`` so the audit trail still shows what
|
|
15
|
+
changed and why.
|
|
16
|
+
|
|
17
|
+
The backfill is deterministic and opt-in; nothing on the hot path
|
|
18
|
+
imports this module, so it never runs without an explicit CLI / admin
|
|
19
|
+
POST.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .classifier import classify_page
|
|
26
|
+
from .scope import (
|
|
27
|
+
auto_scope_config,
|
|
28
|
+
build_scope_audit,
|
|
29
|
+
derive_scope_from_sources,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reclassify_legacy_pages(store: Any, *, batch: int = 200) -> dict:
|
|
34
|
+
"""Re-classify pages whose ``auto_classification`` is NULL.
|
|
35
|
+
|
|
36
|
+
Pages with ``auto_classification`` set already went through the
|
|
37
|
+
classifier under the new contract and are left alone; only
|
|
38
|
+
legacy rows are touched. The result dict summarises what
|
|
39
|
+
changed so the caller can show a diff to the user.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
store : MemoryStore
|
|
44
|
+
Backing store. ``list_wiki_pages`` / ``upsert_wiki_page`` /
|
|
45
|
+
``get_wiki_page`` are used; no other methods are required.
|
|
46
|
+
batch : int
|
|
47
|
+
Maximum number of rows to scan. Pages are visited in updated_at
|
|
48
|
+
DESC order so the most-recently-changed legacy rows get
|
|
49
|
+
priority.
|
|
50
|
+
"""
|
|
51
|
+
cfg = auto_scope_config(store)
|
|
52
|
+
pages = store.list_wiki_pages(limit=batch)
|
|
53
|
+
legacy = [
|
|
54
|
+
p for p in pages
|
|
55
|
+
if (p.get("scope") or "global") == "global"
|
|
56
|
+
and p.get("auto_classification") is None
|
|
57
|
+
]
|
|
58
|
+
summary = {
|
|
59
|
+
"scanned": len(pages),
|
|
60
|
+
"legacy_global": len(legacy),
|
|
61
|
+
"kept_global": 0,
|
|
62
|
+
"downgraded": 0,
|
|
63
|
+
"skipped": 0,
|
|
64
|
+
"items": [],
|
|
65
|
+
}
|
|
66
|
+
for page in legacy:
|
|
67
|
+
classification = classify_page(
|
|
68
|
+
title=page.get("title") or "",
|
|
69
|
+
body=page.get("body") or "",
|
|
70
|
+
summary=page.get("summary") or "",
|
|
71
|
+
tags=page.get("tags") or [],
|
|
72
|
+
evidence_sources=[],
|
|
73
|
+
mode=cfg["mode"] if cfg["enabled"] else "off",
|
|
74
|
+
)
|
|
75
|
+
if classification.auto_global:
|
|
76
|
+
summary["kept_global"] += 1
|
|
77
|
+
summary["items"].append({
|
|
78
|
+
"id": page["id"],
|
|
79
|
+
"slug": page["slug"],
|
|
80
|
+
"title": page.get("title"),
|
|
81
|
+
"decision": "kept-global",
|
|
82
|
+
"scope": "global",
|
|
83
|
+
"reasons": classification.reasons[:4],
|
|
84
|
+
})
|
|
85
|
+
continue
|
|
86
|
+
# Derive the per-source scope from any evidence memories.
|
|
87
|
+
evidence_ids = page.get("evidence_ids") or []
|
|
88
|
+
new_scope = derive_scope_from_sources(
|
|
89
|
+
source_hint=None,
|
|
90
|
+
evidence_sources=[],
|
|
91
|
+
fallback="codex",
|
|
92
|
+
)
|
|
93
|
+
# Build the audit so future calls keep history.
|
|
94
|
+
audit = build_scope_audit(
|
|
95
|
+
classification,
|
|
96
|
+
scope=new_scope,
|
|
97
|
+
decision="legacy-backfill",
|
|
98
|
+
enabled=bool(cfg["enabled"]),
|
|
99
|
+
mode=str(cfg["mode"]),
|
|
100
|
+
existing={"auto_classification": None},
|
|
101
|
+
)
|
|
102
|
+
# Force a history anchor on the original global scope so the
|
|
103
|
+
# audit captures the change.
|
|
104
|
+
audit.setdefault("history", []).append({
|
|
105
|
+
"scope_decision": "legacy-explicit",
|
|
106
|
+
"scope_applied": "global",
|
|
107
|
+
"at_backfill": True,
|
|
108
|
+
})
|
|
109
|
+
try:
|
|
110
|
+
store.upsert_wiki_page(
|
|
111
|
+
slug=page["slug"],
|
|
112
|
+
title=page.get("title") or "",
|
|
113
|
+
body=page.get("body") or "",
|
|
114
|
+
summary=page.get("summary") or "",
|
|
115
|
+
tags=page.get("tags") or [],
|
|
116
|
+
importance=page.get("importance") or 0.5,
|
|
117
|
+
evidence_ids=evidence_ids,
|
|
118
|
+
run_id=page.get("run_id"),
|
|
119
|
+
scope=new_scope,
|
|
120
|
+
auto_classification=audit,
|
|
121
|
+
)
|
|
122
|
+
summary["downgraded"] += 1
|
|
123
|
+
summary["items"].append({
|
|
124
|
+
"id": page["id"],
|
|
125
|
+
"slug": page["slug"],
|
|
126
|
+
"title": page.get("title"),
|
|
127
|
+
"decision": "legacy-backfill",
|
|
128
|
+
"scope": new_scope,
|
|
129
|
+
"reasons": classification.reasons[:4],
|
|
130
|
+
})
|
|
131
|
+
except Exception as e:
|
|
132
|
+
summary["skipped"] += 1
|
|
133
|
+
summary["items"].append({
|
|
134
|
+
"id": page["id"],
|
|
135
|
+
"slug": page["slug"],
|
|
136
|
+
"title": page.get("title"),
|
|
137
|
+
"decision": "skipped",
|
|
138
|
+
"error": str(e),
|
|
139
|
+
})
|
|
140
|
+
return summary
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ["reclassify_legacy_pages"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Cheap, explainable classification for distilled wiki knowledge.
|
|
2
|
+
|
|
3
|
+
The classifier deliberately runs without an LLM. It is used on write paths,
|
|
4
|
+
where a network call would make the dashboard fragile and would turn a local
|
|
5
|
+
privacy decision into an external data disclosure. The result is a
|
|
6
|
+
recommendation, never an instruction to override an explicit user scope.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
import re
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Classification:
|
|
17
|
+
"""Explainable classification and scope recommendation for one page."""
|
|
18
|
+
|
|
19
|
+
kind: str
|
|
20
|
+
is_security: bool
|
|
21
|
+
is_universal: bool
|
|
22
|
+
auto_global: bool
|
|
23
|
+
confidence: float
|
|
24
|
+
reasons: list[str] = field(default_factory=list)
|
|
25
|
+
scope_hint: str = "per-source"
|
|
26
|
+
security_hits: int = 0
|
|
27
|
+
universal_hits: int = 0
|
|
28
|
+
personalization_hits: int = 0
|
|
29
|
+
incident_hits: int = 0
|
|
30
|
+
mode: str = "pattern"
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
"""Return a JSON-serialisable audit representation."""
|
|
34
|
+
return asdict(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_SECURITY_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
38
|
+
("security-domain", r"安全|\bsecurity\b|secure coding"),
|
|
39
|
+
("credentials", r"凭证|\bcredentials?\b|用户名和密码|账号密码"),
|
|
40
|
+
("secrets", r"密钥|机密|秘密|\bsecrets?\b|敏感信息|敏感数据"),
|
|
41
|
+
("passwords", r"密码|口令|\bpasswords?\b|\bpasswd\b|\bpassphrase\b"),
|
|
42
|
+
("tokens", r"令牌|访问令牌|\btokens?\b|\bbearer\b"),
|
|
43
|
+
("api-keys", r"api[ _-]?key|access[ _-]?key|api密钥"),
|
|
44
|
+
("private-keys", r"private[ _-]?key|私钥|ssh key|ssh密钥"),
|
|
45
|
+
("injection", r"sql[ _-]?injection|sql注入|命令注入|注入攻击|参数化\s*(?:查询|sql)|(?:parameter|parametr)i[sz]ed? (?:sql|quer(?:y|ies))"),
|
|
46
|
+
("web-vulnerabilities",
|
|
47
|
+
r"\bxss\b|跨站脚本|\bcsrf\b|跨站请求伪造|\bssrf\b|\brce\b|远程代码执行|remote code execution"),
|
|
48
|
+
("vulnerabilities", r"漏洞|vulnerability|security flaw|安全缺陷|安全漏洞"),
|
|
49
|
+
("cve", r"\bcve[- ]?\d{4}[- ]?\d{3,}\b"),
|
|
50
|
+
("authentication", r"鉴权|认证|身份验证|\bauthentication\b|\bauthorization\b|授权|访问控制|access control"),
|
|
51
|
+
("encryption", r"加密|解密|\bencryption\b|decrypt(?:ion)?|\btls\b|\bhttps\b"),
|
|
52
|
+
("integrity", r"哈希|散列|\b(?:sha[-_ ]?1|sha[-_ ]?256|sha[-_ ]?512|md5|hmac|bcrypt|argon2|pbkdf2|scrypt)\b|签名校验|完整性校验|\bintegrity\b"),
|
|
53
|
+
("privacy", r"隐私|个人信息|\bprivacy\b|personal data|\bpii\b|\bgdpr\b"),
|
|
54
|
+
("compliance", r"合规|\bcompliance\b|监管要求|数据保护"),
|
|
55
|
+
("incidents", r"泄露|外泄|泄密|leak(?:ed)?|breach|被盗|入侵|phishing|钓鱼|恶意软件|malware"),
|
|
56
|
+
("least-privilege", r"最小权限|least privilege|principle of least privilege"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
_UNIVERSAL_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
60
|
+
("must", r"必须|务必|务须|\bmust\b|\bshall\b"),
|
|
61
|
+
("never", r"切勿|请勿|绝不要|永远不要|禁止|不要|\bnever\b|do not|don't"),
|
|
62
|
+
("always", r"始终|总是|\balways\b"),
|
|
63
|
+
("recommendation", r"推荐|建议|推荐做法|\brecommended\b|\brecommend\b|\bshould\b"),
|
|
64
|
+
("best-practice", r"最佳实践|安全实践|best[ _-]practice|secure practice"),
|
|
65
|
+
("universal", r"通用|普适|一般情况下|无论|不论|所有客户端|各客户端|全局|\bglobal\b|universal|generic|regardless|all clients|every client|any user"),
|
|
66
|
+
("rotation-practice", r"定期轮换|定期更换|rotate(?:d|s)?\s+(?:api[ _-]?keys?|credentials?|secrets?)|quarterly rotation|key rotation"),
|
|
67
|
+
("parameterized-query", r"参数化\s*(?:查询|sql)|(?:parameter|parametr)i[sz]ed(?:\s+sql)?(?:\s+quer(?:y|ies))?|prepared statements?"),
|
|
68
|
+
("safe-handling", r"不要粘贴|切勿粘贴|不要提交.*(?:密钥|secret|token)|never paste|do not commit|never commit"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_PERSONAL_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
72
|
+
("first-person", r"\b(?:i|me|my|mine|we|our|私の|我|我的|本人)\b"),
|
|
73
|
+
("family-detail", r"妻子|老婆|丈夫|老公|家人|孩子|生日|wife|husband|birthday|family"),
|
|
74
|
+
("specific-date", r"\b(?:19|20)\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\b"),
|
|
75
|
+
("issue-or-instance", r"(?:bug|issue|ticket|工单|事故|incident)\s*#?\d{2,}|仅本项目|项目内部|内部项目|this repo|this project|internal only"),
|
|
76
|
+
("local-path", r"(?:/Users/|/home/|~/|c:\\|d:\\)[^\s]+"),
|
|
77
|
+
("secret-value", r"(?:api[ _-]?key|access[ _-]?key|token|password|secret|密钥|令牌|密码|口令)\s*(?:is|=|:|:)\s*[A-Za-z0-9_\-/.]{8,}"),
|
|
78
|
+
("known-secret-format", r"\b(?:sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{16,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{20,})\b"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _as_text(value: object) -> str:
|
|
83
|
+
if value is None:
|
|
84
|
+
return ""
|
|
85
|
+
if isinstance(value, (list, tuple, set)):
|
|
86
|
+
return " ".join(str(item) for item in value if item is not None)
|
|
87
|
+
return str(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _hits(text: str, patterns: Iterable[tuple[str, str]]) -> list[str]:
|
|
91
|
+
return [label for label, pattern in patterns if re.search(pattern, text, re.IGNORECASE)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _confidence(
|
|
95
|
+
security_hits: int,
|
|
96
|
+
universal_hits: int,
|
|
97
|
+
personalization_hits: int,
|
|
98
|
+
incident_hits: int,
|
|
99
|
+
auto_global: bool,
|
|
100
|
+
) -> float:
|
|
101
|
+
value = 0.38
|
|
102
|
+
value += min(security_hits, 5) * 0.09
|
|
103
|
+
value += min(universal_hits, 4) * 0.08
|
|
104
|
+
value += min(incident_hits, 2) * 0.03
|
|
105
|
+
value -= min(personalization_hits, 3) * 0.16
|
|
106
|
+
if auto_global:
|
|
107
|
+
value += 0.08
|
|
108
|
+
return round(max(0.05, min(0.99, value)), 3)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def pattern_classify(
|
|
112
|
+
title: str = "",
|
|
113
|
+
body: str = "",
|
|
114
|
+
summary: str = "",
|
|
115
|
+
tags: Iterable[object] | None = None,
|
|
116
|
+
evidence_sources: Iterable[object] | None = None,
|
|
117
|
+
*,
|
|
118
|
+
mode: str = "pattern",
|
|
119
|
+
) -> Classification:
|
|
120
|
+
"""Classify a page using deterministic multilingual patterns.
|
|
121
|
+
|
|
122
|
+
``evidence_sources`` is used only as an explanatory signal. A page is
|
|
123
|
+
never promoted solely because it appeared in several clients; repeated
|
|
124
|
+
personal information must remain client-scoped.
|
|
125
|
+
"""
|
|
126
|
+
selected_mode = str(mode or "pattern").strip().lower()
|
|
127
|
+
if selected_mode not in {"pattern", "llm", "off"}:
|
|
128
|
+
selected_mode = "pattern"
|
|
129
|
+
text = " ".join(
|
|
130
|
+
part for part in (
|
|
131
|
+
_as_text(title),
|
|
132
|
+
_as_text(summary),
|
|
133
|
+
_as_text(body),
|
|
134
|
+
_as_text(tags),
|
|
135
|
+
) if part
|
|
136
|
+
)
|
|
137
|
+
security_labels = _hits(text, _SECURITY_PATTERNS)
|
|
138
|
+
universal_labels = _hits(text, _UNIVERSAL_PATTERNS)
|
|
139
|
+
personal_labels = _hits(text, _PERSONAL_PATTERNS)
|
|
140
|
+
incident_labels = [label for label in security_labels if label in {"incidents", "cve", "vulnerabilities"}]
|
|
141
|
+
|
|
142
|
+
security_hits = len(security_labels)
|
|
143
|
+
universal_hits = len(universal_labels)
|
|
144
|
+
personalization_hits = len(personal_labels)
|
|
145
|
+
incident_hits = len(incident_labels)
|
|
146
|
+
is_security = security_hits > 0
|
|
147
|
+
personal_block = personalization_hits >= 2 or "secret-value" in personal_labels or "known-secret-format" in personal_labels or (
|
|
148
|
+
is_security and personalization_hits >= 1 and "first-person" in personal_labels
|
|
149
|
+
)
|
|
150
|
+
is_universal = universal_hits > 0 and not personal_block
|
|
151
|
+
|
|
152
|
+
if selected_mode == "off":
|
|
153
|
+
auto_global = False
|
|
154
|
+
else:
|
|
155
|
+
strong_practice = any(
|
|
156
|
+
label in universal_labels
|
|
157
|
+
for label in ("best-practice", "rotation-practice", "parameterized-query", "safe-handling")
|
|
158
|
+
)
|
|
159
|
+
auto_global = bool(
|
|
160
|
+
is_security
|
|
161
|
+
and not personal_block
|
|
162
|
+
and (
|
|
163
|
+
(security_hits >= 2 and universal_hits >= 1)
|
|
164
|
+
or (security_hits >= 1 and universal_hits >= 2)
|
|
165
|
+
or (security_hits >= 1 and strong_practice)
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if personal_block:
|
|
170
|
+
kind = "personal"
|
|
171
|
+
elif is_security and incident_hits and not auto_global:
|
|
172
|
+
kind = "security-incident"
|
|
173
|
+
elif is_security:
|
|
174
|
+
kind = "security-rule"
|
|
175
|
+
elif is_universal:
|
|
176
|
+
kind = "best-practice"
|
|
177
|
+
else:
|
|
178
|
+
kind = "general"
|
|
179
|
+
|
|
180
|
+
reasons: list[str] = []
|
|
181
|
+
reasons.extend(f"security:{label}" for label in security_labels)
|
|
182
|
+
reasons.extend(f"universal:{label}" for label in universal_labels)
|
|
183
|
+
reasons.extend(f"personalization:{label}" for label in personal_labels)
|
|
184
|
+
if evidence_sources:
|
|
185
|
+
known_sources = sorted({str(source).strip().lower() for source in evidence_sources if str(source).strip()})
|
|
186
|
+
if len(known_sources) > 1:
|
|
187
|
+
reasons.append(f"evidence-sources:{','.join(known_sources[:6])}")
|
|
188
|
+
if personal_block:
|
|
189
|
+
reasons.append("personalization-blocks-global")
|
|
190
|
+
if auto_global:
|
|
191
|
+
reasons.append("security-best-practice-is-cross-client")
|
|
192
|
+
elif is_security:
|
|
193
|
+
reasons.append("security-content-needs-source-scope-unless-universal")
|
|
194
|
+
if selected_mode == "llm":
|
|
195
|
+
reasons.append("llm-mode-not-enabled-in-pattern-classifier")
|
|
196
|
+
|
|
197
|
+
return Classification(
|
|
198
|
+
kind=kind,
|
|
199
|
+
is_security=is_security,
|
|
200
|
+
is_universal=is_universal,
|
|
201
|
+
auto_global=auto_global,
|
|
202
|
+
confidence=_confidence(
|
|
203
|
+
security_hits,
|
|
204
|
+
universal_hits,
|
|
205
|
+
personalization_hits,
|
|
206
|
+
incident_hits,
|
|
207
|
+
auto_global,
|
|
208
|
+
),
|
|
209
|
+
reasons=reasons,
|
|
210
|
+
scope_hint="global" if auto_global else "per-source",
|
|
211
|
+
security_hits=security_hits,
|
|
212
|
+
universal_hits=universal_hits,
|
|
213
|
+
personalization_hits=personalization_hits,
|
|
214
|
+
incident_hits=incident_hits,
|
|
215
|
+
mode=selected_mode,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def classify_page(
|
|
220
|
+
title: str = "",
|
|
221
|
+
body: str = "",
|
|
222
|
+
summary: str = "",
|
|
223
|
+
tags: Iterable[object] | None = None,
|
|
224
|
+
evidence_sources: Iterable[object] | None = None,
|
|
225
|
+
mode: str = "pattern",
|
|
226
|
+
) -> Classification:
|
|
227
|
+
"""Public classifier entry point used by API and consolidation jobs."""
|
|
228
|
+
return pattern_classify(
|
|
229
|
+
title=title,
|
|
230
|
+
body=body,
|
|
231
|
+
summary=summary,
|
|
232
|
+
tags=tags,
|
|
233
|
+
evidence_sources=evidence_sources,
|
|
234
|
+
mode=mode,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
__all__ = ["Classification", "classify_page", "pattern_classify"]
|