Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
884 lines (823 loc) · 37.2 KB
/
Copy pathserver.py
File metadata and controls
884 lines (823 loc) · 37.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文章的形状本地语义流形服务。
功能:
1. 调用本机 Ollama qwen3-embedding:4b 获取真实 2560 维文本嵌入;
2. 纯 Python 实现余弦距离、离心率滤波、重叠覆盖、局部 DBSCAN、Mapper 交集连边;
3. 纯 Python Classical MDS 给 Three.js 提供确定性的 3D 锚点;
4. 同时提供静态文件服务,避免 Vite/Rollup/numpy 等原生依赖签名问题。
"""
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import json, math, re, time, traceback, hashlib, threading
ROOT = Path(__file__).resolve().parent
OLLAMA_URL = "http://127.0.0.1:11434/api/embed"
OLLAMA_GENERATE_URL = "http://127.0.0.1:11434/api/generate"
EMBED_MODEL = "qwen3-embedding:4b"
COMMENTARY_MODEL = "gemma4:12b-it-qat"
SLICE_BUDGET = 100 # 片数预算:最终切片数上限。粒度随文章长度浮动,超预算就合并相邻句子,不截断丢内容。
EMBED_BATCH = 12 # 嵌入批大小:把切片分批并发请求 Ollama
EMBED_WORKERS = 6 # 嵌入并发数
EMBED_CACHE_DIR = ROOT / ".embed_cache" # 嵌入磁盘缓存目录:按 模型+文本 哈希存向量,算过即命中
_embed_mem = {} # 进程内存缓存:键=哈希,值=向量
_embed_lock = threading.Lock()
# 两只脚走路:图形与嵌入用本地 Ollama(上面的 EMBED_MODEL);
# 文字类点评用 OpenAI 兼容大模型(如硅基流动),配置见 llm_config.json。
LLM_CONFIG_PATH = ROOT / "llm_config.json"
def load_llm_config():
try:
cfg = json.loads(LLM_CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return None
key = str(cfg.get("api_key", "")).strip()
# 未真正填入 key 时视为未配置,避免无谓请求。
if not cfg.get("enabled") or not key or key.startswith("在此填入"):
return None
cfg["api_key"] = key
cfg.setdefault("base_url", "https://api.siliconflow.cn/v1")
cfg.setdefault("model", "Qwen/Qwen2.5-7B-Instruct")
cfg.setdefault("timeout_seconds", 30)
cfg.setdefault("temperature", 0.4)
return cfg
# 只要配置了 OpenAI 兼容大模型,就默认偷偷生成文字点评。
ENABLE_COMMENTARY_BY_DEFAULT = load_llm_config() is not None
DEFAULT_TEXT = """# 文章的形状
应用启动后呈现一个无边界的深色虚拟视界。用户将 Markdown 文档拖入画布,传统白底黑字界面隐去,文章被解析成连续文本切片。
每一个文本切片通过本机 Ollama 的 qwen3-embedding:4b 模型转化为真实高维语义向量。系统不再使用伪嵌入,也不使用随机初始化。
Mapper 算法把高维语义点云压缩为人眼可见的拓扑骨架。离心率滤波负责发现偏离主旨的段落,重叠区间覆盖负责保存连续性,局部 DBSCAN 负责生成语义簇。
如果两个语义簇共享同一个文本切片,它们之间就会被连上一条边。于是文章不再只是文本,而成为一个拥有分支、空洞、瓶颈和断层的语义流形网络。
维度滑块控制相变过程:左侧是原始文本切片,右侧是真实嵌入驱动的 Mapper 语义骨架。节点越大表示质量越高,颜色越偏红表示离心率越高,越可能跑题或成为孤岛。
材料力学参数由文本类型决定。法律合同与学术论文接近刚性超合金,商业策划与营销文案表现为工业太空沙,诗歌故事则类似液态硅胶。
未来用户可以捏合、拉伸或截断这张语义网络。系统将根据局部体积、密度与图拉普拉斯变化,向大模型发送压缩、扩写或重写指令。"""
def _sentences(block):
# 段落内按句末标点切句;无标点的短段落整体作为一句。
raw = re.split(r"(?<=[。!?!?])\s*", block)
out = []
for s in raw:
s = s.strip()
if len(s) >= 6:
out.append(s)
return out or ([block.strip()] if len(block.strip()) >= 2 else [])
def split_text(text):
# 以“段落”为稳定锚点:改某一段只影响那一段的切片,其余段落切片不变→缓存可命中。
text = re.sub(r"```[\s\S]*?```", lambda m: re.sub(r"\s+", " ", m.group(0)), text)
# 1) 按空行/标题分成段落块(标题自成一块)。
blocks = re.split(r"\n\s*\n|(?=^#{1,4}\s)", text, flags=re.M)
paras = []
for b in blocks:
b = re.sub(r"[#>*`_\-\[\]()]+", " ", b)
b = re.sub(r"\s+", "", b).strip() if False else b.strip()
if len(b) >= 6:
paras.append(b)
if not paras:
return ["空白文档。"]
# 2) 每段切成句子(最小不可分单位)。
para_sents = [_sentences(p) for p in paras]
total = sum(len(s) for s in para_sents)
# 3) 没超预算:句子级(古诗4句=4点,最细腻)。
if total <= SLICE_BUDGET:
flat = [s for ss in para_sents for s in ss]
return flat or ["空白文档。"]
# 4) 超预算:在“每个段落内部”按比例合并句子,段落之间不跨界合并。
# 这样改一段只重排该段切片,其它段切片文本完全不变,缓存继续命中。
budget_left = SLICE_BUDGET
paras_left = len(para_sents)
out = []
for ss in para_sents:
if not ss:
paras_left -= 1; continue
# 给当前段分配的切片配额(至少1),按剩余预算/剩余段数均摊,长段多给。
quota = max(1, round(budget_left * (len(ss) / max(1, total))))
quota = min(quota, len(ss))
g = math.ceil(len(ss) / quota)
merged = ["".join(ss[i:i+g]) for i in range(0, len(ss), g)]
out.extend(merged)
budget_left -= len(merged); total -= len(ss); paras_left -= 1
return out[:SLICE_BUDGET] or ["空白文档。"]
def _embed_batch(texts):
payload = json.dumps({"model": EMBED_MODEL, "input": texts}).encode("utf-8")
req = Request(OLLAMA_URL, data=payload, headers={"Content-Type": "application/json"})
with urlopen(req, timeout=240) as resp:
data = json.loads(resp.read().decode("utf-8"))
vecs = data.get("embeddings")
if not vecs or len(vecs) != len(texts):
raise RuntimeError("Ollama 没有返回有效 embeddings")
return vecs
def _embed_key(text):
# 缓存键 = 模型 + 文本内容的哈希,文本一字不差才命中。
return hashlib.sha1((EMBED_MODEL + "\x00" + text).encode("utf-8")).hexdigest()
def _cache_get(key):
if key in _embed_mem:
return _embed_mem[key]
f = EMBED_CACHE_DIR / (key + ".json")
if f.exists():
try:
vec = json.loads(f.read_text())
_embed_mem[key] = vec
return vec
except Exception:
return None
return None
def _cache_put(key, vec):
_embed_mem[key] = vec
try:
EMBED_CACHE_DIR.mkdir(exist_ok=True)
(EMBED_CACHE_DIR / (key + ".json")).write_text(json.dumps(vec))
except Exception:
pass
def _embed_uncached(texts):
# 只对“未命中缓存”的文本真正请求 Ollama(仍按批并发)。
if not texts:
return []
if len(texts) <= EMBED_BATCH:
return _embed_batch(texts)
batches = [(i, texts[i:i+EMBED_BATCH]) for i in range(0, len(texts), EMBED_BATCH)]
results = {}
with ThreadPoolExecutor(max_workers=EMBED_WORKERS) as ex:
futs = {ex.submit(_embed_batch, b): i for (i, b) in batches}
for fut in as_completed(futs):
results[futs[fut]] = fut.result()
vecs = []
for i in sorted(results):
vecs.extend(results[i])
return vecs
def ollama_embed(texts):
# 嵌入缓存:每段文字按内容哈希存向量。命中的直接复用,只把没算过的发给 Ollama。
keys = [_embed_key(t) for t in texts]
out = [None] * len(texts)
miss_idx, miss_txt = [], []
with _embed_lock:
for i, k in enumerate(keys):
v = _cache_get(k)
if v is not None:
out[i] = v
else:
miss_idx.append(i); miss_txt.append(texts[i])
if miss_txt:
fresh = _embed_uncached(miss_txt)
with _embed_lock:
for j, vec in enumerate(fresh):
out[miss_idx[j]] = vec
_cache_put(keys[miss_idx[j]], vec)
return out
def dot(a, b):
return sum(x*y for x, y in zip(a, b))
def norm(v):
return math.sqrt(max(1e-18, dot(v, v)))
def normalize_rows(X):
out = []
for v in X:
n = norm(v)
out.append([x/n for x in v])
return out
def cosine_distances(X):
n = len(X)
D = [[0.0]*n for _ in range(n)]
for i in range(n):
for j in range(i+1, n):
d = max(0.0, min(2.0, 1.0 - dot(X[i], X[j])))
D[i][j] = D[j][i] = d
return D
def entropy(s):
chars = list(s)
total = max(1, len(chars))
counts = {}
for c in chars:
counts[c] = counts.get(c, 0) + 1
e = 0.0
for v in counts.values():
p = v / total
e -= p * math.log(p, 2)
return e
def mat_vec(A, v):
return [sum(A[i][j]*v[j] for j in range(len(v))) for i in range(len(A))]
def vec_norm(v):
return math.sqrt(sum(x*x for x in v)) or 1e-12
def top_eigen_symmetric(A, k=3, iterations=90):
n = len(A)
B = [row[:] for row in A]
pairs = []
for comp in range(min(k, n)):
# 确定性初始向量:不用随机数
v = [math.sin((i+1)*(comp+1)*1.61803398875) + math.cos((i+1)*(comp+2)*0.57721) for i in range(n)]
vn = vec_norm(v)
v = [x/vn for x in v]
for _ in range(iterations):
w = mat_vec(B, v)
wn = vec_norm(w)
v = [x/wn for x in w]
Av = mat_vec(B, v)
lam = sum(v[i]*Av[i] for i in range(n))
if lam < 1e-10:
break
pairs.append((lam, v[:]))
for i in range(n):
for j in range(n):
B[i][j] -= lam * v[i] * v[j]
return pairs
def classical_mds(D, dims=3):
n = len(D)
if n == 1:
return [[0.0, 0.0, 0.0]]
D2 = [[D[i][j]*D[i][j] for j in range(n)] for i in range(n)]
row_mean = [sum(row)/n for row in D2]
total_mean = sum(row_mean)/n
B = [[-0.5*(D2[i][j] - row_mean[i] - row_mean[j] + total_mean) for j in range(n)] for i in range(n)]
eigs = top_eigen_symmetric(B, dims)
coords = [[0.0]*dims for _ in range(n)]
for c, (lam, vec) in enumerate(eigs):
scale = math.sqrt(max(0.0, lam))
for i in range(n):
coords[i][c] = vec[i] * scale
# 归一到好看的画布尺度,但仍是确定性代数变换
max_abs = max(max(abs(x) for x in p) for p in coords) or 1.0
return [[x/max_abs*4.2 for x in p] for p in coords]
def dbscan(indices, D, eps, min_pts=1):
visited, clusters = set(), []
idxset = set(indices)
def neigh(i):
return [j for j in indices if D[i][j] <= eps]
for i in indices:
if i in visited:
continue
visited.add(i)
ns = neigh(i)
if len(ns) < min_pts:
clusters.append([i])
continue
cluster = []
queue = ns[:]
while queue:
q = queue.pop(0)
if q not in visited:
visited.add(q)
qn = neigh(q)
if len(qn) >= min_pts:
for z in qn:
if z not in queue:
queue.append(z)
if q in idxset and q not in cluster:
cluster.append(q)
clusters.append(sorted(cluster))
return [c for c in clusters if c]
def mapper_graph(texts, X, D, coords):
n = len(texts)
if n == 0:
return [], []
# Filter: 离心率 + 时间轴混合,保留文章进程,也突出跑题分支
ecc = []
p = 2.0
for i in range(n):
ecc.append((sum((D[i][j]**p) for j in range(n))/n) ** (1.0/p))
mn, mx = min(ecc), max(ecc)
ecc_norm = [(x-mn)/(mx-mn or 1.0) for x in ecc]
progress = [i/(n-1 or 1) for i in range(n)]
filt = [0.72*ecc_norm[i] + 0.28*progress[i] for i in range(n)]
bins = min(9, max(4, int(math.sqrt(n)) + 2))
overlap = 0.42
fmin, fmax = min(filt), max(filt)
span = fmax - fmin or 1.0
width = span / (bins - (bins-1)*overlap)
step = width * (1-overlap)
nonzero = sorted(D[i][j] for i in range(n) for j in range(i+1, n) if D[i][j] > 1e-9)
eps = nonzero[max(0, min(len(nonzero)-1, int(len(nonzero)*0.32)))] if nonzero else 0.35
nodes = []
for b in range(bins):
lo = fmin + b*step - 1e-9
hi = lo + width + 1e-9
bucket = [i for i, val in enumerate(filt) if lo <= val <= hi]
if not bucket:
continue
clusters = dbscan(bucket, D, eps=eps, min_pts=1)
for c in clusters:
cx = sum(coords[i][0] for i in c)/len(c)
cy = sum(coords[i][1] for i in c)/len(c)
cz = sum(coords[i][2] for i in c)/len(c)
mass = sum((len(texts[i]) * max(0.1, entropy(texts[i]))) for i in c)
avg_ecc = sum(ecc_norm[i] for i in c)/len(c)
nodes.append({
"id": len(nodes),
"members": c,
"label": " / ".join(str(i+1) for i in c[:5]),
"text": texts[c[0]][:96] + ("…" if len(texts[c[0]]) > 96 else ""),
"position": [cx, cy + (avg_ecc-0.5)*1.7, cz],
"mass": mass,
"radius": 0.28 + math.log(1+mass) * 0.11,
"eccentricity": avg_ecc,
"filter": sum(filt[i] for i in c)/len(c),
"entropy": sum(entropy(texts[i]) for i in c)/len(c),
"bucket": b
})
edges = []
for a in range(len(nodes)):
sa = set(nodes[a]["members"])
for b in range(a+1, len(nodes)):
inter = sa.intersection(nodes[b]["members"])
if inter:
edges.append({"source": a, "target": b, "weight": len(inter), "shared": sorted(inter)})
return nodes, edges
# ============ 撞脸蛋白质:形状指纹与最近邻匹配 ============
PROTEIN_DB_PATH = ROOT / "src" / "proteins_db.json"
ARTICLE_SIG_STATS_PATH = ROOT / "src" / "article_sig_stats.json"
_protein_db_cache = None
_protein_stats_cache = None
_protein_sig_stats_cache = None
_article_sig_stats_cache = None
def load_protein_db():
global _protein_db_cache, _protein_stats_cache, _protein_sig_stats_cache
if _protein_db_cache is None:
try:
obj = json.loads(PROTEIN_DB_PATH.read_text(encoding="utf-8"))
_protein_db_cache = obj.get("proteins", [])
_protein_stats_cache = obj.get("stats")
_protein_sig_stats_cache = obj.get("sigStats")
except Exception:
_protein_db_cache = []
_protein_stats_cache = None
_protein_sig_stats_cache = None
return _protein_db_cache
def _load_article_sig_stats():
global _article_sig_stats_cache
if _article_sig_stats_cache is None:
try:
_article_sig_stats_cache = json.loads(ARTICLE_SIG_STATS_PATH.read_text(encoding="utf-8"))
except Exception:
_article_sig_stats_cache = {}
return _article_sig_stats_cache or None
# 形状描述子:文章点云与蛋白主链共用同一把尺(路径走势 + 各向异性,全部缩放无关)。
FEATURE_KEYS = ["kappa2", "asphericity", "acylindricity", "fold_ratio", "windiness", "contact"]
FEATURE_WEIGHTS = [1.4, 1.0, 1.0, 1.8, 1.8, 1.6] # 路径走势对文章最有区分度,权重最高。
def symmetric_eig3(A):
"""3x3 对称矩阵特征值:三角法解特征多项式(确定性,无随机)。"""
a, b, c = A[0][0], A[1][1], A[2][2]
d, e, f = A[0][1], A[0][2], A[1][2]
p1 = a + b + c
p2 = a*b + a*c + b*c - d*d - e*e - f*f
p3 = a*b*c + 2*d*e*f - a*f*f - b*e*e - c*d*d
q = p1/3.0
pp = max((p1*p1 - 3*p2)/9.0, 0.0)
if pp < 1e-15:
return [q, q, q]
r = (2*p1**3 - 9*p1*p2 + 27*p3)/54.0
ratio = max(-1.0, min(1.0, r/(pp**1.5)))
phi = math.acos(ratio)
sq = 2*math.sqrt(pp)
return [max(0.0, q + sq*math.cos((phi + k*2*math.pi)/3.0)) for k in (0, 2, 4)]
def _pdist(a, b):
return math.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2)
def path_descriptor(points):
"""有序三维点(阅读顺序/残基顺序)→ 路径指纹。与 shape_descriptor.py 保持一致。"""
n = len(points)
if n < 3:
return None
c = [sum(p[i] for p in points)/n for i in range(3)]
S = [[0.0]*3 for _ in range(3)]
for p in points:
dd = [p[0]-c[0], p[1]-c[1], p[2]-c[2]]
for i in range(3):
for j in range(3):
S[i][j] += dd[i]*dd[j]
for i in range(3):
for j in range(3):
S[i][j] /= n
lam = sorted(symmetric_eig3(S), reverse=True)
l1, l2, l3 = lam
rg2 = (l1 + l2 + l3) or 1e-12
b = l1 - 0.5*(l2 + l3)
cc = l2 - l3
kappa2 = (b*b + 0.75*cc*cc)/(rg2*rg2)
rg = math.sqrt(rg2)
seg = [_pdist(points[i], points[i+1]) for i in range(n-1)]
contour = sum(seg) or 1e-12
fold_ratio = max(0.0, min(1.0, _pdist(points[0], points[-1]) / contour))
angs = []
for i in range(1, n-1):
u = [points[i][k]-points[i-1][k] for k in range(3)]
v = [points[i+1][k]-points[i][k] for k in range(3)]
nu = math.sqrt(sum(x*x for x in u)) or 1e-12
nv = math.sqrt(sum(x*x for x in v)) or 1e-12
cosang = max(-1.0, min(1.0, sum(u[k]*v[k] for k in range(3))/(nu*nv)))
angs.append(math.acos(cosang))
windiness = (sum(angs)/len(angs)) if angs else 0.0
thr = 0.6 * rg
near = total = 0
for i in range(n):
for j in range(i+3, n):
total += 1
if _pdist(points[i], points[j]) < thr:
near += 1
contact = (near/total) if total else 0.0
return {
"kappa2": round(kappa2, 5),
"asphericity": round(b/rg2, 5),
"acylindricity": round(cc/rg2, 5),
"fold_ratio": round(fold_ratio, 5),
"windiness": round(windiness/math.pi, 5),
"contact": round(contact, 5),
}
def match_protein(article_points):
"""给文章点云算路径指纹,z-score 标准化后在蛋白库找最近邻。"""
db = load_protein_db()
if not db or len(article_points) < 3:
return None
fp = path_descriptor(article_points)
if not fp:
return None
stats = _protein_stats_cache or {"mean": {k: 0.0 for k in FEATURE_KEYS},
"std": {k: 1.0 for k in FEATURE_KEYS}}
def z(v):
return {k: (v[k] - stats["mean"].get(k, 0.0)) / (stats["std"].get(k, 1.0) or 1e-6) for k in FEATURE_KEYS}
za = z(fp)
ranked = []
for prot in db:
pf = prot.get("fingerprint", {})
if not all(k in pf for k in FEATURE_KEYS):
continue
zb = z(pf)
dist = math.sqrt(sum(w*(za[k]-zb[k])**2 for k, w in zip(FEATURE_KEYS, FEATURE_WEIGHTS)))
ranked.append((dist, prot))
if not ranked:
return None
ranked.sort(key=lambda x: x[0])
# 相似度是“相对”概念:表达“在这 108 个里它最像谁”,而非绝对物理重合。
# 以本次查询最近距离为锚,让 top1 有体面分数、次席按距离梯度自然递减。
dmin = ranked[0][0]
def to_pct(dist):
return round(91.0 * math.exp(-(dist - dmin) / 1.6), 1)
matches = []
for dist, prot in ranked[:6]:
matches.append({
"pdb": prot["pdb"], "name": prot["name"], "en": prot["en"],
"organism": prot.get("organism", "—"), "blurb": prot.get("blurb", ""),
"archetype": prot.get("archetype", ""), "residues": prot.get("residues", 0),
"fingerprint": prot["fingerprint"], "cloud": prot["cloud"],
"similarity": to_pct(dist), "distance": round(dist, 4),
})
return {"articleFingerprint": fp, "matches": matches, "libSize": len(db)}
# ============ 序列-距离衰减签名(跨域公平比较的核心)============
# 关键洞察:文章三维坐标经 MDS 已塌缩退化,长文全变成"抖动的线",几何指纹失效。
# 没退化的真实结构在降维之前的高维距离矩阵里。文章和蛋白都是"有序点串",
# 对每个序号间隔 k,量相隔 k 的两段之间的典型距离,并对自身尺度归一——
# 直杆随 k 线性涨、紧球很快饱和、发卡先升后回落。它对长度/尺度/维度都不变,
# 从根上消除文章 vs 蛋白的 domain shift。
SIG_FRACTIONS = [0.04, 0.10, 0.18, 0.30, 0.45, 0.62, 0.82]
SIG_KEYS = [f"s{i}" for i in range(len(SIG_FRACTIONS))]
def seq_signature_from_dist(D):
"""D: n×n 距离矩阵(点按序列顺序)。返回长度/尺度无关的衰减曲线。"""
n = len(D)
if n < 5:
return None
alld = sorted(D[i][j] for i in range(n) for j in range(i+1, n))
if not alld:
return None
scale = alld[len(alld)//2] or 1e-9 # 全局中位距离做尺度归一
sig = {}
for idx, f in enumerate(SIG_FRACTIONS):
k = max(1, round(f*(n-1)))
vals = sorted(D[i][i+k] for i in range(n-k))
med = vals[len(vals)//2] if vals else 0.0
sig[SIG_KEYS[idx]] = round(med/scale, 5)
return sig
def seq_signature_from_points(points):
"""蛋白侧:从有序三维点(CA 残基)构欧氏距离矩阵,再算同一签名。"""
n = len(points)
if n < 5:
return None
D = [[0.0]*n for _ in range(n)]
for i in range(n):
for j in range(i+1, n):
d = math.sqrt(sum((points[i][k]-points[j][k])**2 for k in range(3)))
D[i][j] = D[j][i] = d
return seq_signature_from_dist(D)
def match_protein_by_signature(article_D):
"""矩匹配撞脸:文章签名按文章群白化,蛋白签名按蛋白群白化,在共同 z 空间比。
为什么这样才对:高维嵌入下文章签名全挤在 0.93~1.04 的极窄平带(std≈0.02),
蛋白却铺满 0.2~2.5(std≈0.2)。绝对距离匹配必然全撞同一个平曲线蛋白。
白化后比的是“这篇文章比普通文章更怎样” vs “这个蛋白比普通蛋白更怎样”,
相对位置对齐,文章间的细微真实差异才被放大并稳定区分。"""
db = load_protein_db()
if not db or len(article_D) < 5:
return None
sig = seq_signature_from_dist(article_D)
if not sig:
return None
art_stats = _load_article_sig_stats()
prot_stats = _protein_sig_stats_cache # 由 load_protein_db() 填充
if not art_stats or not prot_stats:
return None # 缺分布则不强行匹配,避免误导
def zw(v, st):
return {k: (v[k] - st["mean"].get(k, 0.0)) / (st["std"].get(k, 1.0) or 1e-6) for k in SIG_KEYS}
za = zw(sig, art_stats)
ranked = []
for prot in db:
ps = prot.get("signature")
if not ps or not all(k in ps for k in SIG_KEYS):
continue
zb = zw(ps, prot_stats)
dist = math.sqrt(sum((za[k]-zb[k])**2 for k in SIG_KEYS))
ranked.append((dist, prot))
if not ranked:
return None
ranked.sort(key=lambda x: x[0])
dmin, dmax = ranked[0][0], ranked[-1][0]
spread = (dmax - dmin) or 1.0
def to_pct(dist):
return round(92.0 * math.exp(-(dist - dmin) / (spread*0.42 + 1e-6)), 1)
matches = []
for dist, prot in ranked[:6]:
matches.append({
"pdb": prot["pdb"], "name": prot["name"], "en": prot["en"],
"organism": prot.get("organism", "—"), "blurb": prot.get("blurb", ""),
"archetype": prot.get("archetype", ""), "residues": prot.get("residues", 0),
"fingerprint": prot.get("fingerprint", {}), "cloud": prot["cloud"],
"similarity": to_pct(dist), "distance": round(dist, 4),
})
return {"articleSignature": sig, "matches": matches, "libSize": len(db)}
def laplacian_spectrum(nodes, edges):
m = len(nodes)
if m == 0:
return []
A = [[0.0]*m for _ in range(m)]
for e in edges:
a, b, w = e["source"], e["target"], e.get("weight", 1)
A[a][b] += w
A[b][a] += w
L = [[0.0]*m for _ in range(m)]
for i in range(m):
deg = sum(A[i])
for j in range(m):
L[i][j] = -A[i][j]
L[i][i] = deg
eigs = top_eigen_symmetric(L, min(6, m), iterations=70)
return [round(x[0], 5) for x in eigs]
def parse_title_and_intro(text):
lines = [x.strip() for x in text.splitlines() if x.strip()]
heading = next((re.sub(r"^#{1,4}\s+", "", x).strip() for x in lines if re.match(r"^#{1,4}\s+", x)), None)
title = heading or (lines[0] if lines else "未命名文章")
body = []
for chunk in re.split(r"\n\s*\n+", text):
c = re.sub(r"^#{1,4}\s+", "", chunk.strip())
c = re.sub(r"[#>*`_\[\]()]", "", c).strip()
if c and c != title and len(c) > 8:
body.append(c)
intro = (body[0] if body else "").replace("\n", " ")[:140]
return title[:60], intro
def parse_model_commentary(raw):
raw = (raw or "").strip()
raw = re.sub(r"<think>[\s\S]*?</think>", "", raw, flags=re.I).strip()
# 先尝试 JSON;失败再尝试固定前缀三行。
m = re.search(r"\{[\s\S]*\}", raw)
if m:
try:
obj = json.loads(m.group(0))
if any(k in obj for k in ("oracle", "intro", "shapeReading")):
return {
"oracle": str(obj.get("oracle", "")).strip(),
"intro": str(obj.get("intro", "")).strip(),
"shapeReading": str(obj.get("shapeReading", "")).strip()
}
except Exception:
pass
fields = {"oracle": "", "intro": "", "shapeReading": ""}
patterns = {
"oracle": r"(?:文脉|总评|oracle)[::]\s*(.+)",
"intro": r"(?:介绍|内容介绍|intro)[::]\s*(.+)",
"shapeReading": r"(?:形状|形状解读|shapeReading)[::]\s*(.+)"
}
for key, pat in patterns.items():
mm = re.search(pat, raw, flags=re.I)
if mm:
fields[key] = mm.group(1).strip()
if any(fields.values()):
return fields
# 最后按非空行兜底解析。
lines = [re.sub(r"^[\-\d\.、\s]+", "", x).strip() for x in raw.splitlines() if x.strip()]
if len(lines) >= 3:
return {"oracle": lines[0], "intro": lines[1], "shapeReading": lines[2]}
return None
def build_commentary_context(text, result):
title, intro = parse_title_and_intro(text)
nodes = result.get("nodes", [])
metrics = result.get("metrics", {})
central = sorted(nodes, key=lambda n: (-(len(n.get("members", []))), n.get("eccentricity", 0)))[0] if nodes else {}
peripheral = sorted(nodes, key=lambda n: n.get("eccentricity", 0), reverse=True)[:3]
summary = {
"title": title,
"intro": intro,
"metrics": metrics,
"central": {"text": central.get("text", ""), "eccentricity": central.get("eccentricity", 0), "members": central.get("members", [])},
"peripheral": [{"text": n.get("text", ""), "eccentricity": n.get("eccentricity", 0), "members": n.get("members", [])} for n in peripheral]
}
prompt = f"""你是中文资深编辑。请根据文章形状数据,输出三行中文,不要解释,不要 Markdown,不要 JSON。
格式必须严格如下:
文脉:一句话总评,25到45字。
介绍:介绍文章内容,40到80字。
形状:说明主墨团、旁支、是否需要删改,60到110字。
注意:不要提“嵌入、Mapper、算法、β”等技术词。要像编辑在点评文章。
数据:{json.dumps(summary, ensure_ascii=False)}
"""
return prompt
def openai_generate_commentary(text, result):
"""OpenAI 兼容大模型(硅基流动等)生成文字点评。未配置或失败时返回 None。"""
cfg = load_llm_config()
if not cfg:
return None
prompt = build_commentary_context(text, result)
url = cfg["base_url"].rstrip("/") + "/chat/completions"
body = json.dumps({
"model": cfg["model"],
"messages": [
{"role": "system", "content": "你是中文资深编辑,擅长用洗练而有文学性的语言点评文章结构。"},
{"role": "user", "content": prompt}
],
"temperature": cfg["temperature"],
"max_tokens": 480,
"stream": False
}).encode("utf-8")
headers = {"Content-Type": "application/json", "Authorization": "Bearer " + cfg["api_key"]}
try:
req = Request(url, data=body, headers=headers)
with urlopen(req, timeout=cfg["timeout_seconds"]) as resp:
data = json.loads(resp.read().decode("utf-8"))
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
parsed = parse_model_commentary(raw)
if parsed and (parsed.get("oracle") or parsed.get("intro") or parsed.get("shapeReading")):
return {
"model": cfg["model"],
"oracle": str(parsed.get("oracle", ""))[:120],
"intro": str(parsed.get("intro", ""))[:180],
"shapeReading": str(parsed.get("shapeReading", ""))[:260]
}
except Exception as e:
print("openai commentary fallback:", repr(e))
return None
def ollama_generate_commentary(text, result):
"""可选文案模型:生成更像人类编辑的文章形状点评。失败时返回 None。"""
title, intro = parse_title_and_intro(text)
nodes = result.get("nodes", [])
metrics = result.get("metrics", {})
central = sorted(nodes, key=lambda n: (-(len(n.get("members", []))), n.get("eccentricity", 0)))[0] if nodes else {}
peripheral = sorted(nodes, key=lambda n: n.get("eccentricity", 0), reverse=True)[:3]
summary = {
"title": title,
"intro": intro,
"metrics": metrics,
"central": {"text": central.get("text", ""), "eccentricity": central.get("eccentricity", 0), "members": central.get("members", [])},
"peripheral": [{"text": n.get("text", ""), "eccentricity": n.get("eccentricity", 0), "members": n.get("members", [])} for n in peripheral]
}
prompt = f"""你是中文资深编辑。请根据文章形状数据,输出三行中文,不要解释,不要 Markdown,不要 JSON。
格式必须严格如下:
文脉:一句话总评,25到45字。
介绍:介绍文章内容,40到80字。
形状:说明主墨团、旁支、是否需要删改,60到110字。
注意:不要提“嵌入、Mapper、算法、β”等技术词。要像编辑在点评文章。
数据:{json.dumps(summary, ensure_ascii=False)}
"""
for model in [COMMENTARY_MODEL, "qwen3.5:9b"]:
try:
payload = json.dumps({"model": model, "prompt": prompt, "stream": False, "options": {"temperature": 0.28, "num_predict": 360}}).encode("utf-8")
req = Request(OLLAMA_GENERATE_URL, data=payload, headers={"Content-Type": "application/json"})
with urlopen(req, timeout=55) as resp:
data = json.loads(resp.read().decode("utf-8"))
raw = data.get("response", "").strip()
parsed = parse_model_commentary(raw)
if parsed and (parsed.get("oracle") or parsed.get("intro") or parsed.get("shapeReading")):
return {
"model": model,
"oracle": str(parsed.get("oracle", ""))[:120],
"intro": str(parsed.get("intro", ""))[:180],
"shapeReading": str(parsed.get("shapeReading", ""))[:260]
}
except Exception as e:
print("commentary model fallback:", model, repr(e))
continue
return None
def analyze(text, enable_commentary=False):
texts = split_text(text)
vectors = normalize_rows(ollama_embed(texts))
D = cosine_distances(vectors)
coords = classical_mds(D, 3)
nodes, edges = mapper_graph(texts, vectors, D, coords)
# 撞脸用降维之前的高维距离矩阵 D(未退化),而非塌缩的三维 coords。
protein_match = match_protein_by_signature(D)
isolates = sum(1 for i in range(len(nodes)) if not any(e["source"] == i or e["target"] == i for e in edges))
beta1 = max(0, len(edges) - len(nodes) + max(1, 1 if nodes else 0))
avg_ecc = sum(n["eccentricity"] for n in nodes)/max(1, len(nodes))
if isolates:
oracle = "幽灵式的语义岛屿浮在主旨之外,文章出现离群分支。"
elif beta1 > 1:
oracle = "思想形成回环,论证在空洞周围自我缠绕。"
elif avg_ecc > 0.62:
oracle = "骨架向外张开,锋芒可见,但主轴仍需收束。"
else:
oracle = "语义管道连续收束,文章的骨骼已经显影。"
result = {
"model": EMBED_MODEL,
"commentaryModel": "本地启发式",
"embeddingDim": len(vectors[0]) if vectors else 0,
"slices": [{"id": i, "text": t, "position": coords[i], "entropy": entropy(t), "length": len(t)} for i, t in enumerate(texts)],
"nodes": nodes,
"edges": edges,
"metrics": {
"sliceCount": len(texts),
"nodeCount": len(nodes),
"edgeCount": len(edges),
"beta1": beta1,
"isolates": isolates,
"laplacianSpectrum": laplacian_spectrum(nodes, edges),
"algorithm": "Ollama true embeddings → cosine distance → eccentricity filter → overlapping cover → local DBSCAN → Mapper graph"
},
"oracle": oracle,
"proteinMatch": protein_match,
"commentary": None
}
if enable_commentary:
commentary = openai_generate_commentary(text, result)
if commentary:
result["commentaryModel"] = commentary.get("model", "OpenAI兼容大模型")
result["commentary"] = commentary
if commentary.get("oracle"):
result["oracle"] = commentary["oracle"]
else:
result["commentaryModel"] = "本地启发式(未配置或大模型未返回)"
return result
class Handler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204); self.end_headers()
def do_POST(self):
if self.path == "/api/shape":
return self.handle_shape()
if self.path == "/api/commentary":
return self.handle_commentary()
if self.path == "/api/savecard":
return self.handle_savecard()
self.send_error(404)
def handle_savecard(self):
# 仅供本地调试:把前端 canvas 的 dataURL 存成 png,便于核验卡片排版。
try:
import base64
payload = self._read_payload()
png = payload.get("png", "")
b64 = png.split(",", 1)[-1]
(ROOT / "_card_debug.png").write_bytes(base64.b64decode(b64))
self._send_json({"ok": True})
except Exception as e:
self._send_json({"ok": False, "error": str(e)}, 500)
def _send_json(self, obj, code=200):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers(); self.wfile.write(data)
def _read_payload(self):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length).decode("utf-8")
return json.loads(body or "{}")
def handle_shape(self):
try:
payload = self._read_payload()
text = payload.get("text") or DEFAULT_TEXT
# 默认只算图形(嵌入+Mapper,约 0.4s),文案改为前端异步单独请求。
enable_commentary = bool(payload.get("enableCommentary", False))
started = time.time()
result = analyze(text, enable_commentary=enable_commentary)
result["durationMs"] = int((time.time()-started)*1000)
self._send_json(result)
except Exception as e:
traceback.print_exc()
self._send_json({"error": str(e), "trace": traceback.format_exc()}, 500)
def handle_commentary(self):
try:
payload = self._read_payload()
text = payload.get("text") or DEFAULT_TEXT
# 复用前端已算好的图数据,绝不重复嵌入。
ctx = {"nodes": payload.get("nodes", []), "metrics": payload.get("metrics", {})}
started = time.time()
commentary = openai_generate_commentary(text, ctx)
self._send_json({
"commentary": commentary,
"commentaryModel": (commentary or {}).get("model", "本地启发式(未配置或大模型未返回)") if commentary else "本地启发式(未配置或大模型未返回)",
"durationMs": int((time.time()-started)*1000)
})
except Exception as e:
traceback.print_exc()
self._send_json({"error": str(e), "commentary": None}, 500)
def translate_path(self, path):
# 固定静态根目录,避免跑到用户其他路径
path = path.split('?', 1)[0].split('#', 1)[0]
if path == "/": path = "/index.html"
return str((ROOT / path.lstrip('/')).resolve())
if __name__ == "__main__":
host, port = "127.0.0.1", 5173
print(f"文章的形状服务启动:http://{host}:{port}/")
print(f"真实嵌入模型:{EMBED_MODEL}")
ThreadingHTTPServer((host, port), Handler).serve_forever()