foldkeep 0.9.2__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.
- foldkeep/__init__.py +16 -0
- foldkeep/__main__.py +3 -0
- foldkeep/cli.py +246 -0
- foldkeep/core/__init__.py +0 -0
- foldkeep/core/compressor.py +346 -0
- foldkeep/diff.py +105 -0
- foldkeep/estimate.py +28 -0
- foldkeep/mcp_server.py +189 -0
- foldkeep/memory/__init__.py +0 -0
- foldkeep/memory/store.py +92 -0
- foldkeep/packing.py +63 -0
- foldkeep/selftest.py +154 -0
- foldkeep/session.py +423 -0
- foldkeep-0.9.2.dist-info/METADATA +117 -0
- foldkeep-0.9.2.dist-info/RECORD +18 -0
- foldkeep-0.9.2.dist-info/WHEEL +5 -0
- foldkeep-0.9.2.dist-info/licenses/LICENSE +21 -0
- foldkeep-0.9.2.dist-info/top_level.txt +1 -0
foldkeep/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""FoldKeep -- Agent Context Infrastructure.
|
|
2
|
+
|
|
3
|
+
Layers:
|
|
4
|
+
compressor : FastLingua engine (entity-preserving heuristic compression)
|
|
5
|
+
estimate : token & cost estimation across popular models
|
|
6
|
+
store : named compressed-context snapshots for agent workflows
|
|
7
|
+
|
|
8
|
+
Layers: core.compressor | estimate | store(SQLite memory bank) | packing | mcp_server.
|
|
9
|
+
"""
|
|
10
|
+
__version__ = "0.6.0"
|
|
11
|
+
|
|
12
|
+
from .core.compressor import FastLingua # noqa: F401
|
|
13
|
+
from .benchmark.suite import run_bench, evolve # noqa: F401
|
|
14
|
+
from .estimate import estimate_tokens, estimate_cost, savings # noqa: F401
|
|
15
|
+
from .memory import store # noqa: F401
|
|
16
|
+
from .packing import pack, render # noqa: F401
|
foldkeep/__main__.py
ADDED
foldkeep/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""FoldKeep unified CLI: oc compress / estimate / store / bench / evolve."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .core.compressor import FastLingua, GENES
|
|
9
|
+
from .benchmark.suite import run_bench, evolve, EVOLVE_FILE
|
|
10
|
+
from .memory import store
|
|
11
|
+
from .estimate import estimate_cost, savings
|
|
12
|
+
from .packing import pack, render
|
|
13
|
+
from .diff import commit as ctx_commit, log as ctx_log, show as ctx_show
|
|
14
|
+
from . import session as sess
|
|
15
|
+
|
|
16
|
+
def _read(src):
|
|
17
|
+
if src == "-":
|
|
18
|
+
raw = sys.stdin.buffer.read()
|
|
19
|
+
try:
|
|
20
|
+
return raw.decode("utf-8")
|
|
21
|
+
except UnicodeDecodeError:
|
|
22
|
+
return raw.decode("gbk", "replace")
|
|
23
|
+
with open(src, encoding="utf-8-sig") as f:
|
|
24
|
+
return f.read().lstrip("\ufeff")
|
|
25
|
+
|
|
26
|
+
def _adapt(fl, stats, target):
|
|
27
|
+
try:
|
|
28
|
+
cur = {}
|
|
29
|
+
if os.path.exists(EVOLVE_FILE):
|
|
30
|
+
with open(EVOLVE_FILE, encoding="utf-8") as f:
|
|
31
|
+
cur = json.load(f)
|
|
32
|
+
err = stats["ratio"] - target
|
|
33
|
+
if abs(err) > 0.05:
|
|
34
|
+
lo, hi = GENES["dup_th"]
|
|
35
|
+
d = float(cur.get("dup_th", 0.72)) - (0.01 if err > 0 else -0.008)
|
|
36
|
+
cur["dup_th"] = round(min(hi, max(lo, d)), 4)
|
|
37
|
+
lo, hi = GENES["ent_w"]
|
|
38
|
+
d = float(cur.get("ent_w", 3.0)) - (0.08 if err > 0 else -0.10)
|
|
39
|
+
cur["ent_w"] = round(min(hi, max(lo, d)), 4)
|
|
40
|
+
lo, hi = GENES["cont_w"]
|
|
41
|
+
d = float(cur.get("cont_w", 1.0)) - (0.05 if err > 0 else -0.04)
|
|
42
|
+
cur["cont_w"] = round(min(hi, max(lo, d)), 4)
|
|
43
|
+
with open(EVOLVE_FILE, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(cur, f, indent=1)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def main(argv=None):
|
|
49
|
+
ap = argparse.ArgumentParser(
|
|
50
|
+
prog="foldkeep",
|
|
51
|
+
description="FoldKeep: Agent Context Infrastructure (compress | estimate | store | bench | evolve)")
|
|
52
|
+
ap.add_argument("--version", action="version", version="foldkeep " + __version__)
|
|
53
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
54
|
+
|
|
55
|
+
c = sub.add_parser("compress", help="compress text via FastLingua engine")
|
|
56
|
+
c.add_argument("input", help="file path or '-' for stdin")
|
|
57
|
+
c.add_argument("--ratio", type=float, default=0.5)
|
|
58
|
+
c.add_argument("--mode", choices=FastLingua.MODES, default="balanced")
|
|
59
|
+
c.add_argument("--anchors", action="store_true")
|
|
60
|
+
c.add_argument("--collapse", action="store_true")
|
|
61
|
+
c.add_argument("--adapt", action="store_true")
|
|
62
|
+
c.add_argument("--no-auto", action="store_true")
|
|
63
|
+
c.add_argument("--save", metavar="NAME", help="also store snapshot for later reuse")
|
|
64
|
+
c.add_argument("-o", "--output")
|
|
65
|
+
|
|
66
|
+
e = sub.add_parser("estimate", help="token & cost estimate")
|
|
67
|
+
e.add_argument("input")
|
|
68
|
+
e.add_argument("--model", default="claude-sonnet")
|
|
69
|
+
e.add_argument("--compress", action="store_true", help="also show post-compression savings")
|
|
70
|
+
|
|
71
|
+
st = sub.add_parser("store", help="snapshot management")
|
|
72
|
+
st.add_argument("op", choices=["list", "get", "delete"])
|
|
73
|
+
st.add_argument("name", nargs="?")
|
|
74
|
+
|
|
75
|
+
b = sub.add_parser("bench", help="run benchmark")
|
|
76
|
+
b.add_argument("--full", action="store_true", help="1000-sample synthetic suite")
|
|
77
|
+
|
|
78
|
+
pk = sub.add_parser("pack", help="fit multiple files into a token budget")
|
|
79
|
+
pk.add_argument("inputs", nargs="+")
|
|
80
|
+
pk.add_argument("--budget", type=int, default=8000)
|
|
81
|
+
pk.add_argument("--ratio", type=float, default=0.5)
|
|
82
|
+
pk.add_argument("--model", default=None, help="also show cost for this model")
|
|
83
|
+
pk.add_argument("--save", metavar="NAME")
|
|
84
|
+
pk.add_argument("-o", "--output")
|
|
85
|
+
sub.add_parser("selftest", help="run the 16-case edge battery")
|
|
86
|
+
|
|
87
|
+
ss = sub.add_parser("session", help="long-conversation rolling compression")
|
|
88
|
+
ss.add_argument("op", choices=["new", "push", "show", "stat"])
|
|
89
|
+
ss.add_argument("name")
|
|
90
|
+
ss.add_argument("-r", "--role", default="user", choices=["user", "assistant", "system"])
|
|
91
|
+
ss.add_argument("-t", "--text")
|
|
92
|
+
ss.add_argument("-f", "--file")
|
|
93
|
+
ss.add_argument("--budget", type=int, default=None)
|
|
94
|
+
ss.add_argument("--roll-threshold", type=int, default=None,
|
|
95
|
+
help="override auto-rollup token threshold")
|
|
96
|
+
cx = sub.add_parser("ctx", help="context version chain: commit/log/show/restore")
|
|
97
|
+
cx.add_argument("op", choices=["commit", "log", "show", "restore"])
|
|
98
|
+
cx.add_argument("target", nargs="?", help="file path (commit) or version id")
|
|
99
|
+
cx.add_argument("-p", "--project", default="default")
|
|
100
|
+
cx.add_argument("-l", "--label", default="")
|
|
101
|
+
cx.add_argument("-o", "--output")
|
|
102
|
+
ev = sub.add_parser("evolve", help="self-evolve compressor genes")
|
|
103
|
+
ev.add_argument("--gens", type=int, default=8)
|
|
104
|
+
ev.add_argument("--pop", type=int, default=8)
|
|
105
|
+
ev.add_argument("--seed", type=int, default=42)
|
|
106
|
+
|
|
107
|
+
a = ap.parse_args(argv)
|
|
108
|
+
|
|
109
|
+
if a.cmd == "compress":
|
|
110
|
+
text = _read(a.input)
|
|
111
|
+
fl = FastLingua(a.mode, a.ratio, a.anchors,
|
|
112
|
+
auto=not a.no_auto, collapse=a.collapse)
|
|
113
|
+
comp, stats = fl.compress(text)
|
|
114
|
+
if a.adapt:
|
|
115
|
+
_adapt(fl, stats, a.ratio)
|
|
116
|
+
out = comp # engine already renders [F#] anchors when enabled
|
|
117
|
+
if a.output:
|
|
118
|
+
with open(a.output, "w", encoding="utf-8") as f:
|
|
119
|
+
f.write(out)
|
|
120
|
+
else:
|
|
121
|
+
print(out)
|
|
122
|
+
if a.save:
|
|
123
|
+
p = store.save(a.save, text, comp,
|
|
124
|
+
{"ratio": stats["ratio"], "mode": a.mode})
|
|
125
|
+
print("[stored] %s" % p, file=sys.stderr)
|
|
126
|
+
|
|
127
|
+
elif a.cmd == "estimate":
|
|
128
|
+
text = _read(a.input)
|
|
129
|
+
info = estimate_cost(text, a.model)
|
|
130
|
+
print("input : %6d tokens | $%.6f (%s)" % (info["tokens"], info["usd"], a.model))
|
|
131
|
+
if a.compress:
|
|
132
|
+
comp, _st = FastLingua("balanced", 0.5).compress(text)
|
|
133
|
+
after = estimate_cost(comp, a.model)
|
|
134
|
+
sv = savings(text, comp, a.model)
|
|
135
|
+
print("after : %6d tokens | $%.6f" % (after["tokens"], after["usd"]))
|
|
136
|
+
print("saved : %6d tokens | $%.6f | compressed to %.1f%%"
|
|
137
|
+
% (sv["tokens_saved"], sv["usd_saved"], sv["ratio"] * 100))
|
|
138
|
+
|
|
139
|
+
elif a.cmd == "store":
|
|
140
|
+
if a.op == "list":
|
|
141
|
+
rows = store.list_all()
|
|
142
|
+
if not rows:
|
|
143
|
+
print("(empty)")
|
|
144
|
+
for r in rows:
|
|
145
|
+
print("%-24s %7d -> %7d chars" % (r["name"], r["orig"], r["comp"]))
|
|
146
|
+
elif a.op == "get":
|
|
147
|
+
r = store.get(a.name or "")
|
|
148
|
+
print(r["text"] if r else "(not found)")
|
|
149
|
+
else:
|
|
150
|
+
print("deleted" if store.delete(a.name or "") else "(not found)")
|
|
151
|
+
|
|
152
|
+
elif a.cmd == "bench":
|
|
153
|
+
run_bench(getattr(a, "full", False))
|
|
154
|
+
|
|
155
|
+
elif a.cmd == "pack":
|
|
156
|
+
items = []
|
|
157
|
+
for src in a.inputs:
|
|
158
|
+
try:
|
|
159
|
+
items.append((os.path.basename(src), _read(src)))
|
|
160
|
+
except Exception as ex:
|
|
161
|
+
print("[skip] %s: %s" % (src, ex), file=sys.stderr)
|
|
162
|
+
if not items:
|
|
163
|
+
print("(no readable inputs)", file=sys.stderr)
|
|
164
|
+
return
|
|
165
|
+
res = pack(items, a.budget, a.ratio)
|
|
166
|
+
out = render(res)
|
|
167
|
+
if a.model:
|
|
168
|
+
from .estimate import estimate_cost
|
|
169
|
+
info = estimate_cost(out, a.model)
|
|
170
|
+
out += "\n=== cost: %d tokens | $%.6f (%s) ===" % (
|
|
171
|
+
info["tokens"], info["usd"], a.model)
|
|
172
|
+
if a.output:
|
|
173
|
+
with open(a.output, "w", encoding="utf-8") as f:
|
|
174
|
+
f.write(out)
|
|
175
|
+
else:
|
|
176
|
+
print(out)
|
|
177
|
+
if a.save:
|
|
178
|
+
pth = store.save(a.save, "".join(t for _, t in items), out,
|
|
179
|
+
{"budget": a.budget, "used": res["used"]})
|
|
180
|
+
print("[stored] %s" % pth, file=sys.stderr)
|
|
181
|
+
|
|
182
|
+
elif a.cmd == "selftest":
|
|
183
|
+
from .selftest import run
|
|
184
|
+
R, fails = run()
|
|
185
|
+
print("---")
|
|
186
|
+
print("TOTAL %d | PASS %d | FAIL %d" % (len(R), len(R) - len(fails), len(fails)))
|
|
187
|
+
if fails:
|
|
188
|
+
raise SystemExit(1)
|
|
189
|
+
|
|
190
|
+
elif a.cmd == "session":
|
|
191
|
+
if a.op == "new":
|
|
192
|
+
sess.new(a.name)
|
|
193
|
+
print("session '%s' ready" % a.name)
|
|
194
|
+
elif a.op == "push":
|
|
195
|
+
text = a.text if a.text is not None else (_read(a.file) if a.file else None)
|
|
196
|
+
if not text:
|
|
197
|
+
print("need -t TEXT or -f FILE", file=sys.stderr)
|
|
198
|
+
return
|
|
199
|
+
kw = {} if a.roll_threshold is None else {"roll_threshold": a.roll_threshold}
|
|
200
|
+
r = sess.push(a.name, a.role, text, **kw)
|
|
201
|
+
st = sess.stat(a.name)
|
|
202
|
+
print("pushed #%d (%s) | rolled %d | session: %d turns, ~%d tok"
|
|
203
|
+
% (r["seq"], a.role, r["rolled"], st["turns"], st["est_tokens"]))
|
|
204
|
+
elif a.op == "show":
|
|
205
|
+
out, used, dropped = sess.render(a.name, a.budget)
|
|
206
|
+
print(out)
|
|
207
|
+
else:
|
|
208
|
+
print(sess.stat(a.name))
|
|
209
|
+
|
|
210
|
+
elif a.cmd == "ctx":
|
|
211
|
+
if a.op == "commit":
|
|
212
|
+
if not a.target:
|
|
213
|
+
print("need file path", file=sys.stderr)
|
|
214
|
+
return
|
|
215
|
+
text = _read(a.target)
|
|
216
|
+
r = ctx_commit(a.project, text, a.label)
|
|
217
|
+
print("committed v%d [%s] %d -> %d chars (%.1f%%), stored %s"
|
|
218
|
+
% (r["id"], a.project, r["orig"], r["comp"],
|
|
219
|
+
r["ratio"] * 100, r["stored"]))
|
|
220
|
+
elif a.op == "log":
|
|
221
|
+
rows = ctx_log(a.project)
|
|
222
|
+
if not rows:
|
|
223
|
+
print("(empty)")
|
|
224
|
+
for r in rows:
|
|
225
|
+
print("v%-4d %-16s parent=%-4s %6d->%6d %s"
|
|
226
|
+
% (r["id"], r["label"] or "-", r["parent"] or "-",
|
|
227
|
+
r["orig"], r["comp"], "FULL" if r["full"] else "delta"))
|
|
228
|
+
else:
|
|
229
|
+
if not a.target or not a.target.isdigit():
|
|
230
|
+
print("need version id", file=sys.stderr)
|
|
231
|
+
return
|
|
232
|
+
text = ctx_show(int(a.target))
|
|
233
|
+
if text is None:
|
|
234
|
+
print("(version not found)", file=sys.stderr)
|
|
235
|
+
return
|
|
236
|
+
if a.output:
|
|
237
|
+
with open(a.output, "w", encoding="utf-8") as f:
|
|
238
|
+
f.write(text)
|
|
239
|
+
else:
|
|
240
|
+
print(text)
|
|
241
|
+
|
|
242
|
+
elif a.cmd == "evolve":
|
|
243
|
+
evolve(a.gens, a.pop, a.seed)
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""FastLingua v2 - model-free context compressor.
|
|
3
|
+
Guarantees: entity retention (hard rules), determinism, no model needed."""
|
|
4
|
+
import json
|
|
5
|
+
import argparse, json, os, random, re, sys, time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
ENTITY_PATTERNS = [
|
|
9
|
+
re.compile(r'https?://[A-Za-z0-9\-._~:/?#@!$&*+,;=%\[\]]+|www\.[A-Za-z0-9\-._~/%]+', re.I),
|
|
10
|
+
re.compile(r'\b[\w.+-]+@[\w-]+\.[\w.]+\b'),
|
|
11
|
+
re.compile(r'[A-Za-z]:\\[^\s,。;:!?、)(]+'),
|
|
12
|
+
re.compile(r'(?:/[\w.\-]+){2,}'),
|
|
13
|
+
re.compile(r'\b[\w\-]+(?:[/\\][\w\-]+)+\.(?:py|js|ts|json|md|txt|html|css|java|c|cpp|go|rs|sql|yaml|yml|bat|sh)\b', re.I),
|
|
14
|
+
re.compile(r'(?<![\w.\-])[\w\-]+\.(?:py|js|ts|json|md|txt|log|db|exe|bat|sh)(?![\w\-])', re.I),
|
|
15
|
+
re.compile(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?'),
|
|
16
|
+
re.compile(r'\b\d{1,2}:\d{2}(?::\d{2})?\b'),
|
|
17
|
+
re.compile(r'\b[vV]?\d+(?:\.\d+)+[a-z]?\b'),
|
|
18
|
+
re.compile(r'\d+(?:\.\d+)?\s*(?:%|px|ms|[kKmMgGtT]?B|kb|mb|gb|tb|hz|万|亿|元|秒|毫秒|分钟|小时|天|周|月|年|个|人|次|倍)', re.I),
|
|
19
|
+
re.compile(r'(?<![A-Za-z0-9_])[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+\(\)'),
|
|
20
|
+
re.compile(r'(?<![A-Za-z0-9_])[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*){1,5}(?![A-Za-z0-9_])'),
|
|
21
|
+
re.compile(r'(?<![A-Za-z0-9_])[a-z_][a-z0-9]*(?:_[a-z0-9]+)+'),
|
|
22
|
+
re.compile(r'(?<![A-Za-z0-9_])[a-z]+(?:[A-Z][a-z0-9]+)+'),
|
|
23
|
+
re.compile(r'(?<![A-Za-z0-9_])[A-Z][A-Z0-9_]{2,}'),
|
|
24
|
+
re.compile(r'"[^"\n]{2,80}"'),
|
|
25
|
+
]
|
|
26
|
+
# single-pass merged entity scanner (order = priority)
|
|
27
|
+
COMBINED_ENT_PAT = re.compile("|".join("(?:%s)" % pp.pattern for pp in ENTITY_PATTERNS))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
KEEP_KEYWORDS = ['must', 'need', 'error', 'Error', 'ERROR', 'fail', 'crash',
|
|
31
|
+
'TODO', 'FIXME', 'critical', 'root cause', 'fix',
|
|
32
|
+
'必须', '错误', '报错', '重要', '关键', '原因', '决定', '结论',
|
|
33
|
+
'总之', '综上', '总结', '建议', '注意', 'summary', 'recommend', 'conclusion']
|
|
34
|
+
|
|
35
|
+
CAUSAL_TERMS = ['because', 'therefore', 'thus', 'hence', 'root cause', 'due to',
|
|
36
|
+
'as a result', '因为', '所以', '因此', '导致', '造成', '原因', '结果',
|
|
37
|
+
'由于', '从而', '进而', '以至于']
|
|
38
|
+
CODE_TERMS = ['error', 'exception', 'traceback', 'bug', 'fix', 'patch', 'deploy',
|
|
39
|
+
'build', 'test', 'crash', 'stack', 'assert', 'timeout', 'rollback',
|
|
40
|
+
'compile', 'runtime', '报错', '异常', '崩溃', '部署', '编译', '构建',
|
|
41
|
+
'测试', '回滚', '补丁', '上线']
|
|
42
|
+
FILLERS = ['basically', 'actually', 'literally', 'obviously', 'essentially',
|
|
43
|
+
'you know', 'i mean', 'i think', 'i believe', 'in my opinion',
|
|
44
|
+
'sort of', 'kind of', 'to be honest', 'needless to say',
|
|
45
|
+
'我觉得', '我认为', '说实话', '坦白说', '众所周知', '毫无疑问',
|
|
46
|
+
'基本上', '一般来说', '就是说', '也就是说',
|
|
47
|
+
'这样的话', '其实吧', '你知道吗', '我们可以看到']
|
|
48
|
+
|
|
49
|
+
STOPWORDS_EN = set('''a an the and or but nor so yet for of to in on at by with
|
|
50
|
+
from as is are was were be been being am do does did doing have has had having
|
|
51
|
+
will would shall should can could may might this that these those i you he she
|
|
52
|
+
it we they me him her us them my your his its our their there here when where
|
|
53
|
+
why how what which who whom whose if then than too very just also only own same
|
|
54
|
+
such no not now out up down off over under again further once about into through
|
|
55
|
+
during before after above below between both each few more most other some any
|
|
56
|
+
all'''.split())
|
|
57
|
+
|
|
58
|
+
STOPCHARS_ZH = set('的吗呢吧啊呀哦喔嘛么呗啦喽哇嘿哟欸诶之乎者矣焉哉')
|
|
59
|
+
|
|
60
|
+
ENT_RE = re.compile('|'.join('(?:%s)' % pp.pattern for pp in ENTITY_PATTERNS))
|
|
61
|
+
PH_RE = re.compile(r'\ue000\d+\ue001') # placeholder scanner (protected text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
GENES = {
|
|
65
|
+
'dup_th': (0.55, 0.90),
|
|
66
|
+
'ent_w': (1.0, 6.0),
|
|
67
|
+
'cont_w': (0.4, 3.0),
|
|
68
|
+
'keep_bonus': (2.0, 20.0),
|
|
69
|
+
'causal_w': (0.5, 4.0),
|
|
70
|
+
'code_w': (0.5, 4.0),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_EVOLVING = False
|
|
74
|
+
|
|
75
|
+
def _evolved_path():
|
|
76
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
77
|
+
'evolved.json')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FastLingua:
|
|
81
|
+
MODES = ('light', 'balanced', 'aggressive')
|
|
82
|
+
|
|
83
|
+
def __init__(self, mode='balanced', target_ratio=0.5, anchors=False, auto=True, collapse=False):
|
|
84
|
+
self.mode = mode
|
|
85
|
+
self.target_ratio = target_ratio
|
|
86
|
+
self.anchors = anchors
|
|
87
|
+
self.auto = auto # statistical auto-discovery of fillers
|
|
88
|
+
self.collapse = collapse # exact-repeat phrase collapsing (opt-in)
|
|
89
|
+
# ---- evolvable genes ----
|
|
90
|
+
self.dup_th = 0.72
|
|
91
|
+
self.ent_w = 3.0
|
|
92
|
+
self.cont_w = 1.0
|
|
93
|
+
self.keep_bonus = 10.0
|
|
94
|
+
self.causal_w = 1.5
|
|
95
|
+
self.code_w = 1.5
|
|
96
|
+
self.evolved = self._load_genome()
|
|
97
|
+
self.last_auto = set() # words auto-discovered in last run
|
|
98
|
+
self._store = []
|
|
99
|
+
|
|
100
|
+
def _load_genome(self):
|
|
101
|
+
try:
|
|
102
|
+
with open(_evolved_path(), encoding='utf-8') as f:
|
|
103
|
+
evo = json.load(f)
|
|
104
|
+
n = 0
|
|
105
|
+
for k in GENES:
|
|
106
|
+
if k in evo:
|
|
107
|
+
setattr(self, k, float(evo[k]))
|
|
108
|
+
n += 1
|
|
109
|
+
return n > 0
|
|
110
|
+
except Exception:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def _protect(self, text):
|
|
114
|
+
self._store = []
|
|
115
|
+
def sub(m):
|
|
116
|
+
self._store.append(m.group(0))
|
|
117
|
+
return '\uE000%d\uE001' % (len(self._store) - 1)
|
|
118
|
+
text = COMBINED_ENT_PAT.sub(sub, text)
|
|
119
|
+
return text
|
|
120
|
+
|
|
121
|
+
def _validate(self, orig, comp):
|
|
122
|
+
return _instance_validate(self, orig, comp)
|
|
123
|
+
|
|
124
|
+
def _restore(self, text):
|
|
125
|
+
def sub(m):
|
|
126
|
+
i = int(m.group(1))
|
|
127
|
+
return self._store[i] if i < len(self._store) else m.group(0)
|
|
128
|
+
return re.sub('\uE000(\\d+)\uE001', sub, text)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _split_sentences(text):
|
|
132
|
+
parts = re.split(r'(?<=[。!?!?;;])|\n'
|
|
133
|
+
r'|(?<=[A-Za-z0-9)\]”])\.\s+(?=[A-Z\u4e00-\u9fff])', text)
|
|
134
|
+
parts = [p.strip() for p in parts if p and p.strip()]
|
|
135
|
+
abbrev = {'com', 'net', 'org', 'gov', 'edu', 'io', 'inc', 'ltd', 'co',
|
|
136
|
+
'vs', 'etc', 'fig', 'no', 'dr', 'mr', 'mrs', 'ms', 'st',
|
|
137
|
+
'jr', 'sr', 'eg', 'ie'}
|
|
138
|
+
merged = []
|
|
139
|
+
for p in parts:
|
|
140
|
+
if merged:
|
|
141
|
+
words = merged[-1].split()
|
|
142
|
+
w = re.sub(r'[^A-Za-z]', '', words[-1]) if words else ''
|
|
143
|
+
if w in abbrev or (len(w) <= 2 and w.isalpha() and w.islower()):
|
|
144
|
+
merged[-1] = merged[-1] + ' ' + p
|
|
145
|
+
continue
|
|
146
|
+
merged.append(p)
|
|
147
|
+
# long-line chunking: punctuation-free mega-lines (raw dumps) can
|
|
148
|
+
# never be pruned as one giant sentence; split into ~500-char pieces
|
|
149
|
+
chunked = []
|
|
150
|
+
for p in merged:
|
|
151
|
+
if len(p) > 2000:
|
|
152
|
+
pieces = []
|
|
153
|
+
for w in p.split(' '):
|
|
154
|
+
while len(w) > 500: # spaceless mega-word: hard slice
|
|
155
|
+
pieces.append(w[:500])
|
|
156
|
+
w = w[500:]
|
|
157
|
+
pieces.append(w)
|
|
158
|
+
buf = ''
|
|
159
|
+
for w in pieces:
|
|
160
|
+
if buf and len(buf) + len(w) + 1 > 500:
|
|
161
|
+
chunked.append(buf)
|
|
162
|
+
buf = w
|
|
163
|
+
else:
|
|
164
|
+
buf = (buf + ' ' + w) if buf else w
|
|
165
|
+
if buf:
|
|
166
|
+
chunked.append(buf)
|
|
167
|
+
else:
|
|
168
|
+
chunked.append(p)
|
|
169
|
+
return chunked
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _sig(s):
|
|
173
|
+
s = PH_RE.sub('', s)
|
|
174
|
+
words = set(w.lower() for w in re.findall(r'[A-Za-z]{2,}|\d+', s))
|
|
175
|
+
if words:
|
|
176
|
+
return words
|
|
177
|
+
chars = [c for c in s if '\u4e00' <= c <= '\u9fff']
|
|
178
|
+
return set('%s%s' % b for b in zip(chars, chars[1:]))
|
|
179
|
+
|
|
180
|
+
def _dedupe(self, sentences):
|
|
181
|
+
kept, sigs = [], []
|
|
182
|
+
for s in sentences:
|
|
183
|
+
sig = self._sig(s)
|
|
184
|
+
if not sig or PH_RE.search(s):
|
|
185
|
+
kept.append(s)
|
|
186
|
+
continue
|
|
187
|
+
dup = any(len(sig & p) / max(len(sig | p), 1) > self.dup_th for p in sigs[-12:])
|
|
188
|
+
if not dup:
|
|
189
|
+
kept.append(s)
|
|
190
|
+
sigs.append(sig)
|
|
191
|
+
return kept
|
|
192
|
+
|
|
193
|
+
def _density(self, s):
|
|
194
|
+
if not s:
|
|
195
|
+
return 0.0
|
|
196
|
+
ent = len(PH_RE.findall(s))
|
|
197
|
+
words = len(re.findall(r'[A-Za-z]{2,}|[\u4e00-\u9fff]', s))
|
|
198
|
+
keep = any(k in s for k in KEEP_KEYWORDS)
|
|
199
|
+
causal = sum(1 for k in CAUSAL_TERMS if k in s)
|
|
200
|
+
code = sum(1 for k in CODE_TERMS if k in s)
|
|
201
|
+
base = (ent * self.ent_w + words * self.cont_w
|
|
202
|
+
+ causal * self.causal_w + code * self.code_w)
|
|
203
|
+
return base / max(len(s), 1) * (self.keep_bonus if keep else 1.0)
|
|
204
|
+
|
|
205
|
+
def _auto_filler_words(self, text):
|
|
206
|
+
"""Statistical discovery: frequent short lowercase words = likely filler.
|
|
207
|
+
Guards: never touch Capitalized words (proper nouns), len<=7, freq>=4."""
|
|
208
|
+
tokens = re.findall(r'(?<![\w\-])[A-Za-z]+(?![\w\-])', PH_RE.sub(' ', text))
|
|
209
|
+
total = len(tokens)
|
|
210
|
+
if total < 60:
|
|
211
|
+
return set()
|
|
212
|
+
cnt = Counter(w.lower() for w in tokens)
|
|
213
|
+
need = max(4, total // 100)
|
|
214
|
+
found = set()
|
|
215
|
+
for w, c in cnt.items():
|
|
216
|
+
if c < need or len(w) > 7 or w in STOPWORDS_EN:
|
|
217
|
+
continue
|
|
218
|
+
# proper-noun guard: capitalized occurrence anywhere -> skip
|
|
219
|
+
if re.search(r'(?<![\w\-])' + re.escape(w.capitalize()) + r'(?![\w\-])', text):
|
|
220
|
+
continue
|
|
221
|
+
found.add(w)
|
|
222
|
+
return found
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _collapse_repeats(s, min_count=2, long_len=16):
|
|
226
|
+
"""Collapse EXACT repeated phrases (zero info loss).
|
|
227
|
+
Eligible: appears >=3 times, or >=2 times when phrase is long (>=16 chars)."""
|
|
228
|
+
changed = True
|
|
229
|
+
while changed:
|
|
230
|
+
changed = False
|
|
231
|
+
L = len(s)
|
|
232
|
+
max_n = min(40, L // max(min_count, 2))
|
|
233
|
+
for n in range(max_n, 5, -1):
|
|
234
|
+
seen = {}
|
|
235
|
+
for i in range(L - n + 1):
|
|
236
|
+
if i > 0 and s[i - 1] not in ' \n':
|
|
237
|
+
continue
|
|
238
|
+
j = i + n
|
|
239
|
+
if j < L and s[j] not in ' \n.,;:!?)':
|
|
240
|
+
continue
|
|
241
|
+
seen.setdefault(s[i:i + n], []).append(i)
|
|
242
|
+
hit = None
|
|
243
|
+
for g, idxs in seen.items():
|
|
244
|
+
if '\n' in g or '\ue000' in g or '\ue001' in g:
|
|
245
|
+
continue
|
|
246
|
+
ok = len(idxs) >= 3 or (len(idxs) >= min_count and n >= long_len)
|
|
247
|
+
if ok:
|
|
248
|
+
hit = (g, idxs)
|
|
249
|
+
break
|
|
250
|
+
if hit:
|
|
251
|
+
g, idxs = hit
|
|
252
|
+
for i in reversed(idxs[1:]):
|
|
253
|
+
s = s[:i] + s[i + n:]
|
|
254
|
+
changed = True
|
|
255
|
+
break
|
|
256
|
+
return s
|
|
257
|
+
|
|
258
|
+
def compress(self, text):
|
|
259
|
+
t0 = time.perf_counter()
|
|
260
|
+
orig_len = len(text)
|
|
261
|
+
has_zh = bool(re.search('[\u4e00-\u9fff]', text))
|
|
262
|
+
orig_text = text
|
|
263
|
+
text = self._protect(text)
|
|
264
|
+
n_ent = len(self._store)
|
|
265
|
+
for f in FILLERS:
|
|
266
|
+
text = re.sub(re.escape(f), '', text, flags=re.I)
|
|
267
|
+
sents = self._dedupe(self._split_sentences(text))
|
|
268
|
+
if self.mode in ('balanced', 'aggressive'):
|
|
269
|
+
# hyphen-aware: never split compounds like in-memory / state-of-the-art
|
|
270
|
+
def prune(m):
|
|
271
|
+
w = m.group(0)
|
|
272
|
+
return '' if w.lower() in STOPWORDS_EN else w
|
|
273
|
+
sents = [re.sub(r'(?<![\w\-])[A-Za-z]+(?![\w\-])', prune, s) for s in sents]
|
|
274
|
+
if self.mode == 'aggressive':
|
|
275
|
+
def prune_zh(m):
|
|
276
|
+
return ''.join(c for c in m.group(0) if c not in STOPCHARS_ZH)
|
|
277
|
+
sents = [re.sub(r'[\u4e00-\u9fff]+', prune_zh, s) for s in sents]
|
|
278
|
+
if self.auto and self.mode != 'light':
|
|
279
|
+
extra = self._auto_filler_words(text)
|
|
280
|
+
self.last_auto = extra
|
|
281
|
+
if extra:
|
|
282
|
+
alt = '|'.join(re.escape(w) for w in sorted(extra, key=len, reverse=True))
|
|
283
|
+
apat = re.compile(r'(?<![\w\-])(?:%s)(?![\w\-])' % alt, re.I)
|
|
284
|
+
sents = [apat.sub('', x) for x in sents]
|
|
285
|
+
if self.collapse:
|
|
286
|
+
joined = self._collapse_repeats('\n'.join(sents))
|
|
287
|
+
sents = [x for x in joined.split('\n') if x.strip()]
|
|
288
|
+
cur = sum(len(s) for s in sents)
|
|
289
|
+
budget = max(int(orig_len * self.target_ratio), 1)
|
|
290
|
+
if cur > budget and self.mode != 'light':
|
|
291
|
+
order = sorted(range(len(sents)), key=lambda i: self._density(sents[i]))
|
|
292
|
+
alive = [True] * len(sents)
|
|
293
|
+
remaining = cur
|
|
294
|
+
for i in order:
|
|
295
|
+
if remaining <= budget:
|
|
296
|
+
break
|
|
297
|
+
if i == 0 or i == len(sents) - 1 or '?' in sents[i] or '?' in sents[i]:
|
|
298
|
+
continue
|
|
299
|
+
# HARD GUARD: never drop a sentence carrying protected entities
|
|
300
|
+
if PH_RE.search(sents[i]):
|
|
301
|
+
continue
|
|
302
|
+
if any(k in sents[i] for k in KEEP_KEYWORDS):
|
|
303
|
+
continue
|
|
304
|
+
remaining -= len(sents[i])
|
|
305
|
+
alive[i] = False
|
|
306
|
+
sents = [s for s, a in zip(sents, alive) if a]
|
|
307
|
+
result = ''.join(sents) if has_zh else ' '.join(sents)
|
|
308
|
+
result = self._restore(result)
|
|
309
|
+
result = re.sub(r'[ \t]{2,}', ' ', result)
|
|
310
|
+
result = re.sub(r'\s+([.,;:!?)\]])', r'\1', result)
|
|
311
|
+
result = result.strip()
|
|
312
|
+
# ---- self-validation with graceful degradation ----
|
|
313
|
+
problems = self._validate(orig_text, result)
|
|
314
|
+
if problems and self.mode != 'light':
|
|
315
|
+
fb = FastLingua('light', min(self.target_ratio * 1.6, 0.95),
|
|
316
|
+
auto=self.auto, collapse=False)
|
|
317
|
+
alt, _st2 = fb.compress(text)
|
|
318
|
+
if not fb._validate(orig_text, alt):
|
|
319
|
+
result = alt
|
|
320
|
+
n_ent = len(fb._store)
|
|
321
|
+
elapsed = time.perf_counter() - t0
|
|
322
|
+
stats = {'orig': orig_len, 'comp': len(result),
|
|
323
|
+
'ratio': round(len(result) / max(orig_len, 1), 3),
|
|
324
|
+
'entities': n_ent, 'ms': round(elapsed * 1000, 2)}
|
|
325
|
+
if not result.strip() and orig_text.strip():
|
|
326
|
+
keep = [sn for sn in self._split_sentences(orig_text) if sn.strip()][:2]
|
|
327
|
+
result = ' '.join(keep) if keep else orig_text.strip()[:200]
|
|
328
|
+
if self.anchors:
|
|
329
|
+
result = '\n'.join('[F%d] %s' % (i + 1, f)
|
|
330
|
+
for i, f in enumerate(self._split_sentences(result)))
|
|
331
|
+
return result, stats
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _instance_validate(fl, orig, comp):
|
|
335
|
+
"""Post-compression validation: entities + keep-keywords retention."""
|
|
336
|
+
problems = []
|
|
337
|
+
raw = sorted(set(ENT_RE.findall(orig)))
|
|
338
|
+
raw = [e for e in raw if not any(e != o and e in o for o in raw)]
|
|
339
|
+
miss = [e for e in raw if e not in comp]
|
|
340
|
+
if miss:
|
|
341
|
+
problems.append('entities:' + ','.join(miss[:3]))
|
|
342
|
+
ko = sum(1 for k in KEEP_KEYWORDS if k in orig)
|
|
343
|
+
kc = sum(1 for k in KEEP_KEYWORDS if k in comp)
|
|
344
|
+
if ko and kc < ko:
|
|
345
|
+
problems.append('keywords %d/%d' % (kc, ko))
|
|
346
|
+
return problems
|