diff --git a/.dockerignore b/.dockerignore index 5be2dc31..108311cd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,10 +3,17 @@ __pycache__/ .venv/ venv/ .env +.env.* +!.env.example .git/ .gitignore todos.db *.db-journal *.db-wal *.db-shm -data/ \ No newline at end of file +data/ +storage/ +experiments/chroma_db/ +docker_data/ +docker_storage/ +docker_chroma_db/ diff --git a/README.md b/README.md index a007b493..c80dd9b1 100644 --- a/README.md +++ b/README.md @@ -296,9 +296,9 @@ Hybrid 检索(同一租户/分类、已索引当前版本,最多 Top14) **校验只证明格式、引用出处和状态接线,不证明语义蕴含、逐句忠实或抗提示注入。** 不把有效引用或实验 gate 放行写成答案正确率,也不改写历史 NO_PROMOTION / FAIL。 -## 3.3 集成验收状态(2026-10-17) +## 3.3 集成验收状态(更新至 2026-10-18) -**当前不接受为已验收发布版。** 已补齐仓库声明的评测测试依赖,在隔离 Windows / Python 3.12.4 环境运行完整 `tests/`:最新 656 项通过、零失败/跳过;不只原重点回归集。主 pytest 进程禁止外部 socket 与读取项目私有 `data/`,测试使用临时数据;CI 包含相同完整离线检查。离线成功不是模型质量验收。 +**当前不接受为已验收发布版。** 已补齐仓库声明的评测测试依赖,在隔离 Windows / Python 3.12.4 环境运行完整 `tests/`:最新 659 项通过、零失败/跳过;不只原重点回归集。主 pytest 进程禁止外部 socket 与读取项目私有 `data/`,测试使用临时数据;CI 包含相同完整离线检查。离线成功不是模型质量验收。 真实问答计划复用同一脚本,默认仍然零调用: @@ -327,7 +327,9 @@ python -m scripts.smoke_hybrid_runtime --ask --execute --env-file .env 本次仅 12 次新加坡分类,均 HTTP 200,无重试;usage 合计 27481 tokens,请求体 119304 bytes,没有重跑检索或生成。`python -m scripts.probe_version_audit` 默认只输出零调用计划;真实执行还要求事先授权、配置专用新加坡凭据并绑定计划哈希。单次/累计请求体上限为 24/192 KiB,最多 12 次调用;发送守卫逐次比对完整请求体,预期标签不进入模型请求。任何网络、提供方或解析异常即停,不自动续跑。候选仅部分有效,下一步应先判断审计改进的收益,不自动进入新一轮付费调参。 -只读检查确认 Docker 引擎仍不可连接;真实运行服务 HTTP、容器重启/持久化与回退验收均未完成。本次使用临时 ASGI 应用、SQL 和 Chroma,不是部署验收;Ticket Agent 仍使用 Dense,不声明全链 Hybrid,默认开关保持关闭。下一步仍需处理版本审计一致性与运行环境验收,而不是因 8 类流程通过就晋级发布。 +2026-10-18 启动 Docker 后完成[隔离本地容器验收](docs/reports/local_container_acceptance_2026-10-18.json):使用原 Dockerfile/Alembic/Uvicorn、假凭据、3 个独立卷和 `--network none`,通过容器内真实回环 TCP HTTP 检查迁移 head、JWT/角色/租户隔离、两篇虚构文档的元数据/原文校验和、独立 Chroma 预制向量及审计记录。在重启、删除重建保留卷、临时开启空范围 Hybrid、恢复关闭配置后均通过,最终审计 10 条,工单/Agent 执行表仍为零;没有付费模型调用。 + +本次不发布宿主机端口、不加载真实 `.env`;Chroma 预制向量不是文档索引/检索质量验证,空范围 `no_context` 不是非空问答验证。完整 Compose、宿主机回环访问、真实服务非空问答及 Dense 答案回退仍待验收,不因此宣称全面发布通过。固定检查入口为 `python -m scripts.smoke_local_persistence`,首次全新验收卷加 `--seed`;**只允许规定假凭据/数据库路径、Docker 无网络环境,拒绝 `-O`,不能直接针对日常服务运行**。临时容器和仅含虚构数据的卷已清理,专用镜像保留供复现。Ticket Agent 仍使用 Dense,默认 Hybrid 开关和 v5.3.1 候选接线均未改变。 --- @@ -670,6 +672,8 @@ docker compose up --build Docker Compose 用于本地可复现运行与核心链路验证,不作为生产部署能力声明。 +镜像构建排除 `.env`/其变体、开发及 Docker 的数据库/上传文件/Chroma 目录,保留公开 `.env.example`。Git 忽略不等于 Docker 构建忽略;不要把日常运行数据复制进镜像。 + --- # 10. 测试 / CI diff --git a/docs/reports/local_container_acceptance_2026-10-18.json b/docs/reports/local_container_acceptance_2026-10-18.json new file mode 100644 index 00000000..3a064d35 --- /dev/null +++ b/docs/reports/local_container_acceptance_2026-10-18.json @@ -0,0 +1,188 @@ +{ + "date": "2026-10-18", + "observed_at_utc": "2026-10-18 14:11:48 UTC", + "status": "LOCAL_CONTAINER_PERSISTENCE_PASS_NOT_FULL_DEPLOYMENT_ACCEPTANCE", + "base_commit": "6b56b3b10726f8cf922b407190cc69ae4bb7f801", + "scope": "Actual Dockerfile-built Linux containers, Alembic and Uvicorn, real TCP HTTP on container loopback. Dedicated disposable volumes, no port publication; not TestClient/ASGI in-process and not the full Compose deployment.", + "docker_engine_version": "29.6.2", + "container_python_version": "3.11.15", + "migration_revision": "20260705_0001 (head)", + "image": "copilot-acceptance-20261018:latest", + "final_image_id": "sha256:3a6991f9dae4ed2f127a97aa2afc818cb72d171220a9abddfd87c781d6069fbc", + "initial_phase_image_id": "sha256:8c4e38c8f99adb8671426f7260882bbbd0a51a7fe361b98ed981a54e2afb2d54", + "source_sha256_raw_at_execution": [ + { + "Name": ".dockerignore", + "Sha256": "f38a591e61b17cbd9f84876a09cd94e14d9f146a9e89bd8b4bac8d68cdd6e480" + }, + { + "Name": "Dockerfile", + "Sha256": "065848ff7de76fa0cac1c21346a1f235692050c6825c6d7b84b68a6b33c8c851" + }, + { + "Name": "smoke_local_persistence.py", + "Sha256": "fd113cf6cf40d47b00e4d23c562a5155832001033294b3a3f6e9a4e833665852" + }, + { + "Name": "test_docker_build_context.py", + "Sha256": "22dcfd8d3e163120025814dd61df6355728c3b077445e4474ab30475360ceb10" + } + ], + "image_build": { + "dockerfile_changed": false, + "requirements_changed": false, + "declared_dependencies_installed": true, + "pip_check": "No broken requirements found.", + "runtime_data_and_env_paths_absent_in_image": true, + "public_env_template_retained": true + }, + "isolation": { + "runtime_network": "none", + "published_ports": {}, + "real_env_loaded": false, + "credentials": "fixed dummy key; provider base URLs point to container loopback port 9", + "data_mounts": [ + { + "Name": "copilot_acceptance_20261018_6b56b3b_sql", + "Destination": "/app/data" + }, + { + "Name": "copilot_acceptance_20261018_6b56b3b_storage", + "Destination": "/app/storage" + }, + { + "Name": "copilot_acceptance_20261018_6b56b3b_chroma", + "Destination": "/app/experiments/chroma_db" + } + ], + "no_primary_checkout_data_mounts": true, + "provider_calls": 0, + "candidate_v5_3_1_activated": false + }, + "phases": [ + { + "name": "fresh_seed_default_off", + "status": "PASS", + "network": "none", + "published_ports": {}, + "documents": 2, + "vector_canaries": 1, + "provider_calls": 0, + "container_id": "57a0029efb63af2d2f7ae4651e111444b7757b8ab3c9a2b877f5695731d430b1", + "hybrid_search_enabled": false, + "hybrid_ask_enabled": false, + "search_http_status": 503, + "ask_http_status": 503, + "audit_records": 2 + }, + { + "name": "same_container_restart", + "status": "PASS", + "network": "none", + "published_ports": {}, + "documents": 2, + "vector_canaries": 1, + "provider_calls": 0, + "container_id": "57a0029efb63af2d2f7ae4651e111444b7757b8ab3c9a2b877f5695731d430b1", + "hybrid_search_enabled": false, + "hybrid_ask_enabled": false, + "search_http_status": 503, + "ask_http_status": 503, + "audit_records": 4 + }, + { + "name": "remove_recreate_same_volumes_empty_scope_on", + "status": "PASS", + "network": "none", + "published_ports": {}, + "documents": 2, + "vector_canaries": 1, + "provider_calls": 0, + "container_id": "5708fdffdbc2fa6fc3c5136ddb985857d289d05f62eb6605fe151ae22f87ef69", + "hybrid_search_enabled": true, + "hybrid_ask_enabled": true, + "search_http_status": 200, + "search_result": "empty results", + "ask_http_status": 200, + "ask_result": "no_context", + "audit_records": 6 + }, + { + "name": "recreate_configuration_rollback_off", + "status": "PASS", + "network": "none", + "published_ports": {}, + "documents": 2, + "vector_canaries": 1, + "provider_calls": 0, + "container_id": "65fb0850ff81f45859f8b105e2982aa4c174861bec37c911a6c097751c7bbd96", + "hybrid_search_enabled": false, + "hybrid_ask_enabled": false, + "search_http_status": 503, + "ask_http_status": 503, + "audit_records": 8 + }, + { + "name": "final_guarded_image_default_off_recheck", + "status": "PASS", + "network": "none", + "published_ports": {}, + "documents": 2, + "vector_canaries": 1, + "provider_calls": 0, + "container_id": "3aef54544ada721714bd45ccbe903acaf1b2a3821004150a7b73b08b2ead6ec7", + "image_id": "sha256:3a6991f9dae4ed2f127a97aa2afc818cb72d171220a9abddfd87c781d6069fbc", + "hybrid_search_enabled": false, + "hybrid_ask_enabled": false, + "search_http_status": 503, + "ask_http_status": 503, + "audit_records": 10 + } + ], + "checks_held_across_successful_phases": [ + "live loopback HTTP", + "migration head/integrity", + "JWT/role/tenant scope", + "SQL/document file checksums", + "Chroma canary (not retrieval quality)", + "Hybrid empty-scope/disabled behavior", + "audit persistence", + "zero tickets/agent actions" + ], + "persistence_checks": { + "sqlite_integrity": "ok", + "documents_metadata_and_checksums": "unchanged for both tenants", + "uploaded_file_content": "fixed fictional source retained", + "chroma": "one directly inserted fixed 2D vector and document retained; separate canary collection, not embedding/index/retrieval quality", + "initial_audit_ids": "retained across restart/recreation", + "ticket_agent_tables": "ticket, agent_runs, approval_requests, tool_calls all zero" + }, + "local_orchestration_note": "One verify exec was issued before the asynchronous docker restart command completed and exited 1 without a result. The command was awaited before running the successful verification; no reseed, no failed exec counted as PASS.", + "offline_verification": { + "passed": 659, + "failed": 0, + "skipped": 0, + "duration_seconds": 72.99, + "environment": "Windows Python 3.12.4; temporary DB/dummy keys; external Python socket and private project data guards" + }, + "cleanup": { + "owned_container_removed": true, + "owned_synthetic_volumes_removed": 3, + "label": "copilot.acceptance=20261018-6b56b3b", + "business_data_removed": false, + "synthetic_data_reproducible_from_seed_script": true, + "dedicated_image_retained_for_reproduction": true, + "global_prune_run": false + }, + "pending": [ + "Original Compose configuration and host-loopback port access; real .env intentionally not loaded", + "Non-empty document index/Hybrid retrieval/classification/generation through a running server (earlier real-provider checks were isolated ASGI)", + "Functional Dense answer fallback, rather than only restoring disabled Hybrid configuration", + "Independent semantic-quality acceptance; v5.3.1 version-audit candidate remains offline and partially effective" + ], + "non_actions": [ + "No paid model calls, business documents, TechQA/DEV, real credentials, public deployment or new ticket/agent action", + "No primary checkout edits, existing container/volume changes, persistent .env edits or candidate-policy promotion", + "Frozen prompts and historical real-provider records unchanged" + ] +} diff --git a/scripts/smoke_local_persistence.py b/scripts/smoke_local_persistence.py new file mode 100644 index 00000000..aa010a39 --- /dev/null +++ b/scripts/smoke_local_persistence.py @@ -0,0 +1,120 @@ +"""Live container-loopback checks; disposable offline volumes ONLY, no model calls.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import time + +import httpx + +STATE = Path("/app/data/runtime_acceptance_state.json") +TEXT = "Fictional CedarDesk local persistence canary. No model or ticket action." +COLLECTION = "runtime_acceptance_canary" + + +def run(seed=False): + if not __debug__: + raise RuntimeError("Acceptance checks require assertions enabled; do not use -O") + # ponytail: use Docker's network-none isolation, not a new mock-provider stack. + assert Path("/.dockerenv").exists() and {p.name for p in Path("/sys/class/net").iterdir()} == {"lo"} + expected = {"DATABASE_URL": "sqlite:////app/data/acceptance.db", "SQL_ECHO": "false", + "DOCUMENT_STORAGE_ROOT": "/app/storage/documents", "PYTHON_DOTENV_DISABLED": "1", + "DASHSCOPE_API_KEY": "offline-acceptance-not-a-key", "DASHSCOPE_BASE_URL": "http://127.0.0.1:9/v1", + "DASHSCOPE_PHASE_B_API_KEY": "offline-acceptance-not-a-key", + "DASHSCOPE_PHASE_B_BASE_URL": "http://127.0.0.1:9/v1", "ANONYMIZED_TELEMETRY": "False"} + assert all(os.getenv(key) == value for key, value in expected.items()), "Disposable environment required" + with httpx.Client(base_url="http://127.0.0.1:8000", trust_env=False, timeout=5) as api: + for _ in range(40): + try: + if api.get("/health").status_code == 200: + break + except httpx.RequestError: + pass + time.sleep(0.5) + else: + raise RuntimeError("Live server did not become healthy") + + def request(method, path, status=200, **kwargs): + response = api.request(method, path, **kwargs) + assert response.status_code == status, f"{method} {path}: HTTP {response.status_code}" + return response.json() + + headers = {} + for user, password in (("support", "support"), ("support_other", "support"), ("employee", "employee")): + token = request("POST", "/auth/token", json={"username": user, "password": password})["access_token"] + headers[user] = {"Authorization": "Bearer " + token} + request("GET", "/documents", 401) + request("GET", "/documents", 403, headers=headers["employee"]) + + from services.document_service import get_document_chroma_collection + collection = get_document_chroma_collection(collection_name=COLLECTION) + if seed: + assert not STATE.exists() and collection.count() == 0, "Refusing to reseed existing state" + state = {"documents": []} + for user in ("support", "support_other"): + assert request("GET", "/documents", headers=headers[user])["total"] == 0 + doc = request("POST", "/documents/upload", 201, headers=headers[user], data={"category": "it"}, + files={"file": ("runtime_canary.md", TEXT.encode(), "text/markdown")}) + state["documents"].append({"user": user, "document": doc}) + collection.add(ids=["synthetic-persistence-canary"], embeddings=[[1.0, 0.0]], documents=[TEXT]) + else: + state = json.loads(STATE.read_text(encoding="utf-8")) + + for item in state["documents"]: + user, original = item["user"], item["document"] + doc = request("GET", "/documents/" + original["id"], headers=headers[user]) + assert doc == original and doc["status"] == "uploaded" # Deliberately no embedding/index call. + path = Path(doc["source_path"]).resolve() + assert path.is_relative_to(Path("/app/storage/documents")) + assert path.read_text(encoding="utf-8") == TEXT + assert hashlib.sha256(path.read_bytes()).hexdigest() == doc["checksum"] + other = "support_other" if user == "support" else "support" + request("GET", "/documents/" + doc["id"], 404, headers=headers[other]) + vector = collection.get(ids=["synthetic-persistence-canary"], include=["documents", "embeddings"]) + assert vector["ids"] == ["synthetic-persistence-canary"] and vector["documents"] == [TEXT] + assert vector["embeddings"].tolist() == [[1.0, 0.0]] + + enabled = os.getenv("RAG_HYBRID_SEARCH_ENABLED") == "true" + search = request("POST", "/rag/search", 200 if enabled else 503, headers=headers["support"], + json={"query": "Fictional empty scope", "category": "other", "retrieval_mode": "hybrid"}) + if enabled: + assert search["results"] == [] and search["retrieval_mode"] == "hybrid" + ask_enabled = enabled and os.getenv("RAG_HYBRID_ASK_ENABLED") == "true" + answer = request("POST", "/rag/ask", 200 if ask_enabled else 503, headers=headers["support"], + json={"question": "Fictional empty scope", "category": "other", "retrieval_mode": "hybrid", + "refusal_policy": "v5_3_experimental"}) + if ask_enabled: + assert answer["retrieval_status"] == "no_context" and answer["sources"] == [] + logs = request("GET", "/agent-ops/retrieval-logs", headers=headers["support"]) + ids = {row["id"] for row in logs} + assert len(ids) >= 2 and all(row["tenant_id"] == "tenant_demo" for row in logs) + if seed: + state["initial_audit_ids"] = sorted(ids) + STATE.write_text(json.dumps(state), encoding="utf-8") + else: + assert set(state["initial_audit_ids"]) <= ids + + with sqlite3.connect("/app/data/acceptance.db") as db: + from alembic.config import Config + from alembic.script import ScriptDirectory + assert {row[0] for row in db.execute("select version_num from alembic_version")} == set( + ScriptDirectory.from_config(Config("alembic.ini")).get_heads()) + assert db.execute("pragma integrity_check").fetchone() == ("ok",) + assert db.execute("select count(*) from documents").fetchone() == (2,) + for table in ("ticket", "agent_runs", "approval_requests", "tool_calls"): + assert db.execute(f"select count(*) from {table}").fetchone() == (0,) + return {"status": "PASS", "phase": "seed" if seed else "verify", "network": "none", + "hybrid_search_enabled": enabled, "hybrid_ask_enabled": ask_enabled, + "documents": 2, "vector_canaries": 1, "audit_records": len(ids), "provider_calls": 0, + "checks": ["live loopback HTTP", "migration head/integrity", "JWT/role/tenant scope", + "SQL/document file checksums", "Chroma canary (not retrieval quality)", + "Hybrid empty-scope/disabled behavior", "audit persistence", "zero tickets/agent actions"]} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", action="store_true", help="Seed once into brand-new disposable volumes") + print(json.dumps(run(parser.parse_args().seed))) diff --git a/tests/test_docker_build_context.py b/tests/test_docker_build_context.py new file mode 100644 index 00000000..4e0d9a18 --- /dev/null +++ b/tests/test_docker_build_context.py @@ -0,0 +1,28 @@ +"""Static safety contract; not evidence that Docker build/runtime was executed.""" + +from pathlib import Path +import subprocess +import sys + +import pytest + + +def test_build_context_excludes_secrets_and_runtime_mounts(): + root = Path(__file__).resolve().parents[1] + rules = set((root / ".dockerignore").read_text(encoding="utf-8").splitlines()) + assert {".env", ".env.*", "!.env.example", ".git/", "data/", "storage/", "experiments/chroma_db/", + "docker_data/", "docker_storage/", "docker_chroma_db/"} <= rules + + +def test_live_check_refuses_non_disposable_environment_before_http(monkeypatch): + from scripts import smoke_local_persistence as smoke + monkeypatch.setattr(Path, "exists", lambda _: False) + monkeypatch.setattr(smoke.httpx, "Client", lambda **_: pytest.fail("Unsafe environment reached HTTP")) + with pytest.raises(AssertionError): + smoke.run() + + +def test_live_check_refuses_optimized_python_before_http(): + result = subprocess.run([sys.executable, "-O", "-m", "scripts.smoke_local_persistence"], + capture_output=True, text=True, timeout=30) + assert result.returncode != 0 and "require assertions enabled" in result.stderr