瀏覽代碼

Support offline full and single-library knowledge imports

wangxi 3 周之前
父節點
當前提交
bd424f01c5

+ 3 - 0
.gitignore

@@ -9,6 +9,9 @@
 .idea/
 /models/
 knowledge/prepared/
+knowledge/prepared.previous-*/
+knowledge/.prepared-import-*/
+knowledge/.prepared.publish.lock
 knowledge/token_stats/
 data/
 output/

+ 22 - 1
deploy-offline.sh

@@ -1,6 +1,7 @@
 #!/usr/bin/env sh
 # Linux offline deployment using the extracted single release archive.
 set -eu
+CALLER_DIR=$(pwd)
 case "$0" in
   */*) SCRIPT_DIR=${0%/*} ;;
   *) SCRIPT_DIR=. ;;
@@ -12,7 +13,10 @@ case "$ACTION" in
   help|-h|--help)
     echo 'Usage: sh deploy-offline.sh [deploy|import-knowledge|restart|start|stop|status|check-milvus|check-redis|check-dms|check-deepseek|check-qcc "company full name"|check "company full name"]'
     echo 'deploy: use the preinstalled .venv and packaged snapshot; never download'
-    echo 'import-knowledge: verify and restore the packaged knowledge snapshot to Milvus'
+    echo 'Usage: sh deploy-offline.sh import-knowledge [ZIP_PATH [DATABASE]]'
+    echo 'import-knowledge: replace the full snapshot, or only the named library; preserve old versions'
+    echo 'DATABASE: policy | finance | qa | article | service_guide | service_catalog'
+    echo 'ZIP_PATH defaults to output/knowledge-snapshot.zip; relative paths use the caller directory'
     echo 'Configuration and prerequisites: docs/deployment.md'
     exit 0 ;;
   deploy|import-knowledge|restart|start|stop|status|serve|check|check-milvus|check-redis|check-dms|check-deepseek|check-qcc) ;;
@@ -21,6 +25,17 @@ esac
 case "$ACTION" in
   check|check-qcc)
     [ "$#" -eq 2 ] && [ -n "$2" ] || { echo 'This check requires one exact company name.' >&2; exit 2; } ;;
+  import-knowledge)
+    [ "$#" -le 3 ] || { echo 'Usage: import-knowledge [ZIP_PATH [DATABASE]]' >&2; exit 2; }
+    if [ "$#" -ge 2 ]; then
+      [ -n "$2" ] || { echo 'ZIP_PATH must not be empty.' >&2; exit 2; }
+      case "$2" in /*) KNOWLEDGE_ZIP=$2 ;; *) KNOWLEDGE_ZIP=$CALLER_DIR/$2 ;; esac
+    fi
+    if [ "$#" -eq 3 ]; then
+      case "$3" in policy|finance|qa|article|service_guide|service_catalog) ;;
+        *) echo 'Unknown knowledge library.' >&2; exit 2 ;;
+      esac
+    fi ;;
   *) [ "$#" -le 1 ] || { echo 'Only one action is accepted.' >&2; exit 2; } ;;
 esac
 [ "$(uname -s)" = Linux ] || { echo 'deploy-offline.sh requires Linux.' >&2; exit 2; }
@@ -30,5 +45,11 @@ SERVICE_PYTHON="$REPO_ROOT/.venv/bin/python"
 [ -x "$SERVICE_PYTHON" ] || { echo 'Offline deployment requires the preinstalled .venv/bin/python.' >&2; exit 1; }
 case "$ACTION" in
   check|check-qcc) exec "$SERVICE_PYTHON" scripts/deploy.py "$ACTION" "$2" ;;
+  import-knowledge)
+    if [ "$#" -eq 3 ]; then
+      exec "$SERVICE_PYTHON" scripts/deploy.py "$ACTION" "$KNOWLEDGE_ZIP" "$3"
+    elif [ "$#" -eq 2 ]; then
+      exec "$SERVICE_PYTHON" scripts/deploy.py "$ACTION" "$KNOWLEDGE_ZIP"
+    fi ;;
 esac
 exec "$SERVICE_PYTHON" scripts/deploy.py "$ACTION"

+ 41 - 1
docs/deployment.md

@@ -59,7 +59,7 @@ sh deploy-offline.sh check-qcc "用于联调的企业完整名称"
 | 命令 | 作用与结果 |
 | --- | --- |
 | `check-dms` | 只读登录并核验五个DMS栏目的ID、名称、父栏目和模型字段;不读取业务记录。失败要区分网络、认证、权限和模型不匹配。 |
-| `import-knowledge` | 校验包内知识快照并恢复到Milvus,检查发布清单;不启动API。首次部署前执行,已有发布清单时`deploy`也可自动导入。 |
+| `import-knowledge [ZIP路径 [库名]]` | 校验并导入指定全量包或单库包,回读成功后切换清单并备份旧目录;不启动或重启API。不传路径时读取项目下`output/knowledge-snapshot.zip`。 |
 | `deploy` | 检查现有`.venv`、本地模型及知识清单;准备完成后停止本项目旧API并启动新API,等待`/health`就绪。首次部署或需要完整部署检查时使用。 |
 | `status` | 显示本项目API进程、健康状态和日志路径;进程不存在或不健康时返回非零退出码。 |
 | `check-milvus` | 只读核对发布清单对应的Milvus六库、集合和记录数量。 |
@@ -82,4 +82,44 @@ sh deploy-offline.sh stop     # 停止本项目API
 
 `start`只启动服务,不导入知识;`restart`会先检查环境和配置,再停止本项目API并启动,不重新导入知识。修改代码或`.env`后使用`restart`使新进程生效;更新知识则先运行`import-knowledge`,再`restart`。`status`的`healthy: true`表示API本身就绪,不表示所有外部依赖实时可用。
 
+### 6. 更新全部知识或指定知识库
+
+知识ZIP必须由本项目的`portable export`生成,包含原文、摘要、向量与校验清单;普通JSON文件压缩包不能直接导入。库名使用下表短名称,必须与单库包内声明一致。单库包是该库的**完整版本**,导入后该库只包含包内记录;不执行条目级追加合并,其他五库保持原有内容与集合。
+
+| 库名 | 内容 |
+| --- | --- |
+| `policy` | 惠企政策 |
+| `finance` | 金融产品 |
+| `qa` | 问答 |
+| `article` | 文章、法规及规范性文件 |
+| `service_guide` | 办事指南 |
+| `service_catalog` | 六大服务板块 |
+
+```sh
+cd /srv/business-assistant
+# 全量更新:不写库名;首次部署也用此方式
+sh deploy-offline.sh import-knowledge /srv/transfer/knowledge-all.zip
+
+# 只更新文章库:传文章库完整包及article,已有全量知识快照时使用
+sh deploy-offline.sh import-knowledge /srv/transfer/knowledge-article.zip article
+
+# 两种更新方式择一执行成功后,核对六库,再按维护窗口重启
+sh deploy-offline.sh check-milvus
+sh deploy-offline.sh restart
+sh deploy-offline.sh status
+```
+
+支持绝对路径及相对于**执行命令时所在目录**的相对路径;含空格的路径需加引号。首次初始化需导入全量包,再进行单库更新。全量包不能带库名,单库包必须带对应库名;错包、错库、模型不匹配或校验失败会明确停止。仍支持省略ZIP路径的旧命令,但不会自动扫描或选择最新ZIP。
+
+导入不调用摘要服务、不生成新向量,使用包内已生成的向量。全量更新允许替换现有不同版本;单库更新要求与其他库使用相同模型及索引契约。写入新集合并回读全部导入记录、向量后才切换本地清单。旧Milvus集合不删除,旧本地目录保存在`knowledge/prepared.previous-<唯一编号>`,成功输出`backup`路径。失败时保留当前清单,远端可能留下未发布的新集合;修复后可重跑。切换目录失败会尝试恢复旧目录;机器在两次目录重命名之间异常断电时,应从保留的旧目录恢复后重试。不要并行运行知识规整、摘要或手工替换文件。
+
+制作后续单库包时,在保存最新知识的机器上执行:
+
+```sh
+.venv/bin/python -m src.step2_knowledge_ingest.portable export /路径/article新版本.zip --database article
+.venv/bin/python -m src.step2_knowledge_ingest.portable verify /路径/article新版本.zip
+```
+
+省略`--database`导出全量包;旧全量ZIP格式继续兼容。任何导入都不会自动重启在线服务,文件更新不代表目标麒麟服务器已完成联调。
+
 每条诊断独立运行;失败时保留脱敏输出和退出码,不继续宣称部署成功。`deploy-offline.sh`不下载依赖或模型;离线只指安装资源离线,业务接口仍需连通。内存隔离见[内存安全说明](memory-safety.md),知识恢复细节见[知识入库说明](../knowledge/ingestion.md)。

+ 11 - 1
knowledge/ingestion.md

@@ -1,5 +1,9 @@
 # 第二步:知识入库
 
+## 2026-10-09 15:47 — F126 规范性文件增量加入文章库
+
+新增35条规范性文件进入article库,当前六库共4266条:policy170、finance185、qa928、article1321、service_guide1604、service_catalog58。快照`55cf59460978742e3d0e5ddc933b486a15ac4a5c9757d4509aaf324b75d6cd73`;生成35个向量、复用4231个,实际写入全量回读及独立`--verify-only`均通过。旧集合及`output/f126-backup`保留,服务需重启加载新manifest。全量知识包为`output/knowledge-snapshot-55cf5946.zip`,已独立验证并附SHA256;此更新未改造离线导入命令的不同快照冲突保护。
+
 2026-09-24 F117:新增998条青浦区介绍与新闻,article库从288增至1286,当前六库共4231条。新快照`da2968c1632af1535c690b7a4bd6f571a5bf26ac4c8eec04c76fb21e15561330`,生成998个新向量、复用3233个旧向量;全量写入回读及独立`--verify-only`均核对4231/4231通过,旧集合保留。在线服务需重启读取新manifest;新闻历史事实、政策效力和真实问题召回尚未业务验收。
 
 2026-09-24 F116:新惠企政策25个政策170个事项替换旧183条,其他3063条知识和摘要保持。当前共3233条,Milvus快照`76f44fbd7eae1383f61cb48669dd23cfd83ddb6c9f58092e735bbfe9a8466600`;policy170、finance185、qa928、article288、service_guide1604、service_catalog58。生成170向量、复用3063向量,全量写入回读及独立`--verify-only`均通过,旧集合未删除。在线服务需重启加载新manifest;真实问答召回、摘要语义和政策效力未验收。
@@ -39,7 +43,13 @@
 .\.venv\Scripts\python.exe -m src.step2_knowledge_ingest.portable import output/knowledge-snapshot.zip
 ```
 
-可用`--prepared`指定知识目录,`--env-file`指定配置文件;Linux使用部署环境的Python执行相同模块。导入会创建目标数据库及当前快照集合,逐库写入、回读验证后写出`knowledge/prepared/ingestion/manifest.json`,可供现有检索端读取。模型文件按相对路径及SHA256逐一核对,Windows与Linux的路径分隔符差异不会误判为换模。目标已有不同完整快照清单或本地规整文件冲突时拒绝切换;失败时可能留下未发布的目标集合,可在排查后重跑。导入不会自动重启API,不会更改运行中服务的清单;部署时先导入并校验,再按维护窗口启动或重启。目标环境的数据库前缀可以不同,恢复清单会写入目标库名。
+可用`--prepared`指定知识目录,`--env-file`指定配置文件;Linux使用部署环境的Python执行相同模块。F127起允许全量包替换已有不同快照:先将ZIP复制到隔离暂存目录并完整核验,再写入与回读新集合,成功后切换完整prepared目录。旧目录保存在同级`prepared.previous-<唯一编号>`,旧Milvus集合保留;失败不发布新清单,可能留下未发布的新集合。模型文件按相对路径及SHA256逐一核对,Windows与Linux的路径分隔符差异不会误判为换模。目标环境的数据库前缀可以不同,恢复清单会写入目标库名。导入不自动重启API;成功后按维护窗口重启读取新清单。
+
+导出单库包使用`portable export 包路径 --database article`;导入使用`portable import 包路径 --database article`,或`sh deploy-offline.sh import-knowledge 包路径 article`。支持policy、finance、qa、article、service_guide、service_catalog。包内库名必须与参数一致;单库包是所选库的完整替换版本,未包含的旧记录退出当前所选库,其他库的记录和集合保持。第一次初始化需用全量包;单库更新校验当前完整清单、本地原始/增强记录和相同模型,拒绝跨库ID冲突。
+
+单库更新后的全局`snapshot`仍是合并知识与模型的内容指纹,各库条目可额外包含自己的`snapshot`用于校验集合名;旧清单无该字段时沿用全局值。检索、独立`--verify-only`和再次导出均按各库实际集合读取。全量导出会重新整理为可独立迁移的统一版本包,单库导出使用`business-assistant-milvus-library-v1`格式,旧全量`business-assistant-milvus-snapshot-v1`继续兼容。原始和增强JSONL字段一致性也纳入包校验。暂存及历史目录只作本地恢复,不提交Git。
+
+导入、导出及入库验证共享prepared目录外的发布锁;不要同时运行规整、摘要或手工修改这些文件。清单完成只代表已导入范围校验成功,不代替其他服务健康检查。长期部署操作见[部署说明](../docs/deployment.md)。
 
 可用 `--prepared` 指定知识目录,`--env-file` 指定环境文件,路径默认以仓库为基准。真实资料、向量缓存与清单位于已忽略的 `knowledge/prepared/`。
 

+ 21 - 7
scripts/deploy.py

@@ -306,17 +306,25 @@ class Deployment:
             raise
         print(f'Knowledge published; previous local snapshot (if any): {backup}', flush=True)
 
-    def execute(self, action, company_name=None):
+    def execute(self, action, company_name=None, archive=None, database=None):
+        libraries = ('policy', 'finance', 'qa', 'article', 'service_guide', 'service_catalog')
+        if database is not None and (database not in libraries or archive is None):
+            raise RuntimeError('invalid_knowledge_import_arguments')
+        if action != 'import-knowledge' and (archive is not None or database is not None):
+            raise RuntimeError('unexpected_knowledge_import_arguments')
         if action == 'serve':
             return self.serve()  # No deployment lock held for the service lifetime.
         with self.lock():
             if action == 'import-knowledge':
                 if not (self.root / '.env').is_file():
                     raise RuntimeError('configure_dotenv_before_deployment')
-                snapshot = self.root / 'output/knowledge-snapshot.zip'
+                snapshot = Path(archive) if archive is not None else self.root / 'output/knowledge-snapshot.zip'
+                if not snapshot.is_absolute():
+                    snapshot = self.root / snapshot
                 if not snapshot.is_file():
                     raise RuntimeError('offline_knowledge_snapshot_missing')
-                self.run(sys.executable, '-m', 'src.step2_knowledge_ingest.portable', 'import', snapshot)
+                options = ('--database', database) if database else ()
+                self.run(sys.executable, '-m', 'src.step2_knowledge_ingest.portable', 'import', snapshot, *options)
                 if not self.manifest_ready():
                     raise RuntimeError('offline_knowledge_import_incomplete')
                 return 0
@@ -366,14 +374,20 @@ def main():
     parser.add_argument('action', choices=('deploy', 'import-knowledge', 'update-knowledge', 'restart', 'start', 'stop',
                                            'status', 'serve', 'check', 'check-milvus', 'check-redis', 'check-dms',
                                            'check-deepseek', 'check-qcc'))
-    parser.add_argument('company_name', nargs='?')
+    parser.add_argument('argument', nargs='?', help='Company name for checks, or ZIP path for import-knowledge')
+    parser.add_argument('database', nargs='?', choices=('policy', 'finance', 'qa', 'article', 'service_guide', 'service_catalog'))
     args = parser.parse_args()
-    if (args.action in ('check', 'check-qcc')) != (args.company_name is not None):
-        parser.error('check and check-qcc require one exact company name; other actions take none')
+    if args.action in ('check', 'check-qcc'):
+        if not args.argument or args.database:
+            parser.error('check and check-qcc require one exact company name')
+    elif args.action != 'import-knowledge' and (args.argument is not None or args.database is not None):
+        parser.error('only import-knowledge accepts a ZIP path and optional library name')
     if not sys.platform.startswith('linux'):
         parser.error('Linux with /proc and pidfd (kernel >= 5.3) is required')
     try:
-        return Deployment().execute(args.action, args.company_name)
+        if args.action == 'import-knowledge':
+            return Deployment().execute(args.action, archive=args.argument, database=args.database)
+        return Deployment().execute(args.action, args.argument)
     except Exception as exc:
         # Never echo subprocess exception command lines, dotenv or service log bodies.
         message = str(exc) if isinstance(exc, RuntimeError) else type(exc).__name__

+ 31 - 0
scripts/tests/test_deploy.py

@@ -224,6 +224,37 @@ class DeployTests(unittest.TestCase):
         self.d.stop.assert_not_called()
         self.d.start.assert_not_called()
 
+    def test_import_custom_zip_and_library_arguments(self):
+        self.controlled()
+        snapshot = self.root / 'my knowledge.zip'
+        snapshot.write_bytes(b'synthetic')
+        self.d.run = Mock()
+        self.d.manifest_ready = Mock(return_value=True)
+        self.d.execute('import-knowledge', archive='my knowledge.zip', database='article')
+        self.assertEqual(self.d.run.call_args.args[1:],
+                         ('-m', 'src.step2_knowledge_ingest.portable', 'import', snapshot, '--database', 'article'))
+        self.d.execute('import-knowledge', archive=snapshot)
+        self.assertEqual(self.d.run.call_args.args[1:],
+                         ('-m', 'src.step2_knowledge_ingest.portable', 'import', snapshot))
+        for action, archive, database in [('import-knowledge', snapshot, 'bad'),
+                                          ('import-knowledge', None, 'article'), ('restart', snapshot, None)]:
+            with self.assertRaises(RuntimeError):
+                self.d.execute(action, archive=archive, database=database)
+        self.d.stop.assert_not_called()
+        self.d.start.assert_not_called()
+
+    def test_import_cli_parameters_and_invalid_extra_arguments(self):
+        with patch.object(deployment.sys, 'platform', 'linux'), \
+                patch.object(deployment, 'Deployment') as controller:
+            controller.return_value.execute.return_value = 0
+            with patch.object(deployment.sys, 'argv', ['deploy.py', 'import-knowledge', '/some path/bank.zip', 'article']):
+                self.assertEqual(deployment.main(), 0)
+            controller.return_value.execute.assert_called_once_with(
+                'import-knowledge', archive='/some path/bank.zip', database='article')
+            for args in (['restart','extra'], ['check-qcc'], ['import-knowledge','x.zip','wrong']):
+                with self.subTest(args=args), patch.object(deployment.sys, 'argv', ['deploy.py', *args]), self.assertRaises(SystemExit):
+                    deployment.main()
+
     def test_health_timeout_stops_the_new_child_and_removes_pidfile(self):
         self.d.settings = Mock(return_value=Mock(host='127.0.0.1', port=8000))
         self.d.processes = Mock(return_value=[])

+ 66 - 0
scripts/tests/test_offline_import_shell.py

@@ -0,0 +1,66 @@
+"""Exercise the POSIX entry point with a fake Python; no services or real venv."""
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+def shell_path(path):
+    value = Path(path).as_posix()
+    if len(value) > 1 and value[1] == ':':
+        return '/' + value[0].lower() + value[2:]
+    return value
+
+
+class OfflineImportShellTests(unittest.TestCase):
+    def setUp(self):
+        self.shell = shutil.which('bash') or shutil.which('sh')
+        if not self.shell and Path('C:/Program Files/Git/bin/bash.exe').is_file():
+            self.shell = 'C:/Program Files/Git/bin/bash.exe'
+        if not self.shell:
+            self.skipTest('POSIX shell unavailable')
+        self.tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.tmp.cleanup)
+        self.root = Path(self.tmp.name)
+        self.shell_cwd = subprocess.run([self.shell, '-c', 'pwd'], cwd=self.root,
+                                        capture_output=True, text=True, check=True).stdout.strip()
+        self.repo = self.root / 'repo with spaces'
+        self.repo.mkdir()
+        self.script = self.repo / 'deploy-offline.sh'
+        self.script.write_bytes((Path(__file__).parents[2]/'deploy-offline.sh').read_bytes().replace(b'\r\n', b'\n'))
+        self.capture = self.root/'arguments.txt'
+        python = self.repo/'.venv/bin/python'
+        python.parent.mkdir(parents=True)
+        python.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE"\n', encoding='utf-8', newline='\n')
+        python.chmod(0o755)
+        self.bin = self.root/'bin'
+        self.bin.mkdir()
+        uname = self.bin/'uname'
+        uname.write_text('#!/bin/sh\nprintf "Linux\\n"\n', encoding='utf-8', newline='\n')
+        uname.chmod(0o755)
+
+    def run_shell(self, *args):
+        env = dict(os.environ, CAPTURE=shell_path(self.capture))
+        # An isolated PATH keeps this a syntax/argument test, including on Git Bash.
+        return subprocess.run([self.shell, '-c', 'PATH="$1:/usr/bin:/bin"; export PATH; shift; exec /bin/sh "$@"',
+                               'test', shell_path(self.bin), shell_path(self.script), *args], cwd=self.root,
+                              env=env, capture_output=True, text=True)
+
+    def test_default_custom_relative_absolute_and_library(self):
+        for args, tail in [
+            ([], ['import-knowledge']),
+            (['a path/new.zip'], ['import-knowledge', self.shell_cwd+'/a path/new.zip']),
+            (['/transfer/new.zip', 'article'], ['import-knowledge','/transfer/new.zip','article'])]:
+            with self.subTest(args=args):
+                result = self.run_shell('import-knowledge', *args)
+                self.assertEqual(result.returncode,0,result.stderr)
+                self.assertEqual(self.capture.read_text().splitlines(),['scripts/deploy.py',*tail])
+
+    def test_invalid_arguments_rejected_before_python(self):
+        for args in [('x.zip','wrong'), ('',), ('x.zip','article','extra')]:
+            with self.subTest(args=args):
+                result = self.run_shell('import-knowledge', *args)
+                self.assertEqual(result.returncode,2)
+                self.assertFalse(self.capture.exists())

+ 41 - 0
src/step2_knowledge_ingest/ingest.py

@@ -2,6 +2,7 @@
 
 import argparse
 from collections import Counter
+from contextlib import contextmanager
 import hashlib
 import json
 import logging
@@ -285,7 +286,24 @@ class Store:
                 raise ValueError('vector_search_smoke_failed')
 
 
+@contextmanager
+def publication_lock(prepared):
+    """Stable lock outside the directory that an import may replace."""
+    try:
+        with run_lock(prepared.parent / ('.' + prepared.name + '.publish.lock')):
+            yield
+    except ValueError as exc:
+        if str(exc) == 'another_summary_run_is_active':
+            raise ValueError('another_knowledge_operation_is_active') from None
+        raise
+
+
 def execute(args):
+    with publication_lock(args.prepared):
+        return _execute(args)
+
+
+def _execute(args):
     settings = config(args.env_file)
     records = load(args.prepared)
     model_dir = settings['EMBEDDING_MODEL_PATH']
@@ -294,6 +312,18 @@ def execute(args):
     snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': records}))
     content_name, index_name = 'content_' + snapshot[:20], 'index_' + snapshot[:20]
     counts = Counter(route(r) for r in records)
+    published = None
+    if args.verify_only:
+        published = json.loads((args.prepared / 'ingestion/manifest.json').read_text(encoding='utf-8'))
+        if (published.get('complete') is not True or published.get('contract') != CONTRACT
+                or published.get('dimension') != DIM or published.get('counts') != dict(counts)
+                or set(published.get('databases', {})) != set(counts)
+                or not model_matches_manifest(model_dir, published)):
+            raise ValueError('invalid_published_manifest')
+        model_hash, model_files = published['model_hash'], published['model_files']
+        snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': records}))
+        if published.get('snapshot') != snapshot:
+            raise ValueError('published_snapshot_mismatch')
     print(json.dumps({'counts': counts, 'snapshot': snapshot, 'mode': 'verify' if args.verify_only else 'dry-run' if args.dry_run else 'ingest'}), flush=True)
     if args.dry_run:
         return
@@ -315,7 +345,18 @@ def execute(args):
                 if not subset:
                     continue
                 database = store.use(kind, create=not args.verify_only)
+                if published:
+                    spec = published['databases'][kind]
+                    version = spec.get('snapshot', snapshot)
+                    if (not isinstance(version, str) or not re.fullmatch(r'[a-f0-9]{64}', version)
+                            or spec.get('database') != database
+                            or spec.get('content_collection') != 'content_' + version[:20]
+                            or spec.get('index_collection') != (None if kind == 'catalog' else 'index_' + version[:20])):
+                        raise ValueError('invalid_published_collection')
+                    content_name, index_name = spec['content_collection'], spec['index_collection']
                 manifest['databases'][kind] = {'database': database, 'content_collection': content_name, 'index_collection': index_name if kind != 'catalog' else None}
+                if published and 'snapshot' in spec:
+                    manifest['databases'][kind]['snapshot'] = spec['snapshot']
                 if not args.verify_only:
                     store.ensure(content_name, content=True)
                     if kind != 'catalog':

+ 168 - 44
src/step2_knowledge_ingest/portable.py

@@ -2,20 +2,26 @@
 
 import argparse
 from collections import Counter
+import copy
 import hashlib
 import json
 from pathlib import Path
 import re
+import shutil
 import sys
+import tempfile
+import uuid
 from zipfile import ZIP_DEFLATED, ZipFile
 
 from src.step1_knowledge_prepare.summarize import atomic_write, utc_now
 from src.step2_knowledge_ingest.ingest import (
     CONTRACT, DIM, KINDS, TYPES, Store, canonical, config, content_parts,
-    index_text, load, model_matches_manifest, restore, route, sha, valid_vector,
+    index_text, load, model_matches_manifest, publication_lock, restore, route, sha, valid_vector,
 )
 
 FORMAT = 'business-assistant-milvus-snapshot-v1'
+LIBRARY_FORMAT = 'business-assistant-milvus-library-v1'
+LIBRARIES = tuple(kind for kind in TYPES if kind != 'catalog')
 SOURCE_FILES = tuple(f'{kind}{suffix}.jsonl' for kind in KINDS
                      for suffix in ('', '.with_summary'))
 ARCHIVE_META = 'archive.json'
@@ -50,9 +56,12 @@ def _check_manifest(manifest):
     for kind, spec in databases.items():
         if not isinstance(spec, dict) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,99}', spec.get('database', '')):
             raise ValueError('invalid_snapshot_manifest')
-        if spec.get('content_collection') != 'content_' + manifest['snapshot'][:20]:
+        version = spec.get('snapshot', manifest['snapshot'])
+        if not isinstance(version, str) or not re.fullmatch(r'[a-f0-9]{64}', version):
+            raise ValueError('invalid_library_snapshot')
+        if spec.get('content_collection') != 'content_' + version[:20]:
             raise ValueError('invalid_snapshot_manifest')
-        if spec.get('index_collection') != (None if kind == 'catalog' else 'index_' + manifest['snapshot'][:20]):
+        if spec.get('index_collection') != (None if kind == 'catalog' else 'index_' + version[:20]):
             raise ValueError('invalid_snapshot_manifest')
 
 
@@ -74,11 +83,29 @@ def _snapshot(records, manifest):
         raise ValueError('snapshot_hash_mismatch')
 
 
-def export(prepared, output, settings, store_factory=Store):
-    manifest = json.loads((prepared / 'ingestion/manifest.json').read_text(encoding='utf-8'))
-    _check_manifest(manifest)
+def export(prepared, output, settings, store_factory=Store, database=None):
+    with publication_lock(prepared):
+        return _export(prepared, output, settings, store_factory, database)
+
+
+def _export(prepared, output, settings, store_factory, database):
+    source_manifest = json.loads((prepared / 'ingestion/manifest.json').read_text(encoding='utf-8'))
+    _check_manifest(source_manifest)
     records = load(prepared)
-    _snapshot(records, manifest)
+    _snapshot(records, source_manifest)
+    if database is not None:
+        if database not in LIBRARIES or database not in source_manifest['counts']:
+            raise ValueError('unknown_or_empty_library')
+        records = [record for record in records if route(record) == database]
+    manifest = copy.deepcopy(source_manifest)
+    manifest['counts'] = dict(Counter(route(record) for record in records))
+    manifest['snapshot'] = sha(canonical({'contract': CONTRACT, 'model': manifest['model_hash'], 'records': records}))
+    # Transfer packages always use one version, even when exporting a live
+    # manifest assembled from several independently updated libraries.
+    manifest['databases'] = {kind: {'database': source_manifest['databases'][kind]['database'],
+        'content_collection': 'content_' + manifest['snapshot'][:20],
+        'index_collection': None if kind == 'catalog' else 'index_' + manifest['snapshot'][:20]}
+        for kind in manifest['counts']}
     if output.exists():
         raise ValueError('archive_already_exists')
     output.parent.mkdir(parents=True, exist_ok=True)
@@ -92,13 +119,17 @@ def export(prepared, output, settings, store_factory=Store):
             archive.writestr(MANIFEST_NAME, manifest_bytes)
             digests = {MANIFEST_NAME: hashlib.sha256(manifest_bytes).hexdigest()}
             for name in SOURCE_FILES:
-                archive.write(prepared / name, 'prepared/' + name)
-                with (prepared / name).open('rb') as source:
-                    digests['prepared/' + name] = hashlib.file_digest(source, 'sha256').hexdigest()
+                content = (prepared / name).read_bytes()
+                if database is not None:
+                    selected_ids = {record['id'] for record in records}
+                    content = b''.join(line for line in content.splitlines(keepends=True)
+                                       if json.loads(line)['id'] in selected_ids)
+                archive.writestr('prepared/' + name, content)
+                digests['prepared/' + name] = hashlib.sha256(content).hexdigest()
             for kind in TYPES:
                 if kind not in manifest['databases']:
                     continue
-                spec = manifest['databases'][kind]
+                spec = source_manifest['databases'][kind]
                 if store.use(kind, create=False) != spec['database']:
                     raise ValueError('source_database_mismatch')
                 subset = [record for record in records if route(record) == kind]
@@ -138,8 +169,11 @@ def export(prepared, output, settings, store_factory=Store):
                     if actual != expected:
                         raise ValueError('source_count_mismatch')
                 print(json.dumps({'type': kind, 'exported': len(subset)}), flush=True)
-            archive.writestr(ARCHIVE_META, canonical({'format': FORMAT, 'created_at': utc_now(),
-                'snapshot': manifest['snapshot'], 'counts': manifest['counts'], 'sha256': digests}) + '\n')
+            metadata = {'format': LIBRARY_FORMAT if database else FORMAT, 'created_at': utc_now(),
+                'snapshot': manifest['snapshot'], 'counts': manifest['counts'], 'sha256': digests}
+            if database:
+                metadata['database'] = database
+            archive.writestr(ARCHIVE_META, canonical(metadata) + '\n')
         verify_archive(partial)
         partial.replace(output)
     except BaseException:
@@ -160,8 +194,11 @@ def verify_archive(path):
         _check_manifest(manifest)
         expected = {MANIFEST_NAME, *(f'prepared/{name}' for name in SOURCE_FILES),
                     *(f'records/{kind}.jsonl' for kind in manifest['counts'])}
-        if meta.get('format') != FORMAT or set(names) != expected | {ARCHIVE_META} or set(meta.get('sha256', {})) != expected:
+        if meta.get('format') not in (FORMAT, LIBRARY_FORMAT) or set(names) != expected | {ARCHIVE_META} or set(meta.get('sha256', {})) != expected:
             raise ValueError('invalid_archive_members')
+        if meta['format'] == LIBRARY_FORMAT:
+            if meta.get('database') not in LIBRARIES or set(manifest['counts']) != {meta['database']}:
+                raise ValueError('invalid_library_archive')
         if meta.get('snapshot') != manifest['snapshot'] or meta.get('counts') != manifest['counts']:
             raise ValueError('archive_manifest_mismatch')
         for name in expected:
@@ -188,7 +225,16 @@ def verify_archive(path):
         source_records = []
         for source_kind in KINDS:
             with archive.open(f'prepared/{source_kind}.with_summary.jsonl') as source:
-                source_records.extend(json.loads(line) for line in source)
+                enhanced = [json.loads(line) for line in source]
+            with archive.open(f'prepared/{source_kind}.jsonl') as source:
+                original = [json.loads(line) for line in source]
+            if len(original) != len(enhanced) or any(
+                    r['kind'] != source_kind or any(k not in r or r[k] != v for k, v in raw.items())
+                    for raw, r in zip(original, enhanced, strict=True)):
+                raise ValueError('archive_original_mismatch')
+            source_records.extend(enhanced)
+        if len(source_records) != len(records) or len({r['id'] for r in source_records}) != len(source_records):
+            raise ValueError('archive_prepared_mismatch')
         if {r['id']: r for r in source_records} != {r['id']: r for r in records}:
             raise ValueError('archive_prepared_mismatch')
         _snapshot(source_records, manifest)
@@ -197,23 +243,109 @@ def verify_archive(path):
         return manifest, len(records)
 
 
-def import_archive(path, prepared, settings, store_factory=Store):
-    manifest, total = verify_archive(path)
-    if not model_matches_manifest(settings['EMBEDDING_MODEL_PATH'], manifest):
-        raise ValueError('target_model_mismatch')
-    target_manifest = prepared / 'ingestion/manifest.json'
-    if target_manifest.exists():
-        existing = json.loads(target_manifest.read_text(encoding='utf-8'))
-        if existing.get('complete') and existing.get('snapshot') != manifest['snapshot']:
-            raise ValueError('target_has_different_snapshot')
-    with ZipFile(path) as archive:
-        for name in SOURCE_FILES:
-            dest = prepared / name
-            if dest.exists():
-                with dest.open('rb') as source:
-                    existing_hash = hashlib.file_digest(source, 'sha256').hexdigest()
-                if existing_hash != _digest(archive, 'prepared/' + name):
-                    raise ValueError('target_prepared_conflict')
+def _stage_sources(archive, stage, prepared, manifest, database, settings):
+    """Assemble and validate all local records before any remote writes."""
+    existing = None
+    if database:
+        path = prepared / 'ingestion/manifest.json'
+        if not path.is_file():
+            raise ValueError('single_library_requires_existing_snapshot')
+        existing = json.loads(path.read_text(encoding='utf-8'))
+        _check_manifest(existing)
+        _snapshot(load(prepared), existing)
+        if not model_matches_manifest(settings['EMBEDDING_MODEL_PATH'], existing):
+            raise ValueError('existing_model_mismatch')
+        # Both manifests must describe identical vector spaces; OS path spelling
+        # in a model fingerprint is not a different model.
+        normalize = lambda m: {k.replace('\\', '/'): v for k, v in m.get('model_files', {}).items()}
+        if existing['model_hash'] != manifest['model_hash'] and normalize(existing) != normalize(manifest):
+            raise ValueError('library_model_mismatch')
+        for kind, spec in existing['databases'].items():
+            if spec['database'] != settings['MILVUS_DB_PREFIX'] + '_' + kind:
+                raise ValueError('existing_database_mismatch')
+    for name in SOURCE_FILES:
+        content = archive.read('prepared/' + name)
+        if database:
+            # Pair IDs with the validated enhanced rows, rather than trusting
+            # raw input fields to implement the routing rules.
+            enhanced_name = name.split('.')[0] + '.with_summary.jsonl'
+            kept = {r['id'] for r in map(json.loads, (prepared / enhanced_name).read_text(encoding='utf-8').splitlines())
+                    if route(r) != database}
+            old_lines = (prepared / name).read_bytes().splitlines(keepends=True)
+            prefix = b''.join(line.rstrip(b'\r\n') + b'\n' for line in old_lines if json.loads(line)['id'] in kept)
+            content = prefix + content
+        (stage / name).write_bytes(content)
+    combined = load(stage)  # Includes global duplicate-ID and raw/enhanced checks.
+    result = copy.deepcopy(existing or manifest)
+    result['snapshot'] = sha(canonical({'contract': CONTRACT, 'model': result['model_hash'], 'records': combined}))
+    result['counts'] = dict(Counter(route(r) for r in combined))
+    if existing:
+        for spec in result['databases'].values():
+            spec.setdefault('snapshot', existing['snapshot'])
+    for kind, spec in manifest['databases'].items():
+        result['databases'][kind] = {**spec, 'database': settings['MILVUS_DB_PREFIX'] + '_' + kind,
+                                    'snapshot': spec.get('snapshot', manifest['snapshot'])}
+    _check_manifest(result)
+    _snapshot(combined, result)
+    return result
+
+
+def _publish(stage, prepared):
+    """Same-filesystem directory switch, with the prior snapshot kept for recovery."""
+    parent = prepared.parent.resolve()
+    backup = parent / (prepared.name + '.previous-' + uuid.uuid4().hex)
+    # Never move a symlink or a computed path outside the selected workspace.
+    if prepared.is_symlink() or stage.is_symlink() or not stage.resolve().is_relative_to(parent):
+        raise ValueError('unsafe_publication_path')
+    had_previous = prepared.exists()
+    if had_previous:
+        prepared.rename(backup)
+    try:
+        stage.rename(prepared)
+    except BaseException:
+        if had_previous:
+            backup.rename(prepared)
+        raise
+    return backup if had_previous else None
+
+
+def import_archive(path, prepared, settings, store_factory=Store, database=None):
+    if database is not None and database not in LIBRARIES:
+        raise ValueError('unknown_library')
+    prepared = Path(prepared).absolute()
+    if prepared.is_symlink():
+        raise ValueError('unsafe_publication_path')
+    prepared.parent.mkdir(parents=True, exist_ok=True)
+    with publication_lock(prepared), tempfile.TemporaryDirectory(prefix='.' + prepared.name + '-import-', dir=prepared.parent) as work:
+        work = Path(work)
+        # Pin the exact bytes being verified and imported, even if the caller
+        # replaces the original ZIP while this operation is running.
+        pinned = work / 'source.zip'
+        shutil.copyfile(path, pinned)
+        manifest, total = verify_archive(pinned)
+        if not model_matches_manifest(settings['EMBEDDING_MODEL_PATH'], manifest):
+            raise ValueError('target_model_mismatch')
+        stage = work / 'prepared'
+        stage.mkdir()
+        with ZipFile(pinned) as archive:
+            meta = _json_entry(archive, ARCHIVE_META)
+            if database is None and meta['format'] != FORMAT:
+                raise ValueError('single_library_requires_database')
+            if database and (meta['format'] != LIBRARY_FORMAT or meta.get('database') != database):
+                raise ValueError('library_archive_mismatch')
+            result = _stage_sources(archive, stage, prepared, manifest, database, settings)
+        _restore(pinned, manifest, settings, store_factory)
+        result['verified_at'] = utc_now()
+        (stage / 'ingestion').mkdir()
+        atomic_write(stage / 'ingestion/manifest.json', json.dumps(result, ensure_ascii=False, indent=2) + '\n')
+        backup = _publish(stage, prepared)
+        print(json.dumps({'published': result['snapshot'], 'imported_library': database,
+                          'total_records': sum(result['counts'].values()),
+                          'backup': str(backup) if backup else None}), flush=True)
+        return result['snapshot'], total
+
+
+def _restore(path, manifest, settings, store_factory):
     store = store_factory(settings)
     try:
         with ZipFile(path) as archive:
@@ -265,19 +397,8 @@ def import_archive(path, prepared, settings, store_factory=Store):
                                     raise ValueError('target_vector_mismatch')
                 manifest['databases'][kind]['database'] = database
                 print(json.dumps({'type': kind, 'restored': len(records)}), flush=True)
-            for name in SOURCE_FILES:
-                dest = prepared / name
-                dest.parent.mkdir(parents=True, exist_ok=True)
-                if not dest.exists():
-                    temp = dest.with_name(dest.name + '.partial')
-                    temp.write_bytes(archive.read('prepared/' + name))
-                    temp.replace(dest)
-        manifest['verified_at'] = utc_now()
-        target_manifest.parent.mkdir(parents=True, exist_ok=True)
-        atomic_write(target_manifest, json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
     finally:
         store.client.close()
-    return manifest['snapshot'], total
 
 
 def main():
@@ -286,7 +407,10 @@ def main():
     parser.add_argument('archive', type=Path)
     parser.add_argument('--prepared', type=Path, default=Path(__file__).resolve().parents[2] / 'knowledge/prepared')
     parser.add_argument('--env-file', type=Path, default=Path(__file__).resolve().parents[2] / '.env')
+    parser.add_argument('--database', choices=LIBRARIES, help='Export/import one complete library; omitted means a full snapshot')
     args = parser.parse_args()
+    if args.mode == 'verify' and args.database:
+        parser.error('verify detects the package type automatically; do not pass --database')
     try:
         if args.mode == 'verify':
             snapshot, total = verify_archive(args.archive)
@@ -294,7 +418,7 @@ def main():
         else:
             settings = config(args.env_file)
             operation = export if args.mode == 'export' else import_archive
-            snapshot, total = operation(args.prepared, args.archive, settings) if args.mode == 'export' else operation(args.archive, args.prepared, settings)
+            snapshot, total = operation(args.prepared, args.archive, settings, database=args.database) if args.mode == 'export' else operation(args.archive, args.prepared, settings, database=args.database)
         print(json.dumps({'mode': args.mode, 'snapshot': snapshot, 'records': total, 'complete': True}), flush=True)
         return 0
     except Exception as exc:

+ 207 - 1
src/step2_knowledge_ingest/tests/test_portable.py

@@ -5,7 +5,7 @@ import tempfile
 import unittest
 from unittest.mock import patch
 
-from src.step2_knowledge_ingest.ingest import CONTRACT, DIM, canonical, content_parts, index_text, route, sha
+from src.step2_knowledge_ingest.ingest import CONTRACT, DIM, KINDS, canonical, content_parts, index_text, load, route, sha
 from src.step2_knowledge_ingest.portable import export, import_archive, verify_archive
 
 
@@ -152,6 +152,212 @@ class PortableTests(unittest.TestCase):
                 import_archive(self.archive, self.root / 'target', self.settings, FakeStore)
         self.assertEqual(FakeStore.data, {})
 
+    def version(self, records, name, database=None, model_files=None):
+        """Produce independently versioned synthetic packages with real hashing."""
+        prepared = self.root / (name + '-source')
+        prepared.mkdir()
+        ordered = []
+        for kind in KINDS:
+            subset = [r for r in records if r['kind'] == kind]
+            ordered.extend(subset)
+            raw = [{k: v for k, v in r.items() if k not in ('summary', 'summary_status', 'retrieval_text')} for r in subset]
+            for suffix, rows in (('', raw), ('.with_summary', subset)):
+                (prepared / f'{kind}{suffix}.jsonl').write_text(''.join(canonical(r) + '\n' for r in rows), encoding='utf-8')
+        from collections import Counter
+        model_hash = sha(canonical(model_files or {}))
+        snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': ordered}))
+        manifest = {**self.manifest, 'model_hash': model_hash, 'model_files': model_files or {},
+                    'snapshot': snapshot, 'counts': dict(Counter(route(r) for r in records)), 'databases': {}}
+        data = {}
+        for record in records:
+            kind = route(record)
+            content, index = 'content_' + snapshot[:20], 'index_' + snapshot[:20]
+            db = 'demo_' + kind
+            manifest['databases'][kind] = {'database': db, 'content_collection': content, 'index_collection': index}
+            tables = data.setdefault(db, {content: {}, index: {}})
+            tables[content].update({p['id']: p for p in content_parts(record)})
+            tables[index][record['id']] = {'id':record['id'], 'vector':[1.0] + [0.0] * (DIM-1),
+                                          'index_text':index_text(record), 'record_hash':sha(canonical(record))}
+        (prepared/'ingestion').mkdir()
+        (prepared/'ingestion/manifest.json').write_text(canonical(manifest), encoding='utf-8')
+        package = self.root / (name + '.zip')
+        FakeStore.data = data
+        export(prepared, package, self.settings, FakeStore, database=database)
+        return package
+
+    def six_records(self):
+        rows = load(self.prepared)
+        guide = make_record('general', 5)
+        guide['collection'] = '办事指南'
+        service = make_record('general', 6)
+        service['collection'] = '企呼我应六大服务板块'
+        return rows + [guide, service]
+
+    def restore_package(self, package, target, data, database=None, store=FakeStore):
+        FakeStore.data = data
+        with patch('src.step2_knowledge_ingest.portable.model_matches_manifest', return_value=True):
+            return import_archive(package, target, self.settings, store, database=database)
+
+    def test_full_update_different_version_and_reimport_preserves_backups(self):
+        rows = self.six_records()
+        first = self.version(rows, 'first')
+        second = self.version(rows + [make_record('general', 9)], 'second')
+        target, data = self.root/'target', {}
+        self.restore_package(first, target, data)
+        old = (target/'ingestion/manifest.json').read_bytes()
+        old_collections = {db:set(tables) for db,tables in data.items()}
+        new_snapshot, count = self.restore_package(second, target, data)
+        self.assertEqual(count, 7)
+        self.assertEqual(len(load(target)), 7)
+        self.assertTrue(any((p/'ingestion/manifest.json').read_bytes() == old for p in self.root.glob('target.previous-*')))
+        for db, names in old_collections.items():
+            self.assertTrue(names <= data[db].keys())
+        self.assertEqual(self.restore_package(second, target, data), (new_snapshot, 7))
+        self.assertEqual(len(load(target)), 7)
+
+    def test_single_library_replaces_removed_rows_and_preserves_other_five(self):
+        rows = self.six_records() + [make_record('general', 9)]
+        first = self.version(rows, 'first')
+        replacement = make_record('general', 10)
+        second = self.version([replacement], 'article-only', database='article')
+        target, data = self.root/'target', {}
+        self.restore_package(first, target, data)
+        before = copy.deepcopy(data)
+        previous = json.loads((target/'ingestion/manifest.json').read_text())
+        snapshot, imported = self.restore_package(second, target, data, 'article')
+        current = json.loads((target/'ingestion/manifest.json').read_text())
+        actual = load(target)
+        self.assertEqual(imported, 1)
+        self.assertEqual([r for r in actual if route(r)=='article'], [replacement])
+        self.assertEqual(len(actual), 6)
+        for kind in previous['databases']:
+            if kind != 'article':
+                self.assertEqual(data['demo_'+kind], before['demo_'+kind])
+                self.assertEqual(current['databases'][kind]['content_collection'], previous['databases'][kind]['content_collection'])
+        # A mixed live snapshot can be exported to a standalone full package,
+        # then imported on a fresh server using the original package contract.
+        mixed = self.root/'mixed.zip'
+        FakeStore.data = data
+        export(target, mixed, self.settings, FakeStore)
+        self.assertEqual(verify_archive(mixed)[0]['snapshot'], snapshot)
+        self.restore_package(mixed, self.root/'fresh', {})
+        self.assertEqual(load(self.root/'fresh'), actual)
+        self.assertEqual(self.restore_package(second, target, data, 'article'), (snapshot, 1))
+        # The independent verifier and online reader must follow each library's
+        # pinned collection, rather than inventing a shared collection name.
+        from argparse import Namespace
+        from src.step2_knowledge_ingest import ingest
+        from src.step3_question_answer.retrieval import Retriever, RetrievalError
+        FakeStore.data = data
+        with patch.object(ingest, 'config', return_value=self.settings), \
+                patch.object(ingest, 'model_fingerprint', return_value={}), \
+                patch.object(ingest, 'model_matches_manifest', return_value=True), \
+                patch.object(ingest, 'Store', FakeStore):
+            ingest.execute(Namespace(prepared=target, env_file=self.root/'missing.env', verify_only=True, dry_run=False))
+        with patch('src.step3_question_answer.retrieval.config', return_value=self.settings):
+            reader = Retriever(self.root/'missing.env', target/'ingestion/manifest.json')
+            self.assertEqual(reader.manifest['databases']['article'], current['databases']['article'])
+            bad = copy.deepcopy(current)
+            bad['databases']['article']['snapshot'] = '../wrong'
+            (target/'ingestion/manifest.json').write_text(canonical(bad), encoding='utf-8')
+            with self.assertRaises(RetrievalError):
+                Retriever(self.root/'missing.env', target/'ingestion/manifest.json')
+
+    def test_scope_and_model_fail_before_writing(self):
+        full = self.version(self.six_records(), 'full')
+        single = self.version([make_record('general', 10)], 'single', database='article')
+        wrong_model = self.version([make_record('general', 11)], 'model', database='article', model_files={'weights.bin':'f'*64})
+        target, data = self.root/'target', {}
+        self.restore_package(full, target, data)
+        before = copy.deepcopy(data)
+        original = (target/'ingestion/manifest.json').read_bytes()
+        for package, library in ((single,None),(single,'policy'),(full,'article'),(single,'unknown'),(wrong_model,'article')):
+            with self.subTest(library=library,package=package.name), self.assertRaises(ValueError):
+                self.restore_package(package, target, data, library)
+            self.assertEqual(data,before)
+            self.assertEqual((target/'ingestion/manifest.json').read_bytes(),original)
+        with self.assertRaisesRegex(ValueError,'single_library_requires_existing_snapshot'):
+            self.restore_package(single,self.root/'empty',{},'article')
+
+    def test_every_library_can_be_updated_without_touching_other_databases(self):
+        rows = self.six_records()
+        full = self.version(rows, 'six')
+        for ordinal, original in enumerate(rows, 20):
+            library = route(original)
+            replacement = copy.deepcopy(original)
+            replacement['id'] = 'k_' + format(ordinal,'024x')
+            package = self.version([replacement], 'only-'+library, database=library)
+            with self.subTest(library=library):
+                target, data = self.root/('target-'+library), {}
+                self.restore_package(full,target,data)
+                before=copy.deepcopy(data)
+                self.restore_package(package,target,data,library)
+                actual=load(target)
+                self.assertEqual([r for r in actual if route(r)==library],[replacement])
+                self.assertEqual({r['id']:r for r in actual if route(r)!=library},
+                                 {r['id']:r for r in rows if route(r)!=library})
+                self.assertEqual({k:v for k,v in data.items() if k!='demo_'+library},
+                                 {k:v for k,v in before.items() if k!='demo_'+library})
+
+    def test_import_refuses_concurrent_publication(self):
+        from src.step2_knowledge_ingest.ingest import publication_lock
+        target = self.root/'target'
+        with publication_lock(target):
+            with self.assertRaisesRegex(ValueError,'another_knowledge_operation_is_active'):
+                self.restore_package(self.root/'not-needed.zip',target,{})
+        self.assertFalse(target.exists())
+
+    def test_remote_failure_and_publication_failure_preserve_live_snapshot(self):
+        first = self.version(self.six_records(), 'first')
+        second = self.version([make_record('general', 10)], 'single', database='article')
+        target, data = self.root/'target', {}
+        self.restore_package(first,target,data)
+        original = {p.relative_to(target):p.read_bytes() for p in target.rglob('*') if p.is_file()}
+        class Failing(FakeStore):
+            def verify(self,*args):
+                raise ValueError('synthetic_remote_failure')
+        with self.assertRaisesRegex(ValueError,'synthetic_remote_failure'):
+            self.restore_package(second,target,data,'article',Failing)
+        self.assertEqual(original,{p.relative_to(target):p.read_bytes() for p in target.rglob('*') if p.is_file()})
+        rename = Path.rename
+        def fail_publish(path,dest):
+            if path.name=='prepared' and '-import-' in str(path.parent):
+                raise OSError('synthetic_publish_failure')
+            return rename(path,dest)
+        with patch.object(Path,'rename',fail_publish), self.assertRaisesRegex(OSError,'synthetic_publish_failure'):
+            self.restore_package(second,target,data,'article')
+        self.assertEqual(original,{p.relative_to(target):p.read_bytes() for p in target.rglob('*') if p.is_file()})
+        self.restore_package(second,target,data,'article')
+
+    def test_cross_library_id_collision_and_corrupt_raw_are_rejected(self):
+        rows=self.six_records()
+        first=self.version(rows,'first')
+        colliding=make_record('general',1)  # ID already belongs to policy.
+        single=self.version([colliding],'collision',database='article')
+        target,data=self.root/'target',{}
+        self.restore_package(first,target,data)
+        before=copy.deepcopy(data)
+        with self.assertRaisesRegex(ValueError,'invalid_id_or_kind'):
+            self.restore_package(single,target,data,'article')
+        self.assertEqual(data,before)
+        # Rehashing the envelope cannot legitimize raw fields differing from
+        # the enhanced record that will actually be indexed.
+        from zipfile import ZipFile
+        import hashlib
+        with ZipFile(first) as z:
+            members={n:z.read(n) for n in z.namelist()}
+        name='prepared/policy.jsonl'
+        raw=json.loads(members[name]);raw['title']='tampered'
+        members[name]=(canonical(raw)+'\n').encode('utf-8')
+        meta=json.loads(members['archive.json'])
+        meta['sha256'][name]=hashlib.sha256(members[name]).hexdigest()
+        members['archive.json']=canonical(meta).encode('utf-8')
+        bad=self.root/'bad.zip'
+        with ZipFile(bad,'w') as z:
+            for n,b in members.items():z.writestr(n,b)
+        with self.assertRaisesRegex(ValueError,'archive_original_mismatch'):
+            verify_archive(bad)
+
 
 if __name__ == '__main__':
     unittest.main()

+ 4 - 1
src/step3_question_answer/retrieval.py

@@ -121,8 +121,11 @@ class Retriever:
             spec = m['databases'][kind]
             if spec['database'] != self.settings['MILVUS_DB_PREFIX'] + '_' + kind:
                 raise RetrievalError('database_mismatch')
+            version = spec.get('snapshot', m['snapshot'])
+            if not isinstance(version, str) or not re.fullmatch('[a-f0-9]{64}', version):
+                raise RetrievalError('invalid_library_snapshot')
             for key in ('content_collection', 'index_collection'):
-                expected = ('content_' if key == 'content_collection' else 'index_') + m['snapshot'][:20]
+                expected = ('content_' if key == 'content_collection' else 'index_') + version[:20]
                 if spec[key] != expected:
                     raise RetrievalError('collection_mismatch')
         self.embedder = None