From 62e72506b21839cc39f0d72859bed16ad8618cf5 Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 17 Aug 2026 19:52:07 +0800 Subject: [PATCH 1/2] feat: add governed public policy promotion --- ...production-promotion-runbook-2026-08-17.md | 186 ++++++++++ .../services/contract-api/pyproject.toml | 1 + .../public_policy_promotion_cli.py | 346 ++++++++++++++++++ .../tests/test_public_policy_promotion.py | 230 ++++++++++++ yuqei-infra/compose/.env.production.example | 8 + .../compose/docker-compose.production.yml | 9 + yuqei-infra/deploy/README.md | 11 + yuqei-infra/deploy/preflight-production.py | 202 +++++++++- .../tests/test_production_release_tools.py | 109 ++++++ 9 files changed, 1101 insertions(+), 1 deletion(-) create mode 100644 project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md create mode 100644 yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py diff --git a/project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md b/project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md new file mode 100644 index 00000000..3a9ea52f --- /dev/null +++ b/project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md @@ -0,0 +1,186 @@ +# V2 公开政策生产 Promotion 运维手册 + +日期:2026-08-17 + +## 边界 + +本手册只描述已验签公开政策的受控状态切换。它不替代法务审批、运营审批、容量审批或公网切流审批。候选 keyring、手工修改数据库、HTTP 写接口、政策私钥和共享预发布凭据都不能用于生产激活。 + +`CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED` 必须保持 `false`。生产读取、注册协议和既有用户重新同意由 Contract API 提供;真正的 promotion 和 rollback 只通过本机受控 CLI 执行,并且不暴露 HTTP 路由。 + +## 前置条件 + +1. Contract API 数据库已经迁移到 `20260812_0046`,`public_policy_state` 存在且状态指针可验证。 +2. 四份政策候选文件、`manifest.json`、具名法务审批文件和 trusted keyring 已由安全/运维安装到生产主机。 +3. 材料目录和文件均为绝对路径,文件所有者可读,未包含 symlink,目录总量不超过 2 MiB。 +4. `CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST` 等于当前 Contract API 不可变镜像 digest。 +5. 生产只读 preflight 通过。 +6. 已取得单独的运营激活授权文件。该文件只用于计算 SHA-256,内容不得进入仓库、聊天、日志或 CI artifact。 +7. 已记录 expected generation、current set ID、trace ID、idempotency key 和授权文件 hash。 + +推荐主机布局: + +```text +/etc/yuqei/public-policy/2026-08-12/ + manifest.json + user-terms.md + privacy-policy.md + service-agreement.md + ai-use-notice.md + named-policy-legal-approval.json + policy-legal-keyring.trusted.json +``` + +生产环境模板中的绑定: + +```text +PUBLIC_POLICY_CANDIDATE_DIR_HOST=/etc/yuqei/public-policy/2026-08-12 +PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/named-policy-legal-approval.json +PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/policy-legal-keyring.trusted.json +CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST=sha256: +CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED=false +``` + +## 1. 只读预检 + +在生产主机执行: + +```bash +python3 yuqei-infra/deploy/preflight-production.py \ + --env-file /etc/yuqei/production.env \ + --artifact-dir /var/lib/yuqei/preflight/public-policy +``` + +必须确认以下检查为 `pass`: + +- `public_policy_deployment` +- `public_policy_credential_isolation` +- `public_policy_candidate_dir` +- `public_policy_manifest` +- `public_policy_legal_approval_file` +- `public_policy_trusted_keyring_file` +- 相关 owner-only 权限检查 + +任一失败时停止,不得通过改名、复制、手工改库或跳过 preflight 继续。 + +## 2. 查看当前状态 + +下面的 `COMPOSE_ARGS` 按生产部署手册替换为真实 project、env 和两个 Compose 文件参数: + +```bash +COMPOSE_ARGS=( + --project-name yuqei-v2-production + --env-file /etc/yuqei/production.env + -f yuqei-infra/compose/docker-compose.yml + -f yuqei-infra/compose/docker-compose.production.yml +) + +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli status +``` + +输出不包含政策正文,只包含状态指针、版本、生效时间、公开路径和每个文件的 SHA-256。 + +## 3. Promotion dry-run + +```bash +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli preflight +``` + +显式 promotion dry-run 还必须提供工单上的 expected state: + +```bash +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + promote \ + --expected-generation 0 \ + --expected-current-set-id none \ + --idempotency-key policy-promotion-20260812-001 \ + --trace-id ops-policy-promotion-20260812-001 +``` + +Dry-run 不写数据库,也不需要授权文件。 + +## 4. 受控 Promotion + +授权文件通过一次性只读挂载进入容器。不要把该挂载写入生产 Compose 文件: + +```bash +AUTHORIZATION_FILE=/run/yuqei/operations/policy-promotion-20260812-001.txt + +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps \ + --volume "$AUTHORIZATION_FILE:/run/yuqei/public-policy/activation-authorization.txt:ro" \ + contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + promote \ + --apply \ + --confirmation I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_PROMOTION \ + --authorization-file /run/yuqei/public-policy/activation-authorization.txt \ + --expected-generation 0 \ + --expected-current-set-id none \ + --idempotency-key policy-promotion-20260812-001 \ + --trace-id ops-policy-promotion-20260812-001 +``` + +命令输出中的 `authorization_sha256`、generation、set ID、event ID 和 state hash 必须写入运维证据。重复执行同一命令必须使用完全相同的材料;换任何材料必须换 idempotency key 并重新审批。 + +## 5. 生效后回归 + +到达批准的 `effective_at` 后验证: + +1. `GET /api/v1/public-policies/current` 返回四份政策。 +2. 四份政策页面返回 approved 字节,页面版本和生效时间正确。 +3. 新注册用户必须绑定四个 effective 协议版本。 +4. 既有用户下次登录进入重新同意流程。 +5. 同意提交记录版本、时间和主体。 +6. 审计和 append-only 政策事件链完整。 +7. Contract Web 不出现未发布、接口故障与真实空态混淆。 +8. 生产 health、schema-check、worker、Provider readiness 和核心合同流程保持通过。 + +## 6. 受控 Rollback + +Rollback 只允许指向曾经 active 的历史 policy set,不能伪造或引用不存在的 set。先执行 dry-run: + +```bash +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + rollback \ + --target-set-id \ + --expected-generation \ + --expected-current-set-id \ + --idempotency-key policy-rollback- \ + --trace-id ops-policy-rollback- +``` + +确认目标后,使用单独的 rollback 授权文件: + +```bash +ROLLBACK_AUTHORIZATION=/run/yuqei/operations/policy-rollback-.txt + +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps \ + --volume "$ROLLBACK_AUTHORIZATION:/run/yuqei/public-policy/rollback-authorization.txt:ro" \ + contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + rollback \ + --apply \ + --confirmation I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_ROLLBACK \ + --authorization-file /run/yuqei/public-policy/rollback-authorization.txt \ + --target-set-id \ + --expected-generation \ + --expected-current-set-id \ + --idempotency-key policy-rollback- \ + --trace-id ops-policy-rollback- +``` + +Rollback 也是 append-only 事件,不删除历史政策、同意记录或审计。执行后必须重复第 5 节回归,并通知法务和运营具名复核。 + +## 7. 明确禁止 + +- 禁止把法务私钥、运营授权文件或任何 policy signing key 放入仓库、生产环境文件或 CI artifact。 +- 禁止使用 candidate keyring 直接激活。 +- 禁止把 trusted keyring 的 `policy_activation_authorized=false` 手工改为 true。 +- 禁止直接 UPDATE/DELETE `public_policy_*` 表。 +- 禁止新增 HTTP promotion、rollback 或 execute 路由。 +- 禁止在 preflight、容量、备份、法务或运营审批失败时启动 edge 或切换 DNS。 +- 禁止把 dry-run 或技术验签输出宣称为法务批准或公网切流授权。 \ No newline at end of file diff --git a/yuqei-contract-platform/services/contract-api/pyproject.toml b/yuqei-contract-platform/services/contract-api/pyproject.toml index fab96d1a..9a0cd955 100644 --- a/yuqei-contract-platform/services/contract-api/pyproject.toml +++ b/yuqei-contract-platform/services/contract-api/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ [project.scripts] yuqei-import-v1-legal-fulltext = "yuqei_contract_api.v1_legal_fulltext_import:main" yuqei-bootstrap-identity-admin = "yuqei_contract_api.identity_bootstrap:main" +yuqei-public-policy-promotion = "yuqei_contract_api.public_policy_promotion_cli:main" [build-system] requires = ["hatchling"] diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py new file mode 100644 index 00000000..36d348f2 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import stat +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import sessionmaker + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.public_policies import ( + PublicPolicyError, + PublicPolicyRepository, + build_promotion_preflight, +) +from yuqei_contract_api.sqlalchemy_engine import build_engine_kwargs, ensure_sqlite_parent_dir + + +PROMOTION_CONFIRMATION = "I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_PROMOTION" +ROLLBACK_CONFIRMATION = "I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_ROLLBACK" +AUTHORIZATION_FILE_MAX_BYTES = 64 * 1024 + + +def main( + argv: list[str] | None = None, + *, + repository: PublicPolicyRepository | None = None, + settings: ContractApiSettings | None = None, + checked_at: datetime | None = None, +) -> int: + parser = build_parser() + args = parser.parse_args(argv) + resolved_settings = settings or ContractApiSettings() + if resolved_settings.public_policy_executor_enabled: + print(json.dumps({ + "status": "error", + "error": "The public policy executor flag must remain false; use this named CLI in an approved operations window.", + }, ensure_ascii=False)) + return 1 + if args.command in {"promote", "rollback"} and resolved_settings.environment == "local": + print(json.dumps({ + "status": "error", + "error": "Public policy writes require test, staging, or production environment settings.", + }, ensure_ascii=False)) + return 1 + + engine = None + try: + if repository is None: + database_url = args.database_url or resolved_settings.database_url + ensure_sqlite_parent_dir(database_url) + engine = create_engine(database_url, **build_engine_kwargs(database_url)) + session_factory = sessionmaker(bind=engine, expire_on_commit=False, future=True) + repository = PublicPolicyRepository(session_factory) + + if args.command == "status": + payload = _status_payload(repository) + elif args.command == "preflight": + payload = _preflight_payload(repository, resolved_settings, checked_at=checked_at) + elif args.command == "promote": + payload = _promote_payload( + args, + repository, + resolved_settings, + checked_at=checked_at, + ) + elif args.command == "rollback": + payload = _rollback_payload(args, repository, checked_at=checked_at) + else: # pragma: no cover - argparse rejects this branch. + raise PublicPolicyError(f"Unsupported command: {args.command}") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + except (PublicPolicyError, ValueError, OSError) as exc: + print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False)) + return 1 + except SQLAlchemyError: + print(json.dumps({"status": "error", "error": "Public policy repository operation failed."}, ensure_ascii=False)) + return 1 + finally: + if engine is not None: + engine.dispose() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Governed, non-HTTP public policy state operations." + ) + parser.add_argument("--database-url", default=None, help="Override CONTRACT_API_DATABASE_URL.") + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("status", help="Read the current public policy state without document bodies.") + subparsers.add_parser("preflight", help="Run the read-only verified-policy promotion preflight.") + + promote = subparsers.add_parser("promote", help="Promote one exact verified policy bundle.") + _add_write_arguments(promote, confirmation=PROMOTION_CONFIRMATION) + promote.add_argument( + "--expected-generation", + type=int, + required=True, + help="Generation observed on the approved operations ticket.", + ) + promote.add_argument( + "--expected-current-set-id", + required=True, + help="Current policy set ID from the ticket; use 'none' for the baseline state.", + ) + + rollback = subparsers.add_parser("rollback", help="Append a rollback pointer to a historical policy set.") + _add_write_arguments(rollback, confirmation=ROLLBACK_CONFIRMATION) + rollback.add_argument( + "--target-set-id", + required=True, + help="Previously active policy set to restore.", + ) + rollback.add_argument( + "--expected-generation", + type=int, + required=True, + help="Generation observed on the approved rollback ticket.", + ) + rollback.add_argument( + "--expected-current-set-id", + required=True, + help="Current policy set ID from the ticket.", + ) + return parser + + +def _add_write_arguments(parser: argparse.ArgumentParser, *, confirmation: str) -> None: + parser.add_argument("--apply", action="store_true", help="Write the state change. Omit for a read-only dry run.") + parser.add_argument( + "--confirmation", + default="", + help=f"Required when --apply is used: {confirmation}", + ) + parser.add_argument( + "--authorization-file", + type=Path, + help="Absolute owner-only file whose exact bytes authorize this operation. Its content is never printed.", + ) + parser.add_argument("--idempotency-key", required=True, help="Stable operation key from the approval record.") + parser.add_argument("--trace-id", required=True, help="Stable operations trace ID.") + parser.add_argument( + "--checked-at", + default=None, + help="Optional timezone-aware ISO-8601 time. Defaults to the current UTC time.", + ) + + +def _status_payload(repository: PublicPolicyRepository) -> dict[str, Any]: + state = repository.state() + catalog = repository.current_catalog() + return { + "command": "status", + "status": "ok", + "state": state.model_dump(mode="json"), + "current_catalog": None if catalog is None else { + "version": catalog.version, + "effective_at": catalog.effective_at, + "existing_user_reconsent": catalog.existing_user_reconsent, + "documents": [ + { + "policy_id": document.policy_id, + "version": document.version, + "public_path": document.public_path, + "sha256": document.sha256, + "size_bytes": document.size_bytes, + } + for document in catalog.documents + ], + }, + } + + +def _preflight_payload( + repository: PublicPolicyRepository, + settings: ContractApiSettings, + *, + checked_at: datetime | None, +) -> dict[str, Any]: + result = build_promotion_preflight(settings, repository, checked_at=checked_at) + return { + "command": "preflight", + **result.model_dump(mode="json"), + } + + +def _promote_payload( + args: argparse.Namespace, + repository: PublicPolicyRepository, + settings: ContractApiSettings, + *, + checked_at: datetime | None, +) -> dict[str, Any]: + _require_configured_absolute_policy_paths(settings) + now = _resolve_now(args, checked_at) + state = _require_expected_state( + repository, + expected_generation=args.expected_generation, + expected_current_set_id=args.expected_current_set_id, + ) + preflight = build_promotion_preflight(settings, repository, checked_at=now) + if not args.apply: + return { + "command": "promote", + "mode": "dry_run", + **preflight.model_dump(mode="json"), + } + _require_write_intent(args, confirmation=PROMOTION_CONFIRMATION) + authorization_sha256 = _authorization_sha256(args.authorization_file) + if preflight.current_state != state: + raise PublicPolicyError("Public policy state changed while this command was running.") + snapshot = repository.promote_verified_bundle( + preflight=preflight, + policy_dir=Path(settings.public_policy_candidate_dir), + authorization_sha256=authorization_sha256, + idempotency_key=args.idempotency_key, + trace_id=args.trace_id, + now=now, + approval_file=Path(settings.public_policy_legal_approval_file), + keyring_file=Path(settings.public_policy_trusted_keyring_file), + release_digest=settings.public_policy_release_digest, + ) + return { + "command": "promote", + "status": "promoted", + "authorization_sha256": authorization_sha256, + "trace_id": args.trace_id, + "state": snapshot.model_dump(mode="json"), + } + + +def _rollback_payload( + args: argparse.Namespace, + repository: PublicPolicyRepository, + *, + checked_at: datetime | None, +) -> dict[str, Any]: + now = _resolve_now(args, checked_at) + state = _require_expected_state( + repository, + expected_generation=args.expected_generation, + expected_current_set_id=args.expected_current_set_id, + ) + if not args.apply: + return { + "command": "rollback", + "mode": "dry_run", + "target_set_id": args.target_set_id, + "state": state.model_dump(mode="json"), + } + _require_write_intent(args, confirmation=ROLLBACK_CONFIRMATION) + authorization_sha256 = _authorization_sha256(args.authorization_file) + snapshot = repository.rollback_verified_set( + expected_state=state, + target_set_id=args.target_set_id, + authorization_sha256=authorization_sha256, + idempotency_key=args.idempotency_key, + trace_id=args.trace_id, + now=now, + ) + return { + "command": "rollback", + "status": "rolled_back", + "authorization_sha256": authorization_sha256, + "trace_id": args.trace_id, + "state": snapshot.model_dump(mode="json"), + } + + +def _require_expected_state( + repository: PublicPolicyRepository, + *, + expected_generation: int, + expected_current_set_id: str, +) -> Any: + state = repository.state() + expected_set_id = None if expected_current_set_id.lower() == "none" else expected_current_set_id + if state.generation != expected_generation or state.current_set_id != expected_set_id: + raise PublicPolicyError( + "Current public policy state does not match the approved expected state." + ) + return state + + +def _require_write_intent(args: argparse.Namespace, *, confirmation: str) -> None: + if not args.apply: + raise PublicPolicyError("A write was requested without --apply.") + if args.confirmation != confirmation: + raise PublicPolicyError(f"Confirmation does not match the exact operation: {confirmation}") + if args.authorization_file is None: + raise PublicPolicyError("An absolute owner-only authorization file is required.") + + +def _authorization_sha256(path: Path | None) -> str: + if path is None: + raise PublicPolicyError("An authorization file is required.") + try: + resolved = path.resolve(strict=True) + if not resolved.is_absolute() or not resolved.is_file(): + raise PublicPolicyError("Authorization file must be an absolute file.") + if resolved.stat().st_size <= 0 or resolved.stat().st_size > AUTHORIZATION_FILE_MAX_BYTES: + raise PublicPolicyError("Authorization file must be non-empty and no larger than 64 KiB.") + if os.name != "nt" and stat.S_IMODE(resolved.stat().st_mode) & 0o077: + raise PublicPolicyError("Authorization file must be owner-only.") + return hashlib.sha256(resolved.read_bytes()).hexdigest() + except PublicPolicyError: + raise + except OSError as exc: + raise PublicPolicyError("Authorization file is unavailable.") from exc + + +def _resolve_now(args: argparse.Namespace, fallback: datetime | None) -> datetime: + if fallback is not None: + return fallback + raw = getattr(args, "checked_at", None) + if not raw: + return datetime.now(UTC) + try: + value = datetime.fromisoformat(raw) + except ValueError as exc: + raise PublicPolicyError("checked_at must be a valid ISO-8601 timestamp.") from exc + if value.tzinfo is None: + raise PublicPolicyError("checked_at must include a timezone.") + return value.astimezone(UTC) + + +def _require_configured_absolute_policy_paths(settings: ContractApiSettings) -> None: + labels = { + "candidate directory": Path(settings.public_policy_candidate_dir), + "legal approval file": Path(settings.public_policy_legal_approval_file), + "trusted keyring file": Path(settings.public_policy_trusted_keyring_file), + } + for label, path in labels.items(): + if not path.is_absolute(): + raise PublicPolicyError(f"Configured public policy {label} must be absolute.") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py b/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py index bc0b5ff4..c256588f 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py @@ -17,6 +17,11 @@ from yuqei_contract_api.persistence.models import ( PublicPolicyStateEventModel, PublicPolicyStateModel, ) +from yuqei_contract_api.public_policy_promotion_cli import ( + PROMOTION_CONFIRMATION, + ROLLBACK_CONFIRMATION, + main as public_policy_cli_main, +) from yuqei_contract_api.public_policies import ( BASELINE_STATE_HASH, PublicPolicyError, @@ -476,3 +481,228 @@ def test_rollback_rejects_missing_and_current_targets(tmp_path: Path, monkeypatc trace_id="trace-current-target", now=datetime.now(UTC), ) + + +def _cli_settings(policy_dir: Path, approval_file: Path, keyring_file: Path) -> ContractApiSettings: + return ContractApiSettings( + environment="test", + public_policy_candidate_dir=str(policy_dir.resolve()), + public_policy_legal_approval_file=str(approval_file.resolve()), + public_policy_trusted_keyring_file=str(keyring_file.resolve()), + public_policy_release_digest=RELEASE_DIGEST, + ) + + +def test_public_policy_cli_dry_run_and_promotion_require_explicit_authorization( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + settings = _cli_settings(policy_dir, approval_file, keyring_file) + + dry_run = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-v1", + "--trace-id", "policy-cli-trace-v1", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + dry_run_payload = json.loads(capsys.readouterr().out) + assert dry_run == 0 + assert dry_run_payload["mode"] == "dry_run" + assert dry_run_payload["status"] == "pass" + assert repository.state().generation == 0 + + authorization = tmp_path / "activation-authorization.txt" + authorization.write_bytes(b"exact operations authorization") + denied = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", "NOT_THEExact_CONFIRMATION", + "--authorization-file", str(authorization), + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-v1", + "--trace-id", "policy-cli-trace-v1", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + denied_payload = json.loads(capsys.readouterr().out) + assert denied == 1 + assert denied_payload["status"] == "error" + assert repository.state().generation == 0 + + promoted = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-v1", + "--trace-id", "policy-cli-trace-v1", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + promotion_payload = json.loads(capsys.readouterr().out) + assert promoted == 0 + assert promotion_payload["status"] == "promoted" + assert promotion_payload["state"]["generation"] == 1 + assert repository.state().generation == 1 + + +def test_public_policy_cli_can_roll_back_only_with_exact_expected_state( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + settings = _cli_settings(policy_dir, approval_file, keyring_file) + authorization = tmp_path / "rollback-authorization.txt" + authorization.write_bytes(b"exact rollback authorization") + + promoted = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-promote-v1", + "--trace-id", "policy-cli-promote-trace", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + promotion_payload = json.loads(capsys.readouterr().out) + assert promoted == 0 + target_set_id = promotion_payload["state"]["current_set_id"] + + denied = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "rollback", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--target-set-id", target_set_id, + "--expected-generation", "99", + "--expected-current-set-id", target_set_id, + "--idempotency-key", "policy-cli-rollback-v1", + "--trace-id", "policy-cli-rollback-trace", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + assert denied == 1 + assert repository.state().generation == 1 + capsys.readouterr() + + rolled_back = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "rollback", + "--apply", + "--confirmation", ROLLBACK_CONFIRMATION, + "--authorization-file", str(authorization), + "--target-set-id", "missing-policy-set", + "--expected-generation", "1", + "--expected-current-set-id", target_set_id, + "--idempotency-key", "policy-cli-rollback-v1", + "--trace-id", "policy-cli-rollback-trace", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + rollback_payload = json.loads(capsys.readouterr().out) + assert rolled_back == 1 + assert rollback_payload["status"] == "error" + assert repository.state().generation == 1 + + second_dir = tmp_path / "candidate-v2" + second_dir.mkdir() + second_approval, second_keyring, second_documents = _evidence( + second_dir, + version="2026-08-13", + effective_at=effective_at, + suffix="-v2", + ) + _mock_verifier( + monkeypatch, + version="2026-08-13", + effective_at=effective_at, + documents=second_documents, + ) + second_settings = _cli_settings(second_dir, second_approval, second_keyring) + second = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--expected-generation", "1", + "--expected-current-set-id", target_set_id, + "--idempotency-key", "policy-cli-promote-v2", + "--trace-id", "policy-cli-promote-trace-v2", + ], + repository=repository, + settings=second_settings, + checked_at=datetime(2026, 8, 14, tzinfo=UTC), + ) + second_payload = json.loads(capsys.readouterr().out) + assert second == 0 + second_set_id = second_payload["state"]["current_set_id"] + + restored = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "rollback", + "--apply", + "--confirmation", ROLLBACK_CONFIRMATION, + "--authorization-file", str(authorization), + "--target-set-id", target_set_id, + "--expected-generation", "2", + "--expected-current-set-id", second_set_id, + "--idempotency-key", "policy-cli-rollback-v2", + "--trace-id", "policy-cli-rollback-trace-v2", + ], + repository=repository, + settings=second_settings, + checked_at=datetime(2026, 8, 14, tzinfo=UTC), + ) + restore_payload = json.loads(capsys.readouterr().out) + assert restored == 0 + assert restore_payload["status"] == "rolled_back" + assert restore_payload["state"]["generation"] == 3 + assert restore_payload["state"]["current_set_id"] == target_set_id \ No newline at end of file diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 2c29a472..f921c46d 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -129,6 +129,14 @@ CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT=50 CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES=20 CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 + +# Verified public-policy material is mounted read-only. The activation executor +# remains false; promotion is performed only through the named operations CLI. +PUBLIC_POLICY_CANDIDATE_DIR_HOST=/etc/yuqei/public-policy/2026-08-12 +PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/named-policy-legal-approval.json +PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/policy-legal-keyring.trusted.json +CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST=sha256:CHANGE_ME_64_HEX_DIGEST +CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED=false CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA=false CONTRACT_WEB_SESSION_SECRET=CHANGE_ME_USE_A_DIFFERENT_RANDOM_32_BYTE_SECRET CONTRACT_WEB_SESSION_SECURE=true diff --git a/yuqei-infra/compose/docker-compose.production.yml b/yuqei-infra/compose/docker-compose.production.yml index eed5c2c0..a3406e9a 100644 --- a/yuqei-infra/compose/docker-compose.production.yml +++ b/yuqei-infra/compose/docker-compose.production.yml @@ -24,8 +24,17 @@ services: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} pull_policy: always # SMTP remains opt-in through docker-compose.production.smtp.yml so disabled mode has no secret-file dependency. + environment: + CONTRACT_API_PUBLIC_POLICY_CANDIDATE_DIR: /run/yuqei/public-policy/candidate + CONTRACT_API_PUBLIC_POLICY_LEGAL_APPROVAL_FILE: /run/yuqei/public-policy/named-policy-legal-approval.json + CONTRACT_API_PUBLIC_POLICY_TRUSTED_KEYRING_FILE: /run/yuqei/public-policy/policy-legal-keyring.trusted.json + CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST: ${CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST:?CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST is required} + CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED: ${CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED:-false} volumes: - contract-files:/data/contract-files + - ${PUBLIC_POLICY_CANDIDATE_DIR_HOST:?PUBLIC_POLICY_CANDIDATE_DIR_HOST is required}:/run/yuqei/public-policy/candidate:ro + - ${PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST:?PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST is required}:/run/yuqei/public-policy/named-policy-legal-approval.json:ro + - ${PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST:?PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST is required}:/run/yuqei/public-policy/policy-legal-keyring.trusted.json:ro contract-review-worker: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} diff --git a/yuqei-infra/deploy/README.md b/yuqei-infra/deploy/README.md index 4a122733..95fbd85c 100644 --- a/yuqei-infra/deploy/README.md +++ b/yuqei-infra/deploy/README.md @@ -91,6 +91,17 @@ not change a keyring, toggle the kill switch, import a receipt, or invoke any id write/execute API. Make control changes through the independently approved operations procedure, then use this workflow only to observe their propagation. +## Public policy promotion + +The production Compose mounts the verified policy candidate directory, named legal approval, and trusted public keyring into Contract API read-only. Keep `CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED=false`; there is no HTTP write route. + +Before activation, run the read-only production preflight and follow: + +```text +project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md +``` + +The named CLI performs `status`, `preflight`, dry-run, idempotent promotion, and historical rollback. A write requires `--apply`, the exact operation confirmation string, an owner-only authorization file, the approved expected generation/set, a trace ID, and an idempotency key. Policy private keys and the one-time authorization file must never be committed or added to Compose permanently. ## Alibaba Cloud frontend variables The frontend deployment must use server-only values: diff --git a/yuqei-infra/deploy/preflight-production.py b/yuqei-infra/deploy/preflight-production.py index 6009905c..0898bb7d 100644 --- a/yuqei-infra/deploy/preflight-production.py +++ b/yuqei-infra/deploy/preflight-production.py @@ -28,6 +28,10 @@ PLACEHOLDER_MARKERS = ("change_me", "local-only", "example.com", "your-api-key", LOOPBACK_VALUES = {"127.0.0.1", "::1", "localhost"} PROVIDER_ACCEPTANCE_RECEIPT_MAX_BYTES = 1024 * 1024 PROVIDER_ACCEPTANCE_KEYRING_MAX_BYTES = 256 * 1024 +PUBLIC_POLICY_CANDIDATE_MAX_BYTES = 2 * 1024 * 1024 +PUBLIC_POLICY_APPROVAL_MAX_BYTES = 256 * 1024 +PUBLIC_POLICY_KEYRING_MAX_BYTES = 256 * 1024 +EXPECTED_PUBLIC_POLICY_IDS = {"user_terms", "privacy_policy", "service_agreement", "ai_use_notice"} ALERT_WEBHOOK_MAX_BYTES = 16 * 1024 SMTP_CREDENTIAL_CONTAINER_PATH = "/run/secrets/contract_smtp_password" LAUNCH_SCOPE_MANIFEST_PATH = ( @@ -146,6 +150,10 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) "AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE_HOST", "AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE_HOST", "AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256", + "PUBLIC_POLICY_CANDIDATE_DIR_HOST", + "PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", + "PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST", + "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST", "PRODUCTION_ALERT_WEBHOOK_URL_FILE", ) missing = [key for key in required if not values.get(key, "").strip()] @@ -157,6 +165,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) "CONTRACT_API_SEED_DEMO_DATA": "false", "CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA": "false", "CONTRACT_API_REQUIRE_WEB_PROXY_AUTH": "true", + "CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED": "false", "CONTRACT_WEB_SESSION_SECURE": "true", "AI_PLATFORM_ENVIRONMENT": "production", "AI_PLATFORM_DATABASE_AUTO_CREATE": "false", @@ -250,6 +259,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) database_checks = _validate_database_urls(values) checks.extend(database_checks) checks.extend(_validate_provider_acceptance_deployment(values, template_mode=template_mode)) + checks.extend(_validate_public_policy_deployment(values, template_mode=template_mode)) public_url = values.get("BACKEND_PUBLIC_BASE_URL", "") parsed_public_url = urlparse(public_url) @@ -280,7 +290,7 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li Check( "protected_files", "warning", - "Template mode skipped TLS, provider key, Provider acceptance receipt/keyring, optional SMTP secret, and backup mount checks.", + "Template mode skipped TLS, provider key, Provider acceptance receipt/keyring, public-policy evidence, optional SMTP secret, and backup mount checks.", ) ] checks: list[Check] = [] @@ -338,6 +348,8 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li ) ) + checks.extend(_validate_public_policy_files(values)) + email_mode = values.get("CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE", "").strip().lower() smtp_username = values.get("CONTRACT_API_IDENTITY_SMTP_USERNAME", "").strip() if email_mode == "smtp" and smtp_username: @@ -412,6 +424,139 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li return checks +def _validate_public_policy_files(values: dict[str, str]) -> list[Check]: + checks: list[Check] = [] + candidate_root = Path(values.get("PUBLIC_POLICY_CANDIDATE_DIR_HOST", "")) + candidate_ok = candidate_root.is_absolute() and candidate_root.is_dir() and not candidate_root.is_symlink() + total_size = 0 + symlink_found = False + if candidate_ok: + try: + for path in candidate_root.rglob("*"): + if path.is_symlink(): + symlink_found = True + elif path.is_file(): + total_size += path.stat().st_size + except OSError: + candidate_ok = False + candidate_ok = candidate_ok and not symlink_found and 0 < total_size <= PUBLIC_POLICY_CANDIDATE_MAX_BYTES + checks.append( + _result( + "public_policy_candidate_dir", + candidate_ok, + "The public-policy candidate directory is absolute, bounded, and contains no symlinks." + if candidate_ok + else "The public-policy candidate directory must be absolute, non-empty, no larger than 2 MiB, and free of symlinks.", + ) + ) + + manifest_ok = False + if candidate_ok: + manifest_path = candidate_root / "manifest.json" + try: + manifest = _read_strict_json_object(manifest_path) + documents = manifest.get("documents") + manifest_ok = ( + manifest.get("schema_version") == "yuqei-public-policy-candidate-v1" + and manifest.get("existing_user_reconsent") == "required_on_next_login" + and isinstance(documents, list) + and len(documents) == 4 + and {item.get("policy_id") for item in documents if isinstance(item, dict)} + == EXPECTED_PUBLIC_POLICY_IDS + ) + if manifest_ok: + for item in documents: + source_file = item.get("source_file") + relative = Path(str(source_file)) + resolved = (candidate_root / relative).resolve() + if ( + not isinstance(source_file, str) + or not source_file.strip() + or relative.is_absolute() + or ".." in relative.parts + or not resolved.is_relative_to(candidate_root.resolve()) + or not resolved.is_file() + or resolved.stat().st_size <= 0 + ): + manifest_ok = False + break + except (OSError, UnicodeError, ValueError, TypeError): + manifest_ok = False + checks.append( + _result( + "public_policy_manifest", + manifest_ok, + "The candidate manifest binds all four approved public-policy source files." + if manifest_ok + else "The candidate manifest must bind exactly user terms, privacy policy, service agreement, and AI notice with safe in-directory sources.", + ) + ) + + approval_path = Path(values.get("PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", "")) + approval_ok = _is_bounded_absolute_file(approval_path, PUBLIC_POLICY_APPROVAL_MAX_BYTES) + if approval_ok: + try: + _read_strict_json_object(approval_path) + except (OSError, UnicodeError, ValueError): + approval_ok = False + checks.append( + _result( + "public_policy_legal_approval_file", + approval_ok, + "The named public-policy legal approval file is present and JSON-valid." + if approval_ok + else "The public-policy legal approval must be an absolute JSON file no larger than 256 KiB.", + ) + ) + + keyring_path = Path(values.get("PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST", "")) + keyring_ok = _is_bounded_absolute_file(keyring_path, PUBLIC_POLICY_KEYRING_MAX_BYTES) + if keyring_ok: + try: + keyring = _read_strict_json_object(keyring_path) + trust_basis = keyring.get("trust_basis") + keyring_ok = ( + isinstance(trust_basis, dict) + and trust_basis.get("policy_activation_authorized") is False + ) + except (OSError, UnicodeError, ValueError): + keyring_ok = False + checks.append( + _result( + "public_policy_trusted_keyring_file", + keyring_ok, + "The trusted public-policy keyring is JSON-valid and does not itself authorize activation." + if keyring_ok + else "The trusted public-policy keyring must be JSON-valid and keep policy_activation_authorized=false.", + ) + ) + + if os.name != "nt": + for key, path, enabled in ( + ("public_policy_legal_approval_permissions", approval_path, approval_ok), + ("public_policy_trusted_keyring_permissions", keyring_path, keyring_ok), + ): + if enabled: + mode = stat.S_IMODE(path.stat().st_mode) + secure_mode = mode & 0o077 == 0 + checks.append( + _result( + key, + secure_mode, + "The public-policy evidence file is owner-only." + if secure_mode + else f"Public-policy evidence file mode {oct(mode)} exposes group or other permissions.", + ) + ) + return checks + + +def _read_strict_json_object(path: Path) -> dict[str, object]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("JSON value is not an object") + return value + def _validate_identity_email_delivery(values: dict[str, str]) -> Check: mode = values.get("CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE", "").strip().lower() self_service = values.get("CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED", "").strip().lower() @@ -651,6 +796,61 @@ def _validate_database_urls(values: dict[str, str]) -> list[Check]: return [_result("database_boundaries", not problems, "Database URLs target distinct production PostgreSQL databases on the internal service host." if not problems else f"Invalid database configuration keys: {', '.join(sorted(set(problems)))}.")] +def _validate_public_policy_deployment( + values: dict[str, str], + *, + template_mode: bool, +) -> list[Check]: + candidate_dir = values.get("PUBLIC_POLICY_CANDIDATE_DIR_HOST", "").strip() + approval_file = values.get("PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", "").strip() + trusted_keyring = values.get("PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST", "").strip() + release_digest = values.get("CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST", "").strip() + contract_image = values.get("CONTRACT_API_IMAGE", "").strip() + deployed_contract_digest = contract_image.rsplit("@", 1)[1] if "@" in contract_image else "" + + paths = (candidate_dir, approval_file, trusted_keyring) + paths_ok = ( + all(paths) + and all(_is_absolute_host_path(path) for path in paths) + and len(set(paths)) == len(paths) + ) + executor_ok = values.get("CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED", "").strip().lower() == "false" + digest_ok = ( + bool(IMAGE_DIGEST_PATTERN.fullmatch(release_digest)) + and release_digest == deployed_contract_digest.lower() + ) + deployment_ok = paths_ok and executor_ok and digest_ok + + forbidden = sorted( + key + for key, value in values.items() + if value.strip() and _is_forbidden_public_policy_key(key) + ) + return [ + _result( + "public_policy_deployment", + template_mode or deployment_ok, + "Public policy evidence uses distinct absolute paths, the executor is disabled, and the release digest matches the Contract API image." + if deployment_ok + else "Public policy requires distinct absolute evidence paths, executor=false, and a release digest matching the immutable Contract API image.", + warning=template_mode and not deployment_ok, + ), + _result( + "public_policy_credential_isolation", + not forbidden, + "Production contains no public-policy private key, password, or staging credential." + if not forbidden + else f"Forbidden public-policy credentials are configured: {', '.join(forbidden)}.", + ), + ] + + +def _is_forbidden_public_policy_key(key: str) -> bool: + normalized = key.upper() + if "PUBLIC_POLICY" not in normalized: + return False + return any(token in normalized for token in ("PRIVATE_KEY", "PRIVATE_KEY_FILE", "PASSWORD", "SECRET", "STAGING")) + def _validate_provider_acceptance_deployment( values: dict[str, str], *, diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index d45e84c1..6294d03b 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -60,6 +60,7 @@ def production_values() -> dict[str, str]: "CONTRACT_API_IMAGE": f"registry.internal/contract-api@sha256:{'1' * 64}", "AI_PLATFORM_API_IMAGE": f"registry.internal/ai-platform-api@sha256:{'2' * 64}", "AI_PLATFORM_RELEASE_IMAGE_DIGEST": f"sha256:{'2' * 64}", + "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST": f"sha256:{'1' * 64}", "AI_WORKER_IMAGE": f"registry.internal/ai-worker@sha256:{'3' * 64}", "POSTGRES_IMAGE": f"registry.internal/pgvector@sha256:{'4' * 64}", "POSTGRES_CLIENT_IMAGE": f"registry.internal/postgres@sha256:{'5' * 64}", @@ -353,11 +354,119 @@ def test_production_compose_mounts_only_promotion_verification_material_read_onl assert "AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256: ${" in override assert ":/run/yuqei/provider-acceptance/receipt.json:ro" in override assert ":/run/yuqei/provider-acceptance/trusted-keys.json:ro" in override + assert "CONTRACT_API_PUBLIC_POLICY_CANDIDATE_DIR: /run/yuqei/public-policy/candidate" in override + assert "${PUBLIC_POLICY_CANDIDATE_DIR_HOST:?PUBLIC_POLICY_CANDIDATE_DIR_HOST is required}:/run/yuqei/public-policy/candidate:ro" in override + assert "${PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST:?PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST is required}:/run/yuqei/public-policy/named-policy-legal-approval.json:ro" in override + assert "${PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST:?PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST is required}:/run/yuqei/public-policy/policy-legal-keyring.trusted.json:ro" in override + assert "CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED: ${CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED:-false}" in override assert 'AI_PLATFORM_PROVIDER_LEGAL_ATTESTATION_SECRET: ""' in override assert "SIGNING_PRIVATE_KEY" not in override assert "STAGING_DATABASE_URL" not in override +def _write_public_policy_evidence(tmp_path: Path) -> dict[str, str]: + candidate = tmp_path / "public-policy" + candidate.mkdir() + documents = [] + for policy_id, source_file in ( + ("user_terms", "user-terms.md"), + ("privacy_policy", "privacy-policy.md"), + ("service_agreement", "service-agreement.md"), + ("ai_use_notice", "ai-use-notice.md"), + ): + (candidate / source_file).write_text(f"# {policy_id}\n", encoding="utf-8") + documents.append({"policy_id": policy_id, "source_file": source_file}) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": "yuqei-public-policy-candidate-v1", + "existing_user_reconsent": "required_on_next_login", + "documents": documents, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + approval = tmp_path / "policy-approval.json" + approval.write_text('{"approval_id":"policy-approval"}', encoding="utf-8") + keyring = tmp_path / "policy-keyring.json" + keyring.write_text( + json.dumps({"trust_basis": {"policy_activation_authorized": False}}), + encoding="utf-8", + ) + return { + "PUBLIC_POLICY_CANDIDATE_DIR_HOST": str(candidate.resolve()), + "PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST": str(approval.resolve()), + "PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST": str(keyring.resolve()), + } + + +def test_production_preflight_rejects_invalid_public_policy_deployment() -> None: + values = production_values() + values["CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST"] = f"sha256:{'f' * 64}" + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["public_policy_deployment"].status == "fail" + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("PUBLIC_POLICY_CANDIDATE_DIR_HOST", "relative/public-policy"), + ("PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", "/etc/yuqei/public-policy/2026-08-12/policy-legal-keyring.trusted.json"), + ("CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED", "true"), + ], +) +def test_production_preflight_rejects_unsafe_public_policy_paths_or_executor( + key: str, + value: str, +) -> None: + values = production_values() + values[key] = value + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["public_policy_deployment"].status == "fail" + + +def test_production_preflight_rejects_public_policy_private_material() -> None: + values = production_values() + values["CONTRACT_API_PUBLIC_POLICY_PRIVATE_KEY_FILE"] = "/etc/yuqei/private.pem" + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["public_policy_credential_isolation"].status == "fail" + + +def test_public_policy_evidence_files_are_present_and_safe(tmp_path: Path) -> None: + values = production_values() + values.update(_write_public_policy_evidence(tmp_path)) + + checks = {check.key: check for check in preflight.validate_files(values)} + + assert checks["public_policy_candidate_dir"].status == "pass" + assert checks["public_policy_manifest"].status == "pass" + assert checks["public_policy_legal_approval_file"].status == "pass" + assert checks["public_policy_trusted_keyring_file"].status == "pass" + + +def test_public_policy_preflight_fails_on_manifest_or_keyring_drift(tmp_path: Path) -> None: + values = production_values() + values.update(_write_public_policy_evidence(tmp_path)) + candidate = Path(values["PUBLIC_POLICY_CANDIDATE_DIR_HOST"]) + (candidate / "manifest.json").write_text('{"schema_version":"wrong"}', encoding="utf-8") + keyring = Path(values["PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST"]) + keyring.write_text( + json.dumps({"trust_basis": {"policy_activation_authorized": True}}), + encoding="utf-8", + ) + + checks = {check.key: check for check in preflight.validate_files(values)} + + assert checks["public_policy_manifest"].status == "fail" + assert checks["public_policy_trusted_keyring_file"].status == "fail" + def test_shared_compose_wires_contract_anti_abuse_controls() -> None: compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text( encoding="utf-8" From 9d00c44ce2e6b847171c53baaf6bbaf2ca4d34ee Mon Sep 17 00:00:00 2001 From: yuqei Date: Sat, 3 Oct 2026 22:37:27 +0800 Subject: [PATCH 2/2] refactor: remove obsolete V1 implementation paths --- .gitignore | 11 - README.md | 16 +- V2-README.md | 8 +- ...evelopment-optimization-plan-2026-06-28.md | 2 +- ...-release-readiness-checklist-2026-07-10.md | 14 +- .../V2-vs-V1-feature-gap-audit-2026-07-03.md | 82 -- .../packages/ai-openapi/openapi.yaml | 4 +- .../services/ai-platform-api/README.md | 2 +- .../services/ai-platform-api/pyproject.toml | 2 +- .../golden_sample_gate.py | 2 +- .../yuqei_ai_platform_api/golden_samples.py | 36 +- ...rt.py => packaged_prompt_assets_import.py} | 30 +- .../src/yuqei_ai_platform_api/prompts.py | 10 +- ... => test_packaged_prompt_assets_import.py} | 22 +- .../tests/test_platform_data.py | 2 +- .../tests/test_provider_flow.py | 14 +- .../src/app/admin/ai-prompts/page.tsx | 48 +- .../app/admin/ai-quality-dashboard/page.tsx | 2 +- .../src/app/admin/legal-assets/page.tsx | 12 +- .../src/app/admin/legal-fulltext/page.tsx | 16 +- .../apps/contract-web/src/app/globals.css | 4 +- .../contract-web/src/app/templates/page.tsx | 2 +- .../services/contract-api/pyproject.toml | 1 - .../v1_legal_fulltext_import.py | 798 ------------------ .../tests/test_v1_legal_fulltext_import.py | 127 --- yuqei-infra/README.md | 4 +- yuqei-infra/compose/docker-compose.yml | 2 +- yuqei-infra/scripts/compare-v1-v2-assets.py | 347 -------- .../scripts/deploy-v2-smoke-docker.ps1 | 2 +- .../scripts/inspect-v2-database-state.ps1 | 47 -- .../scripts/run-ai-provider-acceptance.py | 2 +- .../tests/test_v2_only_repository_layout.py | 58 ++ 32 files changed, 180 insertions(+), 1549 deletions(-) delete mode 100644 project-docs/V2-vs-V1-feature-gap-audit-2026-07-03.md rename yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/{v1_prompt_assets_import.py => packaged_prompt_assets_import.py} (92%) rename yuqei-ai-platform/services/ai-platform-api/tests/{test_v1_prompt_assets_import.py => test_packaged_prompt_assets_import.py} (85%) delete mode 100644 yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/v1_legal_fulltext_import.py delete mode 100644 yuqei-contract-platform/services/contract-api/tests/test_v1_legal_fulltext_import.py delete mode 100644 yuqei-infra/scripts/compare-v1-v2-assets.py delete mode 100644 yuqei-infra/scripts/inspect-v2-database-state.ps1 create mode 100644 yuqei-infra/tests/test_v2_only_repository_layout.py diff --git a/.gitignore b/.gitignore index ffa2b5fa..a805b1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,4 @@ -backend/.venv/ node_modules/ -frontend/.next/ -frontend/.next-dev-log/ -frontend/node_modules/ **/.next/ **/.next-dev-log/ **/node_modules/ @@ -14,7 +10,6 @@ __pycache__/ *.egg-info/ .DS_Store ._* -backend/.pytest_cache/ # Local secrets and generated artifacts .env.deploytmp @@ -22,12 +17,8 @@ backend/.pytest_cache/ **/.env.development.local qa-artifacts/ storage/ -backend/.env -backend/storage/ -backend/tmp_migration_check.db project-docs/qa-artifacts/ project-docs/automation-artifacts/ -backend/project-docs/qa-artifacts/ .claude/worktrees/ .gstack/ .runtime/ @@ -38,8 +29,6 @@ pip-audit-project.json .codex/phase1-mainline-signoff/ .codex-temp/ output/ -frontend/next-env.d.ts -frontend/tsconfig.tsbuildinfo **/next-env.d.ts **/tsconfig.tsbuildinfo diff --git a/README.md b/README.md index 2f443565..33e0cd5b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # Yuqei AI Contract Platform V2 -This repository `main` branch is now the clean V2 rebuild line. - -The previous complete V1 implementation is preserved on the `V1` branch. New product, architecture, database, AI platform, frontend, backend, deployment, and QA work should be developed on `main` using the final V2 four-project layout from day one. +This repository contains only the active V2 product line. Product, architecture, database, AI platform, frontend, backend, deployment, and QA work use the final four-project layout below. ## Project Layout @@ -37,14 +35,12 @@ docs/ project-docs/ ``` -## Branch Policy +## Repository Policy -- `V1`: frozen snapshot of the original system before the V2 rebuild. -- `main`: active V2 rebuild development branch. - -Do not continue adding new product features to the V1 application tree on `main`. If V1 code is needed for reference, check the `V1` branch. - -Do not reintroduce the old root `apps/`、`services/`、`packages/`、`sdks/`、`infra/` layout. V2 is organized around final product boundaries: contract platform, AI platform, Python SDK, and infrastructure. +- `main` is the active V2 development line. +- Do not reintroduce a retired product tree or parallel product implementation. +- Keep the final product boundaries: Contract Platform, AI Platform, Python SDK, and infrastructure. +- Protocol identifiers such as `/api/v1`, persisted schema versions, migration history, and source-provenance labels are compatibility contracts inside V2; they are not a second product implementation. ## Starting Points diff --git a/V2-README.md b/V2-README.md index c19f6425..082a093c 100644 --- a/V2-README.md +++ b/V2-README.md @@ -1,8 +1,6 @@ # Yuqei AI Contract Platform V2 -This branch is the clean V2 rebuild line. - -The previous V1 implementation is preserved on the `V1` branch. New V2 development starts from the final four-project layout below instead of continuing to modify the V1 application tree. +This repository contains only the active V2 product line. Development follows the final four-project layout below. ## Target Projects @@ -42,5 +40,5 @@ docs/ ## Branch Policy -- `V1`: frozen snapshot of the current pre-rebuild system. -- `main`: V2 rebuild development line. +- `main`: active V2 development line. +- Retired product trees and parallel product implementations must not be reintroduced. diff --git a/project-docs/V2-next-development-optimization-plan-2026-06-28.md b/project-docs/V2-next-development-optimization-plan-2026-06-28.md index fd3bf648..eb94bd72 100644 --- a/project-docs/V2-next-development-optimization-plan-2026-06-28.md +++ b/project-docs/V2-next-development-optimization-plan-2026-06-28.md @@ -51,7 +51,7 @@ 任务: -1. 使用 `yuqei-infra/scripts/inspect-v2-database-state.ps1` 只读检查远端数据库状态。 +1. 只读检查远端数据库状态(当时的一次性盘点工具已在 V2-only 收口中退役;当前按现行运行核验流程执行)。 2. 启动并核验 Docker smoke 环境 `yuqei-contract-v2-smoke-*`。 3. 确认 smoke PostgreSQL 中是否存在: - `yuqei_contract` diff --git a/project-docs/V2-release-readiness-checklist-2026-07-10.md b/project-docs/V2-release-readiness-checklist-2026-07-10.md index 1359693c..9a9b8713 100644 --- a/project-docs/V2-release-readiness-checklist-2026-07-10.md +++ b/project-docs/V2-release-readiness-checklist-2026-07-10.md @@ -19,7 +19,7 @@ V2 当前候选版本已完成本地回归和 `192.168.3.90` PostgreSQL smoke | 核心页面 smoke | 通过 | 工作台、模板起草、合同台账、模板库、合同仓库、数据看板均返回 200 | | 核心 API smoke | 通过 | contracts/templates/workspace/files 均返回 200 | | 前端类型检查 | 通过 | `npm run typecheck` 通过 | -| 资产计数脚本 | 通过 | `yuqei-infra/scripts/compare-v1-v2-assets.py` 已生成 artifact | +| 资产计数脚本 | 历史通过 | 当时的对比工具已生成 artifact;该一次性工具已在 V2-only 收口中退役 | | 远端 Contract API schema-check | 通过 | PostgreSQL smoke 返回 `ok` | | 远端 AI Platform schema-check | 通过 | PostgreSQL smoke 返回 `ok`,pgvector 维度与索引通过 | | 远端 Docker smoke | 通过 | Web、API、worker、PostgreSQL、Redis、MinIO 均健康 | @@ -45,17 +45,7 @@ Artifacts: | V1 Prompt 黄金样本文件 | 3 | - | 已作为来源参考 | | V2 AI golden asset 文件 | - | 89 | 已迁入 AI 平台资产目录 | -重复执行命令: - -```powershell -python yuqei-infra\scripts\compare-v1-v2-assets.py --contract-api-base http://localhost:8101 -``` - -远端 smoke 资产计数命令: - -```powershell -python yuqei-infra\scripts\compare-v1-v2-assets.py --contract-api-base http://192.168.3.90:18100 -``` +当时使用的 V1/V2 一次性对比命令已退役;以上表格和 artifact 仅保留为 2026-07-10 历史证据。当前资产完整性由现行 V2 资产门禁验证。 ## Release Candidate 冻结 diff --git a/project-docs/V2-vs-V1-feature-gap-audit-2026-07-03.md b/project-docs/V2-vs-V1-feature-gap-audit-2026-07-03.md deleted file mode 100644 index fbb9e2c4..00000000 --- a/project-docs/V2-vs-V1-feature-gap-audit-2026-07-03.md +++ /dev/null @@ -1,82 +0,0 @@ -# AI 合同平台 V2 对照 V1 功能缺口审计 - -日期:2026-07-03 - -## 对照来源 - -- V1 线上入口:`http://contact.yuqei.com/` -- V1 源码目录:`D:\yuqei-project\ai-contract-platform-v1` -- V2 源码目录:`D:\yuqei-project\ai-contract-platform` - -本次以源码为准,线上 V1 入口用于确认公开产品形态。 - -## 总体结论 - -V2 已经吸收并强化了 V1 的核心合同能力,包括: - -- 模板库、变量 schema、模板树、模板发布/下线/回滚/历史。 -- 标准条款库、审查规则库、条款推荐、规则下线边界。 -- 法律全文库精选迁移、法律版本表、法规变更影响扫描。 -- AI Trace、质量动作、Prompt/模型/知识库管理。 -- 审查结果结构化、风险项/引用依据只读 API、条款修复。 -- 合同审批、版本、文件、通知、履约、签署证据、归档、统一搜索。 -- Docker smoke、schema-check、接口压测、安全扫描、CI 质量门。 - -当前仍值得从 V1 回补的主要不是“合同核心骨架”,而是产品化和交付体验层能力。 - -## 建议加入但 V2 尚未完整闭环 - -| 优先级 | V1 能力 | V2 当前状态 | 是否建议加入 | 建议方式 | -| --- | --- | --- | --- | --- | -| P0 | 审查报告 PDF/报告交付 | V2 有审查风险、引用依据和文件系统,但未看到审查报告生成/下载 API 与前端入口 | 强烈建议 | 基于 V2 结构化审查结果生成报告,不迁 V1 旧实现;先做 HTML/PDF 模板、报告文件入库、下载事件审计 | -| P1 | 用户、租户设置、账号管理 | V2 有 tenant context 和轻量访问控制,但没有完整用户/租户后台页面 | 建议加入 | 做最小管理员后台:用户列表、角色、租户配置、默认配额;先不做复杂组织架构 | -| P1 | 全局审计日志查看 | V2 有合同时间线、审计完整性和 AI Trace,但缺全局 Contract audit log 页面 | 建议加入 | 增加管理员审计中心,聚合合同事件、下载事件、审批、模板/法律资产发布事件 | -| P1 | 法律文书起草体系 | V1 有 `legal-document-types`、element schema、draft、generate-from-draft 和移动端入口;V2 主要是合同模板起草 | 建议分阶段加入 | 先迁“催告函、解除通知、授权委托书、法律意见书”这类高频非合同文书,沿用 V2 模板/快照/版本化模型 | -| P1 | 用户侧法律问答/Chat 页面 | V2 后端已有 `/api/v1/legal/qa` 代理和 AI Platform legal QA,但前端没有独立用户问答页 | 建议加入 | 做轻量法律问答页,支持来源卡、Trace 链接、从合同风险项带上下文进入问答 | -| P2 | 公开首页、演示申请、试点申请 | V1 有 landing、pricing、demo-request、pilot-requests;V2 首页直接进入工作台 | 若 V2 要对外发布则建议 | 做公开首页 + 演示申请,先不做完整订单支付;申请数据进入销售/试点队列 | -| P2 | 商业套餐、计费、订单/发票 | V1 有 pricing、billing、commercial plans、order/invoice 模型 | 可后置 | 先迁套餐展示和用量概览;真实支付、发票、订单后台等商业闭环等产品上线后再做 | -| P2 | 销售跟进和试点运营 | V1 有 sales-followups、pilot readiness、operation-kpis | 可后置但有价值 | 若要做商业化试点,加入试点看板、跟进事件、试点 KPI;内部交付可暂缓 | -| P2 | 运营 KPI 看板 | V2 有 dashboard 和质量门文档,但没有 V1 `operation-kpis` 运营指标页 | 建议后置 | 复用 V2 审查、报告、模板、Trace、知识库指标,做运营看板 | -| P3 | 移动端 Flutter 客户端 | V1 mobile 已覆盖登录、审查、报告、合同生成、法律文书、Chat、版本链、真机 smoke | 暂不建议现在加入 | V2 Web 收口后单独立项;不要把 V1 Flutter 代码直接塞进 V2 主线 | -| P3 | 形式化上线治理工作台 | V1 有 governance assets/imports/releases/workbench | 可按企业交付需要加入 | V2 已有 schema governance 和质量门;若做正式客户交付,可做“交付证据/签收/上线门禁”管理页 | - -## 已加入或基本覆盖,不应重复开发 - -| V1 能力 | V2 状态 | -| --- | --- | -| 模板化合同起草 | 已覆盖,V2 还有发布快照和生命周期管理 | -| 30 个 Phase13 合同模板与变量 schema | 已覆盖,V1 当前 Phase13 模板数和 V2 迁移资产均为 30 | -| 标准条款库和审查规则库 | 已覆盖,V2 有管理页、diff、校验、发布快照 | -| 法律全文库精选与法规影响扫描 | 已覆盖,V2 已有 `legal_fulltext_versions` | -| 黄金样本与质量评估资产 | 已覆盖,V2 AI golden gate 已纳入 Docker smoke | -| Prompt/模型/知识库/Trace 管理 | 已覆盖,V2 AI 管理后台更完整 | -| 文件仓储、审批、合同版本、通知、履约、签署证据、归档 | V2 已覆盖 | -| V1 同步 auto-review 体验 | 不建议迁,V2 异步 worker 更合理 | -| V1 单体后端结构 | 不建议迁,V2 拆分架构更合理 | - -## 不建议直接迁移 - -- V1 单体 FastAPI 后端结构。 -- V1 页面大而全布局。 -- V1 同步审查调用体验。 -- V1 旧 PDF 实现本身。应保留“报告交付”产品目标,用 V2 结构化审查结果重建。 -- V1 商业订单/发票/支付后台的完整实现,除非 V2 确认进入对外商用阶段。 - -## 建议下一步 - -最值得先做的是 P0:审查报告 PDF/报告交付。 - -原因: - -1. V1 线上产品叙事明确包含“报告交付”。 -2. V2 已有结构化风险、引用依据、条款推荐、文件仓储和下载审计,生成报告的基础数据已经齐。 -3. 报告是客户可感知的交付物,比继续扩后台运营更能补齐产品闭环。 - -建议第一片范围: - -- `GET /api/v1/contracts/{contract_id}/review-report` -- `POST /api/v1/contracts/{contract_id}/review-report` -- `GET /api/v1/contracts/{contract_id}/review-report/download` -- 报告内容包含合同摘要、风险分布、风险项、引用依据、条款推荐、修复状态、免责声明。 -- 报告文件进入 V2 file repository,下载写入审计事件。 -- 前端在合同详情页审查区增加“生成/下载审查报告”入口。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index f9a60007..a659db48 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -1063,7 +1063,7 @@ paths: /api/v1/quality/golden-samples: get: operationId: getGoldenSampleCoverage - summary: Get V1 golden sample coverage imported into the V2 AI quality loop + summary: Get packaged golden sample coverage used by the V2 AI quality loop responses: "200": description: Golden sample coverage @@ -1074,7 +1074,7 @@ paths: /api/v1/quality/golden-samples/evaluate: post: operationId: evaluateGoldenSamples - summary: Run asset integrity evaluation for V1 golden samples + summary: Run asset integrity evaluation for packaged golden samples responses: "200": description: Golden sample evaluation report diff --git a/yuqei-ai-platform/services/ai-platform-api/README.md b/yuqei-ai-platform/services/ai-platform-api/README.md index a124e455..62c7175c 100644 --- a/yuqei-ai-platform/services/ai-platform-api/README.md +++ b/yuqei-ai-platform/services/ai-platform-api/README.md @@ -27,7 +27,7 @@ python -m alembic upgrade head ## Golden Sample Asset Gate -The V1 Phase9, Phase13, and Phase15 golden sample assets are bundled with this +The packaged Phase9, Phase13, and Phase15 golden sample assets are bundled with this service and can be checked without starting FastAPI: ```powershell diff --git a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml index 7b22ba96..eee58f75 100644 --- a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml +++ b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ [project.scripts] yuqei-golden-sample-gate = "yuqei_ai_platform_api.golden_sample_gate:main" yuqei-release-readiness-gate = "yuqei_ai_platform_api.release_readiness_gate:main" -yuqei-import-v1-prompt-assets = "yuqei_ai_platform_api.v1_prompt_assets_import:main" +yuqei-import-packaged-prompt-assets = "yuqei_ai_platform_api.packaged_prompt_assets_import:main" [build-system] requires = ["hatchling"] diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_sample_gate.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_sample_gate.py index 77f69faf..1b4f0eb3 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_sample_gate.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_sample_gate.py @@ -14,7 +14,7 @@ from yuqei_ai_platform_api.golden_samples import ( def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( - description="Run the V1 golden sample asset integrity gate for the V2 AI platform.", + description="Run the packaged golden sample asset integrity gate for the V2 AI platform.", ) parser.add_argument( "--format", diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_samples.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_samples.py index 675555ba..b01a99b9 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_samples.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_samples.py @@ -42,7 +42,7 @@ class GoldenSampleSuiteRequirement(BaseModel): class GoldenSampleCoverage(BaseModel): schema_version: str source_product: str = "ai-contract-platform-v1" - source_description: str = "V1 Phase9 quality fixtures and Phase15 prompt golden samples." + source_description: str = "Packaged quality fixtures and prompt golden samples used by V2." generated_at: datetime total_assets: int total_loaded_samples: int @@ -94,34 +94,34 @@ class GoldenSampleEvaluationReport(BaseModel): PHASE9_CSV_ASSETS = [ { "suite_id": "phase9_contract_review", - "title": "V1 Phase9 合同审查黄金样本", + "title": "内置 Phase9 合同审查黄金样本", "source_file": "golden-review-samples.csv", "prompt_type": "contract_review", "sample_family": "phase9_review", }, { "suite_id": "phase9_legal_chat", - "title": "V1 Phase9 知识库问答黄金样本", + "title": "内置 Phase9 知识库问答黄金样本", "source_file": "golden-kb-question-set.csv", "prompt_type": "legal_chat", "sample_family": "phase9_kb_qa", }, { "suite_id": "phase9_contract_generation", - "title": "V1 Phase9 合同生成黄金样本", + "title": "内置 Phase9 合同生成黄金样本", "source_file": "golden-contract-generation-set.csv", "prompt_type": "contract_generation", "sample_family": "phase9_generation", }, { "suite_id": "phase9_quality_metrics", - "title": "V1 Phase9 质量指标字典", + "title": "内置 Phase9 质量指标字典", "source_file": "quality-metric-dictionary.csv", "sample_family": "phase9_quality_metrics", }, { "suite_id": "phase9_prompt_versions", - "title": "V1 Phase9 Prompt 版本登记", + "title": "内置 Phase9 Prompt 版本登记", "source_file": "prompt-version-register.csv", "sample_family": "phase9_prompt_governance", }, @@ -130,14 +130,14 @@ PHASE9_CSV_ASSETS = [ PHASE15_JSON_ASSETS = [ { "suite_id": "phase15_wording_rewrite", - "title": "V1 Phase15 条款润色黄金样本", + "title": "内置 Phase15 条款润色黄金样本", "source_file": "wording-rewrite-golden-samples.json", "prompt_type": "wording_rewrite", "sample_family": "phase15_wording_rewrite", }, { "suite_id": "phase15_business_auxiliary", - "title": "V1 Phase15 业务辅助黄金样本", + "title": "内置 Phase15 业务辅助黄金样本", "source_file": "business-auxiliary-golden-samples.json", "prompt_type": "business_auxiliary", "sample_family": "phase15_business_auxiliary", @@ -147,21 +147,21 @@ PHASE15_JSON_ASSETS = [ PHASE13_CSV_ASSETS = [ { "suite_id": "phase13_contract_generation", - "title": "V1 Phase13 模板化合同生成黄金样本", + "title": "内置 Phase13 模板化合同生成黄金样本", "source_file": "inventories/generation-golden-sample-inventory.csv", "prompt_type": "contract_generation", "sample_family": "phase13_generation", }, { "suite_id": "phase13_contract_review", - "title": "V1 Phase13 合同审查黄金样本", + "title": "内置 Phase13 合同审查黄金样本", "source_file": "inventories/review-golden-sample-inventory.csv", "prompt_type": "contract_review", "sample_family": "phase13_review", }, { "suite_id": "phase13_legal_chat", - "title": "V1 Phase13 法律问答黄金样本", + "title": "内置 Phase13 法律问答黄金样本", "source_file": "inventories/kb-qa-golden-sample-inventory.csv", "prompt_type": "legal_chat", "sample_family": "phase13_kb_qa", @@ -171,7 +171,7 @@ PHASE13_CSV_ASSETS = [ PHASE13_MARKDOWN_SET_ASSETS = [ { "suite_id": "phase13_clause_recommendation", - "title": "V1 Phase13 条款推荐黄金样本", + "title": "内置 Phase13 条款推荐黄金样本", "source_file": "qa-samples/generation/GEN-SAMPLE-CLAUSE-*-expected.md", "prompt_type": "clause_recommendation", "sample_family": "phase13_clause_generation", @@ -198,8 +198,8 @@ def load_golden_sample_coverage() -> GoldenSampleCoverage: return GoldenSampleCoverage( schema_version=str(manifest.get("schema_version", "v1-golden-samples")), source_description=( - "V1 Phase9 quality fixtures, Phase13 legal knowledge template library, " - "and Phase15 prompt golden samples." + "Packaged Phase9 quality fixtures, Phase13 legal knowledge template library, " + "and Phase15 prompt golden samples used by V2." ), generated_at=datetime.now(UTC), total_assets=len(assets), @@ -223,7 +223,7 @@ def run_golden_sample_evaluation() -> GoldenSampleEvaluationReport: observed=coverage.ready_required_suite_count, detail=( f"{coverage.ready_required_suite_count}/{coverage.required_suite_count} " - "个 V1 manifest 必需套件已达到最低样本数。" + "个资产 manifest 必需套件已达到最低样本数。" ), ), _count_check( @@ -231,7 +231,7 @@ def run_golden_sample_evaluation() -> GoldenSampleEvaluationReport: title="黄金样本总量", expected=coverage.total_loaded_samples, observed=coverage.total_loaded_samples, - detail=f"已加载 {coverage.total_loaded_samples} 条 V1 黄金样本资产。", + detail=f"已加载 {coverage.total_loaded_samples} 条内置黄金样本资产。", ), ] empty_assets = [asset.suite_id for asset in coverage.assets if asset.status != "ready" or asset.loaded_samples <= 0] @@ -406,10 +406,10 @@ def _requirement_to_coverage( loaded_samples = _loaded_samples_for_requirement(prompt_type, sample_family, assets) if loaded_samples >= min_samples: status: Literal["ready", "below_minimum", "not_loaded"] = "ready" - coverage_note = "样本数量达到 V1 manifest 要求。" + coverage_note = "样本数量达到资产 manifest 要求。" elif loaded_samples > 0: status = "below_minimum" - coverage_note = f"已接入 {loaded_samples} 条,低于 V1 manifest 要求的 {min_samples} 条。" + coverage_note = f"已接入 {loaded_samples} 条,低于资产 manifest 要求的 {min_samples} 条。" else: status = "not_loaded" coverage_note = "当前 V2 只读资产包尚未接入该套件样本。" diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/v1_prompt_assets_import.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/packaged_prompt_assets_import.py similarity index 92% rename from yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/v1_prompt_assets_import.py rename to yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/packaged_prompt_assets_import.py index 53490043..1c4f829a 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/v1_prompt_assets_import.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/packaged_prompt_assets_import.py @@ -17,7 +17,7 @@ from yuqei_ai_platform_api.repository import build_sqlalchemy_engine DEFAULT_VERSION = "v1-phase15" -DEFAULT_CHANGED_BY = "v1-prompt-assets-import" +DEFAULT_CHANGED_BY = "packaged-prompt-assets-import" DEFAULT_SOURCE_FILE = ( Path(__file__).resolve().parent / "prompt_assets" @@ -27,7 +27,7 @@ DEFAULT_SOURCE_FILE = ( @dataclass(frozen=True) -class V1PromptAsset: +class PackagedPromptAsset: prompt_key: str prompt_type: str name: str @@ -52,7 +52,7 @@ class PromptImportResult: def as_payload(self) -> dict[str, Any]: return { - "command": "import-v1-prompt-assets", + "command": "import-packaged-prompt-assets", "mode": self.mode, "source_file": self.source_file, "source_schema_version": self.source_schema_version, @@ -68,16 +68,16 @@ class PromptImportResult: } -def load_v1_prompt_assets(source_file: Path) -> tuple[list[V1PromptAsset], str | None, list[str]]: +def load_packaged_prompt_assets(source_file: Path) -> tuple[list[PackagedPromptAsset], str | None, list[str]]: payload = json.loads(source_file.read_text(encoding="utf-8-sig")) if not isinstance(payload, dict): - raise ValueError("V1 prompt asset file must contain a JSON object.") + raise ValueError("Packaged prompt asset file must contain a JSON object.") schema_version = _optional_str(payload.get("schema_version")) assets_payload = payload.get("assets") if not isinstance(assets_payload, list): - raise ValueError("V1 prompt asset file must contain an assets array.") + raise ValueError("Packaged prompt asset file must contain an assets array.") - assets_by_key: dict[str, V1PromptAsset] = {} + assets_by_key: dict[str, PackagedPromptAsset] = {} duplicate_keys: list[str] = [] for index, item in enumerate(assets_payload): if not isinstance(item, dict): @@ -85,7 +85,7 @@ def load_v1_prompt_assets(source_file: Path) -> tuple[list[V1PromptAsset], str | prompt_key = _required_str(item.get("prompt_key"), f"assets[{index}].prompt_key") prompt_type = _optional_str(item.get("prompt_type")) or "unknown" name = _optional_str(item.get("name")) or prompt_key - asset = V1PromptAsset( + asset = PackagedPromptAsset( prompt_key=prompt_key, prompt_type=prompt_type, name=name, @@ -98,9 +98,9 @@ def load_v1_prompt_assets(source_file: Path) -> tuple[list[V1PromptAsset], str | return list(assets_by_key.values()), schema_version, sorted(set(duplicate_keys)) -def import_v1_prompt_assets( +def import_packaged_prompt_assets( session_factory, - assets: list[V1PromptAsset], + assets: list[PackagedPromptAsset], *, source_file: Path, source_schema_version: str | None, @@ -202,7 +202,7 @@ def import_v1_prompt_assets( before_template_json=_json_dumps(before_payload) if before_payload is not None else None, after_template_json=_json_dumps(after_payload), changed_by=changed_by, - change_note=change_note or "Import V1 Phase15 default prompt asset.", + change_note=change_note or "Import packaged default prompt asset.", created_at=now, ) ) @@ -224,12 +224,12 @@ def import_v1_prompt_assets( def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Import V1 Phase15 default prompt assets into V2 prompt_templates.") + parser = argparse.ArgumentParser(description="Import packaged default prompt assets into V2 prompt_templates.") parser.add_argument( "--source-file", type=Path, default=DEFAULT_SOURCE_FILE, - help="Prompt asset JSON path. Defaults to the packaged V1 Phase15 asset file.", + help="Prompt asset JSON path. Defaults to the packaged prompt asset file.", ) parser.add_argument("--database-url", default=None, help="Override AI_PLATFORM_DATABASE_URL.") parser.add_argument("--version", default=DEFAULT_VERSION, help="Prompt template version to upsert.") @@ -247,8 +247,8 @@ def main() -> None: engine = build_sqlalchemy_engine(database_url) session_factory = sessionmaker(bind=engine, expire_on_commit=False, future=True) try: - assets, schema_version, duplicate_keys = load_v1_prompt_assets(args.source_file) - result = import_v1_prompt_assets( + assets, schema_version, duplicate_keys = load_packaged_prompt_assets(args.source_file) + result = import_packaged_prompt_assets( session_factory, assets, source_file=args.source_file, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py index cf58f0a2..2812c540 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py @@ -55,7 +55,7 @@ REVIEW_PROMPT_DOMAIN_KEY_BY_CONTRACT_TYPE = { "data_processing": "contract_review_tech_saas_data_compliance", } -_V1_PROMPT_TOKEN = re.compile(r"{{\s*([A-Za-z0-9_]+)\s*}}") +_PACKAGED_PROMPT_TOKEN = re.compile(r"{{\s*([A-Za-z0-9_]+)\s*}}") def legal_qa_prompt_candidates() -> tuple[str, ...]: @@ -82,7 +82,7 @@ def render_legal_qa_prompt( "legal_article_refs": _format_citations(citations), } if prompt_template: - imported_prompt = _render_v1_prompt_asset(prompt_template.content, variables) + imported_prompt = _render_packaged_prompt_asset(prompt_template.content, variables) if imported_prompt: return imported_prompt template = prompt_template.content if prompt_template else _default_legal_qa_template() @@ -108,7 +108,7 @@ def render_contract_review_prompt( "structured_output_instructions": structured_output_instructions, } if prompt_template: - imported_prompt = _render_v1_prompt_asset( + imported_prompt = _render_packaged_prompt_asset( prompt_template.content, variables, supplemental_context=( @@ -126,7 +126,7 @@ def render_contract_review_prompt( return f"{rendered}\n\n{structured_output_instructions}" -def _render_v1_prompt_asset( +def _render_packaged_prompt_asset( content: str, variables: dict[str, str], *, @@ -145,7 +145,7 @@ def _render_v1_prompt_asset( if not prompt_content: return None system_prompt = _optional_text(asset.get("system_prompt")) - rendered_content = _V1_PROMPT_TOKEN.sub( + rendered_content = _PACKAGED_PROMPT_TOKEN.sub( lambda match: variables.get(match.group(1), "未提供"), prompt_content, ) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_v1_prompt_assets_import.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_packaged_prompt_assets_import.py similarity index 85% rename from yuqei-ai-platform/services/ai-platform-api/tests/test_v1_prompt_assets_import.py rename to yuqei-ai-platform/services/ai-platform-api/tests/test_packaged_prompt_assets_import.py index ff9c816e..3556c1c8 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_v1_prompt_assets_import.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_packaged_prompt_assets_import.py @@ -5,14 +5,14 @@ from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from yuqei_ai_platform_api.models import Base, PromptTemplateChangeModel, PromptTemplateModel -from yuqei_ai_platform_api.v1_prompt_assets_import import ( +from yuqei_ai_platform_api.packaged_prompt_assets_import import ( DEFAULT_SOURCE_FILE, - import_v1_prompt_assets, - load_v1_prompt_assets, + import_packaged_prompt_assets, + load_packaged_prompt_assets, ) -def test_load_v1_prompt_assets_dedupes_by_prompt_key(tmp_path: Path) -> None: +def test_load_packaged_prompt_assets_dedupes_by_prompt_key(tmp_path: Path) -> None: source_file = tmp_path / "default-prompt-assets.json" source_file.write_text( json.dumps( @@ -44,7 +44,7 @@ def test_load_v1_prompt_assets_dedupes_by_prompt_key(tmp_path: Path) -> None: encoding="utf-8", ) - assets, schema_version, duplicate_keys = load_v1_prompt_assets(source_file) + assets, schema_version, duplicate_keys = load_packaged_prompt_assets(source_file) assert schema_version == "phase15-default-prompt-assets.v1" assert duplicate_keys == ["legal_chat_default"] @@ -54,8 +54,8 @@ def test_load_v1_prompt_assets_dedupes_by_prompt_key(tmp_path: Path) -> None: assert rendered["asset"]["prompt_content"] == "second" -def test_packaged_v1_prompt_asset_file_is_complete() -> None: - assets, schema_version, duplicate_keys = load_v1_prompt_assets(DEFAULT_SOURCE_FILE) +def test_packaged_prompt_asset_file_is_complete() -> None: + assets, schema_version, duplicate_keys = load_packaged_prompt_assets(DEFAULT_SOURCE_FILE) assert DEFAULT_SOURCE_FILE.exists() assert schema_version == "phase15-default-prompt-assets.v1" @@ -71,7 +71,7 @@ def test_packaged_v1_prompt_asset_file_is_complete() -> None: } -def test_import_v1_prompt_assets_is_idempotent_and_publishes(tmp_path: Path) -> None: +def test_import_packaged_prompt_assets_is_idempotent_and_publishes(tmp_path: Path) -> None: source_file = tmp_path / "default-prompt-assets.json" source_file.write_text( json.dumps( @@ -89,12 +89,12 @@ def test_import_v1_prompt_assets_is_idempotent_and_publishes(tmp_path: Path) -> ), encoding="utf-8", ) - assets, schema_version, duplicate_keys = load_v1_prompt_assets(source_file) + assets, schema_version, duplicate_keys = load_packaged_prompt_assets(source_file) engine = create_engine(f"sqlite+pysqlite:///{(tmp_path / 'prompts.sqlite').as_posix()}", future=True) Base.metadata.create_all(engine) session_factory = sessionmaker(bind=engine, expire_on_commit=False, future=True) - first = import_v1_prompt_assets( + first = import_packaged_prompt_assets( session_factory, assets, source_file=source_file, @@ -102,7 +102,7 @@ def test_import_v1_prompt_assets_is_idempotent_and_publishes(tmp_path: Path) -> apply=True, duplicate_prompt_keys=duplicate_keys, ) - second = import_v1_prompt_assets( + second = import_packaged_prompt_assets( session_factory, assets, source_file=source_file, diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index cc359fc9..84d7ee87 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -1012,7 +1012,7 @@ def test_quality_rules_can_be_hot_updated_and_drive_audit_labels() -> None: assert audit["quality_score"] == 90 -def test_golden_sample_coverage_loads_v1_quality_assets() -> None: +def test_golden_sample_coverage_loads_packaged_quality_assets() -> None: client = make_client() response = client.get("/api/v1/quality/golden-samples") diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py index 4cfa0271..dbf8bb08 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py @@ -510,14 +510,14 @@ def test_render_legal_qa_prompt_includes_question_context_and_citations() -> Non assert "第七百零三条" in prompt -def test_legal_qa_prefers_published_v1_prompt_asset() -> None: +def test_legal_qa_prefers_published_packaged_prompt_asset() -> None: repository = InMemoryAiPlatformRepository() repository.seed_defaults() created = repository.upsert_prompt_template( PromptTemplateCreate( name="legal_chat_default", version="v1-phase15", - content=_v1_prompt_content( + content=_packaged_prompt_content( system_prompt="V1 法律问答专项系统提示。", prompt_content=( "问题={{question}};背景={{review_context}};依据={{legal_article_refs}}。" @@ -552,7 +552,7 @@ def test_contract_review_selects_and_renders_domain_prompt_asset() -> None: PromptTemplateCreate( name="contract_review_investment_finance", version="v1-phase15", - content=_v1_prompt_content( + content=_packaged_prompt_content( system_prompt="V1 投融资专项审查提示。", prompt_content=( "类型={{contract_type}};立场={{party_position}};" @@ -588,7 +588,7 @@ def test_contract_review_selects_and_renders_domain_prompt_asset() -> None: assert "{{" not in audit["prompt_preview"] -def test_contract_review_prompt_candidates_cover_v1_business_domains() -> None: +def test_contract_review_prompt_candidates_cover_packaged_business_domains() -> None: expected = { "investment-agreement": "contract_review_investment_finance", "shareholder-resolution": "contract_review_corporate_governance", @@ -603,13 +603,13 @@ def test_contract_review_prompt_candidates_cover_v1_business_domains() -> None: assert candidates[-2:] == ("contract_review_default", "contract_review.default") -def test_render_contract_review_prompt_supports_v1_asset_json() -> None: +def test_render_contract_review_prompt_supports_packaged_asset_json() -> None: repository = InMemoryAiPlatformRepository() created = repository.upsert_prompt_template( PromptTemplateCreate( name="contract_review_default", version="v1-phase15", - content=_v1_prompt_content( + content=_packaged_prompt_content( system_prompt="审慎审查并保留依据。", prompt_content="类型={{contract_type}};正文={{contract_text}};依据={{legal_article_refs}}。", ), @@ -633,7 +633,7 @@ def test_render_contract_review_prompt_supports_v1_asset_json() -> None: assert "{{" not in prompt -def _v1_prompt_content(*, system_prompt: str, prompt_content: str) -> str: +def _packaged_prompt_content(*, system_prompt: str, prompt_content: str) -> str: return json.dumps( { "source": { diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx index d67d3dbc..c707e2cf 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx @@ -16,13 +16,13 @@ type PromptTemplate = { updated_at: string; }; -type V1PromptVariable = { +type ImportedPromptVariable = { name?: string; required?: boolean; description?: string; }; -type V1PromptAsset = { +type ImportedPromptAsset = { prompt_key?: string; prompt_type?: string; name?: string; @@ -30,7 +30,7 @@ type V1PromptAsset = { target_scene?: string; system_prompt?: string; prompt_content?: string; - variables?: V1PromptVariable[]; + variables?: ImportedPromptVariable[]; output_schema?: unknown; model_config?: Record; change_summary?: string; @@ -42,9 +42,9 @@ type ParsedPromptContent = { phase?: string; schema_version?: string | null; }; - asset?: V1PromptAsset; + asset?: ImportedPromptAsset; raw: string; - isV1Asset: boolean; + isPackagedAsset: boolean; }; type LoadState = @@ -156,7 +156,7 @@ export default function AiPromptsPage() { templates.find((template) => options.preferTemplateId && template.id === options.preferTemplateId) ?? templates.find((template) => options.preferName && template.name === options.preferName && template.status === "published") ?? templates.find((template) => promptTemplateMatchesTarget(template, currentTarget)) ?? - templates.find((template) => template.status === "published" && template.version === "v1-phase15") ?? + templates.find((template) => template.status === "published" && template.enabled) ?? templates.find((template) => template.status === "published") ?? templates[0]; setDraftFromTemplate(nextTemplate); @@ -229,7 +229,7 @@ export default function AiPromptsPage() { .filter((template) => template.name === selectedName) .sort((left, right) => compareDate(right.published_at ?? right.updated_at, left.published_at ?? left.updated_at)); const publishedTemplate = selectedVersions.find((template) => template.status === "published"); - const v1ImportedCount = templates.filter((template) => template.version === "v1-phase15").length; + const packagedAssetCount = templates.filter((template) => parsedById.get(template.id)?.isPackagedAsset).length; const publishedCount = templates.filter((template) => template.status === "published").length; useEffect(() => { @@ -309,7 +309,7 @@ export default function AiPromptsPage() { if (!response.ok) { throw new Error(payload?.message ?? `HTTP ${response.status}`); } - setMessage(`已发布 ${template.name} / ${template.version}。`); + setMessage(`已发布 ${template.name} / ${formatVersionLabel(template.version)}。`); await loadTemplates({ clearMessage: false, preferTemplateId: payload.id, preferName: template.name }); } catch (error) { setMessage(error instanceof Error ? error.message : "Prompt 发布失败"); @@ -331,7 +331,7 @@ export default function AiPromptsPage() { if (!response.ok) { throw new Error(payload?.message ?? `HTTP ${response.status}`); } - setMessage(`已从 ${template.version} 回滚并发布新版本。`); + setMessage(`已从 ${formatVersionLabel(template.version)} 回滚并发布新版本。`); await loadTemplates({ clearMessage: false, preferTemplateId: payload.id, preferName: template.name }); } catch (error) { setMessage(error instanceof Error ? error.message : "Prompt 回滚失败"); @@ -345,7 +345,7 @@ export default function AiPromptsPage() {

Prompt 中心

-

管理 AI 中台提示词草稿、发布版本、回滚记录和 V1 Phase15 导入资产。

+

管理 AI 中台提示词草稿、发布版本、回滚记录和平台内置资产。

{parsed.asset?.prompt_type ?? "未标注类型"} - {template.version} + {formatVersionLabel(template.version)} {formatDate(template.updated_at)}
@@ -459,9 +459,9 @@ export default function AiPromptsPage() { 详情与编辑 - {publishedTemplate &&

当前运行版本:{publishedTemplate.version}

} + {publishedTemplate &&

当前运行版本:{formatVersionLabel(publishedTemplate.version)}

}
- {selectedParsed?.isV1Asset && V1 Phase15} + {selectedParsed?.isPackagedAsset && 内置资产}
{selectedTemplate && selectedParsed && ( @@ -525,10 +525,10 @@ function PromptMetadata({ parsed, template }: { parsed: ParsedPromptContent; tem return (
- + - +
); @@ -617,7 +617,7 @@ function VersionHistory({
- {template.version} + {formatVersionLabel(template.version)} 更新:{formatDate(template.updated_at)} {template.published_at ? ` / 发布:${formatDate(template.published_at)}` : ""} @@ -629,7 +629,7 @@ function VersionHistory({ {isTargetPrompt && Target}
{template.rollback_from_version && ( - 回滚来源:{template.rollback_from_version} + 回滚来源:{formatVersionLabel(template.rollback_from_version)} )} {showTargetPublish && (
@@ -714,17 +714,17 @@ function MiniBlock({ title, value }: { title: string; value: string }) { function parsePromptContent(content: string): ParsedPromptContent { try { - const payload = JSON.parse(content) as { source?: ParsedPromptContent["source"]; asset?: V1PromptAsset }; + const payload = JSON.parse(content) as { source?: ParsedPromptContent["source"]; asset?: ImportedPromptAsset }; return { source: payload.source, asset: payload.asset, raw: content, - isV1Asset: payload.source?.phase === "phase15-prompt-assets" || Boolean(payload.asset?.prompt_key) + isPackagedAsset: payload.source?.phase === "phase15-prompt-assets" || Boolean(payload.asset?.prompt_key) }; } catch { return { raw: content, - isV1Asset: false + isPackagedAsset: false }; } } @@ -733,6 +733,10 @@ function compareDate(left: string, right: string) { return new Date(left).getTime() - new Date(right).getTime(); } +function formatVersionLabel(version: string) { + return version === "v1-phase15" ? "内置基线" : version; +} + function formatDate(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx index 4b60c0e4..e2bfd85f 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx @@ -987,7 +987,7 @@ function GoldenSampleCoveragePanel({
资产 {coverage.total_assets} - 来源 {coverage.source_product} + 来源 平台内置质量样本 合同生成 {requiredSamplesByPromptType["contract_generation"] ?? 0} 合同审查 {requiredSamplesByPromptType["contract_review"] ?? 0} 法律问答 {requiredSamplesByPromptType["legal_chat"] ?? 0} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-assets/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-assets/page.tsx index 1a94374d..9e1a9dd5 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-assets/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-assets/page.tsx @@ -39,7 +39,7 @@ type PageProps = { const text = { title: "条款规则库", - subtitle: "集中查看 V1 迁移到 V2 的标准条款、审查规则、法源绑定和条款修复关联。", + subtitle: "集中查看平台内置的标准条款、审查规则、法源绑定和条款修复关联。", keywordPlaceholder: "搜索条款、规则、法源、建议内容", all: "全部", clauses: "标准条款", @@ -67,7 +67,7 @@ const text = { diff: "差异", emptyClauses: "没有找到匹配的标准条款。", emptyRules: "没有找到匹配的审查规则。", - readOnlyNote: "当前资产正文来自 V1 Phase13 静态库,生命周期状态已进入 V2 持久化管理;编辑、diff、发布前校验可作为下一步继续接入。", + readOnlyNote: "当前内置资产已进入 V2 持久化生命周期管理;编辑、diff 与发布前校验通过现行资产接口执行。", }; const contractTypeLabels: Record = { @@ -248,7 +248,7 @@ export default async function LegalAssetsPage({ searchParams }: PageProps) {

{clauses.length} / {allClauses.length} 条资产匹配当前条件

- V1 Phase13 + 内置资产
{clauses.length === 0 &&
{text.emptyClauses}
} @@ -274,7 +274,7 @@ export default async function LegalAssetsPage({ searchParams }: PageProps) {

{rules.length} / {allRules.length} 条资产匹配当前条件

- V1 Phase13 + 内置资产
{rules.length === 0 &&
{text.emptyRules}
} @@ -325,7 +325,7 @@ async function ClauseCard({ {clause.review_status && {clause.review_status}} {clause.legal_sources.length} 法源 {linkedRuleCount} 规则 - {clause.lifecycle_version || "v1"} + {clause.lifecycle_version || "未版本化"} {clause.lifecycle_event_count} 次变更
{history.length > 0 && } @@ -400,7 +400,7 @@ async function RuleCard({ {rule.status && {rule.status}} {rule.knowledge_card_id && {rule.knowledge_card_id}} {rule.legal_sources.length} 法源 - {rule.lifecycle_version || "v1"} + {rule.lifecycle_version || "未版本化"} {rule.lifecycle_event_count} 次变更
{history.length > 0 && } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-fulltext/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-fulltext/page.tsx index 61656fef..a1d63854 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-fulltext/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/legal-fulltext/page.tsx @@ -12,8 +12,6 @@ import { import { ImpactScanPanel } from "./impact-scan-panel"; const PAGE_SIZE = 20; -const FULL_V1_LEGAL_TOTAL = 789_853; -const FULL_V1_LEGAL_SOURCE_TOTAL = 19_712; type PageProps = { searchParams?: Promise<{ @@ -56,7 +54,7 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) {

法律全文库

-

V1 Phase14 法律全文资产已接入 V2,可按关键词、法源、效力状态分页查看,并用于法规变更影响扫描。

+

平台法律全文资产可按关键词、法源、效力状态分页查看,并用于法规变更影响扫描。

@@ -65,10 +63,10 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) {
- - + + - +
@@ -103,8 +101,8 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) {
- 全量库状态 -

已接入 V1 全量法律库 {formatInteger(FULL_V1_LEGAL_TOTAL)} 条,覆盖约 {formatInteger(FULL_V1_LEGAL_SOURCE_TOTAL)} 个法源。页面按服务端分页读取,不一次性渲染全库。

+ 法律库状态 +

当前查询由服务端返回 {formatInteger(articlePage.total)} 条;页面按服务端分页读取,不一次性渲染全库。

@@ -121,7 +119,7 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) { 共 {formatInteger(articlePage.total)} 条,当前第 {articlePage.page} 页,每页 {articlePage.page_size} 条

- V1 Phase14 + 平台法律库
{articlePage.items.length === 0 &&
没有找到匹配的法条。
} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/globals.css b/yuqei-contract-platform/apps/contract-web/src/app/globals.css index 0d0920e2..8ac7dd9f 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/globals.css +++ b/yuqei-contract-platform/apps/contract-web/src/app/globals.css @@ -2949,7 +2949,7 @@ textarea { gap: 16px; } -/* V1-like visual language, V2-like structure */ +/* Product visual language and current application structure */ .attribution-grid { display: grid; @@ -4626,7 +4626,7 @@ details.panel summary::-webkit-details-marker { display: none; } -/* V1 visual polish on top of the V2 layout */ +/* Product visual polish for the current layout */ .panel, .metric, .metric-card, diff --git a/yuqei-contract-platform/apps/contract-web/src/app/templates/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/templates/page.tsx index d3b07d41..e19f1a47 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/templates/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/templates/page.tsx @@ -29,7 +29,7 @@ type PageProps = { const text = { title: "模板库", - subtitle: "按合同类型组织 V1 模板资产,在 V2 中串起起草、审查、条款修复和版本留痕。", + subtitle: "按合同类型组织平台内置模板资产,串起起草、审查、条款修复和版本留痕。", searchPlaceholder: "搜索模板名称、适用范围、校验规则", search: "搜索", clear: "清空", diff --git a/yuqei-contract-platform/services/contract-api/pyproject.toml b/yuqei-contract-platform/services/contract-api/pyproject.toml index 9a0cd955..5506121c 100644 --- a/yuqei-contract-platform/services/contract-api/pyproject.toml +++ b/yuqei-contract-platform/services/contract-api/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ ] [project.scripts] -yuqei-import-v1-legal-fulltext = "yuqei_contract_api.v1_legal_fulltext_import:main" yuqei-bootstrap-identity-admin = "yuqei_contract_api.identity_bootstrap:main" yuqei-public-policy-promotion = "yuqei_contract_api.public_policy_promotion_cli:main" diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/v1_legal_fulltext_import.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/v1_legal_fulltext_import.py deleted file mode 100644 index 619c04c5..00000000 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/v1_legal_fulltext_import.py +++ /dev/null @@ -1,798 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from collections.abc import Iterable, Iterator -from dataclasses import dataclass, field -from datetime import UTC, datetime -from hashlib import sha256 -from pathlib import Path -from typing import Any - -import psycopg -from psycopg import sql - -from yuqei_contract_api.config import ContractApiSettings - - -DEFAULT_VERSION = "v1-legal-20260616" -DEFAULT_ACTOR = "v1-legal-fulltext-import" -DEFAULT_TENANT_ID = "default" - -TARGET_COLUMNS = [ - "id", - "tenant_id", - "article_id", - "version", - "source_id", - "source_aliases", - "source_title", - "source_type", - "authority", - "article_no", - "title", - "chapter_title", - "section_title", - "article_text", - "summary", - "keywords", - "tags", - "status", - "effective_date", - "source_url", - "published_at", - "created_by", - "updated_by", - "deleted_at", -] - -SOURCE_DATABASE_COLUMNS = [ - "id", - "tenant_id", - "source_id", - "source_title", - "source_type", - "authority", - "effective_date", - "validity_status", - "official_url", - "article_no", - "chapter_title", - "section_title", - "article_text", - # Some legacy V1 rows contain non-UTF-8 JSON bytes in tags_json. Tags are - # supplemental only, so avoid decoding them during the fulltext import. - "NULL::text AS tags_json", - "published_at", - "created_at", - "updated_at", -] - -SOURCE_DATABASE_COLUMN_SQL = sql.SQL(", ").join(sql.SQL(column) for column in SOURCE_DATABASE_COLUMNS) -LEGAL_ARTICLES_TABLE_SQL = sql.Identifier("legal_articles") -SOURCE_ROWS_TABLE_SQL = sql.Identifier("source_rows") - - -@dataclass(frozen=True) -class V1LegalArticle: - source_article_id: str | None - tenant_id: str - source_id: str - source_title: str - source_type: str - authority: str | None - article_no: str - chapter_title: str | None = None - section_title: str | None = None - article_text: str = "" - tags: list[str] = field(default_factory=list) - validity_status: str | None = None - effective_date: str | None = None - source_url: str | None = None - published_at: datetime | None = None - - -@dataclass(frozen=True) -class ImportSummary: - command: str - mode: str - source_kind: str - version: str - source_count: int - with_text_count: int - metadata_only_count: int - source_offset: int = 0 - inserted_count: int = 0 - updated_count: int = 0 - batches: int = 0 - target_before_count: int | None = None - target_after_count: int | None = None - sample_article_ids: list[str] = field(default_factory=list) - - def as_payload(self) -> dict[str, Any]: - return { - "command": self.command, - "mode": self.mode, - "source_kind": self.source_kind, - "version": self.version, - "source_offset": self.source_offset, - "source_count": self.source_count, - "with_text_count": self.with_text_count, - "metadata_only_count": self.metadata_only_count, - "inserted_count": self.inserted_count, - "updated_count": self.updated_count, - "batches": self.batches, - "target_before_count": self.target_before_count, - "target_after_count": self.target_after_count, - "sample_article_ids": self.sample_article_ids, - } - - -def import_from_v1_database( - *, - source_database_url: str, - target_database_url: str, - version: str = DEFAULT_VERSION, - apply: bool = False, - batch_size: int = 5000, - offset: int = 0, - after_source_id: str | None = None, - after_article_no: str | None = None, - after_source_article_id: str | None = None, - limit: int | None = None, - actor_name: str = DEFAULT_ACTOR, -) -> ImportSummary: - offset = max(0, offset) - after_key = _after_key(after_source_id, after_article_no, after_source_article_id) - source_dsn = normalize_database_url(source_database_url) - target_dsn = normalize_database_url(target_database_url) - with psycopg.connect(source_dsn) as source_conn, psycopg.connect(target_dsn) as target_conn: - source_count, with_text_count = _source_database_counts( - source_conn, - offset=offset, - limit=limit, - after_key=after_key, - ) - target_before = _target_version_count(target_conn, version=version) - if not apply: - samples = _source_database_samples( - source_conn, - version=version, - offset=offset, - limit=min(5, limit or 5), - after_key=after_key, - ) - return ImportSummary( - command="import-v1-legal-fulltext", - mode="dry-run", - source_kind="v1-database", - version=version, - source_offset=offset, - source_count=source_count, - with_text_count=with_text_count, - metadata_only_count=source_count - with_text_count, - target_before_count=target_before, - sample_article_ids=samples, - ) - - _create_staging_table(target_conn) - inserted_count = 0 - updated_count = 0 - batches = 0 - for batch in _iter_source_database_articles( - source_conn, - version=version, - batch_size=batch_size, - offset=offset, - after_key=after_key, - limit=limit, - ): - result = _import_batch(target_conn, batch, actor_name=actor_name) - inserted_count += result["inserted"] - updated_count += result["updated"] - batches += 1 - target_after = _target_version_count(target_conn, version=version) - return ImportSummary( - command="import-v1-legal-fulltext", - mode="apply", - source_kind="v1-database", - version=version, - source_offset=offset, - source_count=source_count, - with_text_count=with_text_count, - metadata_only_count=source_count - with_text_count, - inserted_count=inserted_count, - updated_count=updated_count, - batches=batches, - target_before_count=target_before, - target_after_count=target_after, - ) - - -def import_from_phase14_directory( - *, - source_dir: Path, - target_database_url: str, - version: str = DEFAULT_VERSION, - apply: bool = False, - batch_size: int = 5000, - offset: int = 0, - limit: int | None = None, - actor_name: str = DEFAULT_ACTOR, -) -> ImportSummary: - target_dsn = normalize_database_url(target_database_url) - articles = list(iter_phase14_articles(source_dir, version=version, offset=offset, limit=limit)) - source_count = len(articles) - with_text_count = sum(1 for item in articles if item["article_text"]) - with psycopg.connect(target_dsn) as target_conn: - target_before = _target_version_count(target_conn, version=version) - if not apply: - return ImportSummary( - command="import-v1-legal-fulltext", - mode="dry-run", - source_kind="phase14-directory", - version=version, - source_offset=max(0, offset), - source_count=source_count, - with_text_count=with_text_count, - metadata_only_count=source_count - with_text_count, - target_before_count=target_before, - sample_article_ids=[str(row["article_id"]) for row in articles[:5]], - ) - _create_staging_table(target_conn) - inserted_count = 0 - updated_count = 0 - batches = 0 - for batch in batched(articles, batch_size): - result = _import_batch(target_conn, batch, actor_name=actor_name) - inserted_count += result["inserted"] - updated_count += result["updated"] - batches += 1 - target_after = _target_version_count(target_conn, version=version) - return ImportSummary( - command="import-v1-legal-fulltext", - mode="apply", - source_kind="phase14-directory", - version=version, - source_offset=max(0, offset), - source_count=source_count, - with_text_count=with_text_count, - metadata_only_count=source_count - with_text_count, - inserted_count=inserted_count, - updated_count=updated_count, - batches=batches, - target_before_count=target_before, - target_after_count=target_after, - ) - - -def iter_phase14_articles( - source_dir: Path, - *, - version: str, - offset: int = 0, - limit: int | None = None, -) -> Iterator[dict[str, Any]]: - seen: set[tuple[str, str, str, str]] = set() - skipped = 0 - emitted = 0 - offset = max(0, offset) - for path in _phase14_manifest_files(source_dir): - payload = json.loads(path.read_text(encoding="utf-8-sig")) - for source in _iter_sources(payload): - source_id = _text(source.get("source_id")) or _hash_id("source", _text(source.get("title")) or path.stem) - source_title = _text(source.get("title")) or source_id - source_type = _text(source.get("source_type")) or "law" - authority = _text(source.get("authority")) - effective_date = _short_date(source.get("effective_date")) - source_url = _text(source.get("source_url")) or _text(source.get("official_url")) or _text(source.get("homepage")) - for article in source.get("articles") or []: - if not isinstance(article, dict): - continue - article_no = _text(article.get("article_no")) - if not article_no: - continue - chapter_title = _text(article.get("chapter_title")) - section_title = _text(article.get("section_title")) - dedupe_key = (source_id, article_no, chapter_title or "", section_title or "") - if dedupe_key in seen: - continue - seen.add(dedupe_key) - tags = _string_list(article.get("tags")) - article_text = _text( - article.get("article_text") - or article.get("text") - or article.get("content") - or article.get("full_text") - ) or "" - if not article_text: - tags = [*tags, "metadata-only"] - item = V1LegalArticle( - source_article_id=None, - tenant_id=DEFAULT_TENANT_ID, - source_id=source_id, - source_title=source_title, - source_type=source_type, - authority=authority, - article_no=article_no, - chapter_title=chapter_title, - section_title=section_title, - article_text=article_text, - tags=[*tags, "v1-phase14"], - validity_status=_text(source.get("validity_status")), - effective_date=effective_date, - source_url=source_url, - ) - if skipped < offset: - skipped += 1 - continue - yield _target_row(item, version=version) - emitted += 1 - if limit is not None and emitted >= limit: - return - - -def normalize_database_url(database_url: str) -> str: - return database_url.replace("postgresql+psycopg://", "postgresql://", 1) - - -def batched(items: Iterable[dict[str, Any]], batch_size: int) -> Iterator[list[dict[str, Any]]]: - batch: list[dict[str, Any]] = [] - for item in items: - batch.append(item) - if len(batch) >= batch_size: - yield batch - batch = [] - if batch: - yield batch - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Import V1 legal articles into V2 legal_fulltext_versions.") - parser.add_argument("--database-url", default=None, help="Target V2 CONTRACT_API_DATABASE_URL.") - parser.add_argument("--source-database-url", default=None, help="V1 database URL containing legal_articles.") - parser.add_argument("--source-dir", type=Path, default=None, help="V1 Phase14 legal-fulltext-library directory.") - parser.add_argument("--version", default=DEFAULT_VERSION) - parser.add_argument("--batch-size", type=int, default=5000) - parser.add_argument("--offset", type=int, default=0, help="Skip this many ordered source rows before importing.") - parser.add_argument("--after-source-id", default=None, help="Resume after this V1 source_id in source order.") - parser.add_argument("--after-article-no", default=None, help="Resume after this V1 article_no in source order.") - parser.add_argument("--after-source-article-id", default=None, help="Resume after this V1 legal_articles.id.") - parser.add_argument("--limit", type=int, default=None) - parser.add_argument("--apply", action="store_true", help="Write target rows. Omit for dry-run.") - parser.add_argument("--actor-name", default=DEFAULT_ACTOR) - return parser - - -def main() -> None: - args = build_parser().parse_args() - target_database_url = args.database_url or ContractApiSettings().database_url - if args.source_database_url and args.source_dir: - raise SystemExit("--source-database-url and --source-dir are mutually exclusive.") - if args.source_database_url: - result = import_from_v1_database( - source_database_url=args.source_database_url, - target_database_url=target_database_url, - version=args.version, - apply=args.apply, - batch_size=args.batch_size, - offset=args.offset, - after_source_id=args.after_source_id, - after_article_no=args.after_article_no, - after_source_article_id=args.after_source_article_id, - limit=args.limit, - actor_name=args.actor_name, - ) - elif args.source_dir: - result = import_from_phase14_directory( - source_dir=args.source_dir, - target_database_url=target_database_url, - version=args.version, - apply=args.apply, - batch_size=args.batch_size, - offset=args.offset, - limit=args.limit, - actor_name=args.actor_name, - ) - else: - raise SystemExit("One of --source-database-url or --source-dir is required.") - print(json.dumps(result.as_payload(), ensure_ascii=False, indent=2)) - - -def _source_database_counts( - source_conn: psycopg.Connection, - *, - offset: int = 0, - limit: int | None, - after_key: tuple[str, str, str] | None = None, -) -> tuple[int, int]: - where_sql = sql.SQL("WHERE (source_id, article_no, id) > (%s, %s, %s)") if after_key else sql.SQL("") - limit_sql = sql.SQL("LIMIT {limit}").format(limit=sql.Literal(int(limit))) if limit is not None else sql.SQL("") - offset_sql = sql.SQL("OFFSET {offset}").format(offset=sql.Literal(int(max(0, offset)))) if offset else sql.SQL("") - query = sql.SQL( - """ - WITH {source_rows} AS ( - SELECT article_text - FROM {legal_articles} - {where_clause} - ORDER BY source_id, article_no, id - {limit_clause} - {offset_clause} - ) - SELECT count(*), count(*) FILTER (WHERE length(coalesce(article_text, '')) > 0) - FROM {source_rows} - """ - ).format( - source_rows=SOURCE_ROWS_TABLE_SQL, - legal_articles=LEGAL_ARTICLES_TABLE_SQL, - where_clause=where_sql, - limit_clause=limit_sql, - offset_clause=offset_sql, - ) - with source_conn.cursor() as cursor: - cursor.execute(query, after_key or ()) - row = cursor.fetchone() - return int(row[0] or 0), int(row[1] or 0) - - -def _source_database_samples( - source_conn: psycopg.Connection, - *, - version: str, - offset: int = 0, - limit: int = 5, - after_key: tuple[str, str, str] | None = None, -) -> list[str]: - where_sql = sql.SQL("WHERE (source_id, article_no, id) > (%s, %s, %s)") if after_key else sql.SQL("") - params: list[Any] = [*(after_key or ()), max(0, offset), limit] - with source_conn.cursor(row_factory=psycopg.rows.dict_row) as cursor: - cursor.execute( - sql.SQL( - """ - SELECT {columns} - FROM {legal_articles} - {where_clause} - ORDER BY source_id, article_no, id - OFFSET %s - LIMIT %s - """ - ).format( - columns=SOURCE_DATABASE_COLUMN_SQL, - legal_articles=LEGAL_ARTICLES_TABLE_SQL, - where_clause=where_sql, - ), - params, - ) - return [str(_target_row(_article_from_source_database_row(row), version=version)["article_id"]) for row in cursor] - - -def _iter_source_database_articles( - source_conn: psycopg.Connection, - *, - version: str, - batch_size: int, - offset: int, - after_key: tuple[str, str, str] | None, - limit: int | None, -) -> Iterator[list[dict[str, Any]]]: - where_sql = sql.SQL("WHERE (source_id, article_no, id) > (%s, %s, %s)") if after_key else sql.SQL("") - query = sql.SQL( - """ - SELECT {columns} - FROM {legal_articles} - {where_clause} - ORDER BY source_id, article_no, id - """ - ).format( - columns=SOURCE_DATABASE_COLUMN_SQL, - legal_articles=LEGAL_ARTICLES_TABLE_SQL, - where_clause=where_sql, - ) - if limit is not None: - query = sql.SQL("{} LIMIT {}").format(query, sql.Literal(int(limit))) - if offset: - query = sql.SQL("{} OFFSET {}").format(query, sql.Literal(int(max(0, offset)))) - with source_conn.cursor(name="v1_legal_articles_import", row_factory=psycopg.rows.dict_row) as cursor: - cursor.itersize = batch_size - cursor.execute(query, after_key or ()) - while True: - rows = cursor.fetchmany(batch_size) - if not rows: - break - yield [_target_row(_article_from_source_database_row(row), version=version) for row in rows] - - -def _after_key( - source_id: str | None, - article_no: str | None, - source_article_id: str | None, -) -> tuple[str, str, str] | None: - values = (_text(source_id), _text(article_no), _text(source_article_id)) - if not any(values): - return None - if not all(values): - raise ValueError("--after-source-id, --after-article-no and --after-source-article-id must be provided together.") - return values # type: ignore[return-value] - - -def _article_from_source_database_row(row: dict[str, Any]) -> V1LegalArticle: - return V1LegalArticle( - source_article_id=_text(row.get("id")), - tenant_id=DEFAULT_TENANT_ID, - source_id=_text(row.get("source_id")) or _text(row.get("source_title")) or "unknown-source", - source_title=_text(row.get("source_title")) or _text(row.get("source_id")) or "Unknown Legal Source", - source_type=_text(row.get("source_type")) or "law", - authority=_text(row.get("authority")), - article_no=_text(row.get("article_no")) or "unknown-article", - chapter_title=_text(row.get("chapter_title")), - section_title=_text(row.get("section_title")), - article_text=_text(row.get("article_text")) or "", - tags=_string_list(row.get("tags_json")), - validity_status=_text(row.get("validity_status")), - effective_date=_short_date(row.get("effective_date")), - source_url=_text(row.get("official_url")), - published_at=_datetime_or_none(row.get("published_at")) or _datetime_or_none(row.get("updated_at")), - ) - - -def _target_row(article: V1LegalArticle, *, version: str) -> dict[str, Any]: - source_aliases = [article.source_id] - if article.source_article_id: - source_aliases.append(article.source_article_id) - source_id = _canonical_id(article.source_id, prefix="v1-source", max_length=128) - article_id = _hash_id("v1-law", article.tenant_id, article.source_id, article.article_no, article.source_article_id or "") - tags = _unique_strings([*article.tags, "v1-legal-library"]) - if not article.article_text: - tags = _unique_strings([*tags, "metadata-only"]) - published_at = article.published_at or datetime.now(UTC) - return { - "id": _hash_id("lfv", article.tenant_id, article_id, version), - "tenant_id": _clip(article.tenant_id or DEFAULT_TENANT_ID, 64), - "article_id": _clip(article_id, 128), - "version": _clip(version, 128), - "source_id": _clip(source_id, 128), - "source_aliases": source_aliases, - "source_title": _clip(article.source_title, 255), - "source_type": _clip(article.source_type or "law", 64), - "authority": _clip(article.authority, 255), - "article_no": _clip(article.article_no, 128), - "title": _clip(f"{article.source_title} {article.article_no}", 255), - "chapter_title": _clip(article.chapter_title, 255), - "section_title": _clip(article.section_title, 255), - "article_text": article.article_text, - "summary": _summary_for(article), - "keywords": [], - "tags": tags, - "status": _map_status(article.validity_status), - "effective_date": _short_date(article.effective_date), - "source_url": article.source_url, - "published_at": published_at, - "created_by": DEFAULT_ACTOR, - "updated_by": DEFAULT_ACTOR, - "deleted_at": None, - } - - -def _create_staging_table(conn: psycopg.Connection) -> None: - with conn.cursor() as cursor: - cursor.execute( - """ - CREATE TEMP TABLE IF NOT EXISTS legal_fulltext_versions_import_stage ( - id text, - tenant_id text, - article_id text, - version text, - source_id text, - source_aliases json, - source_title text, - source_type text, - authority text, - article_no text, - title text, - chapter_title text, - section_title text, - article_text text, - summary text, - keywords json, - tags json, - status text, - effective_date text, - source_url text, - published_at timestamptz, - created_by text, - updated_by text, - deleted_at timestamptz - ) ON COMMIT PRESERVE ROWS - """ - ) - conn.commit() - - -def _import_batch(conn: psycopg.Connection, batch: list[dict[str, Any]], *, actor_name: str) -> dict[str, int]: - if not batch: - return {"inserted": 0, "updated": 0} - with conn.cursor() as cursor: - cursor.execute("TRUNCATE legal_fulltext_versions_import_stage") - copy_sql = f"COPY legal_fulltext_versions_import_stage ({', '.join(TARGET_COLUMNS)}) FROM STDIN" - with cursor.copy(copy_sql) as copy: - for row in batch: - copy.write_row(_copy_row(row, actor_name=actor_name)) - cursor.execute( - """ - WITH upserted AS ( - INSERT INTO legal_fulltext_versions ( - id, tenant_id, article_id, version, source_id, source_aliases, - source_title, source_type, authority, article_no, title, - chapter_title, section_title, article_text, summary, keywords, - tags, status, effective_date, source_url, published_at, - created_by, updated_by, deleted_at - ) - SELECT - id, tenant_id, article_id, version, source_id, source_aliases, - source_title, source_type, nullif(authority, ''), article_no, title, - nullif(chapter_title, ''), nullif(section_title, ''), article_text, - summary, keywords, tags, status, nullif(effective_date, ''), - nullif(source_url, ''), published_at, created_by, updated_by, deleted_at - FROM legal_fulltext_versions_import_stage - ON CONFLICT (tenant_id, article_id, version) DO UPDATE SET - source_id = EXCLUDED.source_id, - source_aliases = EXCLUDED.source_aliases, - source_title = EXCLUDED.source_title, - source_type = EXCLUDED.source_type, - authority = EXCLUDED.authority, - article_no = EXCLUDED.article_no, - title = EXCLUDED.title, - chapter_title = EXCLUDED.chapter_title, - section_title = EXCLUDED.section_title, - article_text = EXCLUDED.article_text, - summary = EXCLUDED.summary, - keywords = EXCLUDED.keywords, - tags = EXCLUDED.tags, - status = EXCLUDED.status, - effective_date = EXCLUDED.effective_date, - source_url = EXCLUDED.source_url, - published_at = EXCLUDED.published_at, - updated_by = EXCLUDED.updated_by, - deleted_at = EXCLUDED.deleted_at - RETURNING (xmax = 0) AS inserted - ) - SELECT - count(*) FILTER (WHERE inserted), - count(*) FILTER (WHERE NOT inserted) - FROM upserted - """ - ) - inserted, updated = cursor.fetchone() - conn.commit() - return {"inserted": int(inserted or 0), "updated": int(updated or 0)} - - -def _copy_row(row: dict[str, Any], *, actor_name: str) -> list[Any]: - values: list[Any] = [] - for column in TARGET_COLUMNS: - value = row.get(column) - if column in {"source_aliases", "keywords", "tags"}: - value = json.dumps(value or [], ensure_ascii=False) - elif column in {"created_by", "updated_by"}: - value = actor_name - values.append(value) - return values - - -def _target_version_count(conn: psycopg.Connection, *, version: str) -> int: - with conn.cursor() as cursor: - cursor.execute("SELECT count(*) FROM legal_fulltext_versions WHERE version = %s", (version,)) - return int(cursor.fetchone()[0] or 0) - - -def _phase14_manifest_files(source_dir: Path) -> list[Path]: - candidates = [ - source_dir / "legal-article-manifest.json", - source_dir / "legal-article-expansion-batch-001.json", - source_dir / "priority1-law-import-manifest.json", - *sorted((source_dir / "incremental-batches").glob("*.json")), - ] - return [path for path in candidates if path.exists() and path.stat().st_size > 0] - - -def _iter_sources(payload: Any) -> Iterator[dict[str, Any]]: - if isinstance(payload, dict): - sources = payload.get("sources") - if isinstance(sources, list): - for source in sources: - if isinstance(source, dict): - yield source - for value in payload.values(): - if isinstance(value, (dict, list)): - yield from _iter_sources(value) - elif isinstance(payload, list): - for value in payload: - yield from _iter_sources(value) - - -def _summary_for(article: V1LegalArticle) -> str: - if article.article_text: - excerpt = article.article_text.replace("\n", " ").strip()[:180] - return f"V1法律全文库迁移:{article.source_title} {article.article_no}。{excerpt}" - return ( - "V1 Phase14 metadata-only import: source manifest included article metadata " - "but no article_text/full_text payload." - ) - - -def _map_status(validity_status: str | None) -> str: - normalized = (validity_status or "").strip().lower() - if any(token in normalized for token in ("废止", "失效", "repealed", "invalid")): - return "repealed" - if any(token in normalized for token in ("修订", "修改", "amended")): - return "amended" - return "effective" - - -def _canonical_id(value: str, *, prefix: str, max_length: int) -> str: - text = _text(value) or prefix - if len(text) <= max_length: - return text - return _hash_id(prefix, text) - - -def _hash_id(prefix: str, *parts: object) -> str: - digest = sha256("|".join(str(part or "") for part in parts).encode("utf-8")).hexdigest() - return f"{prefix}-{digest[:32]}" - - -def _clip(value: object, max_length: int) -> str | None: - text = _text(value) - if text is None: - return None - return text[:max_length] - - -def _short_date(value: object) -> str | None: - text = _text(value) - if not text: - return None - return text[:32] - - -def _text(value: object) -> str | None: - if value is None: - return None - if isinstance(value, datetime): - return value.date().isoformat() - text = str(value).strip() - return text or None - - -def _datetime_or_none(value: object) -> datetime | None: - if isinstance(value, datetime): - return value if value.tzinfo else value.replace(tzinfo=UTC) - return None - - -def _string_list(value: object) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - try: - parsed = json.loads(value) - except json.JSONDecodeError: - parsed = [value] - return _string_list(parsed) - if isinstance(value, list): - return _unique_strings(str(item).strip() for item in value if str(item).strip()) - return [] - - -def _unique_strings(values: Iterable[str]) -> list[str]: - seen: set[str] = set() - result: list[str] = [] - for value in values: - if value not in seen: - seen.add(value) - result.append(value) - return result - - -if __name__ == "__main__": - main() diff --git a/yuqei-contract-platform/services/contract-api/tests/test_v1_legal_fulltext_import.py b/yuqei-contract-platform/services/contract-api/tests/test_v1_legal_fulltext_import.py deleted file mode 100644 index 31d66ca6..00000000 --- a/yuqei-contract-platform/services/contract-api/tests/test_v1_legal_fulltext_import.py +++ /dev/null @@ -1,127 +0,0 @@ -import json -from datetime import UTC, datetime -from pathlib import Path - -from yuqei_contract_api.v1_legal_fulltext_import import ( - _article_from_source_database_row, - _map_status, - _target_row, - iter_phase14_articles, -) - - -def test_phase14_directory_rows_include_metadata_only_tag(tmp_path: Path) -> None: - source_dir = tmp_path / "phase14-legal-fulltext-library" - source_dir.mkdir() - (source_dir / "legal-article-manifest.json").write_text( - json.dumps( - { - "sources": [ - { - "source_id": "civil-code", - "title": "Civil Code", - "source_type": "law", - "authority": "NPC", - "validity_status": "effective", - "articles": [ - { - "article_no": "Article 1", - "article_text": "Civil law text.", - "tags": ["contract"], - }, - { - "article_no": "Article 2", - "chapter_title": "General", - }, - ], - } - ] - } - ), - encoding="utf-8", - ) - - rows = list(iter_phase14_articles(source_dir, version="test-version")) - - assert len(rows) == 2 - assert rows[0]["version"] == "test-version" - assert rows[0]["source_id"] == "civil-code" - assert rows[0]["article_text"] == "Civil law text." - assert "Civil Code" in rows[0]["summary"] - assert "metadata-only" not in rows[0]["tags"] - assert rows[1]["article_text"] == "" - assert "metadata-only" in rows[1]["tags"] - assert "v1-phase14" in rows[1]["tags"] - assert "v1-legal-library" in rows[1]["tags"] - - -def test_phase14_directory_supports_offset_and_limit(tmp_path: Path) -> None: - source_dir = tmp_path / "phase14-legal-fulltext-library" - source_dir.mkdir() - (source_dir / "legal-article-manifest.json").write_text( - json.dumps( - { - "sources": [ - { - "source_id": "civil-code", - "title": "Civil Code", - "articles": [ - {"article_no": "Article 1", "article_text": "one"}, - {"article_no": "Article 2", "article_text": "two"}, - {"article_no": "Article 3", "article_text": "three"}, - ], - } - ] - } - ), - encoding="utf-8", - ) - - rows = list(iter_phase14_articles(source_dir, version="test-version", offset=1, limit=1)) - - assert len(rows) == 1 - assert rows[0]["article_no"] == "Article 2" - assert rows[0]["article_text"] == "two" - - -def test_source_database_row_maps_status_and_stable_target_fields() -> None: - row = { - "id": "v1-row-1", - "tenant_id": None, - "source_id": "law-source", - "source_title": "Example Law", - "source_type": None, - "authority": "Authority", - "effective_date": "2026-01-01", - "validity_status": "\u5df2\u5e9f\u6b62", - "official_url": "https://example.test/law", - "article_no": "Article 9", - "chapter_title": "Chapter", - "section_title": "Section", - "article_text": "Legal body", - "tags_json": "[\"sample\"]", - "published_at": datetime(2026, 1, 2, tzinfo=UTC), - "created_at": None, - "updated_at": None, - } - - article = _article_from_source_database_row(row) - target = _target_row(article, version="v1-test") - - assert target["tenant_id"] == "default" - assert target["version"] == "v1-test" - assert target["source_id"] == "law-source" - assert target["source_aliases"] == ["law-source", "v1-row-1"] - assert target["source_type"] == "law" - assert target["status"] == "repealed" - assert target["tags"] == ["sample", "v1-legal-library"] - assert "Example Law" in target["summary"] - assert target["article_id"].startswith("v1-law-") - - -def test_map_status_handles_chinese_and_english_statuses() -> None: - assert _map_status("\u5df2\u5e9f\u6b62") == "repealed" - assert _map_status("\u5df2\u5931\u6548") == "repealed" - assert _map_status("2024\u5e74\u4fee\u8ba2") == "amended" - assert _map_status("amended") == "amended" - assert _map_status("effective") == "effective" diff --git a/yuqei-infra/README.md b/yuqei-infra/README.md index 46c898da..cffd6fa0 100644 --- a/yuqei-infra/README.md +++ b/yuqei-infra/README.md @@ -61,7 +61,7 @@ The script checks: ## Remote Smoke Deploy -Use this before replacing the old `contact.yuqei.com` deployment. It deploys V2 to an independent directory and independent ports on `192.168.3.90`, so it does not overwrite the V1 backend or the Aliyun frontend. +Use this to validate the V2 stack in an isolated directory and on independent ports at `192.168.3.90` before any separately authorized production promotion. Default remote path: @@ -105,7 +105,7 @@ Latest passing smoke artifact: - Remote database baseline passed for `yuqei_contract`, `yuqei_ai`, and `yuqei_ops`. - Contract API runs with one Uvicorn worker and a 2-second read cache; all mutating requests invalidate the cache before and after execution. -After smoke passes, the old production deployment can be replaced deliberately: backend/AI/worker/database/MinIO on `192.168.3.90`, frontend on Aliyun, and `contact.yuqei.com` kept as the public entry. +After smoke passes, production promotion remains a deliberate, separately authorized action: backend/AI/worker/database/MinIO on `192.168.3.90`, frontend on Aliyun, and `contact.yuqei.com` kept as the public entry. Production is now a separate, guarded path. Use `compose/docker-compose.production.yml` together with the shared compose file, then follow `deploy/README.md`. The production path requires digest-pinned images, loopback-only internal ports, TLS edge configuration, an off-host backup mount, protected provider secret files, and a passing `deploy/preflight-production.py`. It intentionally has no automatic traffic-cutover command. diff --git a/yuqei-infra/compose/docker-compose.yml b/yuqei-infra/compose/docker-compose.yml index 3e778dec..f5fc41cd 100644 --- a/yuqei-infra/compose/docker-compose.yml +++ b/yuqei-infra/compose/docker-compose.yml @@ -244,7 +244,7 @@ services: args: PIP_INDEX_URL: ${PYPI_INDEX_URL:-https://pypi.org/simple} working_dir: /app/yuqei-ai-platform/services/ai-platform-api - command: ["python", "-m", "yuqei_ai_platform_api.v1_prompt_assets_import", "--apply"] + command: ["python", "-m", "yuqei_ai_platform_api.packaged_prompt_assets_import", "--apply"] environment: AI_PLATFORM_ENVIRONMENT: ${AI_PLATFORM_ENVIRONMENT:-local} AI_PLATFORM_DATABASE_URL: ${AI_PLATFORM_DATABASE_URL:-postgresql+psycopg://yuqei:yuqei-local@postgres:5432/yuqei_ai} diff --git a/yuqei-infra/scripts/compare-v1-v2-assets.py b/yuqei-infra/scripts/compare-v1-v2-assets.py deleted file mode 100644 index 616e0a7b..00000000 --- a/yuqei-infra/scripts/compare-v1-v2-assets.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Compare V1 source assets with V2 packaged and runtime assets.""" - -from __future__ import annotations - -import argparse -import json -import os -from datetime import UTC, datetime -from pathlib import Path -from typing import Any -from urllib.error import URLError -from urllib.request import Request, urlopen - - -REPO_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_V1_ROOT = Path("D:/yuqei-project/ai-contract-platform-v1") -DEFAULT_CONTRACT_API_BASE = "http://localhost:8101" -DEFAULT_AI_PLATFORM_API_BASE = "http://localhost:8102" - - -def main() -> int: - parser = argparse.ArgumentParser(description="Compare V1 and V2 release assets.") - parser.add_argument("--v1-root", type=Path, default=DEFAULT_V1_ROOT) - parser.add_argument("--contract-api-base", default=DEFAULT_CONTRACT_API_BASE) - parser.add_argument("--ai-platform-api-base", default=DEFAULT_AI_PLATFORM_API_BASE) - parser.add_argument("--tenant-id", default="default") - parser.add_argument("--user-id", default="user-current-admin") - parser.add_argument("--contract-proxy-key-env", default="CONTRACT_API_WEB_PROXY_SHARED_SECRET") - parser.add_argument("--ai-service-key-env", default="AI_PLATFORM_SERVICE_SHARED_SECRET") - parser.add_argument( - "--artifact-dir", - type=Path, - default=REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-asset-count", - ) - args = parser.parse_args() - - checked_at = datetime.now(UTC).isoformat() - artifact_dir = args.artifact_dir - artifact_dir.mkdir(parents=True, exist_ok=True) - stamp = checked_at.replace(":", "-").replace("+00:00", "Z") - contract_api_base = args.contract_api_base.rstrip("/") - ai_platform_api_base = args.ai_platform_api_base.rstrip("/") - contract_headers = identity_headers(args.tenant_id, args.user_id) - ai_platform_headers = identity_headers(args.tenant_id, args.user_id) - contract_proxy_key = os.getenv(args.contract_proxy_key_env, "").strip() - ai_service_key = os.getenv(args.ai_service_key_env, "").strip() - if contract_proxy_key: - contract_headers["X-Contract-Proxy-Key"] = contract_proxy_key - if ai_service_key: - ai_platform_headers["X-AI-Platform-Service-Key"] = ai_service_key - - counts, parity = build_asset_report( - args.v1_root, - contract_api_base, - ai_platform_api_base, - contract_headers=contract_headers, - ai_platform_headers=ai_platform_headers, - ) - result = { - "status": derive_status(counts, parity), - "checked_at": checked_at, - "v1_root": str(args.v1_root), - "contract_api_base": contract_api_base, - "ai_platform_api_base": ai_platform_api_base, - "request_context": { - "tenant_id": args.tenant_id, - "user_id": args.user_id, - "contract_proxy_auth_configured": bool(contract_proxy_key), - "ai_service_auth_configured": bool(ai_service_key), - }, - "counts": counts, - "parity": parity, - "notes": [ - "Template, clause, rule, and Prompt parity compares stable IDs, not only file counts.", - "Runtime totals depend on the target databases behind the supplied API bases.", - "Semantic legal review and model-output quality still require sampled human approval.", - ], - } - - json_path = artifact_dir / f"v2-asset-count-{stamp}.json" - md_path = artifact_dir / f"v2-asset-count-{stamp}.md" - json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - md_path.write_text(render_markdown(result), encoding="utf-8") - print( - json.dumps( - {"status": result["status"], "json": str(json_path), "markdown": str(md_path)}, - ensure_ascii=False, - ) - ) - return 0 if result["status"] in {"pass", "warning"} else 1 - - -def build_asset_report( - v1_root: Path, - contract_api_base: str, - ai_platform_api_base: str, - *, - contract_headers: dict[str, str] | None = None, - ai_platform_headers: dict[str, str] | None = None, -) -> tuple[dict[str, Any], dict[str, Any]]: - v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library" - v1_templates = v1_phase13 / "contract-templates" - v1_clauses = v1_phase13 / "clause-library" - v1_rules = v1_phase13 / "review-rules" - v1_phase14 = v1_root / "project-docs" / "phase14-legal-fulltext-library" - v1_prompt_golden = v1_root / "project-docs" / "phase15-prompt-golden-samples" - v1_prompt_assets_file = v1_root / "project-docs" / "phase15-prompt-assets" / "default-prompt-assets.json" - - v2_asset_root = REPO_ROOT / "yuqei-contract-platform" / "services" / "contract-api" / "src" / "yuqei_contract_api" / "template_assets" - v2_templates = v2_asset_root / "v1_contract_templates" - v2_clauses = v2_asset_root / "v1_clause_library" - v2_rules = v2_asset_root / "v1_review_rules" - v2_prompt_assets_file = ( - REPO_ROOT - / "yuqei-ai-platform" - / "services" - / "ai-platform-api" - / "src" - / "yuqei_ai_platform_api" - / "prompt_assets" - / "v1_phase15" - / "default-prompt-assets.json" - ) - v2_golden_assets = v2_prompt_assets_file.parents[2] / "golden_assets" - - v1_template_ids = asset_ids(v1_templates, "*.md") - v2_template_ids = asset_ids(v2_templates, "*.md") - v1_clause_ids = asset_ids(v1_clauses, "*.md") - v2_clause_ids = asset_ids(v2_clauses, "*.md") - v1_rule_ids = asset_ids(v1_rules, "*.md") - v2_rule_ids = asset_ids(v2_rules, "*.md") - v1_prompt_keys = prompt_keys(read_json_file(v1_prompt_assets_file)) - v2_prompt_keys = prompt_keys(read_json_file(v2_prompt_assets_file)) - - runtime_payloads = { - "templates": api_json(contract_api_base, "/api/v1/templates?page_size=100", contract_headers), - "clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100", contract_headers), - "rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100", contract_headers), - "legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1", contract_headers), - "contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1", contract_headers), - "prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates", ai_platform_headers), - } - runtime_template_ids = item_ids(runtime_payloads["templates"]) - runtime_clause_ids = item_ids(runtime_payloads["clauses"]) - runtime_rule_ids = item_ids(runtime_payloads["rules"]) - runtime_prompt_items = payload_items(runtime_payloads["prompts"]) - runtime_prompt_keys = sorted( - str(item.get("name")) - for item in runtime_prompt_items - if item.get("version") == "v1-phase15" - and item.get("status") == "published" - and item.get("enabled") is True - and item.get("name") - ) - - counts = { - "v1_template_variable_schema_files": count_files(v1_templates, "*-variables.json"), - "v2_template_variable_schema_files": count_files(v2_templates, "*-variables.json"), - "v1_template_markdown_files": len(v1_template_ids), - "v2_template_markdown_files": len(v2_template_ids), - "v1_clause_library_markdown_files": len(v1_clause_ids), - "v2_clause_library_markdown_files": len(v2_clause_ids), - "v1_review_rule_markdown_files": len(v1_rule_ids), - "v2_review_rule_markdown_files": len(v2_rule_ids), - "v1_phase14_legal_files": count_files(v1_phase14, "*"), - "v1_prompt_golden_files": count_files(v1_prompt_golden, "*"), - "v1_prompt_asset_keys": len(v1_prompt_keys), - "v2_packaged_prompt_asset_keys": len(v2_prompt_keys), - "v2_ai_golden_asset_files": count_files(v2_golden_assets, "*"), - "v2_runtime": { - "templates_total": payload_total(runtime_payloads["templates"]), - "standard_clauses_total": payload_total(runtime_payloads["clauses"]), - "review_rules_total": payload_total(runtime_payloads["rules"]), - "legal_articles_total": payload_total(runtime_payloads["legal_articles"]), - "contracts_total": payload_total(runtime_payloads["contracts"]), - "prompt_templates_total": len(runtime_prompt_items), - "v1_phase15_prompt_templates_published": len(runtime_prompt_keys), - }, - } - parity = { - "templates": compare_ids(v1_template_ids, v2_template_ids, runtime_template_ids), - "standard_clauses": compare_ids(v1_clause_ids, v2_clause_ids, runtime_clause_ids), - "review_rules": compare_ids(v1_rule_ids, v2_rule_ids, runtime_rule_ids), - "prompts": compare_ids(v1_prompt_keys, v2_prompt_keys, runtime_prompt_keys), - } - return counts, parity - - -def asset_ids(root: Path, pattern: str) -> list[str]: - if not root.exists(): - return [] - return sorted( - path.stem - for path in root.rglob(pattern) - if path.is_file() and path.name.upper() != "README.MD" - ) - - -def prompt_keys(payload: Any) -> list[str]: - if not isinstance(payload, dict): - return [] - assets = payload.get("assets") - if not isinstance(assets, list): - return [] - return sorted( - str(item["prompt_key"]) - for item in assets - if isinstance(item, dict) and item.get("prompt_key") - ) - - -def compare_ids(expected: list[str], packaged: list[str], runtime: list[str]) -> dict[str, Any]: - expected_set = set(expected) - packaged_set = set(packaged) - runtime_set = set(runtime) - return { - "expected_count": len(expected), - "packaged_count": len(packaged), - "runtime_count": len(runtime), - "missing_in_packaged": sorted(expected_set - packaged_set), - "missing_in_runtime": sorted(expected_set - runtime_set), - "extra_in_packaged": sorted(packaged_set - expected_set), - "extra_in_runtime": sorted(runtime_set - expected_set), - "status": "pass" if expected_set <= packaged_set and expected_set <= runtime_set else "fail", - } - - -def count_files(root: Path, pattern: str) -> int: - if not root.exists(): - return -1 - return sum(1 for path in root.rglob(pattern) if path.is_file()) - - -def read_json_file(path: Path) -> Any: - if not path.exists(): - return {"__error__": f"Missing file: {path}"} - try: - return json.loads(path.read_text(encoding="utf-8-sig")) - except (OSError, ValueError) as exc: - return {"__error__": f"Unable to read {path}: {exc}"} - - -def identity_headers(tenant_id: str, user_id: str) -> dict[str, str]: - headers: dict[str, str] = {} - if tenant_id.strip(): - headers["X-Tenant-Id"] = tenant_id.strip() - if user_id.strip(): - headers["X-User-Id"] = user_id.strip() - return headers - - -def api_json(base_url: str, path: str, headers: dict[str, str] | None = None) -> Any: - try: - request = Request(f"{base_url}{path}", headers=headers or {}) - with urlopen(request, timeout=20) as response: - return json.loads(response.read().decode("utf-8")) - except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc: - return {"__error__": f"{base_url}{path}: {exc}"} - - -def payload_items(payload: Any) -> list[dict[str, Any]]: - if isinstance(payload, list): - return [item for item in payload if isinstance(item, dict)] - if isinstance(payload, dict) and isinstance(payload.get("items"), list): - return [item for item in payload["items"] if isinstance(item, dict)] - return [] - - -def item_ids(payload: Any) -> list[str]: - return sorted(str(item["id"]) for item in payload_items(payload) if item.get("id")) - - -def payload_total(payload: Any) -> int | str: - if isinstance(payload, dict) and "__error__" in payload: - return str(payload["__error__"]) - if isinstance(payload, dict) and payload.get("total") is not None: - return int(payload["total"]) - if isinstance(payload, list): - return len(payload) - return "ERROR: missing total" - - -def derive_status(counts: dict[str, Any], parity: dict[str, Any]) -> str: - if any(value == -1 for value in counts.values() if isinstance(value, int)): - return "fail" - if any(item["status"] == "fail" for item in parity.values()): - return "fail" - runtime = counts.get("v2_runtime", {}) - if any(isinstance(value, str) and (value.startswith("ERROR:") or value.startswith("Missing")) for value in runtime.values()): - return "warning" - if counts["v1_template_variable_schema_files"] != counts["v2_template_variable_schema_files"]: - return "warning" - return "pass" - - -def render_markdown(result: dict[str, Any]) -> str: - counts = result["counts"] - runtime = counts["v2_runtime"] - rows = [ - ("V1 template variable schemas", counts["v1_template_variable_schema_files"]), - ("V2 template variable schemas", counts["v2_template_variable_schema_files"]), - ("V1 template markdown", counts["v1_template_markdown_files"]), - ("V2 template markdown", counts["v2_template_markdown_files"]), - ("V1 clause markdown", counts["v1_clause_library_markdown_files"]), - ("V2 clause markdown", counts["v2_clause_library_markdown_files"]), - ("V1 review rule markdown", counts["v1_review_rule_markdown_files"]), - ("V2 review rule markdown", counts["v2_review_rule_markdown_files"]), - ("V1 Prompt asset keys", counts["v1_prompt_asset_keys"]), - ("V2 packaged Prompt asset keys", counts["v2_packaged_prompt_asset_keys"]), - ("V1 Prompt golden files", counts["v1_prompt_golden_files"]), - ("V2 AI golden asset files", counts["v2_ai_golden_asset_files"]), - ("V2 runtime templates", runtime["templates_total"]), - ("V2 runtime standard clauses", runtime["standard_clauses_total"]), - ("V2 runtime review rules", runtime["review_rules_total"]), - ("V2 runtime legal articles", runtime["legal_articles_total"]), - ("V2 runtime Prompt templates", runtime["prompt_templates_total"]), - ("V2 published V1 Phase15 Prompts", runtime["v1_phase15_prompt_templates_published"]), - ("V2 runtime contracts", runtime["contracts_total"]), - ] - lines = [ - "# V2 Asset Count", - "", - f"Status: `{result['status']}`", - "", - f"Checked at: `{result['checked_at']}`", - "", - "| Asset | Count |", - "| --- | ---: |", - ] - lines.extend(f"| {name} | {value} |" for name, value in rows) - lines.extend(["", "## ID Parity", "", "| Asset | Expected | Packaged | Runtime | Status |", "| --- | ---: | ---: | ---: | --- |"]) - for name, item in result["parity"].items(): - lines.append( - f"| {name} | {item['expected_count']} | {item['packaged_count']} | {item['runtime_count']} | {item['status']} |" - ) - if item["missing_in_packaged"]: - lines.append(f"| {name} missing packaged | - | `{', '.join(item['missing_in_packaged'])}` | - | fail |") - if item["missing_in_runtime"]: - lines.append(f"| {name} missing runtime | - | - | `{', '.join(item['missing_in_runtime'])}` | fail |") - lines.extend(["", "## Notes", ""]) - lines.extend(f"- {note}" for note in result["notes"]) - lines.append("") - return "\n".join(lines) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 b/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 index 1208aa14..685b7bb3 100644 --- a/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 +++ b/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 @@ -449,7 +449,7 @@ docker compose -p $(ConvertTo-ShellSingleQuote $ComposeProjectName) --env-file $ Add-Check "ai_golden_sample_gate" "AI golden sample asset gate" "fail" "Golden sample asset gate failed." $true @{ log = $goldenGate.log } throw "AI golden sample asset gate failed. See $($goldenGate.log)" } - Add-Check "ai_golden_sample_gate" "AI golden sample asset gate" "pass" "V1 golden assets passed: 90 samples, 6 suites, 16 checks." $true @{ log = $goldenGate.log } + Add-Check "ai_golden_sample_gate" "AI golden sample asset gate" "pass" "Packaged golden assets passed: 90 samples, 6 suites, 16 checks." $true @{ log = $goldenGate.log } $dbCheck = Invoke-Remote "remote-db-check" @" set -euo pipefail diff --git a/yuqei-infra/scripts/inspect-v2-database-state.ps1 b/yuqei-infra/scripts/inspect-v2-database-state.ps1 deleted file mode 100644 index d67abbf4..00000000 --- a/yuqei-infra/scripts/inspect-v2-database-state.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -param( - [string]$HostName = "192.168.3.90", - [string]$User = "root", - [int]$SshPort = 22 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$remoteScript = @' -set -eu - -echo "## host-postgres-databases" -sudo -u postgres psql -Atc "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname;" - -echo -echo "## host-postgres-summaries" -for db in ai_contract_platform yuqei_contract yuqei_ai yuqei_ops yuqei_v2 postgres; do - exists=$(sudo -u postgres psql -Atc "SELECT 1 FROM pg_database WHERE datname = '$db';") - if [ "$exists" = "1" ]; then - tables=$(sudo -u postgres psql -d "$db" -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';") - versions=$(sudo -u postgres psql -d "$db" -Atc "SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE '%alembic_version%' ORDER BY table_name;" | paste -sd ',') - heads="" - for vt in alembic_version contract_api_alembic_version ai_platform_alembic_version; do - has=$(sudo -u postgres psql -d "$db" -Atc "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='$vt';") - if [ "$has" = "1" ]; then - val=$(sudo -u postgres psql -d "$db" -Atc "SELECT version_num FROM $vt LIMIT 5;" | paste -sd ',') - heads="$heads $vt=$val" - fi - done - markers=$(sudo -u postgres psql -d "$db" -Atc "SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name IN ('contracts','generated_contracts','knowledge_documents','ai_run_audits','legal_change_impact_scans') ORDER BY table_name;" | paste -sd ',') - echo "$db|tables=$tables|versions=$versions|heads=$heads|markers=$markers" - else - echo "$db|missing" - fi -done - -echo -echo "## docker-v2-smoke-containers" -docker ps -a --format '{{.Names}}|{{.Status}}|{{.Ports}}' | grep -E 'yuqei-contract-v2-smoke|ai-contract-platform|postgres' || true - -echo -echo "## docker-v2-smoke-volumes" -docker volume ls --format '{{.Name}}' | grep -E 'yuqei-contract-v2-smoke|ai-contract-platform|postgres' || true -'@ - -$remoteScript | ssh -p $SshPort "$User@$HostName" "bash -s" diff --git a/yuqei-infra/scripts/run-ai-provider-acceptance.py b/yuqei-infra/scripts/run-ai-provider-acceptance.py index 5dd62a05..dc9ef4b2 100644 --- a/yuqei-infra/scripts/run-ai-provider-acceptance.py +++ b/yuqei-infra/scripts/run-ai-provider-acceptance.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run real-provider legal QA and contract-review acceptance against V1 golden samples.""" +"""Run real-provider legal QA and contract-review acceptance against packaged golden samples.""" from __future__ import annotations diff --git a/yuqei-infra/tests/test_v2_only_repository_layout.py b/yuqei-infra/tests/test_v2_only_repository_layout.py new file mode 100644 index 00000000..135b86fe --- /dev/null +++ b/yuqei-infra/tests/test_v2_only_repository_layout.py @@ -0,0 +1,58 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +RETIRED_PRODUCT_ROOTS = ( + "backend", + "backups", + "database", + "deploy", + "frontend", + "infra", + "mobile", + "scripts", + "temp-manual-laws", +) + +RETIRED_V1_ARTIFACTS = ( + "project-docs/V2-vs-V1-feature-gap-audit-2026-07-03.md", + "yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/v1_prompt_assets_import.py", + "yuqei-ai-platform/services/ai-platform-api/tests/test_v1_prompt_assets_import.py", + "yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/v1_legal_fulltext_import.py", + "yuqei-contract-platform/services/contract-api/tests/test_v1_legal_fulltext_import.py", + "yuqei-infra/scripts/compare-v1-v2-assets.py", + "yuqei-infra/scripts/inspect-v2-database-state.ps1", +) + + +def test_retired_product_roots_are_not_present() -> None: + present = [name for name in RETIRED_PRODUCT_ROOTS if (REPO_ROOT / name).exists()] + + assert present == [] + + +def test_retired_v1_only_artifacts_are_not_present() -> None: + present = [path for path in RETIRED_V1_ARTIFACTS if (REPO_ROOT / path).exists()] + + assert present == [] + + +def test_repository_entrypoints_describe_a_single_v2_product_line() -> None: + entrypoint_text = "\n".join( + (REPO_ROOT / path).read_text(encoding="utf-8") + for path in ("README.md", "V2-README.md") + ) + + assert "previous complete V1 implementation" not in entrypoint_text + assert "previous V1 implementation" not in entrypoint_text + assert "`V1` branch" not in entrypoint_text + + +def test_active_pages_do_not_present_retired_product_assets() -> None: + app_root = REPO_ROOT / "yuqei-contract-platform/apps/contract-web/src/app" + page_text = "\n".join(path.read_text(encoding="utf-8") for path in app_root.rglob("*.tsx")) + + assert "V1 Phase" not in page_text + assert "V1 模板资产" not in page_text + assert "V1 全量" not in page_text