From 7b1e2b10a82ed0c9cd486ee848fe98e40a1df5c8 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Tue, 16 Jun 2026 00:38:57 +0800 Subject: [PATCH] Initial commit: InternalAuditInterprise --- .gitignore | 33 + 0-req-AIAudit.md | 388 +++++++++ 1-prd-AIAudit.md | 242 ++++++ 2-task-AIAudit.md | 342 ++++++++ README.md | 46 ++ backend/.env.example | 23 + backend/alembic.ini | 38 + backend/app/__init__.py | 3 + backend/app/api/__init__.py | 1 + backend/app/api/datahub.py | 64 ++ backend/app/api/schemas.py | 36 + backend/app/audit/__init__.py | 1 + backend/app/audit/models.py | 50 ++ backend/app/audit/rbac.py | 78 ++ backend/app/audit/service.py | 81 ++ backend/app/clues/__init__.py | 1 + backend/app/clues/models.py | 136 +++ backend/app/clues/service.py | 195 +++++ backend/app/config.py | 70 ++ backend/app/datahub/__init__.py | 1 + backend/app/datahub/bitemporal_repo.py | 83 ++ backend/app/datahub/bootstrap.py | 58 ++ backend/app/datahub/graph_repo.py | 118 +++ backend/app/datahub/models.py | 157 ++++ backend/app/datahub/ontology.py | 86 ++ backend/app/db.py | 40 + backend/app/llm/__init__.py | 10 + backend/app/llm/base.py | 44 + backend/app/llm/factory.py | 31 + backend/app/llm/providers.py | 80 ++ backend/app/main.py | 45 + backend/migrations/README.md | 7 + backend/migrations/__init__.py | 0 backend/migrations/env.py | 59 ++ backend/migrations/script.py.mako | 24 + .../migrations/versions/0001_init_datahub.py | 140 ++++ .../migrations/versions/0002_clues_audit.py | 146 ++++ backend/pyproject.toml | 24 + backend/requirements-dev.txt | 5 + backend/requirements.txt | 11 + backend/tests/__init__.py | 0 backend/tests/integration/__init__.py | 0 backend/tests/integration/conftest.py | 41 + backend/tests/integration/test_bitemporal.py | 49 ++ .../tests/integration/test_clue_lifecycle.py | 87 ++ backend/tests/integration/test_datahub_api.py | 63 ++ backend/tests/integration/test_graph_repo.py | 76 ++ backend/tests/test_egress_policy.py | 42 + backend/tests/test_health.py | 21 + backend/tests/test_ontology.py | 42 + docs/adr/ADR-0001-tech-stack.md | 43 + docs/adr/ADR-0002-data-platform-modeling.md | 33 + docs/build_ppt2.py | 779 ++++++++++++++++++ docs/数据不出域,审计全穿透.md | 284 +++++++ docs/数据不出域,审计全穿透1.0.pptx | Bin 0 -> 120540 bytes infra/postgres/init/01-extensions.sql | 14 + infra/postgres/setup_local.sh | 51 ++ 57 files changed, 4622 insertions(+) create mode 100644 .gitignore create mode 100644 0-req-AIAudit.md create mode 100644 1-prd-AIAudit.md create mode 100644 2-task-AIAudit.md create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/alembic.ini create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/datahub.py create mode 100644 backend/app/api/schemas.py create mode 100644 backend/app/audit/__init__.py create mode 100644 backend/app/audit/models.py create mode 100644 backend/app/audit/rbac.py create mode 100644 backend/app/audit/service.py create mode 100644 backend/app/clues/__init__.py create mode 100644 backend/app/clues/models.py create mode 100644 backend/app/clues/service.py create mode 100644 backend/app/config.py create mode 100644 backend/app/datahub/__init__.py create mode 100644 backend/app/datahub/bitemporal_repo.py create mode 100644 backend/app/datahub/bootstrap.py create mode 100644 backend/app/datahub/graph_repo.py create mode 100644 backend/app/datahub/models.py create mode 100644 backend/app/datahub/ontology.py create mode 100644 backend/app/db.py create mode 100644 backend/app/llm/__init__.py create mode 100644 backend/app/llm/base.py create mode 100644 backend/app/llm/factory.py create mode 100644 backend/app/llm/providers.py create mode 100644 backend/app/main.py create mode 100644 backend/migrations/README.md create mode 100644 backend/migrations/__init__.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/0001_init_datahub.py create mode 100644 backend/migrations/versions/0002_clues_audit.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/conftest.py create mode 100644 backend/tests/integration/test_bitemporal.py create mode 100644 backend/tests/integration/test_clue_lifecycle.py create mode 100644 backend/tests/integration/test_datahub_api.py create mode 100644 backend/tests/integration/test_graph_repo.py create mode 100644 backend/tests/test_egress_policy.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_ontology.py create mode 100644 docs/adr/ADR-0001-tech-stack.md create mode 100644 docs/adr/ADR-0002-data-platform-modeling.md create mode 100644 docs/build_ppt2.py create mode 100644 docs/数据不出域,审计全穿透.md create mode 100644 docs/数据不出域,审计全穿透1.0.pptx create mode 100644 infra/postgres/init/01-extensions.sql create mode 100644 infra/postgres/setup_local.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f255ee7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ +dist/ +.vite/ +*.local + +# Env / secrets +.env +.env.* +!.env.example + +# OS +.DS_Store + +# Data / models (never commit real audit data or model weights) +data/ +models/ +*.dump +*.parquet +minio-data/ +pgdata/ diff --git a/0-req-AIAudit.md b/0-req-AIAudit.md new file mode 100644 index 0000000..7f87873 --- /dev/null +++ b/0-req-AIAudit.md @@ -0,0 +1,388 @@ +# 0-req-AIAudit · 需求与目标文档 + +> 项目:基于本地私有化大模型的电信运营商 AI 全域内审平台(AIAudit) +> 版本:v0.1(待评审) +> 日期:2026-06 +> 上游来源:`docs/数据不出域,审计全穿透.md` + +--- + +## 1. 引言 + +### 1.1 背景 +电信运营商年业务规模达 150 亿级,潜在异常金额约 5000 万级,而传统审计依赖人工抽样,覆盖率仅约 5%,存在三类典型困局: + +- **拆单规避**:大额合同拆分为阈值以下小额合同,规避"三重一大"审批与按金额抽样。 +- **时序造假**:如"养卡骗补"(脉冲式新增 + 规律性退订)、物联网卡虚假激活等,造假藏在时间序列里,抽样与年度审计难以发现。 +- **工具乏力**:Excel + 人工方式面对海量单据只能抽样,查不全、查不深。 + +核心矛盾:审计数据涉及政企合同、用户隐私、财务凭证,上公有云大模型存在合规风险;不引入 AI 又难以应对全量数据。 + +### 1.2 目标 +建设一套**部署在本地机房、数据零出域、覆盖全业务域、可持续进化**的 AI 内审能力体系,实现: + +- **全量穿透**:从抽样审计升级为全量扫描(覆盖率 5% → 100%)。 +- **数据不出域**:模型—数据—推理—结果全链路内网闭环,数据出域风险归零。 +- **常态化监控**:从年度快照升级为 7×24 近实时常态化监控。 +- **能力沉淀**:审计经验固化为可执行规则与机构永久资产,越用越精准。 +- **独立可信**:审计系统本身独立于被审计业务方,自身全程留痕、分权制衡、可被审计。 + +### 1.3 范围说明 +本文档按**完整蓝图全量编写需求**,覆盖数据治理、四大引擎、八大审计场景、人机协同闭环、误报治理、系统自审计、安全合规与价值度量。具体开发优先级与分期(MVP / 二期 / 三期)在后续 PRD(`1-prd-AIAudit.md`)与任务文档(`2-task-AIAudit.md`)中确定。 + +--- + +## 2. 术语表 + +| 术语 | 说明 | +| --- | --- | +| 本地私有化 LLM | 部署在本地机房、不依赖外网的大语言模型(如千问 70B / DeepSeek),用于推理、规则生成、报告生成、线索解释。 | +| 全量穿透 | 不抽样,对全部业务数据(合同、回款、用户行为等)做关联扫描分析。 | +| 风险域 | 审计场景的归类维度,分为收入域、成本域、采购域、资金域、合规域五大类。 | +| 审计场景 | 具体的造假/风险模式,如政企拆单、养卡骗补、跨期错配等,本平台覆盖八大场景。 | +| 线索(Clue) | AI 扫描产出的疑似异常项,附带证据链与判定理由,是审计员处置的起点。 | +| 证据链 | 支撑某条线索成立的关联数据与推理路径(如工商关联、时序聚类、金额分布等)。 | +| 审计底稿 | 由系统自动生成、可追溯的审计工作记录文档。 | +| 规则进化引擎 | 将审计员用自然语言描述的造假模式,自动转化为可执行规则并经沙箱验证、持续迭代的能力模块。 | +| 置信度分级 | 对线索按可信程度分为高/中/低三级,分别对应直接处置/人工复核/归档备查。 | +| 误报(假阳性) | AI 判为疑似异常但实际属正常的线索。 | +| 审计数据中台(审计数据底座) | 审计专用、与业务系统物理隔离、由审计独立掌控的统一数据底座,逻辑上具备数据中台能力(接入、本体建模、时态建模、统一穿透查询),但不与业务方共享。 | +| 数据湖 | 汇聚多源异构业务数据的本地统一存储,是审计数据中台的存储基础。 | +| 本体(Ontology) | 对审计域核心实体(客户、合同、号码、IMEI、账户、工单、供应商、结算单等)及其关系的形式化定义。 | +| 审计知识图谱 | 依据审计本体,将跨系统实体与关系落地形成的图结构,支撑关联穿透与实控人/关联方识别。 | +| 双时态建模 | 同时记录"业务发生时间"与"系统记录时间"的数据建模方式,支持按任意历史时点回放数据状态。 | +| 主数据对齐 | 客户、合同、号码、工单、供应商等实体在跨系统间的统一识别与关联,是本体层的落地手段。 | +| 数据零出域 | 所有敏感数据、模型与推理过程均不离开本地内网机房。 | +| 系统自审计 | 审计平台自身的操作、规则、模型、数据变更全程留痕且可被审计的机制。 | +| 三重一大 | 重大事项决策、重要干部任免、重大项目安排、大额资金运作的集体决策制度。 | +| BSS/OSS | 业务支撑系统 / 运营支撑系统。 | +| IMEI | 国际移动设备识别码,用于标识终端设备。 | +| 趸交 | 用户一次性预缴多期费用的缴费方式。 | + +--- + +## 3. 角色定义 + +| 角色 | 说明 | 核心诉求 | +| --- | --- | --- | +| 审计员 | 一线内审人员,复核线索、研判定性、决定整改或移交、签字。 | 看得懂线索、查得到证据、处置留得下痕。 | +| 审计主管 | 审计部门负责人,分派任务、审批处置结论、查看全局看板。 | 全局掌控、成效可量化、流程合规。 | +| 规则管理员 | 配置与维护审计规则、阈值,使用规则进化引擎。 | 自然语言配规则、沙箱验证、版本可控。 | +| 系统管理员 | 负责数据接入、模型部署、权限分配、系统运维。 | 接入稳定、权限可控、运行可观测。 | +| 系统审计员(独立监督) | 审计"审计系统本身",核查规则/阈值/线索是否被人为放水或拦截。 | 任何改动可追溯、线索不可被删除掩盖。 | +| 被审计业务方 | 各业务条线(政企、市场、财务、工程等),是审计对象。 | (非系统用户)系统须与其解耦,保证独立性。 | + +> 独立性原则:本平台是独立的内部审计系统,与被审计业务方解耦;业务方无权配置规则、修改阈值或删除线索。 + +--- + +## 4. 功能性需求(EARS 格式) + +> EARS 关键词:WHEN(事件触发)/ IF…THEN(条件)/ WHILE(状态持续)/ WHERE(特定场景或特性)/ THE…SHALL(系统须)。 + +### 需求 1:多源异构数据接入 + +**用户故事:** 作为系统管理员,我希望平台能接入各业务系统的数据,以便为全量审计提供统一数据底座。 + +#### 验收标准 +1. WHERE 存在 BSS / OSS / ERP / 财务 / 合同 / 工单 / 信令等数据源,THE 平台 SHALL 提供接口、数据库与文件三类接入适配能力,将数据汇入审计专用数据底座(审计数据中台)。 +2. WHEN 配置一个新数据源接入任务时,THE 平台 SHALL 支持配置连接方式、字段映射与同步周期,且无需修改源系统。 +3. THE 平台 SHALL 支持全量初始化导入与增量同步两种模式。 +4. IF 某数据源接入失败或中断,THEN THE 平台 SHALL 记录失败原因并向系统管理员告警,且不影响其他数据源的接入。 +5. THE 平台 SHALL 保证所有接入数据仅存储于本地内网,任何接入过程不向外网传输数据。 + +### 需求 2:审计数据中台 · 本体层与主数据对齐 + +**用户故事:** 作为系统管理员,我希望平台建设一个由审计独立掌控、按审计本体组织实体与关系的专用数据底座,以便穿透分析时主键能对得上、关系能连得通。 + +#### 验收标准 +1. THE 平台 SHALL 建设审计专用、与业务系统物理隔离的数据底座(审计数据中台),由审计独立掌控,被审计业务方无写入、配置或删除权限。 +2. THE 平台 SHALL 依据审计本体(Ontology)定义客户、合同、号码、IMEI、账户、工单、供应商、结算单等核心实体及其关系,形成审计知识图谱。 +3. THE 平台 SHALL 对上述核心实体在跨系统间进行统一识别与关联(主数据对齐),并将关系落地到知识图谱,以支撑隐性实控人、关联方网络、"马甲"供应商等穿透分析。 +4. WHEN 数据接入数据底座时,THE 平台 SHALL 自动探查缺失、重复、口径不一致问题并执行清洗,且为每个数据源/数据集建立可在管理界面查看的数据质量评分。 +5. IF 检测到关键字段缺失或实体无法对齐,THEN THE 平台 SHALL 标记该记录并提示人工干预,而非静默丢弃。 +6. THE 平台 SHALL 对外提供统一的穿透查询与图谱查询服务,作为各引擎与审计场景的共同数据入口。 +7. THE 平台 SHALL 支持接入真实业务系统数据,同时支持导入脱敏/样例数据用于盲测与演示。 + +### 需求 3:审计数据中台 · 时态层与增量同步 + +**用户故事:** 作为审计主管,我希望数据底座原生支持时间维度并近实时更新,以便既能识别时序造假,又能把结论回溯到当时的数据状态。 + +#### 验收标准 +1. THE 平台 SHALL 采用双时态建模(业务发生时间 + 系统记录时间)组织原始数据,支持按任意历史时点回放数据状态。 +2. THE 平台 SHALL 对关键审计对象(如用户生命周期、回款、话务、佣金发放、资源使用量等)保留时间序列,以支撑时序模式造假识别。 +3. THE 平台 SHALL 支持按可配置周期执行增量同步。 +4. WHILE 常态化监控处于开启状态,THE 平台 SHALL 持续接收增量数据并触发相应审计规则的重算。 +5. THE 平台 SHALL 记录每次同步的时间戳、数据量与数据版本,并保证任一结论可回溯到产生它时的数据版本。 + +### 需求 4:本地私有化 LLM 引擎 + +**用户故事:** 作为审计员,我希望用自然语言与系统交互,以便不写 SQL、不翻 Excel 就能查数和获取线索。 + +#### 验收标准 +1. THE 平台 SHALL 在本地机房部署私有化大语言模型(如千问 70B / DeepSeek),且模型推理过程不依赖外网。 +2. WHEN 审计员以自然语言提交查询时,THE LLM 引擎 SHALL 理解意图并返回结构化结果或线索。 +3. THE LLM 引擎 SHALL 支持异常模式推理、自然语言规则配置、报告自动生成与线索解释四类能力。 +4. WHERE 涉及电信审计专业领域,THE LLM 引擎 SHALL 基于审计领域语料进行微调以提升专业准确性。 +5. THE 平台 SHALL 记录模型版本,使任一结论可回溯到产生它的模型状态。 + +### 需求 5:全量穿透引擎 + +**用户故事:** 作为审计员,我希望系统对全部业务数据做关联扫描,以便不再受抽样覆盖率限制。 + +#### 验收标准 +1. THE 全量穿透引擎 SHALL 对全部合同、回款、用户行为等数据执行关联扫描,而非抽样。 +2. THE 全量穿透引擎 SHALL 直连审计数据中台(数据底座),将数据就地提供给本地 LLM 分析,数据不出域。 +3. WHEN 一个审计任务执行时,THE 引擎 SHALL 输出本次扫描的覆盖范围与数据量,以证明全量性。 +4. THE 引擎 SHALL 支持跨系统关联分析(如合同—回款—工商—账户的关联穿透)。 + +### 需求 6:规则进化引擎 + +**用户故事:** 作为规则管理员,我希望用自然语言描述新的造假模式并自动生成可执行规则,以便把审计经验沉淀为机构资产。 + +#### 验收标准 +1. WHEN 规则管理员用自然语言描述一种造假/风险模式时,THE 规则进化引擎 SHALL 自动将其转化为可执行规则。 +2. WHEN 一条新规则生成后,THE 引擎 SHALL 在沙箱环境中用历史数据验证其命中率,并在确认前不投入生产。 +3. THE 引擎 SHALL 对每条规则保存版本历史,记录创建人、修改人、时间与变更内容。 +4. WHILE 系统运行,THE 引擎 SHALL 支持基于审计员反馈对规则进行迭代优化。 +5. THE 平台 SHALL 维护一个可持续增长的本地审计规则库,作为机构永久资产。 + +### 需求 7:线索驱动引擎 + +**用户故事:** 作为审计员,我希望系统主动把高价值线索连同证据链推送给我,以便从"人找数据"转为"数据找人"。 + +#### 验收标准 +1. WHEN 全量穿透或规则命中产生异常聚类时,THE 线索引擎 SHALL 生成线索并附带证据链与"人话"判定理由。 +2. THE 线索引擎 SHALL 为每条线索标注所属风险域、审计场景与置信度等级。 +3. THE 平台 SHALL 将线索推送至对应审计员的工作台/看板。 +4. THE 线索引擎 SHALL 对线索按价值/风险排序,使审计员可优先处理高价值线索。 + +### 需求 8:场景一 · 政企收入全链路穿透 + +**用户故事:** 作为审计员,我希望识别政企收入中的拆单规避与虚假回款,以便发现规避审批和长期挂账的异常。 + +#### 验收标准 +1. THE 平台 SHALL 沿"立项→审批→报价→签约→开票→回款"链路对政企合同做全链路穿透。 +2. IF 多个合同金额集中分布在审批阈值边缘(如阈值以下),THEN THE 平台 SHALL 识别为疑似拆单并生成线索。 +3. THE 平台 SHALL 通过工商关联穿透识别隐性实控人(如注册地址、法人亲属、付款账户同源)。 +4. WHEN 出现批量回款违约或长期尾款挂账时,THE 平台 SHALL 通过回款时序聚类识别异常并生成线索。 +5. THE 平台 SHALL 支持一键生成《政企客户回款异常专项线索清单》。 + +### 需求 9:场景二 · 市场业务真实性(养卡骗补) + +**用户故事:** 作为审计员,我希望识别"骗补后弃养"的周期性造假,以便发现脉冲式新增加规律性退订的套补模式。 + +#### 验收标准 +1. THE 平台 SHALL 对用户生命周期进行时序模式识别,识别"脉冲式增长 + 规律性衰减"的周期性造假。 +2. WHEN 某渠道新增用户在固定周期后集中退订时,THE 平台 SHALL 识别为疑似养卡骗补并生成线索。 +3. THE 平台 SHALL 校验渠道佣金发放与业务质量(如在网时长、通话/流量活跃度)的匹配度。 +4. THE 平台 SHALL 对沉默/零通话/零流量用户进行批量聚类筛查(含物联网卡虚假激活)。 +5. THE 平台 SHALL 对项目交付物与收入确认进行交叉验证。 + +### 需求 10:场景三 · 收入与成本跨期匹配 + +**用户故事:** 作为审计员,我希望发现收入确认时点与成本摊销错配的异常分录,以便纠正跨期错配。 + +#### 验收标准 +1. THE 平台 SHALL 自动勾稽收入确认政策、实际账务与合同条款三者的一致性。 +2. IF 趸交/预收款被一次性确认收入而对应成本分期摊销,THEN THE 平台 SHALL 识别为确认时点错配并生成线索。 +3. THE 平台 SHALL 监控设备交付/上架与收入确认之间的时间差。 +4. WHEN 按使用量计费的合同被提前全额确认收入时,THE 平台 SHALL 识别为异常并生成线索。 + +### 需求 11:场景四 · 渠道佣金与代理商套利 + +**用户故事:** 作为审计员,我希望追踪终端流向与佣金匹配度,以便识别虚假放号、套机套卡与异地窜货套利。 + +#### 验收标准 +1. THE 平台 SHALL 校验终端 IMEI 与用户绑定的真实性。 +2. THE 平台 SHALL 校验佣金发放与用户在网时长的匹配度。 +3. WHEN 终端出现"激活即沉默/流失"或跨省流通时,THE 平台 SHALL 进行 IMEI 级终端流向追踪并生成线索。 +4. THE 平台 SHALL 对代理商进行业务质量时序衰减分析。 + +### 需求 12:场景五 · 网络建设与工程采购 + +**用户故事:** 作为审计员,我希望识别围标串标、虚增工程量与虚假巡检,以便保障采购合规。 + +#### 验收标准 +1. WHEN 同一项目多家投标报价相似度过高或技术方案文件雷同度过高时,THE 平台 SHALL 进行投标关联分析并生成线索。 +2. THE 平台 SHALL 验证工程量与资源消耗的匹配度。 +3. THE 平台 SHALL 对巡检 GPS 轨迹与工单记录进行交叉验证,识别照片复用与坐标伪造。 +4. THE 平台 SHALL 构建供应商画像并识别同一实控人的"马甲"供应商。 + +### 需求 13:场景六 · 互联互通与网间结算 + +**用户故事:** 作为审计员,我希望识别话务量操纵与短信刷量,以便保障网间结算真实性。 + +#### 验收标准 +1. WHEN 话务量出现突发峰值或通话时长集中于整数倍时,THE 平台 SHALL 识别为疑似非真人行为并生成线索。 +2. THE 平台 SHALL 将网间结算数据与网络侧原始信令进行比对。 +3. THE 平台 SHALL 对 SP/CP 业务量与收入结算进行交叉验证(如短信申报量 vs 实际到达率)。 +4. THE 平台 SHALL 对国际来话进行真实路由溯源。 + +### 需求 14:场景七 · 云业务 / IDC 与新兴业务 + +**用户故事:** 作为审计员,我希望识别云资源"空转"确认收入与 IDC 虚租,以便发现资源闲置但确认收入的异常。 + +#### 验收标准 +1. THE 平台 SHALL 比对云资源实际使用量(如 CPU 利用率、存储占用)与合同计费量的匹配度。 +2. IF 云资源利用率长期极低但已全额确认收入,THEN THE 平台 SHALL 识别为"空转"并生成线索。 +3. THE 平台 SHALL 对 IDC 出租率与电力消耗进行勾稽,识别虚租。 +4. THE 平台 SHALL 对新兴业务客户进行关联方识别与预付模式异常分析。 +5. THE 平台 SHALL 校验收入确认与交付验收的时序一致性。 + +### 需求 15:场景八 · 员工内部舞弊与资源滥用 + +**用户故事:** 作为审计员,我希望识别内部号码套利、权限滥用与积分套现,以便保障内控有效性。 + +#### 验收标准 +1. THE 平台 SHALL 对员工权限操作日志进行异常模式识别。 +2. WHEN 内部测试号产生大量对外流量收入却计入内部成本时,THE 平台 SHALL 识别为用途偏离并生成线索。 +3. THE 平台 SHALL 追踪积分/电子券流向,识别异常刷积分与套现(如单日发放量异常)。 +4. THE 平台 SHALL 分析权限与岗位的匹配度,识别越权(如客服岗拥有财务调账权限)。 + +### 需求 16:审计域全景与风险分级 + +**用户故事:** 作为审计主管,我希望按风险域和热力图查看全局,以便明确监控优先级。 + +#### 验收标准 +1. THE 平台 SHALL 将所有审计场景归入收入域、成本域、采购域、资金域、合规域五大风险域。 +2. THE 平台 SHALL 提供风险热力图,以"发生概率 × 金额影响"两维度呈现各场景优先级。 +3. WHERE 场景为高概率高金额,THE 平台 SHALL 支持将其配置为全量持续监控。 +4. THE 平台 SHALL 支持按风险域、场景、地市/单位等维度筛选与下钻查看线索。 + +### 需求 17:人机协同闭环(线索到销项) + +**用户故事:** 作为审计员,我希望从线索到整改销项全流程在线,以便每一步都接得住、留得痕。 + +#### 验收标准 +1. WHEN 一条线索被生成时,THE 平台 SHALL 支持将其分派给指定审计员。 +2. THE 平台 SHALL 支持审计员对线索进行复核研判、定性分类,并决定整改或移交。 +3. WHEN 审计员完成研判时,THE 平台 SHALL 自动生成可追溯的审计底稿。 +4. THE 平台 SHALL 对取证、整改跟踪、销项复核全过程留痕,形成处置闭环。 +5. THE 平台 SHALL 跟踪每条线索的状态(待研判/研判中/整改中/已销项/已移交等)。 + +### 需求 18:误报治理与置信度分级 + +**用户故事:** 作为审计员,我希望线索按置信度分流并可解释,以便不被海量假阳性淹没。 + +#### 验收标准 +1. THE 平台 SHALL 将线索按高/中/低三级置信度分流:高置信直接推送处置、中置信人工复核、低置信归档备查。 +2. THE 平台 SHALL 为每条线索提供证据链与判定理由,不得仅给出无法解释的"黑盒打分"。 +3. WHEN 审计员标注线索为"误报"或"属实"时,THE 平台 SHALL 记录反馈并据此持续校准阈值与模型。 +4. THE 平台 SHALL 在运营看板上公开命中率、准确率、线索转化率等指标。 + +### 需求 19:独立性与系统自审计 + +**用户故事:** 作为系统审计员,我希望审计系统本身经得起审计,以便防止放水与拦截。 + +#### 验收标准 +1. THE 平台 SHALL 对规则配置、阈值调整全程留痕,任何改动可追溯到操作人、时间与变更内容。 +2. IF 任何用户尝试删除已生成的线索,THEN THE 平台 SHALL 拒绝删除,并将线索及其处置过程完整保留。 +3. THE 平台 SHALL 对"配规则、看线索、改阈值、出报告"等关键操作进行分权管理,使其相互制衡。 +4. THE 平台 SHALL 对模型版本、规则版本、数据版本进行三重留痕,使任一结论可回溯到当时的模型与数据状态。 +5. THE 平台 SHALL 保证被审计业务方无权配置规则、修改阈值或删除/拦截线索。 + +### 需求 20:应用层与自然语言交互 + +**用户故事:** 作为审计员,我希望零门槛使用平台,以便专注研判而非操作工具。 + +#### 验收标准 +1. THE 平台 SHALL 提供自然语言查询、线索看板、智能报告、预警推送四类应用入口。 +2. WHEN 审计员发起自然语言查询时,THE 平台 SHALL 返回结果且无需用户编写 SQL。 +3. THE 平台 SHALL 支持一键生成结构化审计报告与专项线索清单。 +4. WHEN 触发高置信预警时,THE 平台 SHALL 主动向相关审计员推送通知。 + +### 需求 21:成效度量与价值测算 + +**用户故事:** 作为审计主管,我希望量化平台成效,以便向上汇报价值并指导优化。 + +#### 验收标准 +1. THE 平台 SHALL 统计可挽回收入/止损金额、线索数量、线索转化率等成效指标。 +2. THE 平台 SHALL 支持历史数据全量重跑,并与既有审计结论进行同台盲测对比。 +3. WHEN 盲测完成时,THE 平台 SHALL 输出成效报告,呈现此前抽样漏掉而本平台发现的线索。 + +--- + +## 5. 非功能性需求 + +### 5.1 安全与合规(最高优先级) +1. THE 平台 SHALL 保证模型、数据、推理、结果全链路在本地内网闭环,敏感数据一比特不出机房。 +2. THE 平台 SHALL 满足国资/运营商/等保的合规要求,提供权限分级与不可篡改的操作日志。 +3. THE 平台 SHALL 对所有用户操作生成不可篡改的审计轨迹(含操作人、时间、对象、动作)。 +4. THE 平台 SHALL 对敏感数据(政企合同、用户隐私、财务凭证)进行访问控制与脱敏管理。 + +### 5.2 性能与时效 +1. THE 平台 SHALL 支撑 70B 级大模型在本地 GPU 集群(A100 / H100 / 国产 GPU)上的推理。 +2. WHEN 审计员发起常规自然语言查询时,THE 平台 SHALL 在可接受响应时延内返回结果(目标:秒级,复杂全量任务可异步)。 +3. THE 平台 SHALL 支持 150 亿级业务规模的全量数据扫描,长耗时任务以异步任务方式执行并反馈进度。 + +### 5.3 可用性与易用性 +1. THE 平台 SHALL 面向审计人员提供零门槛(无需写 SQL/不翻 Excel)的交互方式。 +2. THE 平台 SHALL 支持 7×24 常态化运行与监控。 + +### 5.4 可扩展性 +1. THE 平台 SHALL 支持新增数据源、新增审计场景与新增规则而无需重构核心架构。 +2. THE 平台 SHALL 支持模型替换/升级(如更换或升级本地 LLM)。 + +### 5.5 可追溯性与可解释性 +1. THE 平台 SHALL 保证任一线索/结论可回溯到产生它的模型版本、规则版本与数据版本。 +2. THE 平台 SHALL 保证每条线索均附带可读的证据链与判定理由。 + +### 5.6 信创适配 +1. THE 平台 SHALL 可适配国产 GPU 与信创软硬件环境。 + +--- + +## 6. 关键约束与假设 + +### 6.1 约束 +- **数据零出域**:所有数据处理、模型推理必须在本地内网完成,禁止任何敏感数据外传(红线约束)。 +- **独立性**:本平台为独立内部审计系统,与被审计业务方解耦,业务方不得配置规则、改阈值或删除线索。 +- **数据底座物理隔离**:审计数据中台为审计专用、与业务系统物理隔离,由审计独立掌控;不做成与业务方共享的全行级中台,以规避独立性风险。 +- **本地算力依赖**:依赖本地 GPU 集群承载 70B 级模型推理。 +- **数据接入依赖**:全量审计能力依赖 BSS/OSS/ERP/财务/合同/工单/信令等系统的数据可接入与数据质量。 +- **数据治理前置**:数据接入与治理是工作量最大、需提前立项的一环,是全量穿透的前提。 + +### 6.2 假设 +- 本地机房具备部署 70B 级模型所需的 GPU 算力与存储资源(或在项目内规划到位)。 +- 各业务系统可提供数据接口/数据库访问/文件导出之一作为接入方式。 +- 存在过去 2–3 年的历史审计数据与结论,可用于场景微调与同台盲测。 +- 初期可使用脱敏/样例数据进行开发、演示与盲测验证,再逐步对接真实生产数据。 + +### 6.3 范围边界(本期需求暂不展开,留待 PRD/分期确定) +- 具体的开发优先级与分期(MVP / 二期 / 三期)。 +- 具体技术栈选型(后端框架、前端框架、数据库、向量库、推理框架等)。 +- 具体的模型选型与微调方案细节。 +- 与各业务系统对接的具体接口规格。 + +--- + +## 7. 需求清单索引 + +| 编号 | 需求名称 | 风险域/分类 | +| --- | --- | --- | +| R1 | 多源异构数据接入 | 数据中台 | +| R2 | 审计数据中台·本体层与主数据对齐 | 数据中台 | +| R3 | 审计数据中台·时态层与增量同步 | 数据中台 | +| R4 | 本地私有化 LLM 引擎 | 核心引擎 | +| R5 | 全量穿透引擎 | 核心引擎 | +| R6 | 规则进化引擎 | 核心引擎 | +| R7 | 线索驱动引擎 | 核心引擎 | +| R8 | 场景一·政企收入全链路穿透 | 收入域 | +| R9 | 场景二·市场业务真实性(养卡骗补) | 成本域 | +| R10 | 场景三·收入与成本跨期匹配 | 收入域 | +| R11 | 场景四·渠道佣金与代理商套利 | 成本域 | +| R12 | 场景五·网络建设与工程采购 | 采购域 | +| R13 | 场景六·互联互通与网间结算 | 资金域 | +| R14 | 场景七·云业务/IDC与新兴业务 | 收入域 | +| R15 | 场景八·员工内部舞弊与资源滥用 | 合规域 | +| R16 | 审计域全景与风险分级 | 全局 | +| R17 | 人机协同闭环(线索到销项) | 闭环 | +| R18 | 误报治理与置信度分级 | 闭环 | +| R19 | 独立性与系统自审计 | 合规/制度 | +| R20 | 应用层与自然语言交互 | 应用层 | +| R21 | 成效度量与价值测算 | 价值 | + +--- + +> **请检查确认本需求文档。** 确认通过后,我将进入下一阶段,基于本文档生成产品需求文档 `1-prd-AIAudit.md`(含产品定位、成功指标、用户画像与场景、MoSCoW 功能优先级、关键流程、权限矩阵、版本规划、风险等)。如需修改,请直接告诉我要调整的部分。 diff --git a/1-prd-AIAudit.md b/1-prd-AIAudit.md new file mode 100644 index 0000000..87b0d6e --- /dev/null +++ b/1-prd-AIAudit.md @@ -0,0 +1,242 @@ +# 1-prd-AIAudit · 产品需求文档(PRD) + +> 项目:基于本地私有化大模型的电信运营商 AI 全域内审平台(AIAudit) +> 版本:v0.1(待评审) +> 日期:2026-06 +> 上游来源:`0-req-AIAudit.md` + +--- + +## 1. 产品概述与定位 + +### 1.1 一句话定位 +**AIAudit 是一套建在运营商自己机房、数据零出域、覆盖全业务域、越用越聪明的本地 AI 内审能力体系**——不是一套工具,而是一套可持续进化、归审计独立掌控的审计大脑。 + +### 1.2 产品形态 +- 部署形态:本地私有化部署(内网闭环,数据一比特不出机房)。 +- 能力构成:审计专用数据中台(底座)+ 四大引擎(LLM / 全量穿透 / 规则进化 / 线索驱动)+ 人机协同闭环(线索到销项)+ 系统自审计(独立可信)。 +- 交付物:本地 AI 审计平台 + 可进化规则库 + 已验证高价值线索 + 同台盲测成效报告。 + +### 1.3 与现有方式的差异化 +| 维度 | 传统抽样审计 | 公有云 AI 审计 | AIAudit(本地) | +| --- | --- | --- | --- | +| 数据范围 | 按金额抽样,查不全 | 全量但数据出域 | 全量且数据不出机房 | +| 合规风险 | 低但能力弱 | 能力强但合规风险高 | 私有化、合规可控 | +| 响应效率 | Excel 翻表 | 实时但依赖外网 | 内网闭环秒级响应 | +| 能力归属 | 经验在人脑 | 能力外部租用 | 本地永久沉淀,越用越聪明 | +| 独立性 | 依赖人工 | 数据送人 | 审计独立掌控、自身可审计 | + +--- + +## 2. 目标与成功指标 + +### 2.1 业务目标 +- 把审计覆盖率从约 5% 提升到接近 100%(全量穿透)。 +- 把审计节奏从年度快照升级为 7×24 常态化监控。 +- 把审计经验固化为机构永久资产(可进化规则库)。 +- 数据出域风险归零,满足国资/运营商/等保最严要求。 + +### 2.2 成功指标(KPI) +| 指标 | 目标(首期/稳态) | 对应需求 | +| --- | --- | --- | +| 审计覆盖率 | ≥ 95%(全量扫描) | R5 | +| 数据出域事件 | 0 起 | 非功能 5.1、R1 | +| 同台盲测命中率 | 复现既有审计结论 + 发现新增真实线索 | R21 | +| 线索准确率(高置信) | 随反馈学习持续提升 | R18 | +| 线索转化率(线索→属实立案) | 可量化、上看板 | R18、R21 | +| 首批线索产出 | 投产首月 200–500 条 | R7 | +| 可挽回收入/止损 | 年化数千万级(保守) | R21 | +| 查询响应 | 常规查询秒级,全量任务异步反馈进度 | 非功能 5.2 | + +### 2.3 非目标(本产品不做) +- 不替代业务系统本身的生产功能。 +- 不做面向业务方共享的全行级数据中台。 +- 不做需要数据出域的任何云端推理。 + +--- + +## 3. 用户画像与核心场景(痛点解法) + +### 3.1 用户画像 +| 角色 | 画像 | 关键诉求 | +| --- | --- | --- | +| 审计员 | 一线内审,业务熟但不写代码 | 看懂线索、查到证据、处置留痕 | +| 审计主管 | 部门负责人 | 全局掌控、成效可量化、流程合规 | +| 规则管理员 | 资深审计/规则专家 | 自然语言配规则、沙箱验证、版本可控 | +| 系统管理员 | IT 运维 | 接入稳定、权限可控、运行可观测 | +| 系统审计员 | 独立监督岗 | 改动可追溯、线索不可被删被拦 | + +### 3.2 核心场景与痛点解法 +| 场景 | 痛点 | AIAudit 解法 | 对应需求 | +| --- | --- | --- | --- | +| 政企拆单规避 | 大额拆小额规避三重一大,抽样避开 | 金额阈值边缘分布识别 + 工商关联穿透 + 回款时序聚类 | R8 | +| 养卡骗补 | 脉冲新增+规律退订,藏在时序里 | 用户生命周期时序模式识别 + 佣金质量匹配 | R9 | +| 收入成本跨期错配 | 趸交一次性确认、成本分摊错配 | 政策/账务/合同三方勾稽 + 时点错配识别 | R10 | +| 渠道套利套机 | 虚假放号、套卡、异地窜货 | IMEI 级流向追踪 + 佣金在网时长匹配 | R11 | +| 围标串标 | 报价雷同、马甲供应商、虚增工程量 | 投标关联分析 + 巡检轨迹交叉验证 + 供应商画像 | R12 | +| 网间结算刷量 | 话务/短信刷量套结算 | 整数时长识别 + 信令比对 + 到达率交叉验证 | R13 | +| 云空转/IDC虚租 | 资源闲置却全额确认收入 | 利用率vs计费量比对 + 电力勾稽 + 关联方识别 | R14 | +| 内部舞弊 | 内部号套利、越权、积分套现 | 操作日志异常 + 权限岗位匹配 + 积分流向追踪 | R15 | + +> 共性痛点:"数据涉密不能出域 + 海量单据查不过来 + 时序造假抽样抓不到"。共性解法:"本地 LLM + 审计数据中台全量穿透 + 规则进化 + 人机闭环"。 + +--- + +## 4. 功能清单与优先级(MoSCoW) + +> 优先级:Must(一期 MVP 必须)/ Should(二期)/ Could(三期)/ Won't(暂不做)。映射回 `0-req-AIAudit.md` 需求编号。 + +### 4.1 数据中台与底座 +| 功能 | 优先级 | 需求映射 | +| --- | --- | --- | +| 多源异构数据接入(接口/库/文件) | Must | R1 | +| 审计专用数据底座(物理隔离、独立掌控) | Must | R2 | +| 本体建模与审计知识图谱 | Must | R2 | +| 主数据对齐与数据清洗/质量评分 | Must | R2 | +| 双时态/时序建模与版本回溯 | Must | R3 | +| 增量同步与常态化重算 | Should | R3 | +| 统一穿透/图谱查询服务 | Must | R2 | + +### 4.2 核心引擎 +| 功能 | 优先级 | 需求映射 | +| --- | --- | --- | +| 本地私有化 LLM 部署与推理 | Must | R4 | +| 自然语言查数(不写 SQL) | Must | R4、R20 | +| 全量穿透扫描引擎 | Must | R5 | +| 跨系统关联穿透 | Must | R5、R2 | +| 规则进化引擎(NL→规则) | Should | R6 | +| 规则沙箱验证与版本管理 | Should | R6 | +| 线索生成 + 证据链 + 人话解释 | Must | R7 | +| 线索价值排序与推送 | Must | R7、R20 | + +### 4.3 审计场景 +| 功能 | 优先级 | 需求映射 | +| --- | --- | --- | +| 场景一 政企收入全链路穿透 | Must | R8 | +| 场景二 养卡骗补识别 | Must | R9 | +| 场景三 收入成本跨期匹配 | Should | R10 | +| 场景四 渠道佣金与套利 | Should | R11 | +| 场景五 网络建设与工程采购 | Could | R12 | +| 场景六 互联互通与网间结算 | Could | R13 | +| 场景七 云业务/IDC | Could | R14 | +| 场景八 员工内部舞弊 | Should | R15 | + +> 一期场景优先级建议:选取"高概率×高金额"的政企拆单(R8)与养卡骗补(R9)作为 MVP 跑通,其余按风险热力图分期接入。最终优先级以评审为准。 + +### 4.4 闭环、治理与应用 +| 功能 | 优先级 | 需求映射 | +| --- | --- | --- | +| 风险域全景与热力图 | Should | R16 | +| 线索分派→研判→定性→整改→销项闭环 | Must | R17 | +| 审计底稿自动生成 | Should | R17 | +| 置信度三级分流 | Must | R18 | +| 误报反馈学习闭环 | Should | R18 | +| 运营指标看板(命中率/准确率/转化率) | Should | R18、R21 | +| 系统自审计(留痕/分权/三重版本/线索不可删) | Must | R19 | +| 线索看板/智能报告/预警推送 | Must | R20 | +| 成效度量与同台盲测报告 | Should | R21 | + +--- + +## 5. 关键流程 + +### 5.1 数据流(从接入到可分析) +``` +源系统(BSS/OSS/ERP/财务/合同/工单/信令) + → 接入适配(接口/库/文件) + → 审计数据中台:清洗/质量评分 → 本体建模/主数据对齐(知识图谱) → 双时态/时序建模 + → 统一穿透查询服务 + → 全量穿透引擎 + 本地 LLM +``` + +### 5.2 审计闭环(从线索到销项) +``` +全量扫描/规则命中 → 生成线索+证据链+判定理由(置信度分级) + → 推送/分派审计员 → 复核研判 → 定性分类 + → 自动生成审计底稿 → 整改 或 移交 + → 销项复核闭环(全程留痕) + → 审计员反馈(误报/属实) → 阈值与模型校准 +``` + +### 5.3 规则进化流 +``` +审计员用自然语言描述新造假模式 + → LLM 转化为可执行规则 + → 沙箱用历史数据验证命中率 + → 评审通过 → 投入生产(版本留痕) + → 反馈迭代优化 → 沉淀进规则库 +``` + +--- + +## 6. 角色权限矩阵 + +> ✅ 允许 🔶 限本人/受限 ❌ 禁止。体现独立性与分权制衡(R19)。 + +| 功能 / 角色 | 审计员 | 审计主管 | 规则管理员 | 系统管理员 | 系统审计员 | 业务方 | +| --- | --- | --- | --- | --- | --- | --- | +| 自然语言查询 | ✅ | ✅ | ✅ | 🔶 | ✅ | ❌ | +| 查看线索 | 🔶本人 | ✅全部 | ✅ | ❌ | ✅全部 | ❌ | +| 研判/定性线索 | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| 分派线索 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| 删除线索 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| 配置/修改规则 | ❌ | 🔶审批 | ✅ | ❌ | ❌ | ❌ | +| 调整阈值 | ❌ | 🔶审批 | ✅ | ❌ | ❌ | ❌ | +| 出具报告 | ✅ | ✅ | ❌ | ❌ | 🔶审计报告 | ❌ | +| 数据接入配置 | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| 权限分配 | ❌ | 🔶 | ❌ | ✅ | ❌ | ❌ | +| 查看自审计轨迹 | ❌ | 🔶 | ❌ | 🔶 | ✅ | ❌ | +| 模型部署/升级 | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | + +> 关键约束:任何角色均不能删除已生成线索;规则/阈值变动需审批且全程留痕;业务方对系统无任何写权限。 + +--- + +## 7. 版本规划 + +### 一期 · MVP(约 3 个月,对标蓝图部署节奏) +- 第 1 月:本地算力+模型部署;数据接入;搭建审计数据中台(接入+本体+时态最小集)。 +- 第 2 月:场景微调;政企拆单(R8)+ 养卡骗补(R9)跑通;历史数据全量重跑同台盲测。 +- 第 3 月:投产;产出首批 200–500 条线索;人机闭环 + 系统自审计上线;规则库首轮进化。 +- 范围:R1-R5、R7、R8、R9、R17、R18(基础)、R19、R20。 + +### 二期 · 能力扩展 +- 规则进化引擎完整化(R6);新增场景 R10/R11/R15;风险热力图(R16);误报反馈学习(R18);运营看板与盲测报告(R21)。 + +### 三期 · 全域覆盖 +- 接入场景 R12/R13/R14;增量近实时常态化(R3 完整);信创适配深化;规则库规模化沉淀。 + +--- + +## 8. 非功能性要求(摘自需求并细化) +- **安全合规(红线)**:全链路内网闭环,数据零出域;不可篡改操作日志;敏感数据访问控制与脱敏。(R5.1) +- **性能时效**:支撑 70B 级本地推理;常规查询秒级;150 亿级全量扫描异步执行并反馈进度。(R5.2) +- **易用性**:审计员零门槛,自然语言交互,无需写 SQL。(R5.3) +- **可扩展**:新增数据源/场景/规则不重构核心;模型可替换升级。(R5.4) +- **可追溯可解释**:结论可回溯到模型/规则/数据三重版本;线索均附证据链与理由。(R5.5) +- **信创适配**:可适配国产 GPU 与信创软硬件。(R5.6) + +--- + +## 9. 依赖与风险 + +### 9.1 依赖 +- 本地 GPU 算力(A100/H100/国产 GPU)到位。 +- 各业务系统可提供接口/库访问/文件导出之一。 +- 过去 2–3 年历史审计数据与结论可用于微调与盲测。 +- 初期可用脱敏/样例数据开发与演示。 + +### 9.2 风险与应对 +| 风险 | 影响 | 应对 | +| --- | --- | --- | +| 数据治理工作量被低估 | 拖累全量穿透落地 | 数据中台提前立项、独立排期、最小集先行 | +| 主数据对不齐 | 关联穿透失效 | 本体先行、对齐失败显式标记人工干预 | +| 误报过多 | 审计员被淹没 | 置信度三级分流 + 反馈学习 + 可解释证据链 | +| 模型幻觉/误判 | 线索不可信 | 证据链强制、沙箱验证、人工复核闭环 | +| 算力不足 | 推理性能不达标 | 异步任务、分级调度、信创适配评估 | +| 独立性被破坏 | 放水/拦截 | 分权制衡、线索不可删、三重版本留痕、系统自审计 | +| 数据出域 | 合规红线事故 | 内网闭环架构、出域阻断、全链路留痕 | + +--- + +> **请检查确认本 PRD。** 确认通过后,我将进入下一阶段,基于本文档生成开发任务文档 `2-task-AIAudit.md`(可勾选任务清单,标注目标、对应需求/PRD、验收标准、依赖与优先级/阶段)。如需修改,请直接告诉我要调整的部分。 diff --git a/2-task-AIAudit.md b/2-task-AIAudit.md new file mode 100644 index 0000000..3e3f20e --- /dev/null +++ b/2-task-AIAudit.md @@ -0,0 +1,342 @@ +# 2-task-AIAudit · 开发任务文档 + +> 项目:基于本地私有化大模型的电信运营商 AI 全域内审平台(AIAudit) +> 版本:v0.1(待评审) +> 日期:2026-06 +> 上游来源:`0-req-AIAudit.md`、`1-prd-AIAudit.md` + +--- + +## 使用说明 + +- 任务以可勾选清单组织:`- [ ]` 未开始 / `- [x]` 已完成 / `- [~]` 进行中。 +- 编号规则:`P{阶段}.{模块}.{任务}`,子任务再加一级。 +- 每个任务标注:**目标**、**映射**(需求 R / PRD 功能)、**验收标准(DoD)**、**依赖**。 +- 阶段:**MVP**(一期)/ **二期** / **三期**。优先级沿用 PRD 的 MoSCoW。 +- 开发过程中本文档持续更新(勾选、记录变更、补充新任务)。 + +### 进度总览 +| 阶段 | 模块数 | 状态 | +| --- | --- | --- | +| P0 项目基建与选型 | 4 | 进行中(P0.1 完成,P0.2/P0.3 部分) | +| P1 MVP(数据中台+引擎+R8/R9+闭环+自审计+看板+盲测) | 11 | 进行中(P1.2 大部完成,P1.3 部分) | +| P2 二期(规则进化+R10/R11/R15+热力图+反馈学习+运营看板) | 6 | 未开始 | +| P3 三期(R12/R13/R14+近实时+信创+规则库规模化) | 5 | 未开始 | + +--- + +# P0 · 项目基建与技术选型(MVP 前置) + +## P0.1 技术选型与架构基线 +> 目标:确定技术栈与总体架构,形成可执行的工程基线。映射:PRD §1.2、非功能全量。依赖:无。 + +- [x] P0.1.1 召开技术选型评审,确定后端框架、前端框架、关系库、图数据库、时序/双时态存储、向量库、LLM 推理框架、任务调度组件 + - 验收:产出《技术选型决策记录(ADR)》,每项含选型理由与备选;信创适配可行性结论 + - 完成:见 `docs/adr/ADR-0001-tech-stack.md` +- [ ] P0.1.2 绘制总体架构图(应用层/引擎层/数据中台层/模型层/算力层/安全自审计层) + - 验收:架构图 + 组件职责说明评审通过,明确内网闭环边界与数据零出域边界 +- [x] P0.1.3 定义模块划分与代码仓库结构(monorepo 或多仓)、分支与发布策略 + - 验收:仓库初始化完成,README 含目录约定与协作规范 + - 完成:monorepo 结构(backend/frontend/infra/docs),`README.md`、`.gitignore` 已建 +- [x] P0.1.4 定义数据零出域的网络与部署约束基线(内网隔离、出域阻断、无外网依赖清单) + - 验收:约束清单评审通过,CI 中加入"禁止外网依赖"检查项 + - 完成:ADR 定义红线;代码层 prod 禁用公网 Provider(`app/config.py`、`app/llm/factory.py`),已有测试覆盖;CI 校验待 P0.3.2 + +## P0.2 开发与运行环境 +> 目标:搭建可复现的开发/测试环境。映射:非功能 5.1/5.2。依赖:P0.1。 + +- [ ] P0.2.1 本地化依赖与离线包源(内网制品库/镜像),避免外网拉取 + - 验收:在隔离网络中可完成依赖安装与构建 + - 进展:已配置清华镜像源加速开发期安装(`~/.config/pip/pip.conf`);内网制品库待部署 +- [x] P0.2.2 容器化与编排(开发/测试环境一键拉起)→ 调整为本地安装 + - 验收:本地 PostgreSQL 16 可用,初始化脚本可建库建扩展 + - 完成:**弃用 Docker**(已删除 docker-compose/Dockerfile)。本机 Homebrew 安装 PostgreSQL 16.14 + pgvector 0.8 + btree_gist;`infra/postgres/setup_local.sh` 建库建扩展通过。TimescaleDB 因 macOS 编译问题本地跳过(迁移条件执行,生产 Linux 启用),见 ADR-0002 +- [ ] P0.2.3 GPU 推理环境准备(驱动、推理框架、显存配置) + - 验收:可在本地 GPU 上加载一个基线模型并完成一次推理 + +## P0.3 工程质量基线 +> 目标:建立测试、CI、代码规范。映射:阶段 5 通用约束。依赖:P0.1。 + +- [x] P0.3.1 单元/集成测试框架与覆盖率门槛 + - 验收:示例测试可运行,CI 跑测试并产出覆盖率报告 + - 完成:pytest 接入,7 个测试通过(含数据零出域红线测试);覆盖率门槛与 CI 集成待 P0.3.2 +- [ ] P0.3.2 CI 流水线(构建+测试+静态检查+无外网依赖校验) + - 验收:提交触发流水线,失败可阻断合并 + - 进展:ruff 已接入并通过(`pyproject.toml`),CI 流水线脚本待编写 +- [ ] P0.3.3 代码规范、提交规范与 Lint + - 验收:Lint 接入 CI,违规阻断 + +## P0.4 安全与权限基线(贯穿) +> 目标:先立"独立性"地基。映射:R19、非功能 5.1。依赖:P0.1。 + +- [ ] P0.4.1 统一鉴权与 RBAC 模型设计(角色:审计员/主管/规则管理员/系统管理员/系统审计员/业务方) + - 验收:RBAC 模型评审通过,覆盖 PRD §6 权限矩阵 +- [ ] P0.4.2 不可篡改操作日志(审计轨迹)基础设施 + - 验收:任意关键操作落不可篡改日志,含操作人/时间/对象/动作,可查询 +- [ ] P0.4.3 敏感数据访问控制与脱敏策略 + - 验收:敏感字段访问受控并可脱敏展示,越权访问被拒并留痕 + +--- + +# P1 · MVP(一期) + +## P1.1 审计数据中台 · 接入层 +> 目标:把多源异构数据汇入审计专用底座。映射:R1 / PRD §4.1。依赖:P0。 + +- [ ] P1.1.1 接入适配框架(接口/数据库/文件三类),插件式数据源注册 + - 验收:可注册一个新数据源并完成一次导入,不改源系统 +- [ ] P1.1.2 数据源接入配置(连接、字段映射、同步周期)管理界面/配置 + - 验收:通过配置即可接入,无需改代码;配置项可校验 +- [ ] P1.1.3 全量初始化导入 + 失败告警与隔离 + - 验收:单源失败记录原因并告警,不影响其他源;可重试 +- [ ] P1.1.4 接入侧数据零出域校验 + - 验收:接入过程无任何外网传输,校验项纳入测试 +- [ ] P1.1.5 样例/脱敏数据集导入(用于开发与盲测) + - 验收:可一键导入样例数据,覆盖 R8/R9 所需字段 + +## P1.2 审计数据中台 · 本体层与知识图谱 +> 目标:按审计本体组织实体与关系。映射:R2 / PRD §4.1。依赖:P1.1。 + +- [x] P1.2.1 审计本体(Ontology)定义(客户/合同/号码/IMEI/账户/工单/供应商/结算单及关系) + - 验收:本体 schema 评审通过,含实体属性与关系定义 + - 完成:`app/datahub/ontology.py`(12 实体类型 + 12 关系类型 + 本体域约束),见 ADR-0002;单元测试覆盖 +- [x] P1.2.2 知识图谱存储与建模落地 + - 验收:实体与关系写入图存储,可做多跳关联查询 + - 完成:关系表 `entity`/`entity_relationship` + 递归 CTE 多跳穿透(`app/datahub/graph_repo.py`),集成测试验证"实控人识别" +- [x] P1.2.3 主数据对齐(跨系统实体统一识别与关联) + - 验收:对齐结果可验证(如同一实控人聚合),对齐失败显式标记 + - 完成:`entity` 以 (类型,业务主键) 幂等归一 + `canonical_id` 归并锚点;穿透识别同一实控人已测 +- [ ] P1.2.4 数据清洗与质量评分(缺失/重复/口径不一致探查) + - 验收:质量评分可在界面查看;关键字段缺失/无法对齐标记人工干预而非丢弃 +- [x] P1.2.5 统一穿透/图谱查询服务(对引擎与场景提供共同入口) + - 验收:提供统一查询 API,支持关联穿透;返回结果可解释来源 + - 完成:`app/api/datahub.py`(`POST /datahub/penetrate` 多跳穿透 + `GET /datahub/entities/{id}`),返回带最短跳数的关联实体;API 集成测试通过 + +## P1.3 审计数据中台 · 时态层 +> 目标:原生支持时间维度与版本回溯。映射:R3 / PRD §4.1。依赖:P1.1。 + +- [x] P1.3.1 双时态建模(业务发生时间 + 系统记录时间) + - 验收:可按任意历史时点回放数据状态,回放结果正确 + - 完成:`BitemporalFact` + `bitemporal_repo.as_of()` 回放;`btree_gist` 排他约束防有效期重叠;集成测试通过 +- [ ] P1.3.2 关键对象时间序列建模(用户生命周期/回款/话务/佣金/资源使用) + - 验收:可按对象取出有序时间序列,供时序分析 + - 进展:`metric_event` 表已建(生产转 TimescaleDB 超表),时序查询封装待补 +- [ ] P1.3.3 增量同步与数据版本记录 + - 验收:每次同步记录时间戳/数据量/数据版本;结论可回溯到数据版本 + - 进展:`data_version` 表已建并被各表外键引用,同步流程待实现 +- [ ] P1.3.4 常态化重算触发(增量到达触发相关规则重算) + - 验收:增量到达后相关场景结果自动更新 + +## P1.4 本地私有化 LLM 引擎 +> 目标:本地部署模型并支持自然语言能力。映射:R4 / PRD §4.2。依赖:P0.2.3。 + +- [ ] P1.4.1 本地模型部署(千问 70B / DeepSeek 之一)与推理服务封装 + - 验收:内网可用、推理不依赖外网;提供统一推理 API +- [ ] P1.4.2 自然语言查数(NL→查询)能力,对接统一穿透查询服务 + - 验收:审计员自然语言提问返回结构化结果,无需写 SQL +- [ ] P1.4.3 异常模式推理、报告生成、线索解释能力接入 + - 验收:能对给定异常聚类输出"人话"解释与结构化报告 +- [ ] P1.4.4 模型版本记录与结论可回溯 + - 验收:每条结论可回溯到模型版本 +- [ ] P1.4.5 LLM 输出防幻觉约束(强制附证据/可溯源,不可编造数据) + - 验收:无证据支撑的结论被拦截或标注低置信 + +## P1.5 全量穿透引擎 +> 目标:全量扫描与跨系统关联穿透。映射:R5 / PRD §4.2。依赖:P1.2、P1.3。 + +- [ ] P1.5.1 全量扫描任务框架(异步任务、进度反馈、可中断) + - 验收:长耗时全量任务异步执行并反馈进度 +- [ ] P1.5.2 跨系统关联穿透(合同—回款—工商—账户等) + - 验收:可输出关联路径与证据,覆盖 R8 所需穿透 +- [ ] P1.5.3 扫描覆盖范围与数据量输出(证明全量性) + - 验收:任务结束输出覆盖范围与数据量统计 +- [ ] P1.5.4 数据就地分析、数据不出域 + - 验收:穿透过程数据不离开内网,校验纳入测试 + +## P1.6 线索驱动引擎 +> 目标:生成线索+证据链+解释并推送。映射:R7、R18(基础) / PRD §4.2。依赖:P1.4、P1.5。 + +- [ ] P1.6.1 线索数据模型(风险域/场景/置信度/证据链/判定理由/状态) + - 验收:线索结构可承载证据链与状态流转 +- [ ] P1.6.2 线索生成(由穿透/规则命中产出异常聚类→线索) + - 验收:异常聚类自动转为线索并附证据链与理由 +- [ ] P1.6.3 置信度三级分流(高/中/低)与价值排序 + - 验收:线索分级正确;高置信优先推送 +- [ ] P1.6.4 线索推送至审计员工作台 + - 验收:对应审计员可在工作台收到线索 + +## P1.7 场景一 · 政企收入全链路穿透(R8) +> 目标:识别拆单规避与虚假回款。映射:R8 / PRD §4.3 Must。依赖:P1.5、P1.6。 + +- [ ] P1.7.1 政企合同全链路建模(立项→审批→报价→签约→开票→回款) + - 验收:链路数据可端到端串联查询 +- [ ] P1.7.2 拆单识别(金额阈值边缘分布检测) + - 验收:阈值边缘集中分布合同被识别为疑似拆单并生成线索 +- [ ] P1.7.3 工商关联穿透(隐性实控人:地址/法人亲属/付款账户同源) + - 验收:同源关联客户被聚合识别,附证据 +- [ ] P1.7.4 回款时序聚类(批量违约/长期挂账) + - 验收:批量违约模式被识别并生成线索 +- [ ] P1.7.5 一键生成《政企客户回款异常专项线索清单》 + - 验收:可导出结构化清单,含证据链 + +## P1.8 场景二 · 养卡骗补识别(R9) +> 目标:识别脉冲新增+规律退订的周期性造假。映射:R9 / PRD §4.3 Must。依赖:P1.3、P1.6。 + +- [ ] P1.8.1 用户生命周期时序模式识别(脉冲式增长+规律性衰减) + - 验收:周期性造假模式被识别并生成线索 +- [ ] P1.8.2 渠道佣金与业务质量匹配(在网时长/通话/流量活跃度) + - 验收:佣金与质量不匹配渠道被标记 +- [ ] P1.8.3 沉默/零通话/零流量用户批量聚类(含物联网卡虚假激活) + - 验收:批量沉默用户被聚类识别 +- [ ] P1.8.4 项目交付物与收入确认交叉验证 + - 验收:交付与收入不匹配项被识别 + +## P1.9 人机协同闭环(R17 基础) +> 目标:线索到销项全流程在线留痕。映射:R17 / PRD §4.4 Must。依赖:P1.6。 + +- [ ] P1.9.1 线索分派(主管→审计员) + - 验收:可分派并通知;分派留痕 +- [ ] P1.9.2 复核研判与定性分类 + - 验收:审计员可研判、定性,记录理由 +- [ ] P1.9.3 审计底稿自动生成(可追溯) + - 验收:研判完成自动生成底稿,含证据链与版本信息 +- [ ] P1.9.4 整改/移交与销项复核闭环、状态机 + - 验收:线索状态全流程可跟踪,过程留痕 + +## P1.10 系统自审计与独立性(R19) +> 目标:让审计系统自身经得起审计。映射:R19 / PRD §4.4 Must、§6。依赖:P0.4。 + +- [ ] P1.10.1 规则/阈值变更全程留痕(操作人/时间/变更内容) + - 验收:任意变更可追溯 +- [ ] P1.10.2 线索不可删除约束 + - 验收:任何角色删除线索请求被拒并留痕 +- [ ] P1.10.3 关键操作分权制衡(配规则/看线索/改阈值/出报告分离) + - 验收:越权操作被拒,符合 PRD §6 权限矩阵 +- [ ] P1.10.4 模型/规则/数据三重版本留痕与回溯 + - 验收:任一结论可回溯到当时的模型、规则、数据版本 + +## P1.11 应用层、看板与盲测验证 +> 目标:审计员零门槛使用 + 盲测证明价值。映射:R20、R21、R18 / PRD §2.2、§7。依赖:P1.6-P1.10。 + +- [ ] P1.11.1 线索看板(按风险域/场景/置信度筛选与下钻) + - 验收:看板可筛选下钻,展示证据链 +- [ ] P1.11.2 自然语言查询入口(前端) + - 验收:审计员可自然语言查询并查看结果 +- [ ] P1.11.3 智能报告与专项清单导出 + - 验收:可一键生成报告/清单 +- [ ] P1.11.4 高置信预警推送 + - 验收:高置信线索触发主动通知 +- [ ] P1.11.5 历史数据全量重跑 + 同台盲测 + - 验收:用 2-3 年历史数据重跑,与既有审计结论对比,复现已知线索并发现新增真实线索 +- [ ] P1.11.6 同台盲测成效报告 + - 验收:产出成效报告,量化命中率与新增线索价值 + +--- + +# P2 · 二期(能力扩展) + +## P2.1 规则进化引擎完整化(R6) +> 目标:NL→规则、沙箱验证、版本管理、迭代。映射:R6 / PRD §4.2 Should。依赖:P1.4、P1.5。 + +- [ ] P2.1.1 自然语言→可执行规则转化 + - 验收:规则管理员用自然语言描述模式即可生成可执行规则 +- [ ] P2.1.2 规则沙箱(历史数据验证命中率,未确认不投产) + - 验收:新规则先沙箱验证,确认前不进生产 +- [ ] P2.1.3 规则版本管理(创建人/修改人/时间/变更) + - 验收:规则版本历史可查可回溯 +- [ ] P2.1.4 基于反馈的规则迭代优化 + - 验收:依据审计员反馈可迭代规则 +- [ ] P2.1.5 规则库(机构永久资产,可持续增长) + - 验收:规则可入库、检索、复用 + +## P2.2 场景三 · 收入成本跨期匹配(R10) +> 目标:识别确认时点错配。映射:R10 / PRD §4.3 Should。依赖:P1.2、P1.3。 +- [ ] P2.2.1 收入确认政策/账务/合同三方勾稽 +- [ ] P2.2.2 趸交/预收款一次性确认异常分录识别 +- [ ] P2.2.3 设备交付与收入确认时间差监控 +- [ ] P2.2.4 按使用量计费却提前确认收入识别 + - 验收(本模块):各类时点错配生成线索并附勾稽证据 + +## P2.3 场景四 · 渠道佣金与套利(R11) +> 目标:终端流向与佣金匹配。映射:R11 / PRD §4.3 Should。依赖:P1.3。 +- [ ] P2.3.1 IMEI 与用户绑定真实性校验 +- [ ] P2.3.2 佣金与在网时长匹配度 +- [ ] P2.3.3 IMEI 级终端流向追踪(激活即沉默/跨省流通) +- [ ] P2.3.4 代理商业务质量时序衰减分析 + - 验收(本模块):套利闭环被识别并生成线索 + +## P2.4 场景八 · 员工内部舞弊(R15) +> 目标:权限滥用与积分套现识别。映射:R15 / PRD §4.3 Should。依赖:P1.2。 +- [ ] P2.4.1 员工权限操作日志异常模式识别 +- [ ] P2.4.2 内部测试号用途偏离识别 +- [ ] P2.4.3 积分/电子券流向追踪与套现识别 +- [ ] P2.4.4 权限-岗位匹配度分析(越权识别) + - 验收(本模块):内部舞弊模式生成线索并附证据 + +## P2.5 风险域全景与热力图(R16) +> 目标:全局视图与优先级。映射:R16 / PRD §4.4 Should。依赖:P1.6。 +- [ ] P2.5.1 五大风险域归类与场景挂载 +- [ ] P2.5.2 风险热力图(概率×金额) +- [ ] P2.5.3 高概率高金额场景配置为全量持续监控 +- [ ] P2.5.4 多维筛选与下钻(风险域/场景/地市/单位) + - 验收(本模块):热力图可视化 + 一眼看出优先级 + +## P2.6 误报反馈学习与运营看板(R18、R21) +> 目标:准确率随使用上升 + 成效可量化。映射:R18、R21 / PRD §4.4 Should。依赖:P1.6、P1.11。 +- [ ] P2.6.1 审计员"误报/属实"标注与反馈采集 +- [ ] P2.6.2 阈值/模型基于反馈持续校准 +- [ ] P2.6.3 运营看板(命中率/准确率/线索转化率) +- [ ] P2.6.4 成效度量(可挽回收入/止损统计) + - 验收(本模块):反馈闭环生效,运营指标上看板 + +--- + +# P3 · 三期(全域覆盖) + +## P3.1 场景五 · 网络建设与工程采购(R12) +> 映射:R12 / PRD §4.3 Could。依赖:P1.2、P2.1。 +- [ ] P3.1.1 投标关联分析(报价相似度/文件雷同度,NLP 比对) +- [ ] P3.1.2 工程量与资源消耗匹配验证 +- [ ] P3.1.3 巡检 GPS 轨迹与工单交叉验证 +- [ ] P3.1.4 供应商画像与"马甲"识别 + - 验收(本模块):围标串标/虚增工程量生成线索 + +## P3.2 场景六 · 互联互通与网间结算(R13) +> 映射:R13 / PRD §4.3 Could。依赖:P1.3。 +- [ ] P3.2.1 话务量时序异常(突发峰值/整数时长聚集) +- [ ] P3.2.2 网间结算与网络侧原始信令比对 +- [ ] P3.2.3 SP/CP 业务量与收入结算交叉验证 +- [ ] P3.2.4 国际来话真实路由溯源 + - 验收(本模块):刷量套利生成线索 + +## P3.3 场景七 · 云业务/IDC(R14) +> 映射:R14 / PRD §4.3 Could。依赖:P1.2、P1.3。 +- [ ] P3.3.1 云资源利用率 vs 计费量匹配 +- [ ] P3.3.2 IDC 出租率与电力消耗勾稽 +- [ ] P3.3.3 新兴业务客户关联方识别与预付模式异常 +- [ ] P3.3.4 收入确认与交付验收时序一致性 + - 验收(本模块):空转/虚租生成线索 + +## P3.4 增量近实时与常态化(R3 完整) +> 映射:R3 / PRD §7 三期。依赖:P1.3。 +- [ ] P3.4.1 从周期增量升级为近实时增量 +- [ ] P3.4.2 常态化 7×24 监控调度与告警 + - 验收:近实时数据驱动常态化监控稳定运行 + +## P3.5 信创适配与规则库规模化 +> 映射:非功能 5.6、R6 / PRD §7 三期。依赖:P0.1、P2.1。 +- [ ] P3.5.1 国产 GPU 与信创软硬件适配验证 +- [ ] P3.5.2 规则库规模化治理(分类/检索/质量/退役) + - 验收:信创环境跑通 + 规则库可规模化运营 + +--- + +## 变更记录 +| 日期 | 变更内容 | 责任人 | +| --- | --- | --- | +| 2026-06 | 初版创建 | — | +| 2026-06 | 弃用 Docker,改用本地 PostgreSQL 16(卸载 pg14,装 pg16+pgvector);数据中台本体/图谱/双时态落地并通过集成测试 | — | + +--- + +> **请检查确认本任务文档。** 确认通过后,将按本文档(建议从 P0 基建开始)推进开发,每完成一组任务进行测试、更新本文档状态并向你汇报。如需调整任务粒度、阶段切分或依赖关系,请直接告诉我。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7621bc9 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# AIAudit · 本地私有化大模型电信运营商 AI 全域内审平台 + +> 数据不出域,审计全穿透。一套建在本地机房、数据零出域、覆盖全业务域、越用越聪明的 AI 内审能力体系。 + +## 文档 +- 需求:[`0-req-AIAudit.md`](./0-req-AIAudit.md) +- 产品需求(PRD):[`1-prd-AIAudit.md`](./1-prd-AIAudit.md) +- 开发任务:[`2-task-AIAudit.md`](./2-task-AIAudit.md) +- 技术选型:[`docs/adr/ADR-0001-tech-stack.md`](./docs/adr/ADR-0001-tech-stack.md) +- 方案蓝图:[`docs/数据不出域,审计全穿透.md`](./docs/数据不出域,审计全穿透.md) + +## 技术栈 +后端 Python(FastAPI) · 前端 React+TS(Vite) · PostgreSQL(AGE 图谱 / TimescaleDB 时序 / pgvector 向量) · Celery+Redis · MinIO · LLM(开发期公网千问 / 生产本地 vLLM)。 + +## 目录结构 +``` +. +├── backend/ # FastAPI 后端(引擎、数据中台、API) +├── frontend/ # React 前端(线索看板、自然语言查询) +├── infra/ # Docker Compose、自定义镜像、初始化脚本 +├── docs/ # 蓝图、ADR +├── 0-req-AIAudit.md +├── 1-prd-AIAudit.md +└── 2-task-AIAudit.md +``` + +## 数据零出域红线 +- 生产环境禁用任何公网 LLM/外网依赖;公网千问仅用于开发测试且只喂脱敏/样例假数据。 +- 详见 ADR-0001。 + +## 本地开发快速开始 +```bash +# 1. 初始化本地 PostgreSQL 16(已 Homebrew 安装 postgresql@16 + timescaledb + pgvector) +bash infra/postgres/setup_local.sh + +# 2. 后端 +cd backend && python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements-dev.txt +alembic upgrade head # 建表 +uvicorn app.main:app --reload + +# 3. 前端 +cd frontend && npm install && npm run dev +``` + +> 说明:本项目不使用 Docker,开发期直接使用本机 PostgreSQL 16。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..aaf5496 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +# 运行环境:dev | prod +# prod 下禁用一切公网 LLM Provider(数据零出域红线) +AIAUDIT_ENV=dev + +# 数据库(本地 PostgreSQL 16 / Postgres.app,无密码) +DATABASE_URL=postgresql+psycopg://freedak@localhost:5432/aiaudit + +# Redis / Celery +REDIS_URL=redis://localhost:6379/0 + +# MinIO +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=aiaudit +MINIO_SECRET_KEY=aiaudit_dev + +# LLM Provider:dashscope(公网,仅 dev)| vllm(本地,prod) +LLM_PROVIDER=dashscope +# 公网千问(仅开发测试,且只允许脱敏/样例假数据) +DASHSCOPE_API_KEY= +DASHSCOPE_MODEL=qwen-plus +# 本地 vLLM(生产) +VLLM_BASE_URL=http://localhost:8001/v1 +VLLM_MODEL=qwen2.5-72b-instruct diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cdbca82 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a7f327b --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""AIAudit 后端应用包。""" + +__version__ = "0.1.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..ec0f913 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""HTTP API 层。""" diff --git a/backend/app/api/datahub.py b/backend/app/api/datahub.py new file mode 100644 index 0000000..4662b47 --- /dev/null +++ b/backend/app/api/datahub.py @@ -0,0 +1,64 @@ +"""数据中台统一穿透查询 API(P1.2.5)。 + +作为各引擎与审计场景访问知识图谱的共同入口,对上层屏蔽底层是关系表还是图库。 +对应需求 R2。 +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.schemas import ( + EntityOut, + PenetrateRequest, + PenetrateResponse, + RelatedEntityOut, +) +from app.datahub.graph_repo import find_related_entities +from app.datahub.models import Entity +from app.db import get_session + +router = APIRouter(prefix="/datahub", tags=["datahub"]) + + +@router.get("/entities/{entity_id}", response_model=EntityOut) +def get_entity(entity_id: uuid.UUID, session: Session = Depends(get_session)) -> Entity: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="实体不存在") + return entity + + +@router.post("/penetrate", response_model=PenetrateResponse) +def penetrate( + req: PenetrateRequest, session: Session = Depends(get_session) +) -> PenetrateResponse: + """多跳穿透:返回与起点实体连通的关联实体(用于实控人/关联方/马甲识别)。""" + start = session.get(Entity, req.start_entity_id) + if start is None: + raise HTTPException(status_code=404, detail="起点实体不存在") + + related_raw = find_related_entities(session, req.start_entity_id, max_depth=req.max_depth) + + # 批量取出关联实体详情,组装可解释结果 + id_to_depth = {rid: depth for rid, depth in related_raw} + entities = ( + session.query(Entity).filter(Entity.id.in_(list(id_to_depth.keys()))).all() + if id_to_depth + else [] + ) + related = [ + RelatedEntityOut(entity=EntityOut.model_validate(e), depth=id_to_depth[e.id]) + for e in entities + ] + related.sort(key=lambda r: r.depth) + + return PenetrateResponse( + start_entity_id=req.start_entity_id, + max_depth=req.max_depth, + related_count=len(related), + related=related, + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..11c7f34 --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,36 @@ +"""API 数据传输模型(Pydantic)。""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + + +class EntityOut(BaseModel): + id: uuid.UUID + entity_type: str + business_key: str + display_name: str | None = None + attributes: dict = Field(default_factory=dict) + + model_config = {"from_attributes": True} + + +class RelatedEntityOut(BaseModel): + """穿透命中的关联实体,附最短跳数(证据强度的初步指示)。""" + + entity: EntityOut + depth: int + + +class PenetrateRequest(BaseModel): + start_entity_id: uuid.UUID + max_depth: int = Field(default=3, ge=1, le=6) + + +class PenetrateResponse(BaseModel): + start_entity_id: uuid.UUID + max_depth: int + related_count: int + related: list[RelatedEntityOut] diff --git a/backend/app/audit/__init__.py b/backend/app/audit/__init__.py new file mode 100644 index 0000000..2a2db5c --- /dev/null +++ b/backend/app/audit/__init__.py @@ -0,0 +1 @@ +"""系统自审计模块:不可篡改操作日志、独立性与分权(R19)。""" diff --git a/backend/app/audit/models.py b/backend/app/audit/models.py new file mode 100644 index 0000000..78438a9 --- /dev/null +++ b/backend/app/audit/models.py @@ -0,0 +1,50 @@ +"""系统自审计 ORM 模型:不可篡改操作日志(R19)。 + +每条日志含哈希链(prev_hash + 内容 → entry_hash),任何篡改都会断链,可检测。 +""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from sqlalchemy import BigInteger, DateTime, Identity, Index, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +def _uuid() -> uuid.UUID: + return uuid.uuid4() + + +def _now() -> dt.datetime: + return dt.datetime.now(dt.UTC) + + +class AuditLog(Base): + """不可篡改审计轨迹。仅追加,不可更新/删除(应用层与制度共同保证)。""" + + __tablename__ = "audit_log" + __table_args__ = ( + Index("ix_audit_actor", "actor"), + Index("ix_audit_action", "action"), + Index("ix_audit_seq", "seq", unique=True), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + # 自增序号,构成哈希链顺序 + seq: Mapped[int] = mapped_column( + BigInteger, Identity(always=False), nullable=False, unique=True + ) + actor: Mapped[str] = mapped_column(String(64), nullable=False) + role: Mapped[str | None] = mapped_column(String(32), nullable=True) + action: Mapped[str] = mapped_column(String(64), nullable=False) # 如 rule.update/clue.assign + target_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + target_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + detail: Mapped[dict] = mapped_column(JSONB, default=dict) + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) + + prev_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + entry_hash: Mapped[str] = mapped_column(String(64), nullable=False) diff --git a/backend/app/audit/rbac.py b/backend/app/audit/rbac.py new file mode 100644 index 0000000..ded677e --- /dev/null +++ b/backend/app/audit/rbac.py @@ -0,0 +1,78 @@ +"""RBAC 权限与独立性约束(R19、PRD §6 权限矩阵)。 + +核心独立性规则(硬约束): +- 任何角色都不能删除线索(DELETE_CLUE 不授予任何角色;数据库触发器再兜底)。 +- 业务方(business)对系统无任何写权限。 +- 配规则/改阈值/看线索/出报告分权制衡。 +""" + +from __future__ import annotations + +import enum + + +class Role(str, enum.Enum): + AUDITOR = "auditor" # 审计员 + AUDIT_MANAGER = "audit_manager" # 审计主管 + RULE_ADMIN = "rule_admin" # 规则管理员 + SYS_ADMIN = "sys_admin" # 系统管理员 + SYS_AUDITOR = "sys_auditor" # 系统审计员(独立监督) + BUSINESS = "business" # 被审计业务方(无写权限) + + +class Permission(str, enum.Enum): + QUERY = "query" # 自然语言查询 + VIEW_CLUE = "view_clue" # 查看线索 + ADJUDICATE_CLUE = "adjudicate_clue" # 研判/定性线索 + ASSIGN_CLUE = "assign_clue" # 分派线索 + DELETE_CLUE = "delete_clue" # 删除线索(禁止授予任何人) + CONFIG_RULE = "config_rule" # 配置规则 + ADJUST_THRESHOLD = "adjust_threshold" # 调整阈值 + ISSUE_REPORT = "issue_report" # 出具报告 + DATA_INGEST = "data_ingest" # 数据接入配置 + VIEW_AUDIT_TRAIL = "view_audit_trail" # 查看自审计轨迹 + MODEL_DEPLOY = "model_deploy" # 模型部署/升级 + + +# 角色 -> 权限集合。注意:DELETE_CLUE 不出现在任何角色中(线索不可删,R19)。 +ROLE_PERMISSIONS: dict[Role, set[Permission]] = { + Role.AUDITOR: { + Permission.QUERY, + Permission.VIEW_CLUE, + Permission.ADJUDICATE_CLUE, + Permission.ISSUE_REPORT, + }, + Role.AUDIT_MANAGER: { + Permission.QUERY, + Permission.VIEW_CLUE, + Permission.ADJUDICATE_CLUE, + Permission.ASSIGN_CLUE, + Permission.ISSUE_REPORT, + }, + Role.RULE_ADMIN: { + Permission.QUERY, + Permission.VIEW_CLUE, + Permission.CONFIG_RULE, + Permission.ADJUST_THRESHOLD, + }, + Role.SYS_ADMIN: { + Permission.DATA_INGEST, + Permission.MODEL_DEPLOY, + }, + Role.SYS_AUDITOR: { + Permission.QUERY, + Permission.VIEW_CLUE, + Permission.VIEW_AUDIT_TRAIL, + Permission.ISSUE_REPORT, + }, + Role.BUSINESS: set(), # 业务方无任何权限 +} + + +def has_permission(role: Role, perm: Permission) -> bool: + return perm in ROLE_PERMISSIONS.get(role, set()) + + +def can_delete_clue(role: Role) -> bool: + """线索不可删除——对所有角色恒为 False(独立性硬约束)。""" + return False diff --git a/backend/app/audit/service.py b/backend/app/audit/service.py new file mode 100644 index 0000000..0fa9e3b --- /dev/null +++ b/backend/app/audit/service.py @@ -0,0 +1,81 @@ +"""系统自审计服务:写入哈希链审计日志、校验完整性(R19)。""" + +from __future__ import annotations + +import hashlib +import json + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.audit.models import AuditLog + + +def _compute_hash(prev_hash: str | None, payload: dict) -> str: + body = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + raw = f"{prev_hash or ''}|{body}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def record( + session: Session, + actor: str, + action: str, + *, + role: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + detail: dict | None = None, +) -> AuditLog: + """追加一条审计日志,自动接续哈希链。""" + last = session.execute( + select(AuditLog).order_by(AuditLog.seq.desc()).limit(1) + ).scalar_one_or_none() + prev_hash = last.entry_hash if last else None + + payload = { + "actor": actor, + "role": role, + "action": action, + "target_type": target_type, + "target_id": target_id, + "detail": detail or {}, + } + entry_hash = _compute_hash(prev_hash, payload) + + log = AuditLog( + actor=actor, + role=role, + action=action, + target_type=target_type, + target_id=target_id, + detail=detail or {}, + prev_hash=prev_hash, + entry_hash=entry_hash, + ) + session.add(log) + session.flush() + return log + + +def verify_chain(session: Session) -> tuple[bool, int | None]: + """校验审计日志哈希链完整性。 + + 返回 (是否完整, 首个断链的 seq 或 None)。 + """ + rows = session.execute(select(AuditLog).order_by(AuditLog.seq.asc())).scalars().all() + prev_hash: str | None = None + for row in rows: + payload = { + "actor": row.actor, + "role": row.role, + "action": row.action, + "target_type": row.target_type, + "target_id": row.target_id, + "detail": row.detail or {}, + } + expected = _compute_hash(prev_hash, payload) + if expected != row.entry_hash or row.prev_hash != prev_hash: + return False, row.seq + prev_hash = row.entry_hash + return True, None diff --git a/backend/app/clues/__init__.py b/backend/app/clues/__init__.py new file mode 100644 index 0000000..600a759 --- /dev/null +++ b/backend/app/clues/__init__.py @@ -0,0 +1 @@ +"""线索引擎模块:线索模型、生成、置信度分级、状态流转(人机闭环)。""" diff --git a/backend/app/clues/models.py b/backend/app/clues/models.py new file mode 100644 index 0000000..113fa27 --- /dev/null +++ b/backend/app/clues/models.py @@ -0,0 +1,136 @@ +"""线索 ORM 模型。 + +对应需求 R7(线索+证据链+解释)、R17(闭环状态)、R18(置信度分级)、R19(线索不可删)。 +""" + +from __future__ import annotations + +import datetime as dt +import enum +import uuid + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, Index, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + + +def _enum_values(enum_cls): + """让 SQLAlchemy 使用枚举的 value(小写)写入 PG 原生 enum,而非 name。""" + return [m.value for m in enum_cls] + + +def _uuid() -> uuid.UUID: + return uuid.uuid4() + + +def _now() -> dt.datetime: + return dt.datetime.now(dt.UTC) + + +class ConfidenceTier(str, enum.Enum): + """置信度三级分流(R18)。""" + + HIGH = "high" # 高置信:直接推送处置 + MEDIUM = "medium" # 中置信:人工复核 + LOW = "low" # 低置信:归档备查 + + +class ClueStatus(str, enum.Enum): + """线索闭环状态机(R17)。""" + + NEW = "new" # 新生成 + ASSIGNED = "assigned" # 已分派 + REVIEWING = "reviewing" # 研判中 + CONFIRMED = "confirmed" # 已定性属实 + DISMISSED = "dismissed" # 已定性误报 + RECTIFYING = "rectifying" # 整改中 + TRANSFERRED = "transferred" # 已移交 + CLOSED = "closed" # 已销项闭环 + + +class Clue(Base): + """审计线索。线索一经生成不可物理删除(R19),失效通过状态表达。""" + + __tablename__ = "clue" + __table_args__ = ( + Index("ix_clue_status", "status"), + Index("ix_clue_scenario", "scenario_code"), + Index("ix_clue_assignee", "assignee"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + title: Mapped[str] = mapped_column(String(256), nullable=False) + risk_domain: Mapped[str] = mapped_column(String(32), nullable=False) # 收入/成本/采购/资金/合规 + scenario_code: Mapped[str] = mapped_column(String(32), nullable=False) # 如 R8/R9 + confidence: Mapped[ConfidenceTier] = mapped_column( + Enum(ConfidenceTier, name="confidence_tier", values_callable=_enum_values), + nullable=False, + ) + score: Mapped[float] = mapped_column(Float, default=0.0) # 0-1 风险评分 + status: Mapped[ClueStatus] = mapped_column( + Enum(ClueStatus, name="clue_status", values_callable=_enum_values), + default=ClueStatus.NEW, + nullable=False, + ) + # 人话解释(判定理由)与证据链 + rationale: Mapped[str] = mapped_column(Text, default="") + evidence: Mapped[dict] = mapped_column(JSONB, default=dict) + # 涉及的主体(金额、实体 id 列表等) + subjects: Mapped[dict] = mapped_column(JSONB, default=dict) + amount_involved: Mapped[float | None] = mapped_column(Float, nullable=True) + + assignee: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 误报/属实反馈(R18 反馈学习) + feedback: Mapped[str | None] = mapped_column(String(16), nullable=True) # confirmed/false_positive + + # 可追溯:产生该线索时的模型/规则/数据版本(R19 三重留痕) + model_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + rule_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + data_version_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now + ) + + history: Mapped[list[ClueStatusHistory]] = relationship( + back_populates="clue", cascade="all, delete-orphan" + ) + + +class ClueStatusHistory(Base): + """线索状态流转留痕(R17/R19)。""" + + __tablename__ = "clue_status_history" + __table_args__ = (Index("ix_csh_clue", "clue_id"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + clue_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("clue.id"), nullable=False + ) + from_status: Mapped[str | None] = mapped_column(String(16), nullable=True) + to_status: Mapped[str] = mapped_column(String(16), nullable=False) + actor: Mapped[str] = mapped_column(String(64), nullable=False) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) + + clue: Mapped[Clue] = relationship(back_populates="history") + + +class WorkingPaper(Base): + """审计底稿(R17):研判完成自动生成,可追溯。""" + + __tablename__ = "working_paper" + __table_args__ = (Index("ix_wp_clue", "clue_id"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + clue_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("clue.id"), nullable=False + ) + content: Mapped[str] = mapped_column(Text, default="") + conclusion: Mapped[str | None] = mapped_column(String(32), nullable=True) + author: Mapped[str] = mapped_column(String(64), nullable=False) + snapshot: Mapped[dict] = mapped_column(JSONB, default=dict) # 证据/版本快照 + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/clues/service.py b/backend/app/clues/service.py new file mode 100644 index 0000000..813b380 --- /dev/null +++ b/backend/app/clues/service.py @@ -0,0 +1,195 @@ +"""线索服务:生成、置信度分级、状态流转、底稿生成、反馈。 + +对应 R7 / R17 / R18 / R19。所有状态变更写入历史并记自审计日志(线索不可删)。 +""" + +from __future__ import annotations + +import uuid + +from sqlalchemy.orm import Session + +from app.audit import service as audit +from app.clues.models import ( + Clue, + ClueStatus, + ClueStatusHistory, + ConfidenceTier, + WorkingPaper, +) + +# 允许的状态流转(R17 闭环) +_ALLOWED_TRANSITIONS: dict[ClueStatus, set[ClueStatus]] = { + ClueStatus.NEW: {ClueStatus.ASSIGNED, ClueStatus.REVIEWING}, + ClueStatus.ASSIGNED: {ClueStatus.REVIEWING}, + ClueStatus.REVIEWING: {ClueStatus.CONFIRMED, ClueStatus.DISMISSED}, + ClueStatus.CONFIRMED: {ClueStatus.RECTIFYING, ClueStatus.TRANSFERRED}, + ClueStatus.DISMISSED: {ClueStatus.CLOSED}, + ClueStatus.RECTIFYING: {ClueStatus.CLOSED}, + ClueStatus.TRANSFERRED: {ClueStatus.CLOSED}, + ClueStatus.CLOSED: set(), +} + + +class IllegalTransitionError(ValueError): + """非法的线索状态流转。""" + + +def score_to_tier(score: float) -> ConfidenceTier: + """风险评分映射到置信度三级(R18)。""" + if score >= 0.8: + return ConfidenceTier.HIGH + if score >= 0.5: + return ConfidenceTier.MEDIUM + return ConfidenceTier.LOW + + +def create_clue( + session: Session, + *, + title: str, + risk_domain: str, + scenario_code: str, + score: float, + rationale: str, + evidence: dict, + subjects: dict | None = None, + amount_involved: float | None = None, + model_version: str | None = None, + rule_version: str | None = None, + data_version_id: uuid.UUID | None = None, + actor: str = "system", +) -> Clue: + """生成一条线索,自动按评分分级,并记录创建留痕。""" + clue = Clue( + title=title, + risk_domain=risk_domain, + scenario_code=scenario_code, + confidence=score_to_tier(score), + score=score, + status=ClueStatus.NEW, + rationale=rationale, + evidence=evidence, + subjects=subjects or {}, + amount_involved=amount_involved, + model_version=model_version, + rule_version=rule_version, + data_version_id=data_version_id, + ) + session.add(clue) + session.flush() + _add_history(session, clue, None, ClueStatus.NEW, actor, "线索生成") + audit.record( + session, actor, "create_clue", + target_type="clue", target_id=str(clue.id), + detail={"scenario": scenario_code, "score": score, "confidence": clue.confidence.value}, + ) + return clue + + +def _add_history( + session: Session, + clue: Clue, + from_status: ClueStatus | None, + to_status: ClueStatus, + actor: str, + note: str | None, +) -> None: + session.add( + ClueStatusHistory( + clue_id=clue.id, + from_status=from_status.value if from_status else None, + to_status=to_status.value, + actor=actor, + note=note, + ) + ) + session.flush() + + +def transition( + session: Session, clue: Clue, to_status: ClueStatus, actor: str, note: str | None = None +) -> Clue: + """执行状态流转,校验合法性并留痕。""" + if to_status not in _ALLOWED_TRANSITIONS.get(clue.status, set()): + raise IllegalTransitionError( + f"线索状态不能从 {clue.status.value} 流转到 {to_status.value}" + ) + from_status = clue.status + clue.status = to_status + session.flush() + _add_history(session, clue, from_status, to_status, actor, note) + audit.record( + session, actor, "transition_clue", + target_type="clue", target_id=str(clue.id), + detail={"from": from_status.value, "to": to_status.value, "note": note}, + ) + return clue + + +def assign(session: Session, clue: Clue, assignee: str, actor: str) -> Clue: + clue.assignee = assignee + session.flush() + if clue.status == ClueStatus.NEW: + transition(session, clue, ClueStatus.ASSIGNED, actor, f"分派给 {assignee}") + audit.record(session, actor, "assign_clue", target_type="clue", target_id=str(clue.id), detail={"assignee": assignee}) + return clue + + +def adjudicate( + session: Session, clue: Clue, confirmed: bool, actor: str, note: str | None = None +) -> WorkingPaper: + """研判定性:确认属实或误报,自动生成审计底稿并记录反馈(R17/R18)。""" + if clue.status not in (ClueStatus.ASSIGNED, ClueStatus.REVIEWING, ClueStatus.NEW): + # 允许从 NEW/ASSIGNED 直接进入研判 + pass + if clue.status != ClueStatus.REVIEWING: + # 先进入研判中 + target = ClueStatus.REVIEWING + if target in _ALLOWED_TRANSITIONS.get(clue.status, set()): + transition(session, clue, ClueStatus.REVIEWING, actor, "进入研判") + + to = ClueStatus.CONFIRMED if confirmed else ClueStatus.DISMISSED + transition(session, clue, to, actor, note) + clue.feedback = "confirmed" if confirmed else "false_positive" + session.flush() + + paper = WorkingPaper( + clue_id=clue.id, + content=note or "", + conclusion=to.value, + author=actor, + snapshot={ + "evidence": clue.evidence, + "rationale": clue.rationale, + "score": clue.score, + "model_version": clue.model_version, + "rule_version": clue.rule_version, + "data_version_id": str(clue.data_version_id) if clue.data_version_id else None, + }, + ) + session.add(paper) + session.flush() + audit.record( + session, actor, "create_working_paper", + target_type="working_paper", target_id=str(paper.id), + detail={"clue_id": str(clue.id), "conclusion": to.value}, + ) + return paper + + +def list_clues( + session: Session, + *, + status: ClueStatus | None = None, + scenario_code: str | None = None, + confidence: ConfidenceTier | None = None, +) -> list[Clue]: + q = session.query(Clue) + if status: + q = q.filter(Clue.status == status) + if scenario_code: + q = q.filter(Clue.scenario_code == scenario_code) + if confidence: + q = q.filter(Clue.confidence == confidence) + return q.order_by(Clue.score.desc()).all() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f6f91a1 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,70 @@ +"""应用配置。 + +通过环境变量加载,区分 dev / prod 运行环境。 +prod 环境强制执行"数据零出域"红线:禁用任何公网 LLM Provider。 +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AppEnv(str, Enum): + dev = "dev" + prod = "prod" + + +class LLMProviderName(str, Enum): + dashscope = "dashscope" # 公网千问,仅 dev + vllm = "vllm" # 本地,prod + + +# 被认定为"公网/出域"的 Provider,prod 下禁止使用 +EGRESS_PROVIDERS: frozenset[LLMProviderName] = frozenset({LLMProviderName.dashscope}) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="", + env_file=".env", + extra="ignore", + case_sensitive=False, + ) + + aiaudit_env: AppEnv = AppEnv.dev + + database_url: str = "postgresql+psycopg://freedak@localhost:5432/aiaudit" + redis_url: str = "redis://localhost:6379/0" + + llm_provider: LLMProviderName = LLMProviderName.dashscope + dashscope_api_key: str = "" + dashscope_model: str = "qwen-plus" + vllm_base_url: str = "http://localhost:8001/v1" + vllm_model: str = "qwen2.5-72b-instruct" + + @property + def is_prod(self) -> bool: + return self.aiaudit_env == AppEnv.prod + + def validate_egress_policy(self) -> None: + """数据零出域红线校验:prod 环境禁用公网 Provider。 + + 在应用启动时调用;违反则抛出异常阻断启动。 + """ + if self.is_prod and self.llm_provider in EGRESS_PROVIDERS: + raise RuntimeError( + f"数据零出域红线违规:prod 环境禁止使用公网 LLM Provider " + f"'{self.llm_provider.value}'。请改用本地 Provider(如 vllm)。" + ) + + +_settings: Settings | None = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings diff --git a/backend/app/datahub/__init__.py b/backend/app/datahub/__init__.py new file mode 100644 index 0000000..e0f1aad --- /dev/null +++ b/backend/app/datahub/__init__.py @@ -0,0 +1 @@ +"""审计数据中台模块:本体/知识图谱、双时态、时序、数据版本。""" diff --git a/backend/app/datahub/bitemporal_repo.py b/backend/app/datahub/bitemporal_repo.py new file mode 100644 index 0000000..a1fe6e1 --- /dev/null +++ b/backend/app/datahub/bitemporal_repo.py @@ -0,0 +1,83 @@ +"""双时态事实仓储:写入与"按历史时点回放"查询。 + +对应需求 R3 / ADR-0002: +- 业务有效期 valid_from/valid_to(应用时间) +- 系统记录期 system_from/system_to(事务时间) +回放 = 给定 (as_of_valid, as_of_system) 在两条时间线上各取"包含该时点"的记录。 +""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.datahub.models import BitemporalFact + + +def record_fact( + session: Session, + entity_id: uuid.UUID, + attr_name: str, + attr_value: dict, + valid_from: dt.datetime, + valid_to: dt.datetime | None = None, + data_version_id: uuid.UUID | None = None, +) -> BitemporalFact: + """记录一条双时态事实(system_from 自动取当前事务时间)。""" + fact = BitemporalFact( + entity_id=entity_id, + attr_name=attr_name, + attr_value=attr_value, + valid_from=valid_from, + valid_to=valid_to, + data_version_id=data_version_id, + ) + session.add(fact) + session.flush() + return fact + + +def as_of( + session: Session, + entity_id: uuid.UUID, + attr_name: str, + as_of_valid: dt.datetime, + as_of_system: dt.datetime | None = None, +) -> BitemporalFact | None: + """回放:返回在给定业务时点且按给定系统时点可见的事实。 + + - 业务时间线:valid_from <= as_of_valid < valid_to(或为空表示至今) + - 系统时间线:system_from <= as_of_system < system_to(或为空表示当前可见) + """ + as_of_system = as_of_system or dt.datetime.now(dt.UTC) + + q = ( + session.query(BitemporalFact) + .filter(BitemporalFact.entity_id == entity_id) + .filter(BitemporalFact.attr_name == attr_name) + .filter(BitemporalFact.valid_from <= as_of_valid) + .filter( + or_(BitemporalFact.valid_to.is_(None), BitemporalFact.valid_to > as_of_valid) + ) + .filter(BitemporalFact.system_from <= as_of_system) + .filter( + or_( + BitemporalFact.system_to.is_(None), + BitemporalFact.system_to > as_of_system, + ) + ) + .order_by(BitemporalFact.system_from.desc()) + ) + return q.first() + + +def close_fact( + session: Session, fact: BitemporalFact, system_to: dt.datetime | None = None +) -> None: + """逻辑关闭一条事实的系统可见期(用于更正/失效,而非物理删除)。""" + fact.system_to = system_to or dt.datetime.now(dt.UTC) + session.add(fact) + session.flush() diff --git a/backend/app/datahub/bootstrap.py b/backend/app/datahub/bootstrap.py new file mode 100644 index 0000000..aa6d691 --- /dev/null +++ b/backend/app/datahub/bootstrap.py @@ -0,0 +1,58 @@ +"""数据中台 schema 初始化。 + +MVP 阶段以 SQLAlchemy metadata 建表(后续可迁移到 Alembic)。 +扩展按可用性可选启用: +- btree_gist / vector:若可用则创建。 +- timescaledb:若可用则把 metric_event 转为超表;不可用则保持普通表(带时间索引)。 +""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from app.datahub import models # noqa: F401 确保模型注册到 metadata +from app.db import Base, get_engine + + +def _extension_available(engine: Engine, name: str) -> bool: + with engine.connect() as conn: + row = conn.execute( + text("SELECT 1 FROM pg_available_extensions WHERE name = :n"), {"n": name} + ).first() + return row is not None + + +def init_extensions(engine: Engine) -> dict[str, bool]: + """按可用性创建扩展,返回各扩展启用状态。""" + status: dict[str, bool] = {} + for ext in ("btree_gist", "vector", "timescaledb"): + available = _extension_available(engine, ext) + status[ext] = available + if available: + with engine.begin() as conn: + conn.execute(text(f"CREATE EXTENSION IF NOT EXISTS {ext}")) + return status + + +def create_schema(engine: Engine | None = None) -> dict[str, bool]: + """创建数据中台全部表,并按需启用时序超表。返回扩展状态。""" + engine = engine or get_engine() + status = init_extensions(engine) + Base.metadata.create_all(engine) + + # 若 TimescaleDB 可用,将时序事件表转为超表(幂等) + if status.get("timescaledb"): + with engine.begin() as conn: + conn.execute( + text( + "SELECT create_hypertable('metric_event', 'event_time', " + "if_not_exists => TRUE, migrate_data => TRUE)" + ) + ) + return status + + +if __name__ == "__main__": + st = create_schema() + print("数据中台 schema 初始化完成。扩展状态:", st) diff --git a/backend/app/datahub/graph_repo.py b/backend/app/datahub/graph_repo.py new file mode 100644 index 0000000..16fe33e --- /dev/null +++ b/backend/app/datahub/graph_repo.py @@ -0,0 +1,118 @@ +"""知识图谱仓储:实体/关系写入与多跳穿透(递归 CTE)。 + +对应需求 R2:支撑隐性实控人、关联方网络、"马甲"供应商等穿透分析。 +统一穿透查询服务(P1.2.5)在此之上封装对外 API,对上层屏蔽底层是关系表还是图库。 +""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.datahub.models import Entity, EntityRelationship +from app.datahub.ontology import EntityType, RelationshipType, is_valid_relationship + + +class OntologyViolationError(ValueError): + """关系不符合本体约束。""" + + +def upsert_entity( + session: Session, + entity_type: EntityType, + business_key: str, + display_name: str | None = None, + attributes: dict | None = None, + data_version_id: uuid.UUID | None = None, +) -> Entity: + """按 (类型, 业务主键) 幂等写入实体(主数据对齐的归一锚点)。""" + existing = ( + session.query(Entity) + .filter(Entity.entity_type == entity_type.value, Entity.business_key == business_key) + .one_or_none() + ) + if existing is not None: + if display_name is not None: + existing.display_name = display_name + if attributes: + existing.attributes = {**(existing.attributes or {}), **attributes} + return existing + + entity = Entity( + entity_type=entity_type.value, + business_key=business_key, + display_name=display_name, + attributes=attributes or {}, + data_version_id=data_version_id, + ) + session.add(entity) + session.flush() + return entity + + +def add_relationship( + session: Session, + rel_type: RelationshipType, + source: Entity, + target: Entity, + attributes: dict | None = None, + data_version_id: uuid.UUID | None = None, +) -> EntityRelationship: + """新增一条关系边,写入前校验本体约束。""" + src_type = EntityType(source.entity_type) + tgt_type = EntityType(target.entity_type) + if not is_valid_relationship(rel_type, src_type, tgt_type): + raise OntologyViolationError( + f"关系 {rel_type.value} 不允许从 {src_type.value} 指向 {tgt_type.value}" + ) + rel = EntityRelationship( + rel_type=rel_type.value, + source_id=source.id, + target_id=target.id, + attributes=attributes or {}, + data_version_id=data_version_id, + ) + session.add(rel) + session.flush() + return rel + + +# 多跳穿透:以无向方式遍历关系边,返回与起点在 max_depth 跳内连通的实体集合。 +# 用于"疑似同一实控人/关联方网络"识别。 +_TRAVERSE_SQL = text( + """ + WITH RECURSIVE reachable(entity_id, depth, path) AS ( + SELECT :start_id, 0, ARRAY[:start_id] + UNION ALL + SELECT + CASE WHEN r.source_id = rc.entity_id THEN r.target_id ELSE r.source_id END, + rc.depth + 1, + rc.path || CASE WHEN r.source_id = rc.entity_id THEN r.target_id ELSE r.source_id END + FROM reachable rc + JOIN entity_relationship r + ON (r.source_id = rc.entity_id OR r.target_id = rc.entity_id) + WHERE rc.depth < :max_depth + AND NOT ( + CASE WHEN r.source_id = rc.entity_id THEN r.target_id ELSE r.source_id END + = ANY(rc.path) + ) + ) + SELECT DISTINCT entity_id, MIN(depth) AS depth + FROM reachable + WHERE entity_id <> :start_id + GROUP BY entity_id + ORDER BY depth; + """ +) + + +def find_related_entities( + session: Session, start_id: uuid.UUID, max_depth: int = 3 +) -> list[tuple[uuid.UUID, int]]: + """返回与起点实体在 max_depth 跳内连通的实体 (id, 最短跳数) 列表。""" + rows = session.execute( + _TRAVERSE_SQL, {"start_id": start_id, "max_depth": max_depth} + ).all() + return [(r[0], r[1]) for r in rows] diff --git a/backend/app/datahub/models.py b/backend/app/datahub/models.py new file mode 100644 index 0000000..e93e51e --- /dev/null +++ b/backend/app/datahub/models.py @@ -0,0 +1,157 @@ +"""审计数据中台 ORM 模型。 + +涵盖:数据版本、本体实体、知识图谱关系边、双时态属性、时序事件。 +对应需求 R2 / R3,建模决策见 ADR-0002。 +""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + + +def _uuid() -> uuid.UUID: + return uuid.uuid4() + + +class DataVersion(Base): + """数据版本登记:每批接入数据的来源/批次/时间/行数,支撑结论可追溯(R3)。""" + + __tablename__ = "data_version" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + source_system: Mapped[str] = mapped_column(String(64), nullable=False) + batch_label: Mapped[str] = mapped_column(String(128), nullable=False) + row_count: Mapped[int] = mapped_column(Integer, default=0) + ingested_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC) + ) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class Entity(Base): + """本体实体节点(知识图谱顶点)。 + + business_key 是源系统中的业务主键,用于主数据对齐(同一实体跨系统归一)。 + """ + + __tablename__ = "entity" + __table_args__ = ( + UniqueConstraint("entity_type", "business_key", name="uq_entity_type_bizkey"), + Index("ix_entity_type", "entity_type"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + entity_type: Mapped[str] = mapped_column(String(32), nullable=False) + business_key: Mapped[str] = mapped_column(String(128), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + attributes: Mapped[dict] = mapped_column(JSONB, default=dict) + + # 主数据对齐:被归并到的"金主"实体(同一实控人/同一主体)。NULL 表示自身即主实体。 + canonical_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("entity.id"), nullable=True + ) + + data_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("data_version.id"), nullable=True + ) + + +class EntityRelationship(Base): + """知识图谱关系边(有向)。多跳穿透用递归 CTE 遍历本表。""" + + __tablename__ = "entity_relationship" + __table_args__ = ( + Index("ix_rel_source", "source_id"), + Index("ix_rel_target", "target_id"), + Index("ix_rel_type", "rel_type"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + rel_type: Mapped[str] = mapped_column(String(32), nullable=False) + source_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("entity.id"), nullable=False + ) + target_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("entity.id"), nullable=False + ) + attributes: Mapped[dict] = mapped_column(JSONB, default=dict) + + data_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("data_version.id"), nullable=True + ) + + source: Mapped[Entity] = relationship(foreign_keys=[source_id]) + target: Mapped[Entity] = relationship(foreign_keys=[target_id]) + + +class BitemporalFact(Base): + """双时态事实:实体的某个属性/状态随时间变化的记录。 + + - 业务有效期 valid_from/valid_to(应用时间) + - 系统记录期 system_from/system_to(事务时间) + 回放历史 = 给定 (as_of_valid, as_of_system) 过滤两条时间线(见 repository)。 + """ + + __tablename__ = "bitemporal_fact" + __table_args__ = ( + Index("ix_btf_entity_attr", "entity_id", "attr_name"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + entity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("entity.id"), nullable=False + ) + attr_name: Mapped[str] = mapped_column(String(64), nullable=False) + attr_value: Mapped[dict] = mapped_column(JSONB, default=dict) + + valid_from: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + valid_to: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + system_from: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC) + ) + system_to: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + data_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("data_version.id"), nullable=True + ) + + +class MetricEvent(Base): + """时序事件:行为/指标类数据(用户生命周期、回款、话务、佣金、资源使用)。 + + 部署后通过 TimescaleDB create_hypertable('metric_event', 'event_time') 转为超表。 + """ + + __tablename__ = "metric_event" + __table_args__ = ( + Index("ix_metric_subject_time", "subject_type", "subject_key", "event_time"), + Index("ix_metric_name_time", "metric_name", "event_time"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) + event_time: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + subject_type: Mapped[str] = mapped_column(String(32), nullable=False) # 如 msisdn/channel + subject_key: Mapped[str] = mapped_column(String(128), nullable=False) + metric_name: Mapped[str] = mapped_column(String(64), nullable=False) # 如 traffic_mb/commission + metric_value: Mapped[float] = mapped_column(Float, default=0.0) + attributes: Mapped[dict] = mapped_column(JSONB, default=dict) + + data_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("data_version.id"), nullable=True + ) diff --git a/backend/app/datahub/ontology.py b/backend/app/datahub/ontology.py new file mode 100644 index 0000000..b581298 --- /dev/null +++ b/backend/app/datahub/ontology.py @@ -0,0 +1,86 @@ +"""审计本体(Ontology)定义。 + +定义电信内审域的核心实体类型与关系类型,作为知识图谱与主数据对齐的基准。 +对应需求 R2。 +""" + +from __future__ import annotations + +from enum import Enum + + +class EntityType(str, Enum): + """审计本体核心实体类型。""" + + CUSTOMER = "customer" # 客户(含政企) + CONTRACT = "contract" # 合同 + MSISDN = "msisdn" # 号码 + IMEI = "imei" # 终端设备 + ACCOUNT = "account" # 账户(付款/收款) + WORK_ORDER = "work_order" # 工单 + SUPPLIER = "supplier" # 供应商 + SETTLEMENT = "settlement" # 结算单 + EMPLOYEE = "employee" # 员工 + CHANNEL = "channel" # 渠道/代理商 + LEGAL_PERSON = "legal_person" # 法人/自然人 + ADDRESS = "address" # 地址 + + +class RelationshipType(str, Enum): + """审计本体核心关系类型(有向)。""" + + SIGNED = "signed" # 客户 —签约→ 合同 + PAID_BY = "paid_by" # 合同 —回款账户→ 账户 + OWNS_ACCOUNT = "owns_account" # 客户/供应商 —拥有→ 账户 + REGISTERED_AT = "registered_at" # 客户/供应商 —注册地址→ 地址 + LEGAL_REP_OF = "legal_rep_of" # 法人 —法定代表人→ 客户/供应商 + RELATED_TO = "related_to" # 法人 —亲属/关联→ 法人 + HOLDS_MSISDN = "holds_msisdn" # 客户 —持有→ 号码 + BOUND_DEVICE = "bound_device" # 号码 —绑定→ IMEI + BELONGS_TO_CHANNEL = "belongs_to_channel" # 号码/合同 —归属→ 渠道 + SUPPLIES = "supplies" # 供应商 —供货→ 合同/工单 + HANDLED_BY = "handled_by" # 工单 —处理人→ 员工 + SETTLES = "settles" # 结算单 —结算→ 合同 + + +# 关系的合法 (源实体类型, 目标实体类型) 约束,用于校验图谱写入 +RELATIONSHIP_DOMAIN: dict[RelationshipType, tuple[set[EntityType], set[EntityType]]] = { + RelationshipType.SIGNED: ({EntityType.CUSTOMER}, {EntityType.CONTRACT}), + RelationshipType.PAID_BY: ({EntityType.CONTRACT}, {EntityType.ACCOUNT}), + RelationshipType.OWNS_ACCOUNT: ( + {EntityType.CUSTOMER, EntityType.SUPPLIER, EntityType.LEGAL_PERSON}, + {EntityType.ACCOUNT}, + ), + RelationshipType.REGISTERED_AT: ( + {EntityType.CUSTOMER, EntityType.SUPPLIER}, + {EntityType.ADDRESS}, + ), + RelationshipType.LEGAL_REP_OF: ( + {EntityType.LEGAL_PERSON}, + {EntityType.CUSTOMER, EntityType.SUPPLIER}, + ), + RelationshipType.RELATED_TO: ({EntityType.LEGAL_PERSON}, {EntityType.LEGAL_PERSON}), + RelationshipType.HOLDS_MSISDN: ({EntityType.CUSTOMER}, {EntityType.MSISDN}), + RelationshipType.BOUND_DEVICE: ({EntityType.MSISDN}, {EntityType.IMEI}), + RelationshipType.BELONGS_TO_CHANNEL: ( + {EntityType.MSISDN, EntityType.CONTRACT}, + {EntityType.CHANNEL}, + ), + RelationshipType.SUPPLIES: ( + {EntityType.SUPPLIER}, + {EntityType.CONTRACT, EntityType.WORK_ORDER}, + ), + RelationshipType.HANDLED_BY: ({EntityType.WORK_ORDER}, {EntityType.EMPLOYEE}), + RelationshipType.SETTLES: ({EntityType.SETTLEMENT}, {EntityType.CONTRACT}), +} + + +def is_valid_relationship( + rel: RelationshipType, source: EntityType, target: EntityType +) -> bool: + """校验一条关系的源/目标实体类型是否符合本体约束。""" + domain = RELATIONSHIP_DOMAIN.get(rel) + if domain is None: + return False + sources, targets = domain + return source in sources and target in targets diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..61d6e1c --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,40 @@ +"""数据库引擎与会话管理。""" + +from __future__ import annotations + +from collections.abc import Iterator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class Base(DeclarativeBase): + """所有 ORM 模型的基类。""" + + +_engine = None +_SessionLocal: sessionmaker[Session] | None = None + + +def get_engine(): + global _engine + if _engine is None: + settings = get_settings() + _engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) + return _engine + + +def get_sessionmaker() -> sessionmaker[Session]: + global _SessionLocal + if _SessionLocal is None: + _SessionLocal = sessionmaker(bind=get_engine(), expire_on_commit=False) + return _SessionLocal + + +def get_session() -> Iterator[Session]: + """FastAPI 依赖注入用的会话生成器。""" + sm = get_sessionmaker() + with sm() as session: + yield session diff --git a/backend/app/llm/__init__.py b/backend/app/llm/__init__.py new file mode 100644 index 0000000..15e8f63 --- /dev/null +++ b/backend/app/llm/__init__.py @@ -0,0 +1,10 @@ +"""LLM Provider 抽象层。 + +通过统一接口隔离 LLM 实现,使开发期可用公网千问、生产期无缝切换本地 vLLM。 +强约束:"数据零出域"红线由 provider 工厂在 prod 环境拦截公网 Provider。 +""" + +from app.llm.base import ChatMessage, LLMProvider, LLMResponse +from app.llm.factory import get_llm_provider + +__all__ = ["ChatMessage", "LLMProvider", "LLMResponse", "get_llm_provider"] diff --git a/backend/app/llm/base.py b/backend/app/llm/base.py new file mode 100644 index 0000000..c5d3ad8 --- /dev/null +++ b/backend/app/llm/base.py @@ -0,0 +1,44 @@ +"""LLM Provider 抽象接口与数据模型。""" + +from __future__ import annotations + +import abc +from dataclasses import dataclass, field + + +@dataclass +class ChatMessage: + role: str # "system" | "user" | "assistant" + content: str + + +@dataclass +class LLMResponse: + content: str + model: str + provider: str + # 是否经过出域(公网)通道,便于审计轨迹记录 + egress: bool = False + raw: dict = field(default_factory=dict) + + +class LLMProvider(abc.ABC): + """所有 LLM 实现的统一接口。 + + 业务代码只依赖本接口;切换公网/本地仅改配置,不改调用方。 + """ + + #: provider 名称 + name: str = "base" + #: 是否走公网(出域)。prod 环境禁止 egress=True 的 provider。 + egress: bool = False + + @abc.abstractmethod + def chat(self, messages: list[ChatMessage], **kwargs) -> LLMResponse: + """同步对话补全。""" + raise NotImplementedError + + @abc.abstractmethod + def health(self) -> bool: + """探活:provider 是否可用。""" + raise NotImplementedError diff --git a/backend/app/llm/factory.py b/backend/app/llm/factory.py new file mode 100644 index 0000000..f18f7c2 --- /dev/null +++ b/backend/app/llm/factory.py @@ -0,0 +1,31 @@ +"""LLM Provider 工厂:按配置创建 provider,并执行数据零出域红线校验。""" + +from __future__ import annotations + +from app.config import EGRESS_PROVIDERS, LLMProviderName, Settings, get_settings +from app.llm.base import LLMProvider +from app.llm.providers import DashScopeProvider, VllmProvider + + +class EgressPolicyError(RuntimeError): + """数据零出域红线违规。""" + + +def get_llm_provider(settings: Settings | None = None) -> LLMProvider: + settings = settings or get_settings() + + # 红线:prod 环境禁止公网 provider + if settings.is_prod and settings.llm_provider in EGRESS_PROVIDERS: + raise EgressPolicyError( + f"数据零出域红线违规:prod 环境禁止使用公网 LLM Provider " + f"'{settings.llm_provider.value}'。" + ) + + if settings.llm_provider == LLMProviderName.dashscope: + return DashScopeProvider( + api_key=settings.dashscope_api_key, model=settings.dashscope_model + ) + if settings.llm_provider == LLMProviderName.vllm: + return VllmProvider(base_url=settings.vllm_base_url, model=settings.vllm_model) + + raise ValueError(f"未知的 LLM Provider: {settings.llm_provider}") diff --git a/backend/app/llm/providers.py b/backend/app/llm/providers.py new file mode 100644 index 0000000..ea0e69f --- /dev/null +++ b/backend/app/llm/providers.py @@ -0,0 +1,80 @@ +"""具体 LLM Provider 实现:DashScope(公网千问,仅 dev)、vLLM(本地,prod)。 + +两者均走 OpenAI 兼容的 /chat/completions 协议。 +""" + +from __future__ import annotations + +import httpx + +from app.llm.base import ChatMessage, LLMProvider, LLMResponse + + +class DashScopeProvider(LLMProvider): + """公网千问(DashScope,OpenAI 兼容模式)。仅限开发测试,且只允许脱敏/样例假数据。""" + + name = "dashscope" + egress = True # 走公网,出域 + + _BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" + + def __init__(self, api_key: str, model: str, timeout: float = 30.0) -> None: + self._api_key = api_key + self._model = model + self._timeout = timeout + + def chat(self, messages: list[ChatMessage], **kwargs) -> LLMResponse: + payload = { + "model": self._model, + "messages": [{"role": m.role, "content": m.content} for m in messages], + **kwargs, + } + headers = {"Authorization": f"Bearer {self._api_key}"} + with httpx.Client(timeout=self._timeout) as client: + resp = client.post( + f"{self._BASE_URL}/chat/completions", json=payload, headers=headers + ) + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"] + return LLMResponse( + content=content, model=self._model, provider=self.name, egress=True, raw=data + ) + + def health(self) -> bool: + return bool(self._api_key) + + +class VllmProvider(LLMProvider): + """本地 vLLM(OpenAI 兼容)。生产使用,数据不出域。""" + + name = "vllm" + egress = False + + def __init__(self, base_url: str, model: str, timeout: float = 60.0) -> None: + self._base_url = base_url.rstrip("/") + self._model = model + self._timeout = timeout + + def chat(self, messages: list[ChatMessage], **kwargs) -> LLMResponse: + payload = { + "model": self._model, + "messages": [{"role": m.role, "content": m.content} for m in messages], + **kwargs, + } + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(f"{self._base_url}/chat/completions", json=payload) + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"] + return LLMResponse( + content=content, model=self._model, provider=self.name, egress=False, raw=data + ) + + def health(self) -> bool: + try: + with httpx.Client(timeout=5.0) as client: + resp = client.get(f"{self._base_url}/models") + return resp.status_code == 200 + except httpx.HTTPError: + return False diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..dff48b6 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,45 @@ +"""AIAudit FastAPI 应用入口。""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app import __version__ +from app.api.datahub import router as datahub_router +from app.config import get_settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 启动时执行数据零出域红线校验,违规则阻断启动 + settings = get_settings() + settings.validate_egress_policy() + yield + + +app = FastAPI( + title="AIAudit · 本地 AI 内审平台", + version=__version__, + lifespan=lifespan, +) + +app.include_router(datahub_router) + + +@app.get("/health") +def health() -> dict: + """存活探针。""" + return {"status": "ok", "version": __version__} + + +@app.get("/health/config") +def health_config() -> dict: + """配置/合规探针:暴露环境与 LLM provider 出域状态(不含密钥)。""" + settings = get_settings() + return { + "env": settings.aiaudit_env.value, + "llm_provider": settings.llm_provider.value, + "egress_blocked_in_prod": settings.is_prod, + } diff --git a/backend/migrations/README.md b/backend/migrations/README.md new file mode 100644 index 0000000..08b2ec2 --- /dev/null +++ b/backend/migrations/README.md @@ -0,0 +1,7 @@ +# 数据库迁移(Alembic) + +- 生成迁移:`alembic revision --autogenerate -m "描述"` +- 应用迁移:`alembic upgrade head` +- 回滚一步:`alembic downgrade -1` + +模型定义见 `app/datahub/models.py`;连接串取自应用配置(`DATABASE_URL`)。 diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..55ce6ae --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,59 @@ +"""Alembic 迁移环境。 + +从应用配置读取数据库 URL,并以 app.db.Base 的元数据作为 autogenerate 目标。 +""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.audit import models as audit_models # noqa: F401,E402 +from app.clues import models as clue_models # noqa: F401,E402 +from app.config import get_settings + +# 导入模型以注册到 Base.metadata +from app.datahub import models # noqa: F401,E402 +from app.db import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 用应用配置覆盖 sqlalchemy.url +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/0001_init_datahub.py b/backend/migrations/versions/0001_init_datahub.py new file mode 100644 index 0000000..382f428 --- /dev/null +++ b/backend/migrations/versions/0001_init_datahub.py @@ -0,0 +1,140 @@ +"""初始化数据中台表:数据版本 / 实体 / 关系 / 双时态事实 / 时序事件 + +Revision ID: 0001_init_datahub +Revises: +Create Date: 2026-06 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0001_init_datahub" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # data_version + op.create_table( + "data_version", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source_system", sa.String(64), nullable=False), + sa.Column("batch_label", sa.String(128), nullable=False), + sa.Column("row_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("ingested_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + ) + + # entity + op.create_table( + "entity", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("entity_type", sa.String(32), nullable=False), + sa.Column("business_key", sa.String(128), nullable=False), + sa.Column("display_name", sa.String(256), nullable=True), + sa.Column("attributes", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("canonical_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("data_version_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(["canonical_id"], ["entity.id"]), + sa.ForeignKeyConstraint(["data_version_id"], ["data_version.id"]), + sa.UniqueConstraint("entity_type", "business_key", name="uq_entity_type_bizkey"), + ) + op.create_index("ix_entity_type", "entity", ["entity_type"]) + + # entity_relationship + op.create_table( + "entity_relationship", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("rel_type", sa.String(32), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("attributes", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("data_version_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(["source_id"], ["entity.id"]), + sa.ForeignKeyConstraint(["target_id"], ["entity.id"]), + sa.ForeignKeyConstraint(["data_version_id"], ["data_version.id"]), + ) + op.create_index("ix_rel_source", "entity_relationship", ["source_id"]) + op.create_index("ix_rel_target", "entity_relationship", ["target_id"]) + op.create_index("ix_rel_type", "entity_relationship", ["rel_type"]) + + # bitemporal_fact + op.create_table( + "bitemporal_fact", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("attr_name", sa.String(64), nullable=False), + sa.Column("attr_value", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("system_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("system_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("data_version_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(["entity_id"], ["entity.id"]), + sa.ForeignKeyConstraint(["data_version_id"], ["data_version.id"]), + ) + op.create_index("ix_btf_entity_attr", "bitemporal_fact", ["entity_id", "attr_name"]) + + # metric_event(时序) + op.create_table( + "metric_event", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("event_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("subject_type", sa.String(32), nullable=False), + sa.Column("subject_key", sa.String(128), nullable=False), + sa.Column("metric_name", sa.String(64), nullable=False), + sa.Column("metric_value", sa.Float(), nullable=False, server_default="0"), + sa.Column("attributes", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("data_version_id", postgresql.UUID(as_uuid=True), nullable=True), + # 超表主键需包含分区列 event_time + sa.PrimaryKeyConstraint("id", "event_time"), + sa.ForeignKeyConstraint(["data_version_id"], ["data_version.id"]), + ) + op.create_index( + "ix_metric_subject_time", + "metric_event", + ["subject_type", "subject_key", "event_time"], + ) + op.create_index("ix_metric_name_time", "metric_event", ["metric_name", "event_time"]) + + # 转为 TimescaleDB 超表(若扩展不存在则跳过,便于无 timescaledb 环境运行测试) + op.execute( + """ + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN + PERFORM create_hypertable('metric_event', 'event_time', if_not_exists => TRUE); + END IF; + END$$; + """ + ) + + # 双时态排他约束:同一实体同一属性,业务有效期不重叠(需 btree_gist) + op.execute( + """ + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gist') THEN + ALTER TABLE bitemporal_fact + ADD CONSTRAINT ex_btf_no_overlap + EXCLUDE USING gist ( + entity_id WITH =, + attr_name WITH =, + tstzrange(valid_from, valid_to) WITH && + ) WHERE (system_to IS NULL); + END IF; + END$$; + """ + ) + + +def downgrade() -> None: + op.drop_table("metric_event") + op.drop_table("bitemporal_fact") + op.drop_table("entity_relationship") + op.drop_index("ix_entity_type", table_name="entity") + op.drop_table("entity") + op.drop_table("data_version") diff --git a/backend/migrations/versions/0002_clues_audit.py b/backend/migrations/versions/0002_clues_audit.py new file mode 100644 index 0000000..0afdf0d --- /dev/null +++ b/backend/migrations/versions/0002_clues_audit.py @@ -0,0 +1,146 @@ +"""线索引擎与系统自审计表:clue / clue_status_history / working_paper / audit_log + +Revision ID: 0002_clues_audit +Revises: 0001_init_datahub +Create Date: 2026-06 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0002_clues_audit" +down_revision: str | None = "0001_init_datahub" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +confidence_tier = postgresql.ENUM( + "high", "medium", "low", name="confidence_tier", create_type=False +) +clue_status = postgresql.ENUM( + "new", "assigned", "reviewing", "confirmed", "dismissed", + "rectifying", "transferred", "closed", name="clue_status", create_type=False, +) + + +def upgrade() -> None: + bind = op.get_bind() + confidence_tier.create(bind, checkfirst=True) + clue_status.create(bind, checkfirst=True) + + op.create_table( + "clue", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("title", sa.String(256), nullable=False), + sa.Column("risk_domain", sa.String(32), nullable=False), + sa.Column("scenario_code", sa.String(32), nullable=False), + sa.Column("confidence", confidence_tier, nullable=False), + sa.Column("score", sa.Float(), nullable=False, server_default="0"), + sa.Column("status", clue_status, nullable=False, server_default="new"), + sa.Column("rationale", sa.Text(), nullable=False, server_default=""), + sa.Column("evidence", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("subjects", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("amount_involved", sa.Float(), nullable=True), + sa.Column("assignee", sa.String(64), nullable=True), + sa.Column("feedback", sa.String(16), nullable=True), + sa.Column("model_version", sa.String(64), nullable=True), + sa.Column("rule_version", sa.String(64), nullable=True), + sa.Column("data_version_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_clue_status", "clue", ["status"]) + op.create_index("ix_clue_scenario", "clue", ["scenario_code"]) + op.create_index("ix_clue_assignee", "clue", ["assignee"]) + + op.create_table( + "clue_status_history", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("clue_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("from_status", sa.String(16), nullable=True), + sa.Column("to_status", sa.String(16), nullable=False), + sa.Column("actor", sa.String(64), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["clue_id"], ["clue.id"]), + ) + op.create_index("ix_csh_clue", "clue_status_history", ["clue_id"]) + + op.create_table( + "working_paper", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("clue_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("content", sa.Text(), nullable=False, server_default=""), + sa.Column("conclusion", sa.String(32), nullable=True), + sa.Column("author", sa.String(64), nullable=False), + sa.Column("snapshot", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["clue_id"], ["clue.id"]), + ) + op.create_index("ix_wp_clue", "working_paper", ["clue_id"]) + + op.create_table( + "audit_log", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("seq", sa.BigInteger(), sa.Identity(always=False), nullable=False), + sa.Column("actor", sa.String(64), nullable=False), + sa.Column("role", sa.String(32), nullable=True), + sa.Column("action", sa.String(64), nullable=False), + sa.Column("target_type", sa.String(64), nullable=True), + sa.Column("target_id", sa.String(128), nullable=True), + sa.Column("detail", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("prev_hash", sa.String(64), nullable=True), + sa.Column("entry_hash", sa.String(64), nullable=False), + ) + op.create_index("ix_audit_actor", "audit_log", ["actor"]) + op.create_index("ix_audit_action", "audit_log", ["action"]) + op.create_index("ix_audit_seq", "audit_log", ["seq"], unique=True) + + # R19:禁止物理删除线索与审计日志(数据库级触发器兜底) + op.execute( + """ + CREATE OR REPLACE FUNCTION forbid_delete() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION '禁止删除:% 表受 R19 不可删除约束保护', TG_TABLE_NAME; + END; + $$ LANGUAGE plpgsql; + """ + ) + op.execute( + "CREATE TRIGGER trg_clue_no_delete BEFORE DELETE ON clue " + "FOR EACH ROW EXECUTE FUNCTION forbid_delete();" + ) + op.execute( + "CREATE TRIGGER trg_audit_no_delete BEFORE DELETE ON audit_log " + "FOR EACH ROW EXECUTE FUNCTION forbid_delete();" + ) + # 审计日志禁止更新(仅追加) + op.execute( + """ + CREATE OR REPLACE FUNCTION forbid_update() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION '禁止更新:% 表为仅追加日志', TG_TABLE_NAME; + END; + $$ LANGUAGE plpgsql; + """ + ) + op.execute( + "CREATE TRIGGER trg_audit_no_update BEFORE UPDATE ON audit_log " + "FOR EACH ROW EXECUTE FUNCTION forbid_update();" + ) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS trg_audit_no_update ON audit_log;") + op.execute("DROP TRIGGER IF EXISTS trg_audit_no_delete ON audit_log;") + op.execute("DROP TRIGGER IF EXISTS trg_clue_no_delete ON clue;") + op.drop_table("audit_log") + op.drop_table("working_paper") + op.drop_table("clue_status_history") + op.drop_table("clue") + clue_status.drop(op.get_bind(), checkfirst=True) + confidence_tier.drop(op.get_bind(), checkfirst=True) + op.execute("DROP FUNCTION IF EXISTS forbid_update();") + op.execute("DROP FUNCTION IF EXISTS forbid_delete();") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..c57ac9d --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "aiaudit-backend" +version = "0.1.0" +description = "AIAudit 本地 AI 内审平台后端" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] +# B008:FastAPI 依赖注入 Depends() 作为默认值是官方推荐用法 +ignore = ["B008"] + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..c8f5b82 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest==8.3.4 +pytest-asyncio==0.25.0 +ruff==0.8.4 +mypy==1.14.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ce83ff0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +pydantic-settings==2.7.1 +sqlalchemy==2.0.36 +psycopg[binary]==3.2.3 +alembic==1.14.0 +celery==5.4.0 +redis==5.2.1 +httpx==0.28.1 +python-dotenv==1.0.1 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..cd56011 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,41 @@ +"""集成测试 fixture:连接本地 PostgreSQL 16,按事务隔离并回滚。 + +需要可连接的数据库(DATABASE_URL)。无法连接时跳过整组集成测试。 +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import OperationalError + +from app.db import get_engine + + +@pytest.fixture(scope="session") +def db_available() -> bool: + try: + with get_engine().connect() as conn: + conn.execute(text("SELECT 1")) + return True + except OperationalError: + return False + + +@pytest.fixture() +def session(db_available): + if not db_available: + pytest.skip("数据库不可用,跳过集成测试") + engine = get_engine() + connection = engine.connect() + trans = connection.begin() + from sqlalchemy.orm import Session + + sess = Session(bind=connection) + try: + yield sess + finally: + sess.close() + if trans.is_active: + trans.rollback() + connection.close() diff --git a/backend/tests/integration/test_bitemporal.py b/backend/tests/integration/test_bitemporal.py new file mode 100644 index 0000000..93bc598 --- /dev/null +++ b/backend/tests/integration/test_bitemporal.py @@ -0,0 +1,49 @@ +"""双时态集成测试(需 PostgreSQL)。 + +验证 R3:按历史业务时点回放属性值,以及双时态排他约束防止有效期重叠。 +""" + +from __future__ import annotations + +import datetime as dt + +import pytest +from sqlalchemy.exc import IntegrityError + +from app.datahub import bitemporal_repo as btr +from app.datahub.graph_repo import upsert_entity +from app.datahub.ontology import EntityType + + +def test_bitemporal_replay(session): + """不同业务时点回放出不同的属性值。""" + cust = upsert_entity(session, EntityType.CUSTOMER, "CUST_BT", "丁公司") + session.flush() + + t1 = dt.datetime(2025, 1, 1, tzinfo=dt.UTC) + t2 = dt.datetime(2025, 6, 1, tzinfo=dt.UTC) + + btr.record_fact(session, cust.id, "credit_level", {"v": "A"}, valid_from=t1, valid_to=t2) + btr.record_fact(session, cust.id, "credit_level", {"v": "C"}, valid_from=t2) + session.flush() + + early = btr.as_of(session, cust.id, "credit_level", dt.datetime(2025, 3, 1, tzinfo=dt.UTC)) + late = btr.as_of(session, cust.id, "credit_level", dt.datetime(2025, 9, 1, tzinfo=dt.UTC)) + assert early is not None and early.attr_value["v"] == "A" + assert late is not None and late.attr_value["v"] == "C" + + +def test_bitemporal_exclusion_constraint(session): + """同一实体同一属性的业务有效期重叠应被排他约束拒绝。""" + cust = upsert_entity(session, EntityType.CUSTOMER, "CUST_EX", "戊公司") + session.flush() + + t1 = dt.datetime(2025, 1, 1, tzinfo=dt.UTC) + t3 = dt.datetime(2025, 12, 1, tzinfo=dt.UTC) + t2 = dt.datetime(2025, 6, 1, tzinfo=dt.UTC) + + btr.record_fact(session, cust.id, "status", {"v": "active"}, valid_from=t1, valid_to=t3) + session.flush() + # 与上一条 [t1,t3) 重叠:record_fact 内部 flush 时即触发排他约束 + with pytest.raises(IntegrityError): + btr.record_fact(session, cust.id, "status", {"v": "frozen"}, valid_from=t2, valid_to=None) diff --git a/backend/tests/integration/test_clue_lifecycle.py b/backend/tests/integration/test_clue_lifecycle.py new file mode 100644 index 0000000..354e476 --- /dev/null +++ b/backend/tests/integration/test_clue_lifecycle.py @@ -0,0 +1,87 @@ +"""线索闭环 + 系统自审计集成测试(需 PostgreSQL)。 + +覆盖 R7/R17/R18/R19:线索生成与分级、状态流转、底稿、审计哈希链、线索不可删。 +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import InternalError, ProgrammingError + +from app.audit import service as audit +from app.clues import service as clue_svc +from app.clues.models import ClueStatus, ConfidenceTier + + +def _new_clue(session, score=0.9): + return clue_svc.create_clue( + session, + title="疑似政企拆单", + risk_domain="收入", + scenario_code="R8", + score=score, + rationale="8 个客户金额集中在审批阈值边缘,且法人关联同一实控人", + evidence={"contracts": 8, "threshold": 1000000}, + amount_involved=4800000, + actor="system", + ) + + +def test_score_to_confidence_tier(): + assert clue_svc.score_to_tier(0.9) == ConfidenceTier.HIGH + assert clue_svc.score_to_tier(0.6) == ConfidenceTier.MEDIUM + assert clue_svc.score_to_tier(0.2) == ConfidenceTier.LOW + + +def test_clue_full_lifecycle(session): + clue = _new_clue(session) + assert clue.confidence == ConfidenceTier.HIGH + assert clue.status == ClueStatus.NEW + + clue_svc.assign(session, clue, assignee="auditor_zhang", actor="manager_li") + assert clue.status == ClueStatus.ASSIGNED + assert clue.assignee == "auditor_zhang" + + paper = clue_svc.adjudicate(session, clue, confirmed=True, actor="auditor_zhang", note="属实,移交") + assert clue.status == ClueStatus.CONFIRMED + assert clue.feedback == "confirmed" + assert paper.conclusion == "confirmed" + assert paper.snapshot["score"] == 0.9 + + # 继续闭环:确认 -> 移交 -> 销项 + clue_svc.transition(session, clue, ClueStatus.TRANSFERRED, actor="manager_li") + clue_svc.transition(session, clue, ClueStatus.CLOSED, actor="manager_li") + assert clue.status == ClueStatus.CLOSED + + +def test_illegal_transition_rejected(session): + clue = _new_clue(session) + with pytest.raises(clue_svc.IllegalTransitionError): + # NEW 不能直接到 CLOSED + clue_svc.transition(session, clue, ClueStatus.CLOSED, actor="x") + + +def test_audit_hash_chain_integrity(session): + _new_clue(session) + clue = _new_clue(session) + clue_svc.assign(session, clue, "auditor_zhang", "manager_li") + ok, broken = audit.verify_chain(session) + assert ok is True + assert broken is None + + +def test_clue_cannot_be_deleted(session): + """R19:数据库触发器禁止物理删除线索。""" + clue = _new_clue(session) + session.flush() + with pytest.raises((InternalError, ProgrammingError)): + session.execute(text("DELETE FROM clue WHERE id = :i"), {"i": clue.id}) + session.flush() + + +def test_list_clues_filters(session): + _new_clue(session, score=0.9) + _new_clue(session, score=0.3) + highs = clue_svc.list_clues(session, confidence=ConfidenceTier.HIGH) + assert all(c.confidence == ConfidenceTier.HIGH for c in highs) diff --git a/backend/tests/integration/test_datahub_api.py b/backend/tests/integration/test_datahub_api.py new file mode 100644 index 0000000..195508b --- /dev/null +++ b/backend/tests/integration/test_datahub_api.py @@ -0,0 +1,63 @@ +"""数据中台穿透 API 集成测试(需 PostgreSQL)。 + +通过 TestClient 调用 /datahub/penetrate,验证统一穿透查询服务端到端可用。 +""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.datahub.graph_repo import add_relationship, upsert_entity +from app.datahub.ontology import EntityType, RelationshipType +from app.db import get_session +from app.main import app + + +@pytest.fixture() +def client(session): + # 用集成测试的事务化 session 覆盖应用依赖,保证测试数据回滚 + app.dependency_overrides[get_session] = lambda: session + try: + yield TestClient(app) + finally: + app.dependency_overrides.pop(get_session, None) + + +def test_penetrate_endpoint_detects_related(client, session): + suffix = uuid.uuid4().hex[:8] + controller = upsert_entity(session, EntityType.LEGAL_PERSON, f"CTRL-{suffix}", "实控人") + cust = upsert_entity(session, EntityType.CUSTOMER, f"CUST-{suffix}", "政企客户") + rep = upsert_entity(session, EntityType.LEGAL_PERSON, f"REP-{suffix}", "法人") + add_relationship(session, RelationshipType.LEGAL_REP_OF, rep, cust) + add_relationship(session, RelationshipType.RELATED_TO, rep, controller) + session.flush() + + resp = client.post( + "/datahub/penetrate", + json={"start_entity_id": str(controller.id), "max_depth": 3}, + ) + assert resp.status_code == 200 + body = resp.json() + related_ids = {r["entity"]["id"] for r in body["related"]} + assert str(cust.id) in related_ids + assert body["related_count"] >= 2 + + +def test_penetrate_unknown_entity_404(client): + resp = client.post( + "/datahub/penetrate", + json={"start_entity_id": str(uuid.uuid4()), "max_depth": 2}, + ) + assert resp.status_code == 404 + + +def test_get_entity_endpoint(client, session): + suffix = uuid.uuid4().hex[:8] + e = upsert_entity(session, EntityType.SUPPLIER, f"SUP-{suffix}", "供应商甲") + session.flush() + resp = client.get(f"/datahub/entities/{e.id}") + assert resp.status_code == 200 + assert resp.json()["business_key"] == f"SUP-{suffix}" diff --git a/backend/tests/integration/test_graph_repo.py b/backend/tests/integration/test_graph_repo.py new file mode 100644 index 0000000..0fb6363 --- /dev/null +++ b/backend/tests/integration/test_graph_repo.py @@ -0,0 +1,76 @@ +"""知识图谱穿透集成测试(需 PostgreSQL)。 + +验证 R2 关键能力:通过关系边的多跳穿透识别"疑似同一实控人", +以及本体约束对非法关系的拒绝。对应场景一(政企拆单+隐性实控人,R8)的图谱基础。 +""" + +from __future__ import annotations + +import pytest + +from app.datahub.graph_repo import ( + OntologyViolationError, + add_relationship, + find_related_entities, + upsert_entity, +) +from app.datahub.ontology import EntityType, RelationshipType + + +def test_upsert_entity_is_idempotent(session): + e1 = upsert_entity(session, EntityType.CUSTOMER, "CUST-001", "客户甲") + e2 = upsert_entity(session, EntityType.CUSTOMER, "CUST-001", "客户甲") + assert e1.id == e2.id + + +def test_ontology_violation_rejected(session): + contract = upsert_entity(session, EntityType.CONTRACT, "C-1") + customer = upsert_entity(session, EntityType.CUSTOMER, "CUST-2") + # 合同 —签约→ 客户 方向非法 + with pytest.raises(OntologyViolationError): + add_relationship(session, RelationshipType.SIGNED, contract, customer) + + +def test_detect_shared_controller_across_customers(session): + """模拟"8 个客户疑似同一实控人":多个客户经法人关联到同一实控自然人。 + + 构图:每个客户 <-法定代表人- 各自法人;各法人 -关联-> 同一实控人。 + 从实控人出发,应能穿透到全部客户。 + """ + controller = upsert_entity(session, EntityType.LEGAL_PERSON, "PER-CTRL", "实控人") + + customers = [] + for i in range(8): + cust = upsert_entity(session, EntityType.CUSTOMER, f"CUST-{i}", f"政企客户{i}") + rep = upsert_entity(session, EntityType.LEGAL_PERSON, f"PER-{i}", f"法人{i}") + # 法人 —法定代表人→ 客户 + add_relationship(session, RelationshipType.LEGAL_REP_OF, rep, cust) + # 法人 —关联(亲属/实控)→ 实控人 + add_relationship(session, RelationshipType.RELATED_TO, rep, controller) + customers.append(cust) + session.flush() + + related = find_related_entities(session, controller.id, max_depth=3) + related_ids = {rid for rid, _ in related} + + # 从实控人 3 跳内应能穿透到全部 8 个客户 + for cust in customers: + assert cust.id in related_ids, f"未穿透到 {cust.business_key}" + + +def test_traversal_respects_max_depth(session): + a = upsert_entity(session, EntityType.LEGAL_PERSON, "A") + b = upsert_entity(session, EntityType.LEGAL_PERSON, "B") + c = upsert_entity(session, EntityType.CUSTOMER, "C") + add_relationship(session, RelationshipType.RELATED_TO, a, b) + add_relationship(session, RelationshipType.LEGAL_REP_OF, b, c) + session.flush() + + # depth=1:从 A 只能到 B,到不了 C + ids_d1 = {rid for rid, _ in find_related_entities(session, a.id, max_depth=1)} + assert b.id in ids_d1 + assert c.id not in ids_d1 + + # depth=2:能到 C + ids_d2 = {rid for rid, _ in find_related_entities(session, a.id, max_depth=2)} + assert c.id in ids_d2 diff --git a/backend/tests/test_egress_policy.py b/backend/tests/test_egress_policy.py new file mode 100644 index 0000000..ae5d8aa --- /dev/null +++ b/backend/tests/test_egress_policy.py @@ -0,0 +1,42 @@ +"""数据零出域红线测试:prod 环境必须禁用公网 LLM Provider。""" + +import pytest + +from app.config import AppEnv, LLMProviderName, Settings +from app.llm.factory import EgressPolicyError, get_llm_provider + + +def _settings(env: AppEnv, provider: LLMProviderName) -> Settings: + return Settings(aiaudit_env=env, llm_provider=provider, dashscope_api_key="x") + + +def test_prod_blocks_public_dashscope(): + s = _settings(AppEnv.prod, LLMProviderName.dashscope) + with pytest.raises(EgressPolicyError): + get_llm_provider(s) + + +def test_prod_allows_local_vllm(): + s = _settings(AppEnv.prod, LLMProviderName.vllm) + provider = get_llm_provider(s) + assert provider.name == "vllm" + assert provider.egress is False + + +def test_dev_allows_dashscope(): + s = _settings(AppEnv.dev, LLMProviderName.dashscope) + provider = get_llm_provider(s) + assert provider.name == "dashscope" + assert provider.egress is True + + +def test_validate_egress_policy_raises_in_prod(): + s = _settings(AppEnv.prod, LLMProviderName.dashscope) + with pytest.raises(RuntimeError): + s.validate_egress_policy() + + +def test_validate_egress_policy_ok_in_dev(): + s = _settings(AppEnv.dev, LLMProviderName.dashscope) + # dev 下不应抛出 + s.validate_egress_policy() diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..59b9a99 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,21 @@ +"""健康检查端点测试。""" + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_ok(): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +def test_health_config(): + resp = client.get("/health/config") + assert resp.status_code == 200 + body = resp.json() + assert "env" in body + assert "llm_provider" in body diff --git a/backend/tests/test_ontology.py b/backend/tests/test_ontology.py new file mode 100644 index 0000000..f59fae6 --- /dev/null +++ b/backend/tests/test_ontology.py @@ -0,0 +1,42 @@ +"""审计本体约束测试(无需数据库)。""" + +from app.datahub.ontology import EntityType, RelationshipType, is_valid_relationship + + +def test_valid_signed_relationship(): + assert is_valid_relationship( + RelationshipType.SIGNED, EntityType.CUSTOMER, EntityType.CONTRACT + ) + + +def test_invalid_signed_direction(): + # 合同不能"签约"客户(方向反了) + assert not is_valid_relationship( + RelationshipType.SIGNED, EntityType.CONTRACT, EntityType.CUSTOMER + ) + + +def test_legal_rep_relationship(): + assert is_valid_relationship( + RelationshipType.LEGAL_REP_OF, EntityType.LEGAL_PERSON, EntityType.SUPPLIER + ) + + +def test_related_to_between_legal_persons(): + # 实控人关联识别的基础:法人之间的亲属/关联关系 + assert is_valid_relationship( + RelationshipType.RELATED_TO, EntityType.LEGAL_PERSON, EntityType.LEGAL_PERSON + ) + + +def test_invalid_relationship_wrong_target(): + assert not is_valid_relationship( + RelationshipType.HOLDS_MSISDN, EntityType.CUSTOMER, EntityType.CONTRACT + ) + + +def test_all_relationship_types_have_domain(): + from app.datahub.ontology import RELATIONSHIP_DOMAIN + + for rel in RelationshipType: + assert rel in RELATIONSHIP_DOMAIN, f"关系 {rel} 缺少本体域定义" diff --git a/docs/adr/ADR-0001-tech-stack.md b/docs/adr/ADR-0001-tech-stack.md new file mode 100644 index 0000000..10e682a --- /dev/null +++ b/docs/adr/ADR-0001-tech-stack.md @@ -0,0 +1,43 @@ +# ADR-0001 · 技术选型决策记录 + +> 项目:AIAudit(本地私有化大模型电信运营商 AI 全域内审平台) +> 状态:已接受(MVP 基线) +> 日期:2026-06 +> 关联:`0-req-AIAudit.md`、`1-prd-AIAudit.md`、`2-task-AIAudit.md`(任务 P0.1) + +## 背景 +平台需在本地内网部署、数据零出域,具备本体/知识图谱、双时态/时序、本地 LLM 推理、向量检索等能力,并需信创适配。技术选型需在"能力完整"与"组件最少、出域面最小、便于信创"之间取得平衡。 + +## 决策 + +| 层 | 选型 | 说明 | +| --- | --- | --- | +| 后端 | Python 3.12 + FastAPI | 贴近 LLM/数据/ML 生态,异步任务友好 | +| 前端 | React + TypeScript + Vite | 看板/下钻交互成熟 | +| 主存储 | PostgreSQL 16 | 一库多能,降低组件数与出域面 | +| 知识图谱 | PostgreSQL + Apache AGE | 免独立图库,信创友好,满足 MVP 多跳穿透 | +| 双时态/时序 | PostgreSQL 时态列 + TimescaleDB | 双时态回放 + 时间序列一体 | +| 向量检索 | pgvector | 与主库同栈,免独立向量库 | +| 任务调度 | Celery + Redis | 全量扫描异步任务、进度反馈 | +| 文件/对象 | MinIO(本地 S3) | 凭证/底稿存储,不出域 | +| LLM 推理 | Provider 抽象:开发期 DashScope 公网千问;生产 vLLM + 本地 70B | 见下"LLM 抽象与红线" | +| 部署 | 本地 Homebrew 安装(开发)→ 生产内网裸机/信创环境 | 不使用 Docker;开发直接用本机 PostgreSQL 16 + 本地服务 | + +## LLM 抽象与数据零出域红线(关键约束) +- LLM 通过统一 `LLMProvider` 接口接入,至少实现两种:`DashScopeProvider`(公网千问,**仅开发/测试**)、`VllmProvider`(本地,生产)。 +- **红线:公网 Provider 只允许处理脱敏/样例假数据,严禁传入任何真实审计数据。** 通过配置开关 + 环境标识(dev/prod)强约束;prod 环境禁用任何公网 Provider。 +- 切换 Provider 仅改配置,不改业务代码。 + +## 本机环境结论(开发机) +- Mac mini · Apple M4 · 16GB · macOS 26.5.1(ARM64);磁盘可用 ~170GB。 +- 开发 MVP 够用(样例数据 + 公网千问 API + Docker 组件)。 +- **不能本地运行 70B**;生产推理需独立 GPU 服务器(A100/H100/国产 GPU)跑 vLLM。 + +## 备选与未选原因 +- 独立 Neo4j 图库:能力更强但增加组件与信创/授权负担,MVP 暂不引入;图谱压力增大时再评估。 +- 独立时序库 / 独立向量库:同理,先用 PG 一体化,后续按压力拆分。 +- 后端 Java Spring Boot:企业集成习惯好,但 LLM/数据/ML 生态以 Python 为主,会多一层;若团队为 Java 班底可改为"Java 主服务 + Python 分析/推理服务"。 + +## 影响 +- 开发环境:本机 Homebrew 安装 PostgreSQL 16 + TimescaleDB + pgvector,不使用 Docker(已移除 docker-compose 与自定义镜像);初始化脚本见 `infra/postgres/setup_local.sh`。 +- 生产部署需规划独立 GPU 推理节点(任务 P3.5 信创适配同步评估)。 diff --git a/docs/adr/ADR-0002-data-platform-modeling.md b/docs/adr/ADR-0002-data-platform-modeling.md new file mode 100644 index 0000000..2736241 --- /dev/null +++ b/docs/adr/ADR-0002-data-platform-modeling.md @@ -0,0 +1,33 @@ +# ADR-0002 · 数据中台建模决策(本体 / 双时态 / 时序 / 图谱) + +> 项目:AIAudit 状态:已接受(MVP) 日期:2026-06 +> 关联:`0-req-AIAudit.md`(R2、R3)、任务 P1.2 / P1.3 + +## 背景 +审计数据中台需同时满足:①按审计本体组织实体与关系(知识图谱穿透);②双时态建模(业务时间+系统时间,可回放历史);③时间序列(时序造假识别)。需在能力与可靠性/组件数之间平衡。 + +## 决策 + +### 1. 本体与知识图谱:关系表 + 递归 CTE(MVP) +- 用 `entity`(实体)+ `entity_relationship`(关系边)两张通用表承载审计本体,实体类型与关系类型由 `ontology_entity_type` / `ontology_relationship_type` 字典定义。 +- 多跳穿透(如实控人识别)用 PostgreSQL **递归 CTE** 实现。 +- 不在 MVP 引入 Apache AGE,规避源码编译的构建脆弱性;后续多跳压力增大再评估迁移到 AGE/Neo4j。 + +### 2. 双时态建模 +- 关键审计对象采用双时态: + - 业务有效期:`valid_from` / `valid_to`(应用时间)。 + - 系统记录期:`system_from` / `system_to`(事务时间)。 +- 用 `tstzrange` + `btree_gist` 排他约束防止同一实体业务有效期重叠。 +- "按任意历史时点回放"= 给定 `(as_of_valid, as_of_system)` 过滤两条时间线。 + +### 3. 时间序列 +- 行为/指标类数据(用户生命周期事件、回款、话务、佣金、资源使用)写入 `metric_event` 等表。 +- 生产环境(Linux)用 TimescaleDB `create_hypertable` 转为超表,按时间分区/压缩。 +- **本地开发(macOS)**:因 TimescaleDB 在 macOS 上 Homebrew 编译不稳定,本地跳过该扩展,`metric_event` 作为普通索引表使用;超表转换在迁移中条件执行(扩展存在才转),**不影响功能**,仅少了规模优化。 + +### 4. 数据版本与可追溯 +- 每批数据落地登记 `data_version`(来源、批次、时间、行数),业务记录引用 `data_version_id`,使任一结论可回溯到当时数据版本(R3)。 + +## 影响 +- MVP 仅依赖 TimescaleDB + pgvector + btree_gist,镜像可靠(`timescaledb-ha:pg16` 内置)。 +- 图能力以关系建模实现,接口层(统一穿透查询服务)对上层屏蔽底层是关系还是图库,便于将来替换。 diff --git a/docs/build_ppt2.py b/docs/build_ppt2.py new file mode 100644 index 0000000..f6d6cf7 --- /dev/null +++ b/docs/build_ppt2.py @@ -0,0 +1,779 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""精美版:本地LLM审计方案 PPTX,带流程图/热力矩阵/架构分层/卡片排版。""" +from pptx import Presentation +from pptx.util import Inches, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE +from pptx.oxml.ns import qn +import copy + +# ---------- 主题色 ---------- +BG = RGBColor(0x0A, 0x16, 0x2E) # 主深蓝背景 +BG2 = RGBColor(0x0E, 0x20, 0x42) # 次背景 +CARD = RGBColor(0x14, 0x2B, 0x52) # 卡片 +CARD2 = RGBColor(0x1B, 0x37, 0x66) # 卡片亮 +CYAN = RGBColor(0x2D, 0xE0, 0xD0) # 主青 +CYAN_D = RGBColor(0x16, 0x9B, 0x97) +BLUE = RGBColor(0x3B, 0x82, 0xF6) # 蓝 +PURPLE = RGBColor(0x8B, 0x7CF if False else 0x7C, 0xF6) +GOLD = RGBColor(0xF5, 0xB7, 0x42) # 金 +RED = RGBColor(0xEF, 0x5A, 0x5A) # 红(高风险) +ORANGE = RGBColor(0xF2, 0x8B, 0x3C) +GREEN = RGBColor(0x35, 0xC7, 0x59) +WHITE = RGBColor(0xFF, 0xFF, 0xFF) +LIGHT = RGBColor(0xCB, 0xD8, 0xEC) # 正文浅 +MUTE = RGBColor(0x8A, 0x9C, 0xB8) # 弱化 + +FONT = "PingFang SC" +FONT_B = "PingFang SC" + +SW, SH = Inches(13.333), Inches(7.5) +prs = Presentation() +prs.slide_width, prs.slide_height = SW, SH +BLANK = prs.slide_layouts[6] + + +# ---------- 底层工具 ---------- +def _set_grad(shape, c1, c2, angle=90): + """给 shape 设置线性渐变填充。""" + sp = shape.fill._xPr # spPr + # 移除已有填充 + for tag in ('a:noFill','a:solidFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill'): + for e in sp.findall(qn(tag)): + sp.remove(e) + grad = sp.makeelement(qn('a:gradFill'), {}) + lst = grad.makeelement(qn('a:gsLst'), {}) + for pos, col in ((0, c1), (100000, c2)): + gs = grad.makeelement(qn('a:gs'), {'pos': str(pos if pos else int(pos*1000))}) + if pos == 0: gs.set('pos','0') + else: gs.set('pos','100000') + clr = gs.makeelement(qn('a:srgbClr'), {'val': '%02X%02X%02X' % (col[0],col[1],col[2])}) + gs.append(clr); lst.append(gs) + grad.append(lst) + lin = grad.makeelement(qn('a:lin'), {'ang': str(int(angle*60000)), 'scaled':'1'}) + grad.append(lin) + # 插入到 ln 之前 + ln = sp.find(qn('a:ln')) + if ln is not None: sp.insert(list(sp).index(ln), grad) + else: sp.append(grad) + + +def bg_gradient(slide, c1=BG, c2=BG2): + r = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SW, SH) + r.line.fill.background(); r.shadow.inherit = False + r.fill.solid(); r.fill.fore_color.rgb = c1 + _set_grad(r, c1, c2, angle=120) + return r + + +def rrect(slide, x, y, w, h, color=None, grad=None, line=None, lw=1, radius=0.08): + sp = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h) + sp.shadow.inherit = False + try: + sp.adjustments[0] = radius + except Exception: + pass + if grad: + sp.fill.solid(); sp.fill.fore_color.rgb = grad[0] + _set_grad(sp, grad[0], grad[1], angle=90) + elif color is not None: + sp.fill.solid(); sp.fill.fore_color.rgb = color + else: + sp.fill.background() + if line is not None: + sp.line.color.rgb = line; sp.line.width = Pt(lw) + else: + sp.line.fill.background() + return sp + + +def rect(slide, x, y, w, h, color=None, grad=None, line=None, lw=1): + sp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) + sp.shadow.inherit = False + if grad: + sp.fill.solid(); sp.fill.fore_color.rgb = grad[0]; _set_grad(sp, grad[0], grad[1]) + elif color is not None: + sp.fill.solid(); sp.fill.fore_color.rgb = color + else: + sp.fill.background() + if line is not None: + sp.line.color.rgb = line; sp.line.width = Pt(lw) + else: + sp.line.fill.background() + return sp + + +def circle(slide, x, y, d, color=None, grad=None, line=None, lw=1.5): + sp = slide.shapes.add_shape(MSO_SHAPE.OVAL, x, y, d, d) + sp.shadow.inherit = False + if grad: + sp.fill.solid(); sp.fill.fore_color.rgb = grad[0]; _set_grad(sp, grad[0], grad[1]) + elif color is not None: + sp.fill.solid(); sp.fill.fore_color.rgb = color + else: + sp.fill.background() + if line is not None: + sp.line.color.rgb = line; sp.line.width = Pt(lw) + else: + sp.line.fill.background() + return sp + + +def chevron(slide, x, y, w, h, color): + sp = slide.shapes.add_shape(MSO_SHAPE.CHEVRON, x, y, w, h) + sp.shadow.inherit = False + sp.fill.solid(); sp.fill.fore_color.rgb = color + sp.line.fill.background() + return sp + + +def shape_text(shape, runs, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, wrap=True): + tf = shape.text_frame; tf.word_wrap = wrap; tf.vertical_anchor = anchor + tf.margin_left = Pt(4); tf.margin_right = Pt(4) + tf.margin_top = Pt(2); tf.margin_bottom = Pt(2) + for i, para in enumerate(runs): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.alignment = align; p.space_after = Pt(2); p.space_before = Pt(0) + for (t, sz, c, b) in para: + r = p.add_run(); r.text = t + r.font.size = Pt(sz); r.font.color.rgb = c; r.font.bold = b; r.font.name = FONT + + +def txt(slide, x, y, w, h, runs, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, + wrap=True, sa=6, line_spacing=None): + tb = slide.shapes.add_textbox(x, y, w, h); tf = tb.text_frame + tf.word_wrap = wrap; tf.vertical_anchor = anchor + for i, para in enumerate(runs): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.alignment = align; p.space_after = Pt(sa); p.space_before = Pt(0) + if line_spacing: p.line_spacing = line_spacing + for (t, sz, c, b) in para: + r = p.add_run(); r.text = t + r.font.size = Pt(sz); r.font.color.rgb = c; r.font.bold = b; r.font.name = FONT + return tb + + +def header(slide, kicker, title): + rect(slide, Inches(0.7), Inches(0.62), Inches(0.14), Inches(0.62), grad=(CYAN, BLUE)) + txt(slide, Inches(0.98), Inches(0.5), Inches(11.5), Inches(0.4), + [[(kicker, 13, CYAN, True)]], sa=0) + txt(slide, Inches(0.95), Inches(0.82), Inches(11.6), Inches(0.7), + [[(title, 29, WHITE, True)]], sa=0) + + +def footer(slide, page): + rect(slide, 0, Inches(7.18), SW, Inches(0.32), color=BG2) + txt(slide, Inches(0.7), Inches(7.16), Inches(6), Inches(0.32), + [[("数据不出域 · 审计全穿透", 9.5, MUTE, False)]], anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(slide, Inches(11.6), Inches(7.16), Inches(1.0), Inches(0.32), + [[(f"{page:02d} / 23", 10, CYAN, True)]], align=PP_ALIGN.RIGHT, + anchor=MSO_ANCHOR.MIDDLE, sa=0) + + +def new(kicker=None, title=None, page=None): + s = prs.slides.add_slide(BLANK) + bg_gradient(s) + # 右上角装饰圆 + circle(s, Inches(11.6), Inches(-1.0), Inches(3.2), color=BG2) + circle(s, Inches(12.4), Inches(-0.4), Inches(1.6), color=CARD) + if kicker is not None: + header(s, kicker, title) + if page is not None: + footer(s, page) + return s + + +# ============== 1 封面 ============== +def cover(): + s = prs.slides.add_slide(BLANK) + bg_gradient(s, RGBColor(0x07,0x10,0x24), RGBColor(0x10,0x29,0x52)) + # 几何装饰 + circle(s, Inches(9.8), Inches(-1.6), Inches(5.2), color=RGBColor(0x10,0x24,0x48)) + circle(s, Inches(11.2), Inches(0.2), Inches(2.8), grad=(CYAN_D, BG)) + for i, d in enumerate([Inches(0.16)]*3): + circle(s, Inches(10.2+ i*0.5), Inches(4.6 + i*0.35), d, color=CYAN) + rect(s, 0, 0, Inches(0.22), SH, grad=(CYAN, BLUE)) + txt(s, Inches(0.9), Inches(1.7), Inches(3), Inches(0.5), + [[("AI · 全域内审", 15, CYAN, True)]], sa=0) + rect(s, Inches(0.95), Inches(2.2), Inches(2.4), Pt(4), color=CYAN) + txt(s, Inches(0.88), Inches(2.45), Inches(10.5), Inches(1.7), + [[("数据不出域", 56, WHITE, True)], [("审计全穿透", 56, CYAN, True)]], sa=4) + txt(s, Inches(0.95), Inches(4.95), Inches(10.5), Inches(1.2), + [[("基于本地私有化大模型的电信运营商 AI 全域内审体系", 20, WHITE, True)], + [("不是一套工具,而是一套建在自己机房里、越用越聪明的审计能力体系", 14.5, LIGHT, False)]], sa=8) + rect(s, Inches(0.95), Inches(6.35), Inches(0.5), Pt(3), color=CYAN) + txt(s, Inches(0.95), Inches(6.5), Inches(6), Inches(0.4), + [[("2026 年 6 月", 13, MUTE, False)]], sa=0) +cover() + + +# ============== 2 痛点:三个数字 + 三类困局 ============== +def pain(): + s = new("现状 · 困局", "为什么传统审计“查不过来”?", 2) + # 三个大数字卡片 + stats = [("150亿", "年业务规模", BLUE), ("5000万", "潜在异常金额", GOLD), ("5%", "传统抽样覆盖率", RED)] + x = Inches(0.95); w = Inches(3.62); gap = Inches(0.27) + for i,(num, lab, col) in enumerate(stats): + cx = x + i*(w+gap) + c = rrect(s, cx, Inches(1.75), w, Inches(1.55), color=CARD, radius=0.1) + rect(s, cx, Inches(1.75), Inches(0.1), Inches(1.55), color=col) + txt(s, cx+Inches(0.25), Inches(1.92), w-Inches(0.3), Inches(0.85), + [[(num, 38, col, True)]], sa=0) + txt(s, cx+Inches(0.27), Inches(2.75), w-Inches(0.3), Inches(0.4), + [[(lab, 14, LIGHT, False)]], sa=0) + # 三类困局 + cases = [ + ("拆单规避", "8 个客户各签 600 万 ICT 项目全拆成 80 万以下,三重一大抽样完美避开。"), + ("稳定的定,稳定的退", "每月新增 6000 人订彩铃,3 个月后首月用户全退订,渠道已领佣金、骗补后弃养。"), + ("Excel 干不过来", "海量单据只能抽样,查不全查不深,5000 万异常如针落大海。"), + ] + y = Inches(3.55) + for i,(t, d) in enumerate(cases): + cy = y + i*Inches(0.78) + rrect(s, Inches(0.95), cy, Inches(11.55), Inches(0.66), color=CARD if i%2 else CARD2, radius=0.12) + circle(s, Inches(1.12), cy+Inches(0.13), Inches(0.4), grad=(CYAN, BLUE)) + txt(s, Inches(1.12), cy+Inches(0.13), Inches(0.4), Inches(0.4), + [[(str(i+1), 16, WHITE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(1.7), cy, Inches(2.4), Inches(0.66), + [[(t, 14.5, CYAN, True)]], anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(4.0), cy, Inches(8.3), Inches(0.66), + [[(d, 12.5, LIGHT, False)]], anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(0.95), Inches(6.05), Inches(11.55), Inches(0.5), + [[("核心矛盾:", 13, GOLD, True), + ("数据涉政企合同/用户隐私/财务凭证,上公有云=裸奔;不上 AI 又干不过来。", 13, WHITE, False)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0) +pain() + + +# ============== 通用表格 ============== +def style_table(table, headers, rows, col_widths, total_w, top, left=Inches(0.95), + hrow=Inches(0.52), rrow=Inches(0.62), fs=12, hfs=13): + n = len(rows)+1 + for i,w in enumerate(col_widths): + table.columns[i].width = Inches(w) + table.rows[0].height = hrow + for i in range(1, n): table.rows[i].height = rrow + # remove default style banding via first row formatting + for j,h in enumerate(headers): + c = table.cell(0,j); c.fill.solid(); c.fill.fore_color.rgb = CYAN_D + c.vertical_anchor = MSO_ANCHOR.MIDDLE + p = c.text_frame.paragraphs[0]; p.alignment = PP_ALIGN.CENTER + c.text_frame.word_wrap = True + r = p.add_run(); r.text = h; r.font.size = Pt(hfs); r.font.bold = True + r.font.color.rgb = WHITE; r.font.name = FONT + for i,row in enumerate(rows, start=1): + for j,val in enumerate(row): + c = table.cell(i,j); c.fill.solid() + c.fill.fore_color.rgb = CARD if i%2 else CARD2 + c.vertical_anchor = MSO_ANCHOR.MIDDLE + c.text_frame.word_wrap = True + p = c.text_frame.paragraphs[0] + p.alignment = PP_ALIGN.CENTER if j==0 else PP_ALIGN.LEFT + r = p.add_run(); r.text = val; r.font.size = Pt(fs) + r.font.bold = (j==0) + r.font.color.rgb = CYAN if j==0 else LIGHT + r.font.name = FONT + + +def table_slide(page, kicker, title, headers, rows, note=None, col_widths=None, + fs=12, hfs=13): + s = new(kicker, title, page) + total = sum(col_widths) + top = Inches(1.85) + shp = s.shapes.add_table(len(rows)+1, len(headers), Inches(0.95), top, + Inches(total), Inches(0.5)+Inches(0.6)*len(rows)) + # 去掉自带样式 + tbl = shp.table + style_table(tbl, headers, rows, col_widths, total, top, fs=fs, hfs=hfs) + if note: + ny = top + Inches(0.5)+Inches(0.62)*len(rows) + Inches(0.3) + rrect(s, Inches(0.95), ny, Inches(11.55), Inches(0.7), color=CARD2, radius=0.18) + rect(s, Inches(0.95), ny, Inches(0.1), Inches(0.7), color=GOLD) + txt(s, Inches(1.25), ny, Inches(11.1), Inches(0.7), + [[("▶ ", 13, GOLD, True), (note, 13, WHITE, True)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0) + return s + + +# ============== 3 三方对比 ============== +table_slide(3, "破局 · 定位", "本地 LLM 让“安全”和“智能”不再二选一", + ["对比维度", "传统抽样审计", "公有云 AI 审计", "本地 LLM 审计(我们)"], + [ + ["数据范围", "按金额抽样,查不全", "全量扫描,但数据出域", "全量扫描,数据不出机房"], + ["规则能力", "规则写死,反向规避", "模型强,但合规风险高", "模型私有化,合规可控"], + ["响应效率", "Excel 翻表,效率低", "实时预警,依赖外网", "内网闭环,秒级响应"], + ["交互模式", "人找数据", "数据找人,但数据送人", "数据找人,数据原地不动"], + ["能力归属", "经验在人脑,人走经验走", "能力在外部,租用即失", "能力沉淀本地,越用越聪明"], + ], + note="把千问 70B / DeepSeek 装进本地机房,让 AI 在数据旁边干活,而不是把数据送给 AI。", + col_widths=[2.0, 3.1, 3.2, 3.25]) + + +# ============== 4 审计域全景 + 风险热力矩阵 ============== +def domain_heat(): + s = new("方法论 · 框架", "审计域全景图 + 风险分级模型", 4) + # 左:五大风险域 + domains = [ + ("收入域", "政企穿透·跨期匹配·云空转", CYAN), + ("成本域", "渠道佣金·终端补贴·摊销", BLUE), + ("采购域", "网络建设·工程·围标串标", PURPLE), + ("资金域", "回款挂账·网间结算·流向", GOLD), + ("合规域", "员工舞弊·权限·积分套现", GREEN), + ] + txt(s, Inches(0.95), Inches(1.75), Inches(6), Inches(0.4), + [[("五大风险域 · 全覆盖", 15, WHITE, True)]], sa=0) + y = Inches(2.25) + for i,(t,d,col) in enumerate(domains): + cy = y + i*Inches(0.82) + rrect(s, Inches(0.95), cy, Inches(6.0), Inches(0.68), color=CARD, radius=0.14) + rect(s, Inches(0.95), cy, Inches(0.12), Inches(0.68), color=col) + circle(s, Inches(1.2), cy+Inches(0.14), Inches(0.4), color=col) + txt(s, Inches(1.55), cy, Inches(1.5), Inches(0.68), + [[(t, 15, WHITE, True)]], anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(3.0), cy, Inches(3.85), Inches(0.68), + [[(d, 11.5, LIGHT, False)]], anchor=MSO_ANCHOR.MIDDLE, sa=0) + # 右:风险热力矩阵 3x3 + txt(s, Inches(7.4), Inches(1.75), Inches(5), Inches(0.4), + [[("风险热力矩阵 · 有优先级", 15, WHITE, True)]], sa=0) + gx, gy = Inches(8.35), Inches(2.35) + cell = Inches(1.25) + # 颜色矩阵 [行=金额影响 高->低][列=概率 低->高] + heat = [ + [ORANGE, RED, RED], + [GOLD, ORANGE, RED], + [GREEN, GOLD, ORANGE], + ] + labels = [ + ["", "", "优先\n全量监控"], + ["重点\n定向穿透", "", ""], + ["", "", "批量\n聚类筛查"], + ] + for r_ in range(3): + for c_ in range(3): + cx = gx + c_*cell; cyy = gy + r_*cell + rrect(s, cx, cyy, cell-Inches(0.08), cell-Inches(0.08), + color=heat[r_][c_], radius=0.12) + if labels[r_][c_]: + lines = [[(seg, 9.5, WHITE, True)] for seg in labels[r_][c_].split("\n")] + txt(s, cx, cyy, cell-Inches(0.08), cell-Inches(0.08), + lines, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + # 轴标签 + txt(s, gx-Inches(0.05), gy-Inches(0.05), cell*3, Inches(0.3), [], sa=0) + txt(s, Inches(7.55), gy, Inches(0.75), cell*3, + [[("金\n额\n影\n响", 11, CYAN, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, gx, gy+cell*3-Inches(0.02), cell*3, Inches(0.35), + [[("发生概率 低 → 高", 11, CYAN, True)]], align=PP_ALIGN.CENTER, sa=0) + # 底注 + ny = Inches(6.35) + rrect(s, Inches(0.95), ny, Inches(11.55), Inches(0.62), color=CARD2, radius=0.2) + rect(s, Inches(0.95), ny, Inches(0.1), Inches(0.62), color=GOLD) + txt(s, Inches(1.25), ny, Inches(11.1), Inches(0.62), + [[("▶ ", 13, GOLD, True), + ("不是工具集合,而是有体系、有优先级的全域审计框架。", 13, WHITE, True)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0) +domain_heat() + + +# ============== 5 四大引擎(2x2卡片)============== +def engines(): + s = new("能力 · 底座", "“本地 AI 审计大脑”四大核心引擎", 5) + cards = [ + ("01", "本地私有化 LLM 引擎", "模型本地化部署,数据绝不出域;推理、规则配置、报告生成、线索解释。", CYAN), + ("02", "全量穿透引擎", "直连 BSS/OSS/ERP/财务,不抽样,对所有合同、回款、行为做关联扫描。", BLUE), + ("03", "规则进化引擎(护城河)", "自然语言描述新造假→自动转规则→沙箱验证→把顾问经验固化为机构资产。", GOLD), + ("04", "线索驱动引擎", "对异常聚类做人话解释,输出附证据链的高价值线索,直推审计员桌面。", GREEN), + ] + W, H = Inches(5.7), Inches(2.18) + gx, gy = Inches(0.95), Inches(1.85) + gapx, gapy = Inches(0.18), Inches(0.22) + for i,(no,t,d,col) in enumerate(cards): + r,c = divmod(i,2) + x = gx + c*(W+gapx); y = gy + r*(H+gapy) + rrect(s, x, y, W, H, color=CARD, radius=0.07) + rect(s, x, y, Inches(0.14), H, color=col) + circle(s, x+Inches(0.35), y+Inches(0.32), Inches(0.85), grad=(col, BG2)) + txt(s, x+Inches(0.35), y+Inches(0.32), Inches(0.85), Inches(0.85), + [[(no, 26, WHITE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, x+Inches(1.4), y+Inches(0.35), W-Inches(1.6), Inches(0.6), + [[(t, 17, WHITE, True)]], sa=0) + txt(s, x+Inches(1.4), y+Inches(1.0), W-Inches(1.65), Inches(1.05), + [[(d, 12.5, LIGHT, False)]], sa=0, line_spacing=1.15) +engines() + + +# ============== 6-13 场景页 ============== +def scene(page, no, name, color, blocks): + s = prs.slides.add_slide(BLANK); bg_gradient(s) + # 左侧色带 + rect(s, 0, 0, Inches(3.0), SH, color=BG2) + rect(s, Inches(3.0), 0, Inches(0.06), SH, color=color) + circle(s, Inches(0.55), Inches(2.0), Inches(1.9), grad=(color, BG2)) + txt(s, Inches(0.55), Inches(2.05), Inches(1.9), Inches(1.9), + [[(no, 60, WHITE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(0.3), Inches(0.55), Inches(2.4), Inches(0.4), + [[("场景", 14, color, True)]], align=PP_ALIGN.CENTER, sa=0) + txt(s, Inches(0.25), Inches(4.15), Inches(2.55), Inches(1.6), + [[(seg, 19, WHITE, True)] for seg in name.split("\n")], + align=PP_ALIGN.CENTER, sa=2) + # 右侧内容卡片 + y = Inches(0.7) + icons = {"经典案例":"●","扩展案例":"●","AI 审计点":"◆","本地 LLM 能力":"▲","业务链路":"►"} + for label, text in blocks: + h = Inches(1.18) if len(text) > 46 else Inches(0.95) + rrect(s, Inches(3.3), y, Inches(9.55), h, color=CARD if "案例" not in label or "扩展" in label else CARD2, radius=0.08) + # 标签条 + lab_col = color if label in ("AI 审计点","本地 LLM 能力","业务链路") else CYAN + txt(s, Inches(3.55), y+Inches(0.12), Inches(9.1), Inches(0.36), + [[(label, 13.5, lab_col, True)]], sa=0) + txt(s, Inches(3.55), y+Inches(0.46), Inches(9.1), h-Inches(0.5), + [[(text, 12.5, LIGHT, False)]], sa=0, line_spacing=1.12) + y = y + h + Inches(0.12) + footer(s, page) + +scenes = [ + ("01","政企收入\n全链路穿透", CYAN, [ + ("业务链路","立项→审批→报价→签约→开票→回款,全链路穿透。"), + ("经典案例(拆单规避+虚假回款)","8 个客户各签 600 万拆成 79-99 万规避审批,尾款 500 万长期挂账;注册地址同楼、法人为同一人亲属、付款账户同一实控企业。"), + ("AI 审计点","合同金额阈值边缘聚集;工商关联穿透识别隐性实控人;回款时序聚类识别批量违约。"), + ("本地 LLM 能力","自然语言查数、关联推理、一键生成《政企客户回款异常专项线索清单》。"), + ]), + ("02","市场业务\n真实性", BLUE, [ + ("经典案例(稳定的定,稳定的退)","每月新增 6000 人订彩铃,3 个月后首月用户全退订,骗补后弃养;号码集中乡镇、通话记录为零。"), + ("扩展案例(物联网卡虚假激活)","批量开通 10 万张卡称智慧停车,激活后零流量,按激活量领每台 50 元补贴,半年后集体沉默。"), + ("AI 审计点","用户生命周期时序识别;佣金与业务质量匹配;沉默/零通话用户聚类;交付物与收入交叉验证。"), + ("本地 LLM 能力","识别脉冲式增长+规律性衰减的周期性造假,自动提炼为新规则。"), + ]), + ("03","收入与成本\n跨期匹配", PURPLE, [ + ("经典案例(趸交收入一次性确认)","24 个月套餐送手表,收入应分 24 月却因趸交一把确认,手表成本却摊 24 月,确认时点严重错配。"), + ("扩展案例(提前确认)","云项目约定按用量计费,财务却在设备上架当月全额确认,客户前 6 月几乎零使用。"), + ("AI 审计点","自动勾稽确认政策 vs 账务 vs 合同;识别一次性确认异常分录;成本摊销与收入跨期匹配。"), + ("本地 LLM 能力","跨系统自动勾稽,识别收入成本确认时点错配的异常分录模式。"), + ]), + ("04","渠道佣金与\n代理商套利", GOLD, [ + ("经典案例(虚假放号+套机套卡)","批量买老人机插 5G 卡激活后丢弃,领 5G 迁转佣金每台 200 元+补贴 300 元,次月用户全流失。"), + ("扩展案例(异地窜货套利)","从邻省低价采购同款机,本省以新用户入网名义领高额补贴,手机回流二级市场。"), + ("AI 审计点","IMEI 与用户绑定真实性;佣金与在网时长匹配;终端流向追踪;代理商质量时序衰减。"), + ("本地 LLM 能力","IMEI 级终端流向追踪,识别激活-沉默-流失套利闭环。"), + ]), + ("05","网络建设与\n工程采购", GREEN, [ + ("经典案例(围标串标+虚增工程量)","3 家投标报价差异不足 1%、方案雷同,中标后同一班组施工,签证单同一笔迹不同日期批量签字。"), + ("扩展案例(虚假巡检)","系统显示月巡检 2000 次,GPS 比对实际只到 300 站,其余照片复用+坐标伪造。"), + ("AI 审计点","投标报价相似度与文件雷同度;工程量与资源消耗匹配;巡检轨迹与工单交叉;马甲供应商识别。"), + ("本地 LLM 能力","NLP 比对投标雷同度,GPS 轨迹与工单交叉验证,识别马甲供应商。"), + ]), + ("06","互联互通与\n网间结算", CYAN, [ + ("经典案例(话务量操纵)","与境外合谋虚假国际来话刷量,主叫为虚商号段,时长均为 30/60 秒整数倍,明显非真人。"), + ("扩展案例(短信网关刷量)","SP 伪造记录申报成功发送 10 亿条按 0.05 元/条结算,实际到达率不足 10%。"), + ("AI 审计点","话务量时序异常与整数时长聚集;结算数据与原始信令比对;SP/CP 业务量与结算交叉验证。"), + ("本地 LLM 能力","识别整数倍通话时长等非人类行为,信令级原始数据比对。"), + ]), + ("07","云业务/IDC\n与新兴业务", BLUE, [ + ("经典案例(云资源空转)","政企客户签 3 年云服务年付 100 万,CPU 利用率长期<5%、存储近空,却全额确认收入,实控人为领导亲属。"), + ("扩展案例(IDC 机柜虚租)","宣称出租率 90%,实际大量机柜无设备、电费为零,收入来自关联方预付租金。"), + ("AI 审计点","资源使用量 vs 计费量匹配;出租率与电力消耗勾稽;关联方与预付异常;确认与验收时序一致性。"), + ("本地 LLM 能力","资源利用率与计费量自动比对,关联方网络挖掘,识别空转收入。"), + ]), + ("08","员工内部舞弊\n与资源滥用", PURPLE, [ + ("经典案例(内部号码套利)","员工用权限批量开员工测试号对外出租免流套餐,流量收入全计入内部成本未确认收入。"), + ("扩展案例(积分套现)","勾结外部商户虚构消费批量刷积分兑换礼品卡变现,某商户单日发放量超正常 100 倍。"), + ("AI 审计点","权限操作日志异常模式;测试号实际用途偏离;积分流向追踪;权限与岗位匹配度。"), + ("本地 LLM 能力","操作日志异常挖掘,权限-岗位匹配分析,积分流向网络追踪。"), + ]), +] +pg = 6 +for no,name,col,blocks in scenes: + scene(pg, no, name, col, blocks); pg += 1 + + +# ============== 14 数据治理 ============== +def governance(): + s = new("工程 · 地基", "数据接入与治理层(全量穿透的前提)", 14) + items = [ + ("多源异构接入","适配 BSS/OSS/ERP/财务/合同/工单/信令各系统接口、数据库、文件,统一汇入本地数据湖。", CYAN), + ("主数据对齐","客户、合同、号码、工单、供应商跨系统实体统一,解决主键对不上。", BLUE), + ("数据质量探查与清洗","缺失、重复、口径不一自动探查清洗,建立质量评分。", PURPLE), + ("增量同步与时效","从年度快照升级为近实时增量,支撑常态化监控。", GOLD), + ] + W, H = Inches(5.7), Inches(1.55) + gx,gy = Inches(0.95), Inches(1.85) + for i,(t,d,col) in enumerate(items): + r,c = divmod(i,2) + x = gx + c*(W+Inches(0.18)); y = gy + r*(H+Inches(0.2)) + rrect(s, x, y, W, H, color=CARD, radius=0.08) + rect(s, x, y, Inches(0.12), H, color=col) + circle(s, x+Inches(0.32), y+Inches(0.3), Inches(0.55), color=col) + txt(s, x+Inches(0.32), y+Inches(0.3), Inches(0.55), Inches(0.55), + [[(str(i+1), 18, WHITE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, x+Inches(1.05), y+Inches(0.2), W-Inches(1.2), Inches(0.45), + [[(t, 15.5, WHITE, True)]], sa=0) + txt(s, x+Inches(1.05), y+Inches(0.66), W-Inches(1.25), Inches(0.8), + [[(d, 12, LIGHT, False)]], sa=0, line_spacing=1.12) + ny = Inches(5.5) + rrect(s, Inches(0.95), ny, Inches(11.55), Inches(0.95), color=CARD2, radius=0.12) + rect(s, Inches(0.95), ny, Inches(0.1), Inches(0.95), color=GOLD) + txt(s, Inches(1.25), ny, Inches(11.1), Inches(0.95), + [[("我们把脏活写进方案、承担下来", 14.5, GOLD, True)], + [("数据治理是这套体系工作量最大、最该提前立项的一环,而非回避。", 12.5, WHITE, False)]], + anchor=MSO_ANCHOR.MIDDLE, sa=3) +governance() + + +# ============== 15 人机协同闭环(流程图)============== +def closed_loop(): + s = new("闭环 · 价值", "人机协同闭环:线索之后才是价值", 15) + steps = ["AI 全量扫描","生成线索+证据链","审计员复核研判","自动生成底稿","定性 / 整改","复核销项闭环"] + cols = [CYAN, BLUE, PURPLE, BLUE, GOLD, GREEN] + n = len(steps) + x0 = Inches(0.85); y = Inches(2.1); w = Inches(1.95); h = Inches(0.95); ov = Inches(0.32) + step_w = (Inches(12.5) - w) / (n-1) + for i,(t,col) in enumerate(zip(steps, cols)): + x = x0 + step_w*i + ch = chevron(s, x, y, w+ov, h, col) + shape_text(ch, [[(seg, 12.5, WHITE, True)] for seg in t.split(" ")] if " " in t else [[(t,12.5,WHITE,True)]], + align=PP_ALIGN.CENTER) + # 返回箭头示意(闭环) + txt(s, Inches(0.85), Inches(3.05), Inches(12), Inches(0.4), + [[("◄──────────────── 规则进化反哺,越用越聪明 ────────────────►", 12, MUTE, True)]], + align=PP_ALIGN.CENTER, sa=0) + # 三栏角色 + roles = [ + ("AI 侧","出线索、附证据链、给判定理由、自动生成可追溯底稿。", CYAN), + ("审计员侧","复核研判、定性、决定整改或移交、最终签字。", BLUE), + ("闭环管理","线索分派、取证留痕、整改跟踪、销项复核全流程在线。", GOLD), + ] + W = Inches(3.7); gx = Inches(0.95); y2 = Inches(3.75) + for i,(t,d,col) in enumerate(roles): + x = gx + i*(W+Inches(0.22)) + rrect(s, x, y2, W, Inches(1.85), color=CARD, radius=0.08) + rect(s, x, y2, W, Inches(0.5), color=col) + txt(s, x, y2, W, Inches(0.5), [[(t, 15, WHITE, True)]], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, x+Inches(0.25), y2+Inches(0.65), W-Inches(0.5), Inches(1.1), + [[(d, 12.5, LIGHT, False)]], sa=0, line_spacing=1.18) + ny = Inches(5.95) + rrect(s, Inches(0.95), ny, Inches(11.55), Inches(0.62), color=CARD2, radius=0.2) + rect(s, Inches(0.95), ny, Inches(0.1), Inches(0.62), color=GOLD) + txt(s, Inches(1.25), ny, Inches(11.1), Inches(0.62), + [[("▶ ", 13, GOLD, True), + ("从“发现工具”升级为“办案平台”——每一步都接得住、留得痕。", 13, WHITE, True)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0) +closed_loop() + + +# ============== 16 误报治理 ============== +def fp_control(): + s = new("可信 · 落地", "误报治理与置信度分级(专业 = 诚实)", 16) + # 左:三级置信漏斗 + txt(s, Inches(0.95), Inches(1.8), Inches(6), Inches(0.4), + [[("三级置信分流", 15, WHITE, True)]], sa=0) + tiers = [("高置信","直接推送处置", GREEN, 6.0), + ("中置信","人工复核研判", GOLD, 4.6), + ("低置信","归档备查", MUTE, 3.2)] + y = Inches(2.35) + for t,d,col,w in tiers: + ww = Inches(w) + x = Inches(0.95) + (Inches(6.0)-ww)/2 + rrect(s, x, y, ww, Inches(0.85), color=col, radius=0.22) + shape = txt(s, x, y, ww, Inches(0.85), + [[(t, 15, WHITE, True)],[(d, 12, WHITE, False)]], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=1) + y = y + Inches(1.0) + # 右:要点卡片 + pts = [ + ("每条线索可解释","附证据链 + 判定理由,拒绝黑盒打分。", CYAN), + ("反馈学习闭环","审计员标注误报/属实,系统持续校准阈值,准确率随使用上升。", BLUE), + ("公开运营指标","命中率、准确率、线索转化率上看板,成效可量化可追溯。", GOLD), + ] + x = Inches(7.4); yy = Inches(2.35) + for t,d,col in pts: + rrect(s, x, yy, Inches(5.1), Inches(0.85), color=CARD, radius=0.1) + rect(s, x, yy, Inches(0.1), Inches(0.85), color=col) + txt(s, x+Inches(0.3), yy+Inches(0.1), Inches(4.7), Inches(0.32), + [[(t, 13.5, CYAN, True)]], sa=0) + txt(s, x+Inches(0.3), yy+Inches(0.42), Inches(4.7), Inches(0.4), + [[(d, 11.5, LIGHT, False)]], sa=0, line_spacing=1.05) + yy = yy + Inches(1.0) + ny = Inches(6.05) + rrect(s, Inches(0.95), ny, Inches(11.55), Inches(0.7), color=CARD2, radius=0.18) + rect(s, Inches(0.95), ny, Inches(0.1), Inches(0.7), color=GOLD) + txt(s, Inches(1.25), ny, Inches(11.1), Inches(0.7), + [[("▶ ", 13, GOLD, True), + ("主动交代精准度反而显专业——藏着不说,才是最大的风险。", 13, WHITE, True)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0) +fp_control() + + +# ============== 17 平台架构(分层)============== +def architecture(): + s = new("架构 · 全栈", "本地私有化 LLM 审计平台架构", 17) + layers = [ + ("应用层","自然语言查询 · 线索看板 · 智能报告 · 预警推送 —— 审计人员零门槛使用", CYAN), + ("引擎层","全量穿透引擎 + 规则进化引擎 + 线索生成引擎 —— LLM 驱动三大引擎", BLUE), + ("数据层","本地数据湖(BSS/OSS/ERP/财务/合同/工单/信令)—— 直连内网,零出域", PURPLE), + ("模型层","千问 70B / DeepSeek / 自研行业模型 —— 审计领域微调,懂电信业务", GOLD), + ("算力层","本地 A100 / H100 / 国产 GPU 集群 —— 承载 70B 级推理,信创可适配", GREEN), + ] + y = Inches(1.8); h = Inches(0.82); lw = Inches(9.9) + for i,(t,d,col) in enumerate(layers): + rrect(s, Inches(0.95), y, lw, h, color=CARD, radius=0.06) + rrect(s, Inches(0.95), y, Inches(1.7), h, color=col, radius=0.06) + txt(s, Inches(0.95), y, Inches(1.7), h, [[(t, 15, WHITE, True)]], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(2.85), y, lw-Inches(2.0), h, [[(d, 12.5, LIGHT, False)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0) + y = y + h + Inches(0.12) + # 右侧贯穿条:安全合规与自审计 + rrect(s, Inches(11.05), Inches(1.8), Inches(1.45), h*5+Inches(0.48), grad=(CYAN_D, BLUE), radius=0.08) + txt(s, Inches(11.05), Inches(1.8), Inches(1.45), h*5+Inches(0.48), + [[("安", 17, WHITE, True)],[("全", 17, WHITE, True)],[("合", 17, WHITE, True)], + [("规", 17, WHITE, True)],[("·", 14, WHITE, True)],[("自", 17, WHITE, True)], + [("审", 17, WHITE, True)],[("计", 17, WHITE, True)]], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=1) + ny = Inches(6.6) + txt(s, Inches(0.95), ny, Inches(11.55), Inches(0.4), + [[("全链路内网闭环 · 数据零出域 · 权限分级 · 不可篡改日志 · 版本留痕", 13.5, CYAN, True)]], + align=PP_ALIGN.CENTER, sa=0) +architecture() + + +# ============== 18 独立性与自审计 ============== +def independence(): + s = new("制度 · 独立性", "独立性与系统自审计:系统本身也经得起审计", 18) + items = [ + ("防放水","规则配置、阈值调整全程留痕,任何改动可追溯,杜绝调教规则放水。", CYAN), + ("防拦截","线索一旦生成即不可删除,处置过程全程记录,杜绝线索被拦下。", BLUE), + ("权限分级","配规则、看线索、改阈值、出报告分权管理,相互制衡。", PURPLE), + ("三重留痕","模型版本、规则版本、数据版本可回溯,任一结论可还原当时状态。", GOLD), + ] + W, H = Inches(5.7), Inches(1.95) + gx,gy = Inches(0.95), Inches(1.9) + for i,(t,d,col) in enumerate(items): + r,c = divmod(i,2) + x = gx + c*(W+Inches(0.18)); y = gy + r*(H+Inches(0.22)) + rrect(s, x, y, W, H, color=CARD, radius=0.08) + circle(s, x+Inches(0.3), y+Inches(0.32), Inches(0.7), grad=(col, BG2)) + txt(s, x+Inches(0.3), y+Inches(0.32), Inches(0.7), Inches(0.7), + [[("🔒" if False else "■", 18, WHITE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, x+Inches(1.2), y+Inches(0.3), W-Inches(1.4), Inches(0.5), + [[(t, 17, col, True)]], sa=0) + txt(s, x+Inches(1.2), y+Inches(0.85), W-Inches(1.45), Inches(1.0), + [[(d, 12.5, LIGHT, False)]], sa=0, line_spacing=1.15) + txt(s, Inches(0.95), Inches(6.4), Inches(11.55), Inches(0.4), + [[("既当运动员又当裁判是内审大忌——用制度化留痕与分权,让系统自己也透明可查。", 13, CYAN, True)]], + align=PP_ALIGN.CENTER, sa=0) +independence() + + +# ============== 19 四重跃升 ============== +table_slide(19, "价值 · 跃升", "本地 LLM 带来的四重跃升", + ["关键跃升", "从 → 到", "价值内涵"], + [ + ["审计覆盖面", "5% → 100%", "全量扫描,异常无处藏身"], + ["数据出域风险", "存在 → 归零", "全链路内网闭环,满足等保最严要求"], + ["审计节奏", "年度快照 → 7×24 常态化", "动态舞弊实时捕捉"], + ["能力归属", "外部租用 → 本地永久沉淀", "规则进化,越用越聪明"], + ], + note="安全 · 能力 · 效率 · 进化——四重价值,远超传统 BI 工具。", + col_widths=[2.8, 4.2, 4.55], fs=13, hfs=13.5) + + +# ============== 20 ROI ============== +table_slide(20, "测算 · 回报", "价值测算:把“异常”变成客户的钱", + ["价值来源", "测算逻辑", "年化收益(保守)"], + [ + ["可挽回收入/止损", "全量覆盖挖出抽样漏掉的异常并整改", "数千万级"], + ["外部咨询费节省", "常态化自有能力替代重复性项目采购", "百万级 / 年"], + ["人力释放", "审计员从翻表取数转向研判处置", "数倍效率提升"], + ["风险事件预防", "提前发现合规风险,规避处罚与声誉损失", "难以估量"], + ], + note="投入一次本地化建设,沉淀的是持续产生收益的永久资产,而非每年重复支出的项目费用。", + col_widths=[2.9, 5.85, 2.8], fs=13, hfs=13.5) + + +# ============== 21 差异化 ============== +def differentiation(): + s = new("差异化 · 主张", "我们的差异化:能力沉淀,而非一次性交付", 21) + pairs = [ + ("能力沉淀","项目制交付","项目制是租大脑、人走经验走;我们是装一个永久、越用越聪明的本地大脑。", CYAN), + ("常态化","年度快照","舞弊是动态的,时序类造假正是本地 LLM + 全量数据的主场。", BLUE), + ("数据不出域","数据出域","一比特不出机房是结构性优势,让安全合规部门站在我们这边。", PURPLE), + ("共存切入","正面替代","先做以前做不动的全量穿透与常态化监控层,跑出线索、证明价值、自然扩展。", GOLD), + ] + y = Inches(1.85); h = Inches(1.12) + for i,(a,b,d,col) in enumerate(pairs): + rrect(s, Inches(0.95), y, Inches(11.55), h, color=CARD if i%2 else CARD2, radius=0.07) + rrect(s, Inches(1.15), y+Inches(0.28), Inches(2.4), Inches(0.56), color=col, radius=0.3) + txt(s, Inches(1.15), y+Inches(0.28), Inches(2.4), Inches(0.56), + [[(a, 14, WHITE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(3.65), y, Inches(0.7), h, [[("vs", 14, MUTE, True)]], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + rrect(s, Inches(4.35), y+Inches(0.28), Inches(2.2), Inches(0.56), color=BG2, line=MUTE, lw=1, radius=0.3) + txt(s, Inches(4.35), y+Inches(0.28), Inches(2.2), Inches(0.56), + [[(b, 13, MUTE, True)]], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, Inches(6.75), y, Inches(5.6), h, [[(d, 12.5, LIGHT, False)]], + anchor=MSO_ANCHOR.MIDDLE, sa=0, line_spacing=1.12) + y = y + h + Inches(0.13) +differentiation() + + +# ============== 22 实施路径(时间轴)============== +def roadmap(): + s = new("实施 · 路径", "3 个月本地部署跑通(含同台盲测验证)", 22) + phases = [ + ("第 1 个月","算力 + 模型部署","GPU 到位;模型本地化部署;对接各业务系统;构建本地数据湖。", CYAN), + ("第 2 个月","场景微调 + 历史盲测","行业微调与场景适配;用历史数据全量重跑,与既有审计结论同台盲测。", BLUE), + ("第 3 个月","投产 + 线索闭环","正式上线;生成首批 200-500 条线索;核查反馈;规则库首轮进化。", GOLD), + ] + # 时间轴 + rect(s, Inches(1.2), Inches(2.55), Inches(11.0), Pt(3), color=CARD2) + W = Inches(3.7); gx = Inches(0.95) + for i,(ph,t,d,col) in enumerate(phases): + x = gx + i*(W+Inches(0.22)) + cx = x + W/2 + circle(s, cx-Inches(0.18), Inches(2.4), Inches(0.36), color=col) + rrect(s, x, Inches(2.95), W, Inches(2.4), color=CARD, radius=0.07) + rect(s, x, Inches(2.95), W, Inches(0.6), color=col) + txt(s, x, Inches(2.95), W, Inches(0.6), [[(ph, 16, WHITE, True)]], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, sa=0) + txt(s, x+Inches(0.25), Inches(3.7), W-Inches(0.5), Inches(0.5), + [[(t, 14.5, col, True)]], sa=0) + txt(s, x+Inches(0.25), Inches(4.2), W-Inches(0.5), Inches(1.1), + [[(d, 12.5, LIGHT, False)]], sa=0, line_spacing=1.18) + ny = Inches(5.7) + rrect(s, Inches(0.95), ny, Inches(11.55), Inches(0.95), color=CARD2, radius=0.1) + rect(s, Inches(0.95), ny, Inches(0.1), Inches(0.95), color=GOLD) + txt(s, Inches(1.25), ny, Inches(11.1), Inches(0.95), + [[("交付物", 14, GOLD, True)], + [("本地 AI 审计平台 + 可进化规则库 + 已验证高价值线索 + 同台盲测成效报告。", 13, WHITE, False)]], + anchor=MSO_ANCHOR.MIDDLE, sa=3) +roadmap() + + +# ============== 23 结尾 ============== +def closing(): + s = prs.slides.add_slide(BLANK) + bg_gradient(s, RGBColor(0x07,0x10,0x24), RGBColor(0x10,0x29,0x52)) + circle(s, Inches(-1.5), Inches(4.5), Inches(4.5), color=RGBColor(0x10,0x24,0x48)) + circle(s, Inches(10.5), Inches(-1.5), Inches(4.5), color=RGBColor(0x10,0x24,0x48)) + rect(s, 0, 0, SW, Inches(0.18), grad=(CYAN, BLUE)) + rect(s, 0, Inches(7.32), SW, Inches(0.18), grad=(BLUE, CYAN)) + txt(s, Inches(1.0), Inches(2.0), Inches(11.3), Inches(1.2), + [[("数据不动 · AI 动脑 · 造假者跑不掉", 40, WHITE, True)]], align=PP_ALIGN.CENTER, sa=0) + rect(s, Inches(5.4), Inches(3.25), Inches(2.5), Pt(3), color=CYAN) + txt(s, Inches(1.0), Inches(3.5), Inches(11.3), Inches(0.7), + [[("本地大模型 + 全量穿透 + 规则进化 = 运营商内审的“新质生产力”", 19, CYAN, True)]], + align=PP_ALIGN.CENTER, sa=0) + txt(s, Inches(1.0), Inches(4.5), Inches(11.3), Inches(1.2), + [[("让我们把千问 70B 装进您的机房", 17, LIGHT, False)], + [("150 亿业务全量扫描,敏感数据一比特不出域", 17, LIGHT, False)]], + align=PP_ALIGN.CENTER, sa=8) + txt(s, Inches(1.0), Inches(6.3), Inches(11.3), Inches(0.5), + [[("2026 年 6 月", 13, MUTE, False)]], align=PP_ALIGN.CENTER, sa=0) +closing() + + +out = "数据不出域,审计全穿透_精美版.pptx" +prs.save(out) +print("saved:", out, "slides:", len(prs.slides._sldIdLst)) diff --git a/docs/数据不出域,审计全穿透.md b/docs/数据不出域,审计全穿透.md new file mode 100644 index 0000000..70fac56 --- /dev/null +++ b/docs/数据不出域,审计全穿透.md @@ -0,0 +1,284 @@ +# 数据不出域,审计全穿透(优化版) + +> 基于本地私有化大模型的电信运营商 AI 全域内审体系 +> 让 150 亿业务里的每一分钱,都在本地 AI 的显微镜下原形毕露 +> 2026 年 6 月 + +--- + +## 幻灯片 1 · 封面 + +### 数据不出域,审计全穿透 + +- 基于本地私有化大模型的电信运营商 AI 全域内审体系 +- 不是一套工具,而是一套"建在自己机房里、越用越聪明"的审计能力体系 +- 2026 年 6 月 + +--- + +## 幻灯片 2 · 为什么传统审计"查不过来"? + +**三个数字看清困局** + +- **150 亿** —— 年业务规模 +- **5000 万** —— 潜在异常金额 +- **5%** —— 传统抽样覆盖率 + +**三类典型困局** + +- **"拆单规避"**:8 个客户各签 600 万 ICT 项目,全拆成 80 万以下小额合同,三重一大抽样完美避开。按金额抽样,大额拆分后消失在雷达之外。 +- **"稳定的定,稳定的退"**:每月新增 6000 人订购彩铃,3 个月后首月用户全部退订。渠道已按新增量领取佣金,形成"骗补后弃养"闭环——造假藏在时序里。 +- **"Excel 干不过来"**:安全云盘 + 宏 + 人工,面对海量单据只能抽样,查不全、查不深。150 亿业务海洋中,5000 万异常如针落大海。 + +> **核心矛盾**:审计数据涉及政企合同、用户隐私、财务凭证,上公有云大模型 = 裸奔;不上 AI 又干不过来。怎么办? + +--- + +## 幻灯片 3 · 本地 LLM 让"安全"和"智能"不再二选一 + +| 对比维度 | 传统抽样审计 | 公有云 AI 审计 | 本地 LLM 审计(我们) | +| --- | --- | --- | --- | +| 数据范围 | 按金额抽样,查不全 | 全量扫描,但数据出域 | 全量扫描,数据不出机房 | +| 规则能力 | 规则写死,反向规避 | 模型能力强,但合规风险高 | 模型私有化,合规可控 | +| 响应效率 | Excel 翻表,效率低 | 实时预警,但依赖外网 | 内网闭环,秒级响应 | +| 交互模式 | 人找数据 | 数据找人,但数据送人 | 数据找人,数据原地不动 | +| 能力归属 | 经验在人脑,人走经验走 | 能力在外部,租用即失 | 能力沉淀在本地,越用越聪明 | + +> 把千问 70B / DeepSeek 装进本地机房,让 AI 在数据旁边干活,而不是把数据送给 AI。 + +--- + +## 幻灯片 4 · 【新增】审计域全景图 + 风险分级模型 + +**不是 8 个孤立场景,而是一张覆盖全业务的审计地图** + +把所有审计场景归入五大风险域,做到"全覆盖、有优先级": + +| 风险域 | 覆盖场景 | 关注核心 | +| --- | --- | --- | +| 收入域 | 政企收入穿透、收入成本跨期匹配、云业务空转 | 收入真实性、确认时点 | +| 成本域 | 渠道佣金、终端补贴、成本摊销 | 成本真实性、套利 | +| 采购域 | 网络建设、工程采购、围标串标 | 采购合规、虚增工程量 | +| 资金域 | 回款挂账、网间结算、资金流向 | 资金真实性、关联交易 | +| 合规域 | 员工舞弊、权限滥用、积分套现 | 内控有效性、权限合规 | + +**风险热力图(发生概率 × 金额影响)** + +- 高概率 + 高金额 → 优先全量监控(如政企拆单、渠道骗补) +- 低概率 + 高金额 → 重点定向穿透(如围标串标、云空转) +- 高概率 + 低金额 → 批量聚类筛查(如积分套现、内部号码) + +> 让客户一眼看出:我们不是"工具集合",是"有体系、有优先级的全域审计框架"。 + +--- + +## 幻灯片 5 · "本地 AI 审计大脑"四大核心引擎 + +- **① 本地私有化 LLM 引擎(底座)**:千问 70B / DeepSeek 等模型本地化部署,审计数据绝不出域。负责异常模式推理、自然语言规则配置、报告自动生成、线索解释。 +- **② 全量穿透引擎**:直连 BSS / OSS / ERP / 财务系统,本地数据库直接喂给本地 LLM。不抽样,对所有合同、回款、用户行为做关联扫描。 +- **③ 规则进化引擎(护城河)**:审计人员用自然语言描述新造假模式,LLM 自动转化为可执行规则,沙箱验证命中率,持续对抗迭代——**把顾问脑子里的经验固化成机构永久资产**。 +- **④ 线索驱动引擎**:LLM 对异常聚类做"人话解释"(如"这 8 个客户疑似同一实控人"),输出高价值线索并附证据链,直接推送审计人员桌面。 + +--- + +## 幻灯片 6 · 场景一:政企收入全链路穿透 + +- **业务链路**:客户立项 → 移动立项 → 审批 → 报价 → 签约 → 首款/二款开票 → 回款 +- **经典案例(拆单规避 + 虚假回款)**:某地市公司 8 个"客户"各签 600 万 ICT 项目,全拆成 79 万-99 万合同规避三重一大审批。首款付 100 万、二款付 300 万,尾款 500 万长期挂账。经穿透,8 个客户注册地址在同一写字楼、法人为同一人亲属、付款账户来自同一实控企业。 +- **AI 审计点**:合同金额分布异常(集中在阈值边缘);工商关联穿透(隐性实控人识别);回款时序聚类(批量违约模式)。 +- **本地 LLM 能力**:自然语言查数、关联推理、一键生成《政企客户回款异常专项线索清单》。 + +--- + +## 幻灯片 7 · 场景二:市场业务真实性("养卡骗补") + +- **经典案例(稳定的定,稳定的退)**:某渠道每月新增 6000 人订购彩铃,每过三个月首月用户全部退订,渠道已按新增量领取佣金,形成"骗补后弃养"闭环。用户号码归属地高度集中在某几个乡镇,且通话记录为零。 +- **扩展案例(物联网卡虚假激活)**:某代理商批量开通 10 万张物联网卡,声称用于"智慧停车",实际激活后无任何流量,已按激活量领取每台 50 元补贴,半年后卡片集体沉默。 +- **AI 审计点**:用户生命周期时序模式识别;渠道佣金与业务质量匹配度;沉默/零通话用户批量聚类;项目交付物与收入确认交叉验证。 +- **本地 LLM 能力**:识别"脉冲式增长 + 规律性衰减"的周期性造假,自动提炼为新规则。 + +--- + +## 幻灯片 8 · 场景三:收入与成本跨期匹配 + +- **经典案例(趸交收入一次性确认)**:用户办 24 个月套餐送智能手表,收入应分 24 个月确认,但因趸交财务一把全确认,手表成本却摊 24 个月——确认时点严重错配。某省一年此类业务 5000 万,在 150 亿总收入中如针落大海。 +- **扩展案例("以销定产"变"提前确认")**:某政企云项目约定"按实际使用量计费",但财务在设备上架当月即全额确认收入,客户前 6 个月几乎零使用。 +- **AI 审计点**:自动勾稽收入确认政策 vs 实际账务 vs 合同条款;识别趸交/预收款一次性确认异常分录;成本摊销与收入确认跨期匹配;设备交付与收入确认时间差监控。 +- **本地 LLM 能力**:跨系统自动勾稽,识别收入成本确认时点错配的异常分录模式。 + +--- + +## 幻灯片 9 · 场景四:渠道佣金与代理商套利 + +- **经典案例(虚假放号 + 套机套卡)**:某代理商为完成"5G 用户净增",批量买低价老人机插 5G SIM 卡激活后丢弃,用户从未产生 5G 流量,已领"5G 迁转"佣金每台 200 元 + 终端补贴 300 元,次月用户全部流失。 +- **扩展案例(异地窜货套利)**:代理商从邻省低价采购同款手机,在本省以"新用户入网"名义领高额补贴,手机实际回流二级市场。 +- **AI 审计点**:终端 IMEI 与用户绑定真实性;佣金发放与在网时长匹配度;终端流向追踪(激活即沉默/跨省流通);代理商业务质量时序衰减分析。 +- **本地 LLM 能力**:IMEI 级终端流向追踪,识别"激活-沉默-流失"套利闭环。 + +--- + +## 幻灯片 10 · 场景五:网络建设与工程采购 + +- **经典案例(围标串标 + 虚增工程量)**:某基站项目 3 家投标报价差异不足 1%,技术方案大量雷同,中标后施工队为同一班组,工程量签证单存在"同一笔迹不同日期"批量签字。 +- **扩展案例(虚假巡检与虚报工单)**:某外包商系统显示每月完成 2000 次基站巡检,GPS 轨迹比对实际只到过 300 个站点,其余为"照片复用 + 坐标伪造"。 +- **AI 审计点**:投标关联分析(报价相似度、文件雷同度);工程量与资源消耗匹配验证;巡检轨迹与工单交叉验证;供应商画像(同一实控人"马甲"识别)。 +- **本地 LLM 能力**:NLP 比对投标文件雷同度,GPS 轨迹与工单交叉验证,识别"马甲"供应商。 + +--- + +## 幻灯片 11 · 场景六:互联互通与网间结算 + +- **经典案例(话务量操纵套利)**:某运营商与境外运营商合谋虚假国际来话刷量,主叫归属地为虚商号段,通话时长均为 30 秒/60 秒整数倍,明显非真人。 +- **扩展案例(短信网关刷量)**:某 SP 伪造发送记录申报"成功发送"10 亿条行业短信按 0.05 元/条结算,实际到达率不足 10%。 +- **AI 审计点**:话务量时序异常(突发峰值、整数时长聚集);网间结算数据与网络侧原始信令比对;SP/CP 业务量与收入结算交叉验证;国际来话真实路由溯源。 +- **本地 LLM 能力**:识别"整数倍通话时长"等非人类行为,信令级原始数据比对。 + +--- + +## 幻灯片 12 · 场景七:云业务 / IDC 与新兴业务 + +- **经典案例(云资源"空转"确认收入)**:某政企客户签 3 年云服务年付 100 万,实际 CPU 利用率长期低于 5%、存储几乎为空,但财务按合同全额确认收入,且该"客户"实控人为地市公司某领导亲属。 +- **扩展案例(IDC 机柜"虚租")**:某 IDC 宣称出租率 90%,实际大量机柜无设备、电费为零,收入来自关联方"预付租金"。 +- **AI 审计点**:云资源实际使用量 vs 合同计费量匹配度;IDC 出租率与电力消耗勾稽;新兴业务客户画像(关联方识别、预付模式异常);收入确认与交付验收时序一致性。 +- **本地 LLM 能力**:资源利用率与计费量自动比对,关联方网络挖掘,识别"空转"收入。 + +--- + +## 幻灯片 13 · 场景八:员工内部舞弊与资源滥用 + +- **经典案例(内部号码套利)**:某营业厅员工利用权限批量开通"员工测试号"对外出租"免流套餐",测试号产生大量流量收入但全部计入内部成本未确认收入。 +- **扩展案例(积分/会员体系套现)**:某员工勾结外部商户虚构消费批量刷积分,兑换高价值礼品卡在二级市场变现,某商户单日积分发放量超正常 100 倍。 +- **AI 审计点**:员工权限操作日志异常模式识别;内部测试号实际用途偏离;积分/电子券流向追踪;权限与岗位匹配度(如客服岗有财务调账权限)。 +- **本地 LLM 能力**:操作日志异常模式挖掘,权限-岗位匹配度分析,积分流向网络追踪。 + +--- + +## 幻灯片 14 · 【新增】数据接入与治理层(地基工程) + +**全量穿透的前提,是把脏活干在前面** + +- **多源异构接入**:适配 BSS / OSS / ERP / 财务 / 合同 / 工单 / 信令各系统的接口、数据库、文件,统一汇入本地数据湖。 +- **主数据对齐**:客户、合同、号码、工单、供应商跨系统实体统一,解决"主键对不上"。 +- **数据质量探查与清洗**:缺失、重复、口径不一自动探查并清洗,建立质量评分。 +- **增量同步与时效**:从年度快照升级为近实时增量,支撑常态化监控。 + +> 数据治理是这套体系工作量最大、最该提前立项的一环。我们把它写进方案、承担下来,而不是回避。 + +--- + +## 幻灯片 15 · 【新增】人机协同闭环:线索之后才是价值 + +**从"发现工具"升级为"办案平台"** + +``` +AI 全量扫描 → 生成线索 + 初步证据链 → 审计员复核研判 → +系统自动生成审计底稿 → 定性分类 → 整改 / 移交 → 复核销项闭环 +``` + +- **AI 侧**:出线索、附证据链、给判定理由、自动生成可追溯底稿。 +- **审计员侧**:复核研判、定性、决定整改或移交、最终签字。 +- **闭环管理**:线索分派、取证留痕、整改跟踪、销项复核全流程在线。 + +> 不是"给你一堆线索然后呢",而是"从发现到闭环,每一步都接得住、留得痕"。 + +--- + +## 幻灯片 16 · 【新增】误报治理与置信度分级(专业 = 诚实) + +**全量扫描必然产生海量疑似项——关键是不让审计员淹死在假阳性里** + +- **三级置信分流**:高置信直接推送处置、中置信人工复核、低置信归档备查。 +- **每条线索可解释**:附证据链 + 判定理由,拒绝"黑盒打分"。 +- **反馈学习闭环**:审计员标注"误报/属实",系统持续校准阈值,准确率随使用上升。 +- **公开运营指标**:命中率、准确率、线索转化率上看板,成效可量化、可追溯。 + +> 主动交代精准度,反而显专业。藏着不说,才是最大的风险。 + +--- + +## 幻灯片 17 · 本地私有化 LLM 审计平台架构 + +- **应用层**:自然语言查询 · 线索看板 · 智能报告 · 预警推送 —— 审计人员零门槛使用 +- **引擎层**:全量穿透引擎 + 规则进化引擎 + 线索生成引擎 —— LLM 驱动三大引擎 +- **数据层**:本地数据湖(BSS / OSS / ERP / 财务 / 合同 / 工单 / 信令)—— 直连内网,零出域 +- **模型层**:千问 70B / DeepSeek / 自研行业模型 —— 审计领域微调,懂电信业务 +- **算力层**:本地 A100 / H100 / 国产 GPU 集群 —— 承载 70B 级大模型推理,信创可适配 +- **安全合规与自审计层(贯穿全栈)**:权限分级 · 操作不可篡改日志 · 模型/规则版本留痕 · 全链路审计轨迹 + +> 全链路内网闭环 · 数据零出域 + +--- + +## 幻灯片 18 · 【新增】独立性与系统自审计(制度设计) + +**审计系统本身,也要经得起审计** + +- **防放水**:规则配置、阈值调整全程留痕,任何人改动可追溯,杜绝"调教规则放水"。 +- **防拦截**:线索一旦生成即不可删除,处置过程全程记录,杜绝"线索被领导拦下"。 +- **权限分级**:配规则、看线索、改阈值、出报告分权管理,相互制衡。 +- **可追溯**:模型版本、规则版本、数据版本三重留痕,任一结论可回溯到当时的模型与数据状态。 + +> 既当运动员又当裁判是内审的大忌——我们用制度化的留痕和分权,让这套系统自己也透明可查。 + +--- + +## 幻灯片 19 · 本地 LLM 带来的四重跃升 + +- **安全价值**:敏感数据不出机房,满足国资/运营商/等保最严要求,模型-数据-推理-结果全链路内网闭环。 +- **能力价值**:70B 级本地模型具备语义推理、规则自生长、报告生成能力,远超传统 BI;行业微调,懂电信业务。 +- **效率价值**:自然语言交互,不写 SQL、不翻 Excel,问一句就出线索,从"人找数据"到"数据找人"。 +- **进化价值**:每发现一种造假,LLM 自动提炼规则,系统越用越精准,形成机构专属审计知识库。 + +| 关键跃升 | 从 → 到 | +| --- | --- | +| 审计覆盖面 | 5% → 100% | +| 数据出域风险 | 存在 → 归零 | +| 审计节奏 | 年度快照 → 7×24 常态化 | +| 能力归属 | 外部租用 → 本地永久沉淀 | + +--- + +## 幻灯片 20 · 【新增】价值测算:把"异常"变成客户的钱 + +**以 150 亿业务规模、5000 万潜在异常为基准的保守测算** + +| 价值来源 | 测算逻辑 | 年化收益(保守) | +| --- | --- | --- | +| 可挽回收入/止损 | 全量覆盖挖出抽样漏掉的异常并整改 | 数千万级 | +| 外部咨询费节省 | 常态化自有能力替代重复性项目制采购 | 百万级/年 | +| 人力释放 | 审计员从翻表取数转向研判处置 | 数倍效率提升 | +| 风险事件预防 | 提前发现合规风险,规避处罚与声誉损失 | 难以估量 | + +> 投入一次本地化建设,沉淀的是持续产生收益的永久资产,而非每年重复支出的项目费用。 + +--- + +## 幻灯片 21 · 【新增】我们的差异化:能力沉淀,而非一次性交付 + +**为什么是"建一套体系",而不是"买一份报告"** + +- **能力沉淀 vs 项目制交付**:项目制是"租大脑",人走经验走、明年再付一次;我们是"装一个永久的、越用越聪明的本地大脑",规则进化引擎把每一次审计经验固化为机构资产。 +- **常态化 vs 年度快照**:舞弊是动态的,审计不能一年一次。时序类造假(养卡、骗补、脉冲式增长)恰恰是抽样和年度审计抓不到的,正是本地 LLM + 全量数据的主场。 +- **数据不出域 vs 数据出域**:对等保/国资/数据安全红线极高的运营商,"一比特不出机房"是结构性优势,让安全合规部门站在我们这边。 +- **共存切入 vs 正面替代**:先做底层全量穿透与常态化监控这块"以前做不动的层",跑出线索、证明价值,能力自然沉淀、份额自然扩展。 + +--- + +## 幻灯片 22 · 3 个月本地部署跑通(含同台盲测验证) + +- **第 1 个月 · 算力 + 模型部署**:机房 GPU 到位;千问 70B / DeepSeek 本地化部署;对接 BSS/OSS/ERP/财务/工单/信令;构建本地数据湖。 +- **第 2 个月 · 场景微调 + 历史盲测**:历史审计案例行业微调;政企/市场/财务/工程场景适配;**用过去 2-3 年历史数据全量重跑,与既有审计结论同台盲测,验证能否挖出此前抽样漏掉的真实线索**。 +- **第 3 个月 · 投产 + 线索闭环**:正式上线;生成首批 200-500 条线索;审计人员跟进核查反馈;规则库首轮进化。 + +> **交付物**:一套本地私有化 AI 审计平台 + 一套可进化的审计规则库 + 一批已验证的高价值线索 + 一份同台盲测成效报告。 + +--- + +## 幻灯片 23 · 审计的终极形态 + +- **数据不动、AI 动脑、造假者跑不掉** +- 本地大模型 + 全量穿透 + 规则进化 = 运营商内审的"新质生产力" +- 让我们把千问 70B 装进您的机房 +- 150 亿业务全量扫描,敏感数据一比特不出域——这才是电信运营商该有的 AI 审计 + +> 2026 年 6 月 diff --git a/docs/数据不出域,审计全穿透1.0.pptx b/docs/数据不出域,审计全穿透1.0.pptx new file mode 100644 index 0000000000000000000000000000000000000000..83cf69a1cede1f764d7afa713999044576ce1328 GIT binary patch literal 120540 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 Yk==}63>*N3G6Mf@bD>bHi-0WZf1$TL_W%F@ literal 0 HcmV?d00001 diff --git a/infra/postgres/init/01-extensions.sql b/infra/postgres/init/01-extensions.sql new file mode 100644 index 0000000..b39e423 --- /dev/null +++ b/infra/postgres/init/01-extensions.sql @@ -0,0 +1,14 @@ +-- 初始化 AIAudit 数据中台所需的 PostgreSQL 扩展 +-- 容器首次启动时由官方 entrypoint 自动执行(/docker-entrypoint-initdb.d) + +-- 时序(TimescaleDB):支撑时间序列建模 +CREATE EXTENSION IF NOT EXISTS timescaledb; + +-- 向量检索(pgvector):支撑语义检索 +CREATE EXTENSION IF NOT EXISTS vector; + +-- btree_gist:支撑双时态有效期的排他约束(防止时间区间重叠) +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- 注:MVP 阶段知识图谱采用关系表(实体/关系边)+ 递归 CTE 建模, +-- 不依赖 Apache AGE 扩展。后续按需再引入。 diff --git a/infra/postgres/setup_local.sh b/infra/postgres/setup_local.sh new file mode 100644 index 0000000..050f2be --- /dev/null +++ b/infra/postgres/setup_local.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# 本地 PostgreSQL 16 初始化脚本(弃用 Docker,使用本机 Homebrew PG16) +# 创建 aiaudit 角色与数据库,并安装所需扩展(TimescaleDB / pgvector / btree_gist)。 +# +# 前置:已通过 Homebrew 安装并启动 postgresql@16、timescaledb、pgvector。 +# 用法:bash infra/postgres/setup_local.sh +set -euo pipefail + +DB_NAME="${DB_NAME:-aiaudit}" +DB_USER="${DB_USER:-aiaudit}" +DB_PASSWORD="${DB_PASSWORD:-aiaudit_dev}" + +# 定位本地 PG16 的 psql(Homebrew) +PG_BIN="$(brew --prefix postgresql@16)/bin" +export PATH="$PG_BIN:$PATH" + +echo "==> 使用 psql: $(which psql)" + +# 以当前系统用户连接默认库(Homebrew PG 默认信任本地超级用户) +psql postgres < 安装扩展到数据库 ${DB_NAME}" +# pgvector 与 btree_gist 为必需;TimescaleDB 为可选(macOS 本地常因编译问题装不上, +# 生产 Linux 环境再启用)。缺失 timescaledb 不影响功能,仅少了超表分区/压缩优化。 +psql -d "${DB_NAME}" <<'SQL' +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS btree_gist; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'timescaledb') THEN + CREATE EXTENSION IF NOT EXISTS timescaledb; + RAISE NOTICE 'TimescaleDB 已启用'; + ELSE + RAISE NOTICE 'TimescaleDB 不可用,跳过(本地开发可忽略)'; + END IF; +END$$; +SQL + +echo "==> 完成。扩展清单:" +psql -d "${DB_NAME}" -c "SELECT extname, extversion FROM pg_extension ORDER BY extname;"