295 lines
20 KiB
Python
295 lines
20 KiB
Python
"""Read-only asset audit and isolated defect probes; never calls a model or publishes assets.
|
|
|
|
Run from the repository root. --runtime adds PostgreSQL READ ONLY transactions
|
|
using existing service settings. Business contract text, credentials and user IDs
|
|
are not collected. The only writes are an explicitly requested local JSON report
|
|
and transient SQLite :memory: data used by a publication-isolation probe.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import Counter
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
CONTRACT = ROOT / "yuqei-contract-platform/services/contract-api"
|
|
AI = ROOT / "yuqei-ai-platform/services/ai-platform-api"
|
|
sys.path[:0] = [str(CONTRACT / "src"), str(AI / "src")]
|
|
|
|
from yuqei_contract_api.contract_templates import ( # noqa: E402
|
|
ContractTemplateEditDraft, ContractTemplateItem, ContractTemplateVariable,
|
|
TemplateRenderError, get_contract_template_body, list_contract_templates,
|
|
render_contract_template, render_template_body,
|
|
validate_contract_template_inputs,
|
|
)
|
|
from yuqei_contract_api.template_inputs import input_schema_hash, template_sample_values # noqa: E402
|
|
from yuqei_contract_api.contract_body_quality import inspect_contract_body_quality # noqa: E402
|
|
from yuqei_contract_api.lifecycle_helpers import validate_template_edit_draft # noqa: E402
|
|
from yuqei_ai_platform_api.prompts import ( # noqa: E402
|
|
_render_packaged_prompt_asset, _render_template, contract_review_prompt_candidates,
|
|
)
|
|
from yuqei_ai_platform_api.prompt_compiler import PromptVersionConflict # noqa: E402
|
|
|
|
PROMPT_FILE = AI / "src/yuqei_ai_platform_api/prompt_assets/v1_phase15/default-prompt-assets.json"
|
|
TOKEN = re.compile(r"{{\s*([^{}]+?)\s*}}")
|
|
|
|
|
|
def sha(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def source_inventory() -> dict:
|
|
templates = []
|
|
for item in list_contract_templates().items:
|
|
body = get_contract_template_body(item.id)
|
|
required = {v.name for v in item.required_variables}
|
|
optional = {v.name for v in item.optional_variables}
|
|
placeholders = set(TOKEN.findall(body))
|
|
checks = []
|
|
for mode, variables in (("required_only", item.required_variables),
|
|
("all_fields", [*item.required_variables, *item.optional_variables])):
|
|
values = {v.name: f"AUDIT-VALUE-{i:02d}" for i, v in enumerate(variables)}
|
|
if item.input_schema:
|
|
values = template_sample_values(item.input_schema, [v.name for v in item.required_variables], [v.name for v in item.optional_variables], with_optional=mode == "all_fields")
|
|
rendered = render_contract_template(item.id, values)
|
|
validation = validate_contract_template_inputs(item, values)
|
|
body_values = {name: value for name, value in validation.normalized_values.items() if not item.input_schema or item.input_schema.fields[name].role == "body_fact"}
|
|
checks.append({
|
|
"mode": mode, "body_length": len(rendered),
|
|
"quality_codes": [i.code for i in inspect_contract_body_quality(rendered)],
|
|
"missing_required_values": sorted(v for k, v in body_values.items() if k in validation.required_fields and v not in rendered),
|
|
"instruction_residue_markers": [m for m in ("不得默认生成", "不得自动生成", "必须转专项", "需法务", "必须由业务和法务确认") if m in rendered],
|
|
})
|
|
templates.append({
|
|
"id": item.id, "name": item.name, "type": item.contract_type,
|
|
"version": item.version, "body_sha256": sha(body),
|
|
"required_count": len(required), "optional_count": len(optional),
|
|
"required_fields": sorted(required), "optional_fields": sorted(optional),
|
|
"undeclared_placeholders": sorted(placeholders - required - optional),
|
|
"unused_required_fields": sorted(required - placeholders),
|
|
"unused_optional_fields_in_body": sorted(optional - placeholders),
|
|
"outside_body_roles": {name: spec.role for name, spec in item.input_schema.fields.items() if spec.role != "body_fact"} if item.input_schema else {},
|
|
"input_schema_sha256": input_schema_hash(item.input_schema),
|
|
"executable_rule_ids": [rule.id for rule in item.input_schema.rules] if item.input_schema else [],
|
|
"input_fixture_count": len(item.input_schema.fixtures) if item.input_schema else 0,
|
|
"review_prompt_candidates": contract_review_prompt_candidates(item.contract_type),
|
|
"render_checks": checks,
|
|
})
|
|
assets = json.loads(PROMPT_FILE.read_text(encoding="utf-8"))["assets"]
|
|
prompts = []
|
|
for asset in assets:
|
|
schema = asset.get("output_schema") or {}
|
|
prompts.append({
|
|
"key": asset["prompt_key"], "name": asset["name"], "type": asset["prompt_type"],
|
|
"content_sha256": sha(asset["prompt_content"]),
|
|
"content_length": len(asset["prompt_content"]),
|
|
"system_sha256": sha(asset.get("system_prompt", "")),
|
|
"required_variables": [v["name"] for v in asset.get("variables", []) if v.get("required")],
|
|
"schema_required": schema.get("required", []),
|
|
"untyped_array_fields": [k for k, v in schema.get("properties", {}).items()
|
|
if isinstance(v, dict) and v.get("type") == "array" and "items" not in v],
|
|
"embedded_article_numbers": bool(re.search(r"第\d+", asset["prompt_content"])),
|
|
})
|
|
return {
|
|
"template_count": len(templates), "required_field_count": sum(t["required_count"] for t in templates),
|
|
"optional_field_count": sum(t["optional_count"] for t in templates),
|
|
"prompt_count": len(prompts), "prompt_types": dict(Counter(p["type"] for p in prompts)),
|
|
"template_types_using_generic_review": [t["type"] for t in templates if len(t["review_prompt_candidates"]) == 2],
|
|
"templates": templates, "prompts": prompts,
|
|
}
|
|
|
|
|
|
def isolated_probes() -> list[dict]:
|
|
results: list[dict] = []
|
|
|
|
def check(key: str, expectation: str, passed: bool, observed: object) -> None:
|
|
results.append({"id": key, "expectation": expectation,
|
|
"status": "pass" if passed else "fail", "observed": observed})
|
|
|
|
variables = {"contract_text": "原文保留 {counterparty}", "counterparty": "被替换的公司"}
|
|
output = _render_template("专项约束\n{{contract_text}}", "通用\n{contract_text}", variables)
|
|
check("prompt_recursive_substitution", "用户数据中的变量形状原样保留", variables["contract_text"] in output, output)
|
|
variables["contract_text"] = '接口格式 {"amount": 100}'
|
|
output = _render_template("专项约束\n{{contract_text}}", "通用\n{contract_text}", variables)
|
|
check("prompt_json_fallback", "正文包含 JSON 不应丢弃专项约束", "专项约束" in output, output)
|
|
variables["contract_text"] = "原文保留 {counterparty}"
|
|
imported = json.dumps({"asset": {"prompt_content": "原始正文:{{contract_text}}"}})
|
|
output = _render_packaged_prompt_asset(imported, variables)
|
|
check("imported_prompt_recursive_substitution", "导入资产也不能二次解释用户变量", variables["contract_text"] in output, output)
|
|
imported = json.dumps({"asset": {"prompt_content": "正文:{{contract_text}}", "variables": [{"name": "contract_text", "required": True}]}})
|
|
try:
|
|
output = _render_packaged_prompt_asset(imported, {})
|
|
rejected = False
|
|
except (ValueError, KeyError):
|
|
output, rejected = "rejected", True
|
|
check("prompt_required_input", "缺少声明的必填输入应明确报错", rejected, output)
|
|
|
|
template = ContractTemplateItem(id="AUDIT-ONLY", name="隔离测试", version="draft", contract_type="test", body_preview="",
|
|
required_variables=[ContractTemplateVariable(name="甲方", label="甲方")],
|
|
optional_variables=[ContractTemplateVariable(name="补充说明", label="补充说明", required=False)])
|
|
output = render_template_body(template, "甲方为{{甲方}}(补充{{补充说明}})提供服务。", {"甲方": "隔离甲方"})
|
|
check("optional_parenthetical_baseline", "独立可选括号不应删除必填信息", "隔离甲方" in output, output)
|
|
try:
|
|
output = render_template_body(template, "甲方{{甲方}}备注{{补充说明}}。双方同意按约履行。", {"甲方": "隔离甲方"})
|
|
rejected = False
|
|
except TemplateRenderError as error:
|
|
output, rejected = str(error), True
|
|
check("optional_segment_deletes_required", "混合句段中仍应保留必填值或拒绝渲染", rejected or "隔离甲方" in output, {"rejected": rejected, "output": output})
|
|
output = render_template_body(template, "服务约定:{{甲方}}。其余条款继续有效。", {"甲方": "业务系统不得泄露商业秘密"})
|
|
check("internal_cleanup_deletes_user_content", "用户约定不能被内部说明清理规则静默删除", "业务系统不得泄露商业秘密" in output, output)
|
|
codes = [i.code for i in inspect_contract_body_quality("【特别提示】付款后五个工作日交货。")]
|
|
check("bracket_emphasis_false_positive", "普通强调标题不应被当作未填占位符", not codes, codes)
|
|
codes = [i.code for i in inspect_contract_body_quality("甲方承诺按时交货。补充约定{{尚未闭合")]
|
|
check("unclosed_placeholder_missed", "未闭合的模板标记应被识别", bool(codes), codes)
|
|
|
|
def draft(body: str) -> ContractTemplateEditDraft:
|
|
return ContractTemplateEditDraft(template_id=template.id, base_version="draft", draft_version="audit",
|
|
body_content=body, required_variables=["甲方"], optional_variables=[],
|
|
clause_ids=["CLAUSE-AUDIT"], review_rule_ids=["RULE-AUDIT"], actor_name="isolated-audit", updated_at=datetime.now(UTC))
|
|
|
|
unknown_body = "甲方{{甲方}}。" + "双方依法履行本合同的约定义务。" * 8 + "补充{{未声明}}。"
|
|
validation = validate_template_edit_draft(template, draft(unknown_body), clause_ids={"CLAUSE-AUDIT"}, review_rule_ids={"RULE-AUDIT"})
|
|
try:
|
|
render_template_body(template, unknown_body, {"甲方": "隔离甲方"})
|
|
render_failed = False
|
|
except TemplateRenderError:
|
|
render_failed = True
|
|
check("publish_unknown_placeholder", "无法渲染的未声明变量不能通过发布校验", not validation.valid,
|
|
{"valid": validation.valid, "issues": [i.model_dump() for i in validation.issues], "render_failed": render_failed})
|
|
|
|
purchase = next(t for t in list_contract_templates().items if t.contract_type == "purchase")
|
|
baseline = template_sample_values(purchase.input_schema, [v.name for v in purchase.required_variables], [v.name for v in purchase.optional_variables], with_optional=False)
|
|
baseline_validation = validate_contract_template_inputs(purchase, baseline)
|
|
cases = []
|
|
for name, value, expected_code in (
|
|
("合同价款", "1元", "RULE_EQUAL_PRODUCT"),
|
|
("签署日期", "2026-02-30", "FIELD_TYPE"),
|
|
("争议解决方式", "提交某仲裁委员会仲裁,同时向某人民法院起诉", "FIELD_TYPE"),
|
|
):
|
|
try:
|
|
render_contract_template(purchase.id, {**baseline, name: value})
|
|
rejected, issue_codes = False, []
|
|
except TemplateRenderError as error:
|
|
issue_codes = [issue.code for issue in error.issues if name in issue.fields]
|
|
rejected = expected_code in issue_codes
|
|
cases.append({"field": name, "value": value, "rejected_for_expected_reason": rejected, "issue_codes": issue_codes})
|
|
check("template_semantic_rules_not_executed", "有效基准可生成;独立注入矛盾金额、无效日期和双选争议时,各自应准确拦截", baseline_validation.valid and all(case["rejected_for_expected_reason"] for case in cases),
|
|
{"valid_baseline": baseline_validation.valid, "independent_cases": cases})
|
|
purchase_output = render_contract_template(purchase.id, baseline)
|
|
residues = [marker for marker in ("不得默认生成", "不得自动生成", "必须由业务和法务确认") if marker in purchase_output]
|
|
check("template_instruction_residue", "给生成系统的操作说明不应进入合同正文", not residues, residues)
|
|
|
|
from sqlalchemy import create_engine, func, select
|
|
from sqlalchemy.orm import sessionmaker
|
|
from yuqei_ai_platform_api.models import PromptTemplateModel, PromptTemplateChangeModel
|
|
from yuqei_ai_platform_api.persistence.prompts import SqlAlchemyPromptTemplateRepository, PromptTemplateCreate, PromptTemplatePublishRequest
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
try:
|
|
PromptTemplateModel.__table__.create(engine)
|
|
PromptTemplateChangeModel.__table__.create(engine)
|
|
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
repo = SqlAlchemyPromptTemplateRepository(factory)
|
|
repo.upsert_prompt_template(PromptTemplateCreate(name="audit", version="v1", content="已发布原文"))
|
|
repo.publish_prompt_template("audit", PromptTemplatePublishRequest(version="v1"))
|
|
try:
|
|
repo.upsert_prompt_template(PromptTemplateCreate(name="audit", version="v1", content="保存草稿时的新内容"))
|
|
except PromptVersionConflict:
|
|
pass # Rejection is expected; independently verify unchanged runtime below.
|
|
current = repo.get_prompt_template("audit")
|
|
with factory() as session:
|
|
history_count = session.scalar(select(func.count()).select_from(PromptTemplateChangeModel))
|
|
check("published_prompt_mutable_via_upsert", "保存同版本草稿不能直接更改正在运行的发布版本", current.content == "已发布原文",
|
|
{"current_content": current.content, "status": current.status, "publish_history_count": history_count})
|
|
try:
|
|
repo.upsert_prompt_template(PromptTemplateCreate(name="audit", version="v1", content="模拟前端保存草稿", enabled=False))
|
|
except PromptVersionConflict:
|
|
pass
|
|
current = repo.get_prompt_template("audit")
|
|
check("frontend_save_disables_published_prompt", "前端保存草稿不能下线同版本发布资产", current is not None,
|
|
{"runtime_prompt_missing": current is None})
|
|
finally:
|
|
engine.dispose()
|
|
return results
|
|
|
|
|
|
def runtime_inventory(tenant: str, sources: dict) -> dict:
|
|
from sqlalchemy import create_engine, text
|
|
from yuqei_ai_platform_api.config import AiPlatformSettings
|
|
from yuqei_contract_api.config import ContractApiSettings
|
|
source_hashes = {p["key"]: p["content_sha256"] for p in sources["prompts"]}
|
|
result = {"tenant_scope": tenant, "database_access": "PostgreSQL READ ONLY", "business_contracts_read": False}
|
|
settings = [("ai", AiPlatformSettings(_env_file=AI / ".env")), ("contract", ContractApiSettings(_env_file=CONTRACT / ".env"))]
|
|
for name, config in settings:
|
|
engine = create_engine(config.database_url, connect_args={"connect_timeout": 5})
|
|
try:
|
|
if engine.dialect.name != "postgresql":
|
|
raise ValueError("Runtime audit requires PostgreSQL read-only transactions")
|
|
with engine.connect() as connection:
|
|
connection.execute(text("SET TRANSACTION READ ONLY"))
|
|
connection.execute(text("SET LOCAL statement_timeout = '10000'"))
|
|
if name == "ai":
|
|
result["model_runtime_settings"] = {k: getattr(config, k) for k in
|
|
("provider_call_mode", "codex_cli_model", "provider_config_source", "model_job_dispatch_mode")}
|
|
prompts = []
|
|
for row in connection.execute(text("SELECT name, version, content, status, enabled FROM prompt_templates ORDER BY name, version")).mappings():
|
|
try:
|
|
payload = json.loads(row["content"])
|
|
except ValueError:
|
|
payload = {}
|
|
asset = payload.get("asset", {}) if isinstance(payload, dict) else {}
|
|
content = asset.get("prompt_content", row["content"])
|
|
prompts.append({"name": row["name"], "version": row["version"], "status": row["status"], "enabled": row["enabled"],
|
|
"format": "v1_asset" if asset else "plain_text", "content_length": len(row["content"]),
|
|
"content_sha256": sha(content), "matches_packaged_prompt_content": source_hashes.get(row["name"]) == sha(content),
|
|
"requires_legacy_review_schema": "risk_summary" in content and "rendered_report" in content,
|
|
"mentions_party_position": "party_position" in content})
|
|
result["prompts"] = prompts
|
|
result["providers"] = [dict(r) for r in connection.execute(text("SELECT provider, model, enabled, is_default FROM ai_provider_configs WHERE enabled = true")).mappings()]
|
|
result["trace_counts"] = [dict(r) for r in connection.execute(text("SELECT operation, count(*) AS count FROM ai_run_audits WHERE tenant_id = :tenant GROUP BY operation"), {"tenant": tenant}).mappings()]
|
|
result["trace_prompt_counts"] = [dict(r) for r in connection.execute(text("SELECT p.name, count(*) AS count FROM ai_run_audits a LEFT JOIN prompt_templates p ON p.id = a.prompt_template_id WHERE a.tenant_id = :tenant GROUP BY p.name ORDER BY count(*) DESC"), {"tenant": tenant}).mappings()]
|
|
else:
|
|
result["template_database_counts"] = {}
|
|
for table in ("template_lifecycle_events", "template_edit_drafts", "template_published_snapshots"):
|
|
result["template_database_counts"][table] = connection.scalar(text(f"SELECT count(*) FROM {table} WHERE tenant_id = :tenant"), {"tenant": tenant})
|
|
connection.rollback()
|
|
finally:
|
|
engine.dispose()
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--runtime", action="store_true", help="Read aggregate runtime evidence with READ ONLY transactions")
|
|
parser.add_argument("--tenant", help="Explicit tenant scope required with --runtime")
|
|
parser.add_argument("--output", type=Path, help="Write local JSON evidence; never writes to a service")
|
|
parser.add_argument("--strict", action="store_true", help="Exit 1 when a desired behavior probe fails")
|
|
args = parser.parse_args()
|
|
if args.runtime and not args.tenant:
|
|
parser.error("--runtime requires an explicit --tenant")
|
|
evidence = {"schema_version": "template-prompt-audit.v1", "created_at": datetime.now(UTC).isoformat(),
|
|
"model_calls": 0, "live_writes": 0, "sources": source_inventory(), "probes": isolated_probes()}
|
|
if args.runtime:
|
|
evidence["runtime"] = runtime_inventory(args.tenant, evidence["sources"])
|
|
evidence["probe_summary"] = dict(Counter(p["status"] for p in evidence["probes"]))
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
runtime = evidence.get("runtime", {})
|
|
print(json.dumps({"template_count": evidence["sources"]["template_count"], "prompt_count": evidence["sources"]["prompt_count"],
|
|
"probe_summary": evidence["probe_summary"], "failed_probes": [p for p in evidence["probes"] if p["status"] == "fail"],
|
|
"runtime_prompt_count": len(runtime.get("prompts", [])),
|
|
"model_runtime_settings": runtime.get("model_runtime_settings"),
|
|
"template_database_counts": runtime.get("template_database_counts")}, ensure_ascii=False, indent=2))
|
|
return 1 if args.strict and evidence["probe_summary"].get("fail") else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|