refactor: remove obsolete V1 implementation paths
Build and Test Quality Gate / Contract Web typecheck and build (push) Successful in 3m50s
Build and Test Quality Gate / Python tests and Contract API migration (push) Failing after 9m33s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (push) Failing after 5m40s

This commit is contained in:
2026-10-03 22:37:27 +08:00
parent 62e72506b2
commit 9d00c44ce2
32 changed files with 180 additions and 1549 deletions
-11
View File
@@ -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
+6 -10
View File
@@ -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
+3 -5
View File
@@ -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.
@@ -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`
@@ -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 冻结
@@ -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,下载写入审计事件。
- 前端在合同详情页审查区增加“生成/下载审查报告”入口。
@@ -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
@@ -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
@@ -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"]
@@ -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",
@@ -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 只读资产包尚未接入该套件样本。"
@@ -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,
@@ -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,
)
@@ -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,
@@ -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")
@@ -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": {
@@ -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<string, unknown>;
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() {
<div className="page-heading">
<div>
<h1>Prompt 中心</h1>
<p>管理 AI 中台提示词草稿、发布版本、回滚记录和 V1 Phase15 导入资产。</p>
<p>管理 AI 中台提示词草稿、发布版本、回滚记录和平台内置资产。</p>
</div>
<div className="toolbar toolbar-inline">
<button className="button button-soft" onClick={() => void loadTemplates()}>
@@ -361,7 +361,7 @@ export default function AiPromptsPage() {
<section className="template-library-summary" aria-label="Prompt 资产统计">
<Metric label="Prompt 版本" value={templates.length} />
<Metric label="V1 Phase15" value={v1ImportedCount} />
<Metric label="内置资产" value={packagedAssetCount} />
<Metric label="已发布" value={publishedCount} />
<Metric label="Prompt 类型" value={promptTypes.length} />
</section>
@@ -403,7 +403,7 @@ export default function AiPromptsPage() {
<select className="search-input select-input" value={versionFilter} onChange={(event) => setVersionFilter(event.target.value)}>
<option value="">全部版本</option>
{versions.map((version) => (
<option key={version} value={version}>{version}</option>
<option key={version} value={version}>{formatVersionLabel(version)}</option>
))}
</select>
</label>
@@ -441,7 +441,7 @@ export default function AiPromptsPage() {
</div>
<div className="template-library-card-meta">
<span>{parsed.asset?.prompt_type ?? "未标注类型"}</span>
<span>{template.version}</span>
<span>{formatVersionLabel(template.version)}</span>
<span>{formatDate(template.updated_at)}</span>
</div>
</button>
@@ -459,9 +459,9 @@ export default function AiPromptsPage() {
<FileText size={16} />
详情与编辑
</h2>
{publishedTemplate && <p className="item-meta">当前运行版本:{publishedTemplate.version}</p>}
{publishedTemplate && <p className="item-meta">当前运行版本:{formatVersionLabel(publishedTemplate.version)}</p>}
</div>
{selectedParsed?.isV1Asset && <span className="tag tag-primary">V1 Phase15</span>}
{selectedParsed?.isPackagedAsset && <span className="tag tag-primary">内置资产</span>}
</div>
<div className="panel-body">
{selectedTemplate && selectedParsed && (
@@ -525,10 +525,10 @@ function PromptMetadata({ parsed, template }: { parsed: ParsedPromptContent; tem
return (
<div className="legal-article-meta-grid prompt-metadata-grid">
<MiniBlock title="模板 ID" value={template.id} />
<MiniBlock title="版本" value={template.version} />
<MiniBlock title="版本" value={formatVersionLabel(template.version)} />
<MiniBlock title="状态" value={statusLabels[template.status] ?? template.status} />
<MiniBlock title="类型" value={asset?.prompt_type ?? "未标注"} />
<MiniBlock title="来源阶段" value={parsed.source?.phase ?? "V2"} />
<MiniBlock title="来源阶段" value={parsed.isPackagedAsset ? "内置基线" : parsed.source?.phase ?? "V2"} />
<MiniBlock title="目标场景" value={asset?.target_scene ?? "未标注"} />
</div>
);
@@ -617,7 +617,7 @@ function VersionHistory({
<article className={`quality-action-item ${isTargetPrompt ? "config-targeted" : ""}`} key={template.id}>
<div className="list-row">
<div className="list-main">
<strong>{template.version}</strong>
<strong>{formatVersionLabel(template.version)}</strong>
<span className="item-meta">
更新:{formatDate(template.updated_at)}
{template.published_at ? ` / 发布:${formatDate(template.published_at)}` : ""}
@@ -629,7 +629,7 @@ function VersionHistory({
{isTargetPrompt && <span className="tag tag-primary target-marker">Target</span>}
</div>
{template.rollback_from_version && (
<span className="item-meta">回滚来源:{template.rollback_from_version}</span>
<span className="item-meta">回滚来源:{formatVersionLabel(template.rollback_from_version)}</span>
)}
{showTargetPublish && (
<div className="inline-message">
@@ -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())) {
@@ -987,7 +987,7 @@ function GoldenSampleCoveragePanel({
<div className="panel-body">
<div className="quality-action-stats">
<span>资产 {coverage.total_assets}</span>
<span>来源 {coverage.source_product}</span>
<span>来源 平台内置质量样本</span>
<span>合同生成 {requiredSamplesByPromptType["contract_generation"] ?? 0}</span>
<span>合同审查 {requiredSamplesByPromptType["contract_review"] ?? 0}</span>
<span>法律问答 {requiredSamplesByPromptType["legal_chat"] ?? 0}</span>
@@ -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<string, string> = {
@@ -248,7 +248,7 @@ export default async function LegalAssetsPage({ searchParams }: PageProps) {
</h2>
<p className="item-meta">{clauses.length} / {allClauses.length} 条资产匹配当前条件</p>
</div>
<span className="tag tag-primary">V1 Phase13</span>
<span className="tag tag-primary">内置资产</span>
</div>
<div className="panel-body legal-asset-list">
{clauses.length === 0 && <div className="empty-state empty-state-compact">{text.emptyClauses}</div>}
@@ -274,7 +274,7 @@ export default async function LegalAssetsPage({ searchParams }: PageProps) {
</h2>
<p className="item-meta">{rules.length} / {allRules.length} 条资产匹配当前条件</p>
</div>
<span className="tag tag-primary">V1 Phase13</span>
<span className="tag tag-primary">内置资产</span>
</div>
<div className="panel-body legal-asset-list">
{rules.length === 0 && <div className="empty-state empty-state-compact">{text.emptyRules}</div>}
@@ -325,7 +325,7 @@ async function ClauseCard({
{clause.review_status && <span>{clause.review_status}</span>}
<span>{clause.legal_sources.length} 法源</span>
<span>{linkedRuleCount} 规则</span>
<span>{clause.lifecycle_version || "v1"}</span>
<span>{clause.lifecycle_version || "未版本化"}</span>
<span>{clause.lifecycle_event_count} 次变更</span>
</div>
{history.length > 0 && <LegalAssetHistory events={history} />}
@@ -400,7 +400,7 @@ async function RuleCard({
{rule.status && <span>{rule.status}</span>}
{rule.knowledge_card_id && <span>{rule.knowledge_card_id}</span>}
<span>{rule.legal_sources.length} 法源</span>
<span>{rule.lifecycle_version || "v1"}</span>
<span>{rule.lifecycle_version || "未版本化"}</span>
<span>{rule.lifecycle_event_count} 次变更</span>
</div>
{history.length > 0 && <LegalAssetHistory events={history} />}
@@ -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) {
<div className="page-heading">
<div>
<h1>法律全文库</h1>
<p>V1 Phase14 法律全文资产已接入 V2,可按关键词、法源、效力状态分页查看,并用于法规变更影响扫描。</p>
<p>平台法律全文资产可按关键词、法源、效力状态分页查看,并用于法规变更影响扫描。</p>
</div>
<Link className="button button-soft" href="/admin/legal-assets">
<Scale size={16} />
@@ -65,10 +63,10 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) {
</div>
<section className="template-library-summary" aria-label="法律全文库统计">
<Metric icon={BookOpenText} label="V1 全量条文" value={articlePage.total || FULL_V1_LEGAL_TOTAL} />
<Metric icon={Landmark} label="V1 法源规模" value={FULL_V1_LEGAL_SOURCE_TOTAL} />
<Metric icon={BookOpenText} label="当前命中条文" value={articlePage.total} />
<Metric icon={Landmark} label="本页法源" value={Object.keys(articlePage.source_summary).length} />
<Metric icon={ShieldCheck} label="本页有效条文" value={effectiveCountOnPage} />
<Metric icon={Database} label="当前命中" value={articlePage.total} />
<Metric icon={Database} label="本页条文" value={articlePage.items.length} />
</section>
<form className="toolbar legal-fulltext-toolbar" action="/admin/legal-fulltext">
@@ -103,8 +101,8 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) {
<div className="panel-body legal-asset-filter-list">
<SourceFilter sourceIds={matchedSources} activeSourceId={sourceId} />
<div className="legal-asset-block">
<strong>全量库状态</strong>
<p>已接入 V1 全量法律库 {formatInteger(FULL_V1_LEGAL_TOTAL)} 条,覆盖约 {formatInteger(FULL_V1_LEGAL_SOURCE_TOTAL)} 个法源。页面按服务端分页读取,不一次性渲染全库。</p>
<strong>法律库状态</strong>
<p>当前查询由服务端返回 {formatInteger(articlePage.total)} 条;页面按服务端分页读取,不一次性渲染全库。</p>
</div>
</div>
</aside>
@@ -121,7 +119,7 @@ export default async function LegalFulltextPage({ searchParams }: PageProps) {
共 {formatInteger(articlePage.total)} 条,当前第 {articlePage.page} 页,每页 {articlePage.page_size} 条
</p>
</div>
<span className="tag tag-primary">V1 Phase14</span>
<span className="tag tag-primary">平台法律库</span>
</div>
<div className="panel-body legal-article-list">
{articlePage.items.length === 0 && <div className="empty-state empty-state-compact">没有找到匹配的法条。</div>}
@@ -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,
@@ -29,7 +29,7 @@ type PageProps = {
const text = {
title: "模板库",
subtitle: "按合同类型组织 V1 模板资产,在 V2 中串起起草、审查、条款修复和版本留痕。",
subtitle: "按合同类型组织平台内置模板资产,串起起草、审查、条款修复和版本留痕。",
searchPlaceholder: "搜索模板名称、适用范围、校验规则",
search: "搜索",
clear: "清空",
@@ -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"
@@ -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()
@@ -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"
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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}
-347
View File
@@ -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())
@@ -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
@@ -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"
@@ -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
@@ -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