Initial commit: InternalAuditInterprise

This commit is contained in:
freedakgmail
2026-06-16 00:38:57 +08:00
commit 7b1e2b10a8
57 changed files with 4622 additions and 0 deletions
+33
View File
@@ -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/
+388
View File
@@ -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 功能优先级、关键流程、权限矩阵、版本规划、风险等)。如需修改,请直接告诉我要调整的部分。
+242
View File
@@ -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 |
| 首批线索产出 | 投产首月 200500 条 | 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、验收标准、依赖与优先级/阶段)。如需修改,请直接告诉我要调整的部分。
+342
View File
@@ -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 场景七 · 云业务/IDCR14
> 映射: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 基建开始)推进开发,每完成一组任务进行测试、更新本文档状态并向你汇报。如需调整任务粒度、阶段切分或依赖关系,请直接告诉我。
+46
View File
@@ -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。
+23
View File
@@ -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 Providerdashscope(公网,仅 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
+38
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""AIAudit 后端应用包。"""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""HTTP API 层。"""
+64
View File
@@ -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,
)
+36
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
"""系统自审计模块:不可篡改操作日志、独立性与分权(R19)。"""
+50
View File
@@ -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)
+78
View File
@@ -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
+81
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""线索引擎模块:线索模型、生成、置信度分级、状态流转(人机闭环)。"""
+136
View File
@@ -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)
+195
View File
@@ -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()
+70
View File
@@ -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
# 被认定为"公网/出域"的 Providerprod 下禁止使用
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
+1
View File
@@ -0,0 +1 @@
"""审计数据中台模块:本体/知识图谱、双时态、时序、数据版本。"""
+83
View File
@@ -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()
+58
View File
@@ -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)
+118
View File
@@ -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]
+157
View File
@@ -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
)
+86
View File
@@ -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
+40
View File
@@ -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
+10
View File
@@ -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"]
+44
View File
@@ -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
+31
View File
@@ -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}")
+80
View File
@@ -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
+45
View File
@@ -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,
}
+7
View File
@@ -0,0 +1,7 @@
# 数据库迁移(Alembic
- 生成迁移:`alembic revision --autogenerate -m "描述"`
- 应用迁移:`alembic upgrade head`
- 回滚一步:`alembic downgrade -1`
模型定义见 `app/datahub/models.py`;连接串取自应用配置(`DATABASE_URL`)。
View File
+59
View File
@@ -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()
+24
View File
@@ -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"}
@@ -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")
@@ -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();")
+24
View File
@@ -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"]
# B008FastAPI 依赖注入 Depends() 作为默认值是官方推荐用法
ignore = ["B008"]
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
+5
View File
@@ -0,0 +1,5 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.0
ruff==0.8.4
mypy==1.14.0
+11
View File
@@ -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
View File
+41
View File
@@ -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()
@@ -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)
@@ -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)
@@ -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}"
@@ -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
+42
View File
@@ -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()
+21
View File
@@ -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
+42
View File
@@ -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} 缺少本体域定义"
+43
View File
@@ -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.1ARM64);磁盘可用 ~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 信创适配同步评估)。
@@ -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` 内置)。
- 图能力以关系建模实现,接口层(统一穿透查询服务)对上层屏蔽底层是关系还是图库,便于将来替换。
+779
View File
@@ -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))
+284
View File
@@ -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 月
Binary file not shown.
+14
View File
@@ -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 扩展。后续按需再引入。
+51
View File
@@ -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 的 psqlHomebrew
PG_BIN="$(brew --prefix postgresql@16)/bin"
export PATH="$PG_BIN:$PATH"
echo "==> 使用 psql: $(which psql)"
# 以当前系统用户连接默认库(Homebrew PG 默认信任本地超级用户)
psql postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${DB_USER}') THEN
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\gexec
SQL
echo "==> 安装扩展到数据库 ${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;"