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