emilcechelt/company-redflag-screen
GitHub: emilcechelt/company-redflag-screen
面向英国公司的尽职调查红旗筛查工具,结合 Companies House 与 OpenSanctions 数据执行制裁匹配、地址模式和风险行业检查,并在真实样本上量化验证精确度。
Stars: 0 | Forks: 0
# company-redflag-screen
筛查英国公司的尽职调查红旗 —— 制裁/PEP(政治公众人物)高级职员匹配、共享地址空壳公司模式,以及新公司加高风险行业组合 —— 并按照尽职调查行业真正要求的方式验证结果:真实样本、量化的精确度指标、经人工确认,而不是“看起来能用”。
既可以作为可针对任意英国公司运行的 Web 应用,也可以作为一组 CLI 工具运行。
完整的范围、方法和结果:[`docs/scope.md`](docs/scope.md)。

*一次实时筛查。每项检查都报告了它发现了什么以及原因,而顶部的裁定说明了证据是否足够充分以采取行动。在这里并不充分:两名董事的名字与 PEP 名单上的人重名,但两个匹配项都没有佐证的出生年份,因此这很可能是重名冲突。*
## 结果
**精确度:23.3%** —— 在从真实 250 家英国公司样本(抽取自 Companies House)中标记出的 60 家公司中,经独立人工审核确认有 14 家为真正的红旗;46 家为误报,每一家都有具体且可核查的原因(未匹配出生年份的常见姓名巧合、姊妹公司关系、偶然共享的办公室)。
**审核员推翻了一次工具的建议**,而该情况是数据集中最具参考价值的一行。一家自 2002 年起休眠、持有约 1 英镑、位于有 42 家公司的虚拟办公室的公司,在规则看来就像个空壳公司。但其实不是:空白的资产负债表是没有交易的证据,而不是不当行为的证据;而休眠公司缺少 ICO 注册是其休眠状态的必然结果,而不是罪加一等。因为公司账面为空就将其升级处理,正是本项目要防范的典型误报行为。
召回率被明确排除在测量和声明范围之外 —— 请参阅 `docs/scope.md` 了解原因,以及为什么这是一个诚实的范围界定决定,而不是一个疏漏。
## 这不是什么
`data/` 包含了真实的 250 家公司样本和审核裁定,因为一个无人能核查的精确度数据算不上结果。这意味着提到了真实的英国公司名称。为了避免对它们做出的判定产生歧义,特此声明:
- 这里的一项标记意味着**“这值得看一眼”**,而不是“这家公司有任何违规行为”。测得的命中率是 23.3% —— 从设计上讲,大多数标记并不是调查发现。
- 每一条输入都是来自 Companies House 登记册和 OpenSanctions 开放数据集的公开数据。这里没有任何内容透露了尚未公开的信息,得出的任何结论也没有超出这些记录本身所陈述的范围。
- 制裁或 PEP 姓名匹配只是一次**姓名匹配**。常见姓名经常会发生冲突;这就是为什么 pipeline 会根据出生年份是否能够佐证来对匹配项进行排序,并且拒绝在没有出生年份佐证的情况下进行升级处理。
- 这些裁定是一位分析师对公开记录的判断,记录下来是为了让数据能够被审计。它们不是合规产品,也不应被当作合规产品来依赖。
**应用程序在数据出现的任何地方都声明了一个注意事项。** 测量运行时的共享地址检查只能将每家公司与其自身样本内的 250 家公司进行比较。实时筛查则会查询在该地址注册的每家公司。相同的规则,更完整的数据 —— 因此它会更频繁地触发,而 23.3% 描述的是测量运行的情况,而不是实时筛查的情况。
## 工作原理
1. **Companies House API** (`app/companies_house.py`) —— 查询公司的高级职员、注册地址、成立日期和 SIC 代码。高级职员的信息补充了部分出生日期和国籍,并筛选出当前在职的人员。在本地进行限速,以保持在官方文档规定的 5 分钟 600 次请求的预算内,并在遇到 429 错误时执行尊重 `Retry-After` 的重试。
2. **OpenSanctions 批量数据** (`app/sanctions_index.py`) —— 将高级职员姓名与 OpenSanctions 免费的制裁 + PEP 数据集进行匹配(无需 API 账户),具备以下特性:
- 要求覆盖完整的名字 token(而不是“任意一个 token”),以避免常见姓名导致的匹配泛滥
- 出生年份/国籍合理性检查,将每个命中结果分为三个等级之一:已佐证、未佐证、相矛盾。只有“已佐证”的命中结果才会被 pipeline 升级处理。
- 复合姓氏会根据正确的 token 进行分桶(例如西班牙双姓)
3. **三大红旗类别** (`app/redflags.py`):制裁/PEP 高级职员匹配、共享注册地址以及新公司 + 休眠/非贸易 SIC 代码。地址在分桶前会被标准化(`app/addresses.py`),而没有可用地址的公司会被排除在分桶之外,而不是被混在一起。
4. **裁定规则** (`app/verdicts.py`) —— 对每个被标记的公司给出具体的结论,绝不给出空泛的“需要复核”,而是基于可核查的证据计算得出:已佐证的出生年份、代办处地址、未申报 SIC 代码的 LP(有限合伙企业),对比姊妹公司名称模式或常见姓名巧合。每一个触发的类别都根据其自身的证据进行评判,并由最强的结论胜出。
由人工审核员确认或推翻每一条建议 —— 建议层从不自己做决定,而上文的精确度数据是审核员确认后的裁定,而不是工具本身的判定。
## Web 应用程序
三个视图:
- **筛查 (Screen)** —— 搜索 Companies House,选择一家公司,并观察三项检查的运行过程。进度通过 SSE(`/api/screen/{n}/stream`)从 pipeline 本身进行流式传输,因此显示的阶段就是实际正在进行的工作。
- **证据 (Evidence)** —— 测得的评估结果、按类别的细分、该数字未声明的范围,以及每一家被标记的公司(包含建议裁定和人工裁定)。
- **方法 (Method)** —— 在提取任何数据之前定义的范围、证据层级、已知的局限性。
无需构建步骤,也没有外部请求:服务器已经发布了三个静态文件。

*证据视图。按检查项的细分是最有用的部分:共享地址检查发现了样本中所有真正的红旗,而制裁检查一无所获 —— 22 个标记,无一得到佐证。一个只报告单一综合分数的工具会掩盖这一事实。*
### API
| 路由 | 用途 |
|---|---|
| `GET /api/health` | 索引大小、构建日期、是否配置了 Companies House 密钥 |
| `GET /api/search?q=` | 用于选择器的公司搜索 |
| `GET /api/screen/{number}` | 完整筛查,返回单个 JSON 响应 |
| `GET /api/screen/{number}/stream` | 同上,以 SSE 阶段事件的形式流式传输 |
| `GET /api/evaluation` | 测得的评估结果和每一行被标记的数据 |
| `GET /api/docs` | OpenAPI 文档 |
## 设置
```
pip install -r requirements.txt
```
`.env` 需要 `COMPANIES_HOUSE_API_KEY`(免费,请在
`developer.company-information.service.gov.uk` 注册获取)。
然后构建一次制裁索引。它派生自 OpenSanctions 的免费批量文件 —— 无需账户,且本工具不会替你下载它:
```
curl -o data/opensanctions_targets.csv \
https://data.opensanctions.org/datasets/latest/default/targets.simple.csv # ~443MB
python -m app.build_index # ~30s -> data/sanctions.db
```
**为什么使用索引而不是直接读取 CSV。** 第一个版本在每次启动时将所有 108 万条人员记录加载到 Python 字典中:常驻内存约 2GB,启动时间约 90 秒。这对于脚本来说还可以,但对于在共享机器上运行的长期服务来说并不合适。SQLite 索引只需构建一次,以单个 300MB 文件的形式发布,并通过 B-tree 树查询来响应 —— 部署后的容器常驻内存仅为 **41MB**。匹配语义保持不变。
一旦构建了索引,就可以删除原始批量 CSV 文件。
## 运行
```
python -m app.web # web app on :8000
python -m app.check_one SL018155 # screen one company at a terminal
python -m app.pull_gold_set 250 # pull a fresh sample, write an xlsx review sheet
python -m app.evaluate # rebuild data/evaluation.json from the reviewed verdicts
```
### 审核被标记的公司
测得的评估数据永远是经过人工确认的裁定,因此裁定通过一个命令录入到
`data/reviewed_verdicts.json` 中,而不是通过手动编辑该文件 ——
手动编辑正是导致已发布数据暗中与实际审核内容不符的原因。
```
python -m app.record_verdict --list # what is awaiting a verdict, with links
python -m app.record_verdict 04350070 real --note "why"
python -m app.evaluate # restate the headline figure
```
重新记录一家已有裁定的公司需要使用 `--force`,因此第二轮审核无法静默覆盖第一轮的结果。
## 部署
以单个容器的形式发布。它在家庭实验室服务器上通过 Tailscale Serve 反向代理私有运行 —— 绑定到回环地址,没有公开端口 —— 但它内部并没有对此做任何假设:它是一个普通的容器化 FastAPI 应用程序,任何 HTTPS 前端都可以使用。
```
docker compose build
# 一次性将 index 构建到 ./data 中(compose mount 在运行时是只读的)
docker run --rm -v "$PWD/data:/data" -e REDFLAG_DATA_DIR=/data \
redflag-screen:latest python -m app.build_index
docker compose up -d
```
`./data` 以只读方式挂载,包含 `sanctions.db` 以及已提交的评估数据。该应用程序从不进行磁盘写入操作。
## 提交内容
`data/gold_set_companies.json`(250 家公司样本)、`data/reviewed_verdicts.json`(已冻结的人工裁定)和 `data/evaluation.json`(由前两者构建)已被提交 —— 它们是本项目的证据。原始的批量 CSV 文件和基于其构建的索引未被提交。
## 存在原因
构建此项目是为了填补在根据固定的申请评估标准对尽职调查/调查类职位(Kroll、Control Risks、Robert Walters)进行打分时发现的一个证据空白:虽然有真正的兴趣和可迁移的技能,但拿不出正式的尽职调查工具实践经验。这个项目就是那份证据 —— 完整的背景请参阅所属 Vault 项目的历史记录。
标签:代码示例, 合规审查, 尽职调查, 数据分析, 请求拦截, 逆向工具, 风控