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
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:
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+18
-18
@@ -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 只读资产包尚未接入该套件样本。"
|
||||
|
||||
+15
-15
@@ -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,
|
||||
)
|
||||
|
||||
+11
-11
@@ -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())) {
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
-798
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user