Преглед на файлове

python版本修改+部署文件修改

wangxi преди 1 седмица
родител
ревизия
2bbf685d7e
променени са 7 файла, в които са добавени 43 реда и са изтрити 17 реда
  1. 1 0
      .python-version
  2. 3 3
      README.md
  3. 9 7
      deploy.sh
  4. 9 3
      docs/deployment.md
  5. 15 1
      scripts/test_bootstrap.py
  6. 4 2
      scripts/test_data_analysis.py
  7. 2 1
      src/step1_data_aggregation/data_analysis.py

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.14.5

+ 3 - 3
README.md

@@ -8,7 +8,7 @@
 
 ### 1. 准备环境和配置
 
-需要 Python ≥ 3.11、uv、可连接的 Neo4j 和原生 Redis,以及 DMS、DeepSeek 的访问配置。以下命令均在项目根目录执行。
+开发与默认部署统一使用 Python 3.14.5(由 `.python-version` 固定;依赖声明下限为 3.11)、uv、可连接的 Neo4j 和原生 Redis,以及 DMS、DeepSeek 的访问配置。以下命令均在项目根目录执行。
 
 ```powershell
 uv sync --frozen
@@ -76,7 +76,7 @@ sh deploy.sh restart     # 准备完成 → 停止旧服务 → 加载新代码
 sh deploy.sh model       # 准备环境并检查/补下载模型,不更新业务数据
 ```
 
-不传参数时等同 `templates`。脚本自动检测/安装 uv,安装 Python(默认 3.11,可用 `DEPLOY_PYTHON` 覆盖),执行 `uv sync --frozen`,再下载并验证本地模型;任一步失败会停止后续步骤。完整模型跳过下载,仍进行本地加载和编码验证。
+不传参数时等同 `templates`。脚本自动检测/安装 uv,安装 Python(默认读取 `.python-version` 中的 3.14.5,可用 `DEPLOY_PYTHON` 覆盖),执行 `uv sync --frozen`,再下载并验证本地模型;任一步失败会停止后续步骤。完整模型跳过下载,仍进行本地加载和编码验证。
 
 现有 `.env` 不覆盖;若缺失则从 `.env.example` 创建,完整部署会停止并提示填写真实配置,重新执行后继续。`setup`/`model` 可先完成环境和模型准备。Neo4j、Redis、DMS 和 DeepSeek 仍须提前配置可用,脚本不安装这些外部服务。
 
@@ -320,7 +320,7 @@ uv run python scripts/ask.py
 | 旧模板生成、构图、元图谱和检索脚本/模块 | 仍有兼容入口、运行时导入或文档引用,本次保留;移除前需先退役对应功能并做回归 |
 | `api_log/`、`server.log`、`.runtime/qa/` | 日志和验证证据,本次保留,可按运维留存周期归档 |
 
-部署必须保留 `pyproject.toml`、`uv.lock`、`.env`、`src/`、`scripts/`、`html/`、`deploy.sh`,以及 `.env` 指向的模板、关系和模型文件。已有服务还依赖当前发布清单及其引用的完整版本文件。源码部署不需要携带旧 wheel 或 Python 缓存;Windows `.venv` 不应复制到 Linux。
+部署必须保留 `pyproject.toml`、`uv.lock`、`.python-version`、`.env`、`src/`、`scripts/`、`html/`、`deploy.sh`,以及 `.env` 指向的模板、关系和模型文件。已有服务还依赖当前发布清单及其引用的完整版本文件。源码部署不需要携带旧 wheel 或 Python 缓存;Windows `.venv` 不应复制到 Linux。
 
 ## 排错与验证边界
 

+ 9 - 7
deploy.sh

@@ -9,7 +9,7 @@ case "$ACTION" in
     echo 'Usage: sh deploy.sh [setup|model|templates|dms|start|restart] [--host HOST] [--port PORT]'
     echo 'setup: install environment and model only; model: ensure model is ready'
     echo 'templates/dms: prepare environment and model, update, then start; start: prepare and start'
-    echo 'Environment: AUTO_INSTALL_UV=0 disables uv installation; DEPLOY_PYTHON=3.11 selects Python'
+    echo 'Environment: AUTO_INSTALL_UV=0 disables uv installation; DEPLOY_PYTHON overrides .python-version (default: 3.14.5)'
     exit 0 ;;
   setup|model|templates|dms|start|restart) ;;
   *) echo "Unknown action: $ACTION" >&2; exit 2 ;;
@@ -53,21 +53,23 @@ if [ ! -f .env ]; then
 fi
 export UV_CACHE_DIR="${UV_CACHE_DIR:-$REPO_ROOT/.uv-cache}"
 echo '[1/3] Prepare Python and locked project environment'
-uv python install "${DEPLOY_PYTHON:-3.11}"
-uv sync --frozen --python "${DEPLOY_PYTHON:-3.11}"
+PYTHON_VERSION=${DEPLOY_PYTHON:-$(cat "$REPO_ROOT/.python-version")}
+echo "Using Python $PYTHON_VERSION"
+uv python install "$PYTHON_VERSION"
+uv sync --frozen --python "$PYTHON_VERSION"
 case "$ACTION" in
-  templates|dms|start|restart) uv run --frozen --no-sync python scripts/start_service.py --check-config "$@" ;;
+  templates|dms|start|restart) uv run --frozen --no-sync --python "$PYTHON_VERSION" python scripts/start_service.py --check-config "$@" ;;
 esac
 echo '[2/3] Prepare local embedding model' 
-uv run --frozen --no-sync python scripts/download_model.py
+uv run --frozen --no-sync --python "$PYTHON_VERSION" python scripts/download_model.py
 case "$ACTION" in
   setup|model) echo 'Environment and model ready. Configure .env, then run sh deploy.sh templates.' ;;
   *)
     echo '[3/3] Update/start service'
     if [ "$ACTION" = templates ] || [ "$ACTION" = dms ]; then
-      uv run --frozen --no-sync python scripts/update_data.py --mode "$ACTION"
+      uv run --frozen --no-sync --python "$PYTHON_VERSION" python scripts/update_data.py --mode "$ACTION"
     fi
     if [ "$ACTION" = restart ]; then set -- --restart "$@"; fi
-    exec uv run --frozen --no-sync python scripts/start_service.py "$@"
+    exec uv run --frozen --no-sync --python "$PYTHON_VERSION" python scripts/start_service.py "$@"
     ;;
 esac

+ 9 - 3
docs/deployment.md

@@ -2,7 +2,7 @@
 
 ## Linux 首次部署
 
-准备项目代码(包含 pyproject.toml、uv.lock、src、scripts、html、deploy.sh)、可读取的 metadata 模板及 relation.xlsx;确认 Neo4j、Redis、DMS 和 DeepSeek 可用。
+准备项目代码(包含 pyproject.toml、uv.lock、.python-version、src、scripts、html、deploy.sh)、可读取的 metadata 模板及 relation.xlsx;确认 Neo4j、Redis、DMS 和 DeepSeek 可用。
 
 ```bash
 # 首次创建;已有 .env 时跳过,避免覆盖
@@ -15,6 +15,12 @@ sh deploy.sh templates
 
 环境安装依据 [uv 官方安装说明](https://docs.astral.sh/uv/configuration/installer/) 和 [Python 管理说明](https://docs.astral.sh/uv/guides/install-python/)。项目依赖按 uv.lock 同步,不重新生成锁文件。
 
+## 从旧版 Python 3.11 部署升级
+
+更新代码时一并上传 `.python-version`,然后重新执行 `sh deploy.sh templates`。脚本会通过 uv 安装 Python 3.14.5 并同步项目虚拟环境;不需要修改系统 Python。若服务器曾设置 `DEPLOY_PYTHON=3.11`,先执行 `unset DEPLOY_PYTHON`,或显式运行 `DEPLOY_PYTHON=3.14.5 sh deploy.sh templates`。已有服务应先停止再执行模板更新。
+
+`SyntaxError: f-string expression part cannot include a backslash` 来自旧代码在 f-string 表达式内转义 Markdown 竖线,该写法不兼容 Python 3.11。现已将转义移到表达式外,保持报告内容不变。
+
 ## 操作选择
 
 | 命令 | 作用 |
@@ -57,11 +63,11 @@ uv run --frozen python scripts/download_model.py --force
 以下变量在 shell 中设置(不从 .env 读取),示例:
 
 ```bash
-AUTO_INSTALL_UV=0 DEPLOY_PYTHON=3.11 sh deploy.sh setup
+AUTO_INSTALL_UV=0 DEPLOY_PYTHON=3.14.5 sh deploy.sh setup
 UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple sh deploy.sh setup
 ```
 
-AUTO_INSTALL_UV 默认 1;设为 0 时缺 uv 会报错。DEPLOY_PYTHON 默认 3.11。UV_CACHE_DIR 默认项目 .uv-cache。uv 自身安装需 curl 或 wget 以及到官方安装地址的网络;Python、依赖和模型下载需对应网络访问与磁盘空间。脚本不会执行 .env 中的 shell 表达式。
+AUTO_INSTALL_UV 默认 1;设为 0 时缺 uv 会报错。DEPLOY_PYTHON 未设置时读取项目 `.python-version`(3.14.5),与本地开发版本保持一致;安装、同步和各运行步骤均显式使用选定版本。UV_CACHE_DIR 默认项目 .uv-cache。uv 自身安装需 curl 或 wget 以及到官方安装地址的网络;Python、依赖和模型下载需对应网络访问与磁盘空间。脚本不会执行 .env 中的 shell 表达式。
 
 ## Windows
 

+ 15 - 1
scripts/test_bootstrap.py

@@ -15,10 +15,11 @@ BASH = shutil.which('bash') or ('C:/Program Files/Git/bin/bash.exe' if Path('C:/
 
 @unittest.skipUnless(BASH, 'bash is unavailable')
 class BootstrapTests(unittest.TestCase):
-    def run_script(self, action, fail='', env_exists=True, args=()):
+    def run_script(self, action, fail='', env_exists=True, args=(), deploy_python=None):
         with tempfile.TemporaryDirectory() as tmp:
             root=Path(tmp)
             shutil.copy2(ROOT/'deploy.sh',root/'deploy.sh')
+            shutil.copy2(ROOT/'.python-version',root/'.python-version')
             (root/'.env.example').write_text('EXAMPLE=1\n')
             if env_exists: (root/'.env').write_text('KEEP=unchanged\n')
             bin_dir=root/'bin'; bin_dir.mkdir()
@@ -26,6 +27,8 @@ class BootstrapTests(unittest.TestCase):
             fake.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALL_LOG"\ncase "$*" in *"$FAIL_MATCH"*) if [ -n "$FAIL_MATCH" ]; then exit 9; fi ;; esac\n')
             fake.chmod(0o755)
             env={**os.environ,'PATH':str(bin_dir)+os.pathsep+os.environ['PATH'],'CALL_LOG':str(root/'calls.log'),'FAIL_MATCH':fail}
+            env.pop('DEPLOY_PYTHON', None)
+            if deploy_python is not None: env['DEPLOY_PYTHON'] = deploy_python
             result=subprocess.run([BASH,str(root/'deploy.sh'),action,*args],env=env,capture_output=True,text=True)
             calls=(root/'calls.log').read_text() if (root/'calls.log').exists() else ''
             return result.returncode,calls,(root/'.env').read_text()
@@ -45,6 +48,17 @@ class BootstrapTests(unittest.TestCase):
                 self.assertLess(calls.index('download_model.py'),calls.index('update_data.py'))
             self.assertEqual(env,'KEEP=unchanged\n')
 
+    def test_python_selection_is_consistent_throughout_pipeline(self):
+        for override, expected in ((None, '3.14.5'), ('3.11', '3.11')):
+            with self.subTest(override=override):
+                code, calls, _ = self.run_script('templates', deploy_python=override)
+                self.assertEqual(code, 0, calls)
+                lines = calls.splitlines()
+                self.assertEqual(lines[0], f'python install {expected}')
+                self.assertEqual(lines[1], f'sync --frozen --python {expected}')
+                for line in lines[2:]:
+                    self.assertTrue(line.startswith(f'run --frozen --no-sync --python {expected} python '), line)
+
     def test_restart_starts_with_restart_flag_without_data_update(self):
         code,calls,_=self.run_script('restart')
         self.assertEqual(code,0)

+ 4 - 2
scripts/test_data_analysis.py

@@ -81,7 +81,7 @@ class DataAnalysisTests(unittest.TestCase):
                                 "filterable": True,
                                 "aggregatable": False,
                                 "join_candidate": name == "姓名",
-                                "cautions": [],
+                                "cautions": ["值中可能含有 A|B"],
                             },
                         }
                         for name, meaning in (("姓名", "员工姓名"), ("部门", "所属部门"))
@@ -107,6 +107,8 @@ class DataAnalysisTests(unittest.TestCase):
                 model="fake-model",
             )
             self.assertEqual(len(results), 1)
+            report = Path(results[0].report_file).read_text(encoding="utf-8")
+            self.assertIn(r"值中可能含有 A\|B", report)
             self.assertNotIn("Alice", captured_user)
             self.assertNotIn("Bob", captured_user)
             self.assertNotIn("c_name", captured_user)
@@ -176,7 +178,7 @@ class DataAnalysisTests(unittest.TestCase):
                                 "filterable": True,
                                 "aggregatable": False,
                                 "join_candidate": name == "姓名",
-                                "cautions": [],
+                                "cautions": ["值中可能含有 A|B"],
                             },
                         }
                         for name, meaning in (("姓名", "员工姓名"), ("部门", "所属部门"))

+ 2 - 1
src/step1_data_aggregation/data_analysis.py

@@ -486,11 +486,12 @@ def _markdown_report(profile: Mapping[str, Any], analysis: Mapping[str, Any], mo
         usage = field.get("qa_usage") if isinstance(field.get("qa_usage"), dict) else {}
         intents = "、".join(str(item) for item in usage.get("query_intents", []))
         cautions = ";".join(str(item) for item in usage.get("cautions", []))
+        cautions = cautions.replace("|", "\\|")
         meaning = str(field.get("business_meaning", "")).replace("|", "\\|")
         lines.append(
             f"| {name} | {meaning} | {stats['missing_rate']:.2%} | "
             f"{stats['distinct_count']} | {json.dumps(stats['observed_types'], ensure_ascii=False)} | "
-            f"{intents} | {cautions.replace('|', '\\|')} |"
+            f"{intents} | {cautions} |"
         )
     lines.extend(["", "## 质量问题", ""])
     issues = assessment.get("issues", [])