跳到主要内容

安全出站管制、知识库与新闻订阅

30 秒导读: 这一章讲的是围绕研究核心的三件外围事。第一件是安全边界——LDR 允许你把研究"关"在一个出站范围里(只走公网 / 只走本地 / 只走主引擎),一套策略从"选哪个引擎"一路管到"能不能连这个 socket"。第二件是数据回流——研究命中的来源可以下载、抽文本、做嵌入、进私有库,再作为一个本地搜索引擎回头喂给研究。第三件是自动化订阅——把一个主题订阅下来,后台定时跑研究、AI 过滤汇总、投递到新闻流。

本章聚焦这三块,通用检索/合成的主线见 01 / 02 / 04;加密库、设置快照、LLM 提供方等运行时底座见 05


1. 这一章讲什么(零基础也能懂)

这里不是"通用安全清单",而是三个围着研究核心转的子系统。先把它们各自要解决的问题说清楚。

(1) 出站管制(egress control)——把研究"关"在一个范围里。 LDR 能接很多搜索引擎和 LLM,有的走公网(Google、arXiv、OpenAI),有的在你自己机器上(本地知识库、Ollama)。有人做研究时想要一条硬规矩:"这次研究,数据不许离开我这台机器",或者反过来"只准走公网、别碰我的私有库"。出站管制就是把这条规矩翻译成代码,并且在多个层面反复检查,防止某个新写的、没人复核过的代码路径偷偷把查询发出去。

(2) 纵深防御(defense in depth)一览——其余安全护栏。 除了出站,还有一圈更常规的护栏:防 SSRF(伪造请求打内网/云元数据)、日志脱敏(别把密码写进日志)、账户锁定(防暴力破解)、文件完整性(FAISS 索引被人动过就报警)、只允许白名单模块被动态加载。这些各自独立,本章只做一览 + 点出精华。

(3) 知识库(Library)+ (4) 新闻订阅——两个增值子系统。 研究跑完会命中一堆网页/论文。知识库子系统把它们下载→抽文本→切块嵌入→建 FAISS 索引,变成一个你私有的、可搜索的语料库,还能作为一个"本地搜索引擎"回流给下一次研究用。新闻订阅子系统则把"一个主题"变成"定时自动跑的研究",AI 过滤汇总后投递到新闻流。

(5) 期刊质量评分是个更小的数据子系统,只在本章末尾一句话定位(它的过滤器已在 02 出场)。


2. 顶层全景(它大概怎么转)

先看这三块怎么围着"一次研究运行"咬合。怎么读这张图: 中间竖轴是一次研究的生命周期;左边是套在它外面的安全边界,右边是它产出的数据去了哪、以及订阅怎么反过来驱动它。

安全边界(第3、4节) 一次研究运行 数据回流 & 自动化(第5、6节)
┌───────────────────────────┐ ┌────────────────────────┐
│ 出站策略 PDP(policy.py) │ │ │
│ · 5 种 EgressScope │◄─────│ 选引擎 / 选 LLM / │
│ · evaluate_engine/url/ │ 咨询 │ 选嵌入 / 取 URL │
│ llm/embeddings/retriever│ │ (PEP 调用点) │
└───────────────────────────┘ │ │
┌───────────────────────────┐ │ │ ┌──────────────────────┐
│ 二道防线:PEP-578 审计钩子 │ │ 搜到一批来源 ─────────┼───────►│ 知识库 Library │
│ 拦每一个 socket.connect │◄─────│ │ 下载 │ 下载→抽文本→嵌入→FAISS│
│ (audit_hook.py) │ 兜底 │ │ │ 变成"本地搜索引擎" │
└───────────────────────────┘ │ 合成报告 ◄────────────┼────────┤ 回流给下次研究(引擎) │
┌───────────────────────────┐ │ │ └──────────────────────┘
│ 常规护栏(第4节) │ └───────────▲────────────┘ ┌──────────────────────┐
│ SSRF · 日志脱敏 · 账户锁定 │ │ 定时触发 │ 新闻订阅 News │
│ 文件完整性 · 模块白名单 │ └───────────────────────┤ 订阅→调度→AI 过滤汇总 │
└───────────────────────────┘ │ →投递新闻流 │
└──────────────────────┘

各部件一句话职责:

部件干什么主文件
出站策略 PDP判定"这个引擎/URL/LLM/嵌入/取回该不该放行"security/egress/policy.py
审计钩子(二道防线)进程级拦截每个 socket.connect,兜住漏网路径security/egress/audit_hook.py
保存时校验设置保存时拒绝"把公网主机伪装成本地"security/egress/validators.py
SSRF 校验器拦内网/云元数据 IP、解析差异攻击security/ssrf_validator.py
日志脱敏把密码/token 从日志和错误里抹掉security/log_sanitizer.py
模块白名单只允许已知搜索引擎类被动态导入security/module_whitelist.py
文件完整性FAISS 索引被篡改就报警 + 安全反序列化security/file_integrity/research_library/services/faiss_safe_load.py
知识库服务下载/抽取/索引研究来源,建可搜私有库research_library/services/research_library/downloaders/
库搜索引擎把私有库当搜索引擎回流研究web_search_engines/engines/search_engine_library.py..._collection.py
新闻调度器定时跑订阅研究、后台索引scheduler/background.pynews/subscription_runner.py

3. 出站管制:两道防线怎么咬合

这是本章工程含量最高的一支。先建立最重要的一个直觉。

3.1 一个核心直觉:主防线 + 二道防线

出站管制不是一个函数,而是两层:

  • 主防线 = 显式 PEP(Policy Enforcement Point,策略执行点)。 每个已知的出口——建搜索引擎、建 LLM、建嵌入、取一个 URL——都在动手前先问一句策略。这是"正门检票"。
  • 二道防线 = PEP-578 审计钩子。 万一有个代码路径没接主防线(新贡献者直接 requests.get、某个 MCP 工具自己开连接、prompt 注入把工具带偏成裸 HTTP),二道防线在最底层的 socket.connect 上再拦一次。这是"翻墙也会被最外围围栏挡住"。

术语借自零信任 / XACML(security/egress/policy.py:1-15 顶部注释):

缩写全称在这里是谁
PDPPolicy Decision Point(策略决策点)policy.py 里的 evaluate_* 函数——只判断,不执行
PEPPolicy Enforcement Point(策略执行点)各调用点:工厂建引擎、get_llmDownloadService 取 URL……

诚实边界(源码原话): 这是"进程内的正确性护栏,不是硬安全边界"(policy.py:1-10)。它防的是配置失误、prompt 注入诱导的取 URL、意外出站;它防不住能在 LDR 进程里执行代码的对手——那种对手可以直接 clear_active_context() 把钩子关掉。要硬边界,得在操作系统层叠(network namespace、防火墙、受限 Docker)——audit_hook.py:17-28 的威胁模型讲得很清楚。

3.2 五种出站范围(EgressScope)

用户声明的边界只有五个取值(policy.py:60-91,EgressScope):

Scope含义效果
STRICT只用主引擎,零扩展只有主引擎放行;URL 只允许私有主机
PUBLIC_ONLY只走公网引擎任何公网引擎/公网 URL 放行
PRIVATE_ONLY只走本地引擎只放行本地引擎/私有主机,并强制本地 LLM + 本地嵌入
BOTH任何已分类引擎都行保留策略引入前的行为
ADAPTIVE跟随主引擎默认值;运行开始时解析成上面某个具体 scope

两个关键设计点:

  • 默认是 ADAPTIVE,不是 BOTH。 大多数人从不碰这个设置,ADAPTIVE 的意思是"跟我主引擎走":主引擎是本地私有库 → 自动变 PRIVATE_ONLY;是公网引擎 → 变 PUBLIC_ONLY;分不清 → 退回 BOTH(_resolve_adaptive_scope,policy.py:1143)。解析在运行开始时一次性做完,存进 context 的是解析后的具体 scope,不是 ADAPTIVE(context_from_snapshot:1281-1288)。

  • PRIVATE_ONLY 会连带锁死推理路径。 "我的数据留在本机"这个承诺,只有当 LLM 和嵌入在本地时才成立——云 LLM 会收到查询+检索到的本地片段,云嵌入会在建库时收到整个语料。所以 PRIVATE_ONLY 下 require_local_llm / require_local_embeddings 被强制置 True,哪怕用户没开(context_from_snapshot:1290-1305)。STRICT 故意不这么耦合(它只管搜索引擎集,和推理放哪正交)。

ADAPTIVE 解析的分支很直白:

主引擎是…… ADAPTIVE 解析成……
├─ 明确的本地引擎 ───► PRIVATE_ONLY (is_local=True, is_public≠True)
├─ 明确的公网引擎 ───► PUBLIC_ONLY (is_public=True, is_local≠True)
├─ 注册的本地取回器 ──► PRIVATE_ONLY (查 retriever registry)
└─ 分不清 / 出错 ───► BOTH (宽松兜底,永不硬失败一次运行)

3.3 一次运行怎么"武装"二道防线

审计钩子在导入 security 包时就装好了,而且装了就拆不掉(PEP 578 的设计,audit_hook.py:152 install_audit_hook + security/__init__.py:117 调用)。但它默认是睡着的——只有当前线程调用了 set_active_context(ctx) 才对这个线程生效(audit_hook.py:73)。这是刻意的:随便 import 这个包的脚本、pytest 收集器碰个 socket,都不该突然 PolicyDeniedError

于是每条运行入口都要在开跑前"武装"、跑完"解除":

研究运行开始

├─ 已经有人武装了吗?(get_active_context) ← Web worker 在 research_service 里先武装了
│ 有 → 什么都不做(不是我武装的,不归我拆)
│ 没 → 从设置快照 build 一个 EgressContext,set_active_context(ctx)

├─ 跑完整研究管线(期间每个 socket.connect 都过钩子)

└─ finally: 只有"我武装的"才 clear_active_context() ← 防止线程池复用把 context 泄漏给下一个任务

这段逻辑在 search_system.py:_arm_egress_backstop(351)+ analyze_topic(305,finally 里 336/343 条件清理)。注释点明了为什么要有它:Web worker 会自己武装,但 CLI、新闻调度器、编程式 API 直接构造 AdvancedSearchSystem,不兜一下就会"整条管线在二道防线关闭的状态下跑"(search_system.py:331-335)。

嵌套安全也想到了:active_egress_context 上下文管理器保存/恢复上一个 context,而不是无脑清空(audit_hook.py:120-141)——不然"研究里套聊天"这种嵌套会在内层退出时把外层的 context 也擦掉,悄悄卸掉二道防线。

3.4 审计钩子内部:怎么判一个 socket.connect

钩子只盯 socket.connect 事件,别的事件直接放过(audit_hook.py:_audit_hook:196)。核心判定链条:

socket.connect 触发

├─ 有 active context 吗? 没有 → 放行(快路径,两次属性查找)
├─ 重入保护:钩子内部又触发 connect? → 放行,防止无限递归栈溢出
├─ 是网络 socket 吗?(AF_INET/AF_INET6)不是(AF_UNIX 等)→ 放行
├─ scope 是 PRIVATE_ONLY 或 STRICT 吗?不是 → 放行
│ (PUBLIC_ONLY 管的是"选引擎",不该拦本地 Ollama/嵌入这类基础设施流量)
└─ 是 → 把 host 合成成 http://host,丢给 evaluate_url 判;拒了就 raise PolicyDeniedError

几个容易忽略但很重要的细节:

  • 只在 PRIVATE_ONLY / STRICT 下真拦。 这两个 scope 才明说"不该碰公网主机";PUBLIC_ONLY 管的是引擎选择,拿它拦 socket 会误伤本地 LLM(audit_hook.py:249-262)。
  • bytes 型 host 会被解码再判。 CPython 允许用 bytes host 连接,不解码就会绕过整个二道防线(_extract_host:186-193)。
  • 失败不吞异常。 策略本身报错,连接就大声失败,好让运维发现——"出 bug 就悄悄失效"正是纵深防御最不该有的行为(audit_hook.py:200-207)。
  • ADAPTIVE 不许进钩子。 set_active_context 里有个 fail-fast:存进来的 context 若还是 ADAPTIVE 就抛错,因为钩子只认 PRIVATE_ONLY/STRICT,一个 ADAPTIVE 漏进来会让二道防线变成静默的空操作(audit_hook.py:83-97)。

3.5 PDP:五个 evaluate_* 各判一类出口

主防线的所有判断都收在 policy.py,每个 evaluate_* 返回一个 Decision(allowed, reason)(policy.py:124),reason 永远是机器码(如 "scope_mismatch_public_only"),从不含用户的查询或 URL 内容——避免侧信道泄漏。硬拒时 PEP 抛 PolicyDeniedError(policy.py:135),而不是优雅返回空——这样拒绝延迟一致,堵住"LLM 从返回快慢推断策略状态"的时序泄漏(policy.py:135-147)。

PDP 函数判什么关键规则
evaluate_engine(558)引擎能否实例化scope 与引擎分类是否相容;STRICT 下必须等于主引擎;未分类一律 fail-closed
evaluate_url(917)任意 URL 能否取危险 scheme / 元数据 IP 永拒;按 scope 判公私;带每运行拒绝配额
evaluate_llm_endpoint(743)LLM 提供方能否用require_local_llm 时生效;云厂商恒拒;URL 解析出本地才放
evaluate_embeddings(803)嵌入提供方能否用require_local_embeddings 时生效;OpenAI 除非 base_url 指向本地否则拒
evaluate_retriever(1062)注册取回器能否调读注册时的 is_local;未分类 fail-closed

分类一个主机是"本地/公网/未知"由 _classify_host(233)统一做:先查用户声明的本地主机名 → 查是否 NAT64 包裹的元数据 → 字面 IP 判断 → 最后才 DNS 解析(带 2 秒超时,解析不出按公网处理,fail-safe)。

还有一个 UX 层的建议性预过滤 filter_engines_by_egress(477):在把候选引擎列表交给 LLM 选之前,先把 scope 明确拒绝的引擎摘掉,省得 LLM 浪费选择槽。它故意比工厂 PEP 更宽松(不认识的引擎保留),因为它只是优化,不是执行点——真正的拒绝还在工厂实例化那一关(policy.py:489-500)。

3.6 三个值得带走的精妙细节

(a) 云元数据 IP 永远封,无视 scope。 169.254.169.254(AWS/Azure IMDS)这类地址在 IP 分类里算"链路本地/私有",所以 STRICT 和 PRIVATE_ONLY 本会放行它——那是一条经典的凭证窃取路径(prompt 注入让 agent 去取云凭证)。所以 evaluate_url 在 scope 判断之前先无条件封掉这批 IP(policy.py:964-1003,_METADATA_HOSTNAMES),并且额外处理:NAT64 包裹形式、八进制/十六进制/整数等替代编码(_normalize_alt_ipv4:872)、带尾点的主机名、GCP 的 metadata.google.internal 主机名。底层封锁集在 ssrf_validator.py:ALWAYS_BLOCKED_METADATA_IPS(24),连运维开了 LDR_SECURITY_ALLOW_NAT64 也解不开元数据封锁(is_ip_blocked:139-145)。

(b) 每运行的拒绝配额,防耗尽攻击。 一份恶意索引文档可以让 agent 在几百个被拒 URL 之间打转,拖垮它。所以每次运行累计到 MAX_DENIED_FETCHES_PER_RUN = 50(policy.py:44)就连合法 URL 也一起 fail-closed。计数器锚在"运行的活跃 context"上而不是每个调用点各自的 context(_quota_ctx:893)——不然恶意文档只要把拒绝分散到不同 context 就能绕过配额。良性解析失败(mailto:、坏 href、data: URI)不计入配额,否则一篇满是 data: URI 的文档就能把预算耗光,让长跑的 PUBLIC_ONLY 运行中途开始拒绝合法 URL(_NON_QUOTA_DENIAL_REASONS:55)。

(c) DNS 分类缓存"先写者赢"。 并发子 agent 可能同时分类同一个主机名;轮询 DNS 名可能对一个线程解析出私有 IP、对另一个解析出公网 IP。若"后写者赢",一次晚到的调用能翻掉早先的结论,让 PRIVATE_ONLY/STRICT 在后续取用时被放松。所以缓存写入固定第一个写者的值(_cache_classification:212),让每次运行的分类稳定、确定,与完成顺序无关。DNS I/O 本身在锁外做,避免子 agent 互相串行等待(_classify_host:246-247 lock 讨论)。

3.7 引擎自己也再验一道:_verify_egress_scope

搜索引擎基类在 run() 真正发 HTTP 前,还会自查一次(search_engine_base.py:run:594_verify_egress_scope:479)。这是"工厂 PEP + 策略级过滤 + 审计钩子"之后的又一道防御,专门兜住绕过工厂、直接带快照构造的引擎。它按"快照身份 + 策略相关值(scope、主引擎)"记忆化(_egress_policy_key:549),避免每次 run() 都重跑一遍(ADAPTIVE + URL 型主引擎的评估可能含一次 2 秒 DNS);拒绝从不记忆化(直接抛)。

保存时也有一道:validate_allowed_local_hostnames(validators.py:21)在用户把主机名加进 llm.allowed_local_hostnames 时,用同一个分类器解析,拒绝那些解析到公网的名字——不然用户能把外部主机"声明"成本地,骗过整个策略。它对解析不出的名字 fail-open(DNS 抖动/分裂 DNS 时还能存),这正是这个设置存在的用例。


4. 其余纵深防御一览

出站之外的常规护栏,各自独立、职责单一。一张表看全,再挑两条讲精华。

护栏解决什么入口符号文件
SSRF 校验拦内网/元数据 IP、URL 解析差异攻击is_ip_blockedvalidate_urlsecurity/ssrf_validator.py:66/192
URL 校验危险 scheme、开放重定向、可疑模式URLValidator.is_safe_urlis_safe_redirect_urlsecurity/url_validator.py:86/344
日志脱敏密码/token 不进日志和错误响应redact_secretssanitize_error_messagesecurity/log_sanitizer.py:65/249
账户锁定按用户名限制暴力破解AccountLockoutManager.record_failuresecurity/account_lockout.py:19/88
模块白名单只允许已知引擎类被动态导入validate_module_importget_safe_module_classsecurity/module_whitelist.py:123/174
文件完整性FAISS 索引被篡改就报警FileIntegrityManager.verify_filesecurity/file_integrity/integrity_manager.py:178
安全反序列化FAISS docstore 反序列化不执行任意代码safe_load_faissresearch_library/services/faiss_safe_load.py:99

精华一:URL 里的反斜杠是承重炸弹。 ssrf_validator.py:57-63RFC_FORBIDDEN_URL_CHARS_RE 专门拦一批 RFC 3986 禁止的字符,其中反斜杠是核心载荷:Python 的 urlparse 把它当普通字符,而 requests/urllib3 把它当路径分隔符——于是 http://127.0.0.1\@1.1.1.1 能骗过基于 urlparse 的主机名检查,实际却连到 127.0.0.1(解析差异攻击,GHSA-g23j-2vwm-5c25)。策略里到处对 host 先 unquote 再判,也是同一类防御(HTTP 客户端连接前会百分号解码,不解码就分类会看到假主机)。

精华二:模块白名单是"只准相对导入"。 动态加载搜索引擎类时,validate_module_import(123)不只查白名单,还要求模块路径以 . 开头(相对导入)——这确保所有导入都相对于 local_deep_research.web_search_engines,从根上堵死 ossubprocess 这类绝对路径导入被塞进配置(module_whitelist.py:152-160)。类名也各有白名单(ALLOWED_CLASS_NAMES:73)。注意:出站策略里 _get_engine_class 也是走这个 get_safe_module_class 去查引擎类的(policy.py:332-345),两套系统在这里咬合。

精华三:FAISS 索引不靠校验和,而是不给 pickle 执行机会。 LangChain 的 FAISS.load_local(..., allow_dangerous_deserialization=True) 会用裸 pickle.load 反序列化 .pkl 伴生文件——pickle 反序列化时执行任意代码,一个被替换的 .pkl 就是 RCE。而 .faiss.pkl 是独立加载、不交叉校验的,所以只对 .faiss 记校验和保护不了 .pklfaiss_safe_load.py 的做法是直接移除危险反序列化:自己复刻 load_local,用一个受限 unpickler 只允许 FAISS docstore 合法含有的两个类(InMemoryDocstoreDocument),任何其他 global(os.system、构造的 __reduce__ 载荷)在执行前就抛 UnpicklingError(faiss_safe_load.py:1-29,_RestrictedFaissUnpickler:63,_ALLOWED_GLOBALS:42)。连 copyreg 扩展码和 persistent id 都一并拒掉(get_extension/persistent_load)。这是"防御靠构造,而非靠检测"的范例。文件完整性系统(FileIntegrityManager + FAISSIndexVerifier,file_integrity/verifiers/faiss_verifier.py:13)则对 .faiss 二进制记 SHA256、声明"永不允许手改"作为补充报警。

日志脱敏的一个细节值得记: 密码在加密库场景里是不可恢复的 SQLCipher 主密钥,一旦泄漏无法轮换。所以调度器里凡是 password 在栈帧作用域内的 except,都用 redact_secrets(str(e), password) 抹掉密码、并丢弃 traceback——因为 loguru 的 diagnose=True 会渲染栈帧局部变量,把密码打进日志(scheduler/background.py 里几十处这种成对处理,例如 300-309、649-660)。_CREDENTIAL_PATTERNS(log_sanitizer.py:134)另有一批正则,专抓 HTTP 库异常里的 Bearer token、Authorization 头、URL 里的 user:pass@?api_key= 参数。


5. 知识库(Library):研究命中怎么变成可搜索的私有库

这一节讲数据回流:一次研究命中的网页/论文,怎么落地成一个你私有、可搜索、还能反过来喂研究的语料库。

5.1 一条来源的完整旅程

研究命中一批来源(ResearchResource,带 url)

▼ DownloadService.queue_research_downloads / download_resource
┌───────────────────────────────────────────────┐
│ ① 下载 _download_pdf │
│ 先过 egress 策略闸(_check_url_against_policy)│ ← 这里是真 PEP:被拒的 URL 永不到达下载器
│ 按 url 选下载器:arxiv / pubmed / biorxiv / │
│ openalex / semantic_scholar / 通用 / 直链PDF │
└───────────────────────────────────────────────┘
│ 拿到 PDF 字节 / 或走文本抽取分支
▼ extraction/pipeline.py: extract_content
┌───────────────────────────────────────────────┐
│ ② 抽文本 多引擎降级:trafilatura → readability │
│ → newspaper → justext,取最好的正文 + 元数据 │
└───────────────────────────────────────────────┘
│ text_content 落进 Document 行
▼ LibraryRAGService.index_document
┌───────────────────────────────────────────────┐
│ ③ 切块 + 嵌入 + 建/并 FAISS 索引 │
│ 每个 collection 用自己的嵌入配置 │
│ 落盘索引用 safe_load_faiss 安全加载(见第4节)│
└───────────────────────────────────────────────┘

▼ 作为一个"本地搜索引擎"回流
┌───────────────────────────────────────────────┐
│ ④ LibraryRAGSearchEngine / CollectionSearch │
│ is_local = True → 在 PRIVATE_ONLY 下可搜 │ ← 回到第3节:它就是"本地引擎"
│ 下次研究能把私有库当一个 source 检索 │
└───────────────────────────────────────────────┘

5.2 下载:策略闸开在"真正要连网"的那一点

DownloadService(download_service.py:70)构造时从设置快照建一个 EgressContext(_build_egress_context:251)。关键在 fail-closed 的处理:如果给了快照但策略无法评估(scope 值损坏),它设 _policy_locked = True,之后每个 URL 检查都拒——旧代码在这种情况下返回 None、检查函数又返回 (True, "no_context"),等于在配置坏掉时放行一切下载(_build_egress_context:256-262 的注释就是在讲修这个洞)。

闸门本身开在 _download_pdf 里、真正要发网络请求的那一点,而不是入口的 download_resource——因为不管从哪条路进来,"被拒的 URL 绝不能到达下载器"(_download_pdf:698-707,_check_url_against_policy:301)。空字典 {} 也被当成真快照(用 is None 判而非真值判),避免"空快照 → 跳过策略 → fail-open"。

下载器是一组可插拔实现(research_library/downloaders/),各自 can_handle(url) 认领:arXiv、PubMed(含 Europe PMC 回退)、bioRxiv、OpenAlex、Semantic Scholar、通用 HTML、直链 PDF、Playwright 渲染。基类契约在 downloaders/base.py:BaseDownloader(38),返回 DownloadResult(带 skip_reason,便于诊断为什么某篇没下到)。

5.3 抽文本:多引擎降级取最好正文

抽取管线 extraction/pipeline.py:extract_content(103)不是只用一个库,而是多个抽取器降级(research_library/downloaders/extraction/ 下 trafilatura / readability / newspaper / justext / metadata),各有所长,取正文质量最好的结果 + 结构化元数据。抽出的 text_content 落进 Document 行,成为后续索引的原料。

5.4 索引 + 回流:私有库变成"本地搜索引擎"

LibraryRAGService.index_document(library_rag_service.py:943)负责切块、嵌入、建/并 FAISS 索引;每个 collection 可以有自己的嵌入配置(模型/切块参数),所以按 collection 分组分别建索引。落盘索引的读取一律走第 4 节的 safe_load_faiss

回流的关键是分类标记:库搜索引擎 LibraryRAGSearchEngine(search_engine_library.py:24)和按集合的 CollectionSearchEngine(search_engine_collection.py:21)都声明 is_local = True(30 / 29)。这一个标记把它们接回了第 3 节的整套出站策略——在 PRIVATE_ONLY 下它们能被搜、在 ADAPTIVE 下选它做主引擎会把整次运行拉成 PRIVATE_ONLY。集合的 is_public加性的:标为 public(比如公开论文)让它额外在 PUBLIC_ONLY 下也可搜、可用云推理处理,但它仍然是本地知识库、在 PRIVATE_ONLY 下依旧可搜(policy.py:_engine_bucket:423-440 的详细注释)。


6. 新闻/研究订阅:把研究变成定时投递

这一节讲自动化:订阅一个主题,后台定时跑研究、AI 过滤汇总、投递到新闻流。

6.1 全景:订阅怎么驱动研究

用户订阅一个主题(NewsSubscription,带 query_template + refresh_interval)

▼ BackgroundJobScheduler(APScheduler,per-user)
┌──────────────────────────────────────────────────────┐
│ ① 调度 _schedule_user_subscriptions │
│ 按刷新间隔给每个订阅排 job,带随机 jitter 打散 │
│ 凭据:SchedulerCredentialStore(TTL 48h,内存) │ ← 后台要开加密库,得临时存密码
└──────────────────────────────────────────────────────┘
│ 到点触发 _check_subscription
▼ subscription_runner.build_subscription_request_data
┌──────────────────────────────────────────────────────┐
│ ② 跑研究 组装 /research/api/start 载荷,进程内调用 │
│ query 模板里的 YYYY-MM-DD 换成用户本地日期 │
│ strategy = news_aggregation │
└──────────────────────────────────────────────────────┘

▼ NewsAggregationStrategy(单迭代 + 8 路并行搜索)
┌──────────────────────────────────────────────────────┐
│ ③ AI 过滤汇总 NewsAnalyzer │
│ extract_news_items / generate_big_picture / │
│ generate_watch_for / generate_patterns │
│ RelevanceService 按用户偏好打相关性分 │
└──────────────────────────────────────────────────────┘

▼ 投递到新闻流(card storage,display_in=news_feed)

└─ 成功:advance_refresh_schedule 推进下次刷新
失败:mark_subscription_due_by_id 重置为"待跑",下轮再拾

6.2 调度器:后台开加密库的凭据难题

难点在 05 讲的加密库:每个用户一个 SQLCipher 库,开库要密码。后台 job 没有用户在场,怎么开?SchedulerCredentialStore(background.py:45)在用户每次数据库交互时把密码临时存进内存,带 48 小时 TTL(update_user_info:411)。用户登出就清、TTL 到期就清(unregister_user:469)。这也是为什么第 4 节讲的日志脱敏在这个文件里如此密集——这里到处是明文 SQLCipher 主密钥。

调度器是单例(BackgroundJobScheduler.__new__:108),按用户活跃度管理 job:活跃用户才排订阅 job,不活跃到期就清理。排 job 时加随机 jitter(_schedule_user_subscriptions:589-592)把多个订阅的触发时间打散,避免同一时刻扎堆。

6.3 载荷与调度算术收在一处

有三个地方会"跑一个订阅":手动"立即运行"按钮、逾期扫描、后台调度器。它们以前各写各的,漂移出了三套(元数据不同、漏了搜索引擎字段、有条路径忘了更新刷新时间)。subscription_runner.py 把载荷形状和调度算术收进共享函数:build_subscription_request_data(24)拼 /research/api/start 载荷,advance_refresh_schedule(85)算下次刷新。

一个值得记的正确性细节:订阅在派发时就把 next_refresh 往后推一个间隔,防止研究还在跑时调度器重复触发。但如果研究失败了,"仅成功才推进"的完成钩子不会触发,next_refresh 就被推到了未来、订阅被静默跳过。所以失败处理器调 mark_subscription_due_by_id(125)把 next_refresh 重置为现在,让下个周期重新拾起(subscription_runner.py:125-150 的注释讲了这个坑)。

6.4 后台索引也复用出站二道防线

调度器不只跑订阅,还跑两个文档后台 job:_process_user_documents(background.py:852,下载/抽取研究附带的文档)和 _reconcile_unindexed_documents(1236,把所有未索引文档补索引进库)。这两个都是第 5 节知识库流程的定时批处理版

关键呼应第 3 节:APScheduler 的 worker 线程不带出站 context,所以后台下载时二道防线本来是关的。于是这两个 job 都调 _arm_egress_backstop(background.py:826)从用户设置武装 context,让定时下载和交互式研究享受同一张二道防线,@thread_cleanup 在退出时清理(background.py:919-9261332-1337)。DownloadService 的 evaluate_url PEP 仍是主闸,这只是补齐纵深防御的对等。

6.5 AI 过滤汇总

NewsAnalyzer(news/core/news_analyzer.py:16)把搜索结果加工成新闻:extract_news_items(109)抽条目、generate_big_picture(173)/generate_watch_for(216)/generate_patterns(278)分别生成大图景、关注点、模式。RelevanceService.calculate_relevance(news/core/relevance_service.py:12)按用户偏好(喜欢/不喜欢的类别、影响力阈值)给每张卡打 0~1 相关性分,决定投递优先级。新闻策略本身 NewsAggregationStrategy(news_strategy.py:16)是单迭代 + 8 路并行搜索——新闻要的是广覆盖,不是深挖(max_iterations = 1,questions_per_iteration = 8)。


7. 期刊质量评分子系统(一句话定位)

journal_quality/ 是一个离线参考数据子系统:它从 OpenAlex、DOAJ、JabRef、Stop Predatory Journals、OpenAlex Institutions 等上游批量下载期刊质量数据,编译成一个只读 SQLite 库(mode=ro&immutable=1 + 建库后 chmod 0o444,唯一写者是 build_db() 自己),供 02 里的 journal_reputation_filter(advanced_search_system/filters/journal_reputation_filter.py)在检索时给学术来源按期刊声誉打分、剔除掠夺性期刊(journal_quality/__init__.py:1-22)。本章只讲它的数据来源与作用;打分怎么接进两阶段检索见 02。


8. 边界与局限(诚实)

  • 出站策略是正确性护栏,不是硬安全边界。 能在 LDR 进程里执行代码的对手可以 clear_active_context()、monkey-patch 钩子、或用带外 fd 绕过。要硬边界必须叠 OS 级控制(audit_hook.py:23-28policy.py:6-10)。
  • 审计钩子只在 PRIVATE_ONLY / STRICT 下真拦。 PUBLIC_ONLY / BOTH 下二道防线对 socket 层是放行的,靠主防线 PEP。
  • _verify_egress_scope 只在快照身份变化时重验。 构造后 scope 若被原地就地修改(改的是同一个 dict 的部分键),只有 scope/primary 这两个记忆化键的改动会被发现;其他策略相关键的就地修改会返回陈旧结论(search_engine_base.py:_egress_policy_key:549-567 的 MAINTENANCE 注释)。
  • 调度器凭据是内存、按进程。 多 worker 部署(如 gunicorn)每个 worker 各存各的锁定/凭据状态(account_lockout.py:6-9background.py 凭据 store)。
  • DNS 分类 fail-safe 到公网。 解析不出的主机按公网处理——保守但会误伤某些分裂 DNS / 内网场景(所以才有 allowed_local_hostnames 这个用户声明口子)。

9. 代码地图(导航索引)

主题文件关键符号
出站策略 PDPsecurity/egress/policy.pyEgressScopeEgressContextDecisionPolicyDeniedErrorcontext_from_snapshot_resolve_adaptive_scopeevaluate_engineevaluate_urlevaluate_llm_endpointevaluate_embeddingsevaluate_retriever_classify_hostfilter_engines_by_egress_quota_ctxMAX_DENIED_FETCHES_PER_RUN
二道防线审计钩子security/egress/audit_hook.pyinstall_audit_hookset_active_contextclear_active_contextget_active_contextactive_egress_context_audit_hook_extract_host
出站校验/取用security/egress/validators.pysecurity/egress/fetch.pyvalidate_allowed_local_hostnamespolicy_aware_validate_url
运行时武装出站search_system.pyweb_search_engines/search_engine_base.py_arm_egress_backstopanalyze_topic_verify_egress_scope_egress_policy_key_check_egress_policy
SSRF / URL 校验security/ssrf_validator.pysecurity/url_validator.pyALWAYS_BLOCKED_METADATA_IPSis_ip_blockedvalidate_urlRFC_FORBIDDEN_URL_CHARS_REURLValidator.is_safe_urlis_safe_redirect_url
日志脱敏 / 账户锁定security/log_sanitizer.pysecurity/account_lockout.pyredact_secretssanitize_error_message_CREDENTIAL_PATTERNSAccountLockoutManagerrecord_failure
模块白名单 / 文件完整性security/module_whitelist.pysecurity/file_integrity/ALLOWED_MODULE_PATHSvalidate_module_importget_safe_module_classFileIntegrityManager.verify_fileFAISSIndexVerifier
安全反序列化research_library/services/faiss_safe_load.pysafe_load_faiss_RestrictedFaissUnpickler_ALLOWED_GLOBALS
知识库下载/抽取/索引research_library/services/research_library/downloaders/DownloadService_build_egress_context_check_url_against_policy_download_pdfextract_contentLibraryRAGService.index_document
库搜索引擎(回流)web_search_engines/engines/search_engine_library.py..._collection.pyLibraryRAGSearchEngine(is_local=True)、CollectionSearchEngine
新闻订阅 / 调度news/subscription_runner.pyscheduler/background.pynews/core/build_subscription_request_dataadvance_refresh_schedulemark_subscription_due_by_idBackgroundJobSchedulerSchedulerCredentialStore_arm_egress_backstop_reconcile_unindexed_documentsNewsAggregationStrategyNewsAnalyzerRelevanceService
期刊质量评分journal_quality/advanced_search_system/filters/journal_reputation_filter.pybuild_dbJournalQualityDBjournal_reputation_filter