Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28fa155829 | |||
| db47c1da13 | |||
| 446ad80089 | |||
| b52d1e59a4 | |||
| c7aa6e54c6 | |||
| f93e094ba2 | |||
| 5ee28cef98 | |||
| 8da38694bc | |||
| 850ed8f9a7 | |||
| 43bd5a86bf | |||
| cfb9273f88 | |||
| 3e0cbf9831 | |||
| aa2d3fe244 | |||
| 2e4dd8c6c7 | |||
| 1c4e2fc9b6 | |||
| 69fa0b240f | |||
| 6d26a5b0c1 | |||
| bb9ebaa052 | |||
| d50281a1e9 | |||
| 3e2183798d | |||
| 562618cd68 | |||
| f550fc8955 | |||
| 157144b3f9 | |||
| 3cfce44a25 | |||
| e91255856c | |||
| 1250b674b0 | |||
| 074881b650 |
@@ -2,7 +2,6 @@ name: Verify and deploy Tabularium
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -11,6 +10,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
|
||||
@@ -80,9 +80,81 @@ AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=fal
|
||||
|
||||
`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。
|
||||
|
||||
## AI 公司研究
|
||||
|
||||
导航入口为 **其他研究 → AI 公司研究**,页面路径是
|
||||
`/docs/other-research/ai-company-research/overview`。工作台提供业务分类、公司搜索与筛选、
|
||||
公司明细、收入观测、原始证据和数据核查记录。所有页面与
|
||||
`GET /api/research/ai-companies` 均使用文档站现有的登录认证。
|
||||
|
||||
研究数据与登录状态**共用文档站现有的 SQLite 数据库文件**:
|
||||
`AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`。该文件名是历史名称,未创建第二个数据库。
|
||||
`login_state` 保留登录限流与重放状态,新增的 `research_*` 表保存数据版本、分类、
|
||||
公司、经营指标、证据与核查记录。沿用现有 `/auth` 持久挂载,数据库不会写入镜像或公开目录。
|
||||
|
||||
`content/research/ai-companies.seed.json` 是可评审的研究导入快照,
|
||||
服务端首次读取该版本时将它以事务导入同一数据库,之后从数据库查询记录。
|
||||
导入使用外键校验,不删除登录数据,也不覆盖旧研究版本。每次更新快照必须修改 `version`;
|
||||
同一版本内容变化会拒绝导入,避免静默读取旧数据。回退代码时继续读取对应旧版本。
|
||||
所有修改都经过工作分支和 PR,部署仍等待人工合并主线。
|
||||
|
||||
当前资料包含 1,015 条公司/产品记录、10 类业务及待分类组、1,086 条证据记录和 170 条经营与技术观测。
|
||||
其中保留 80 家个案资料、919 条目录基础资料,并新增 16 个平台产品/业务样本;目录项不等于完成财务和技术核验。
|
||||
补充ThinkWheel与中国NaiveAI;MiroMind成立年未核,公开研发年份单列。保留灵初智能成立年排除、深度原理日期待核和Naive近名实体复核记录。
|
||||
新增7条Speedrun个案,保留标题/正文冲突、七位数数量级、币种不明、合同管线与履历归属排除记录;相对日期不充当同期收入时点。核查页优先按人工确认的公司ID关联明细,旧版本保留按证据归属回退;48条核查中37条指向具体公司、3条属于全局方法、8条是未纳入候选。明细反向列出关联核查;币种不明的声明保留原值,不绘制美元收入长度条。
|
||||
Bud保留为2025成立且YC标记已收购的案例;Rocket.new按前身2021成立、2025产品上线的转型对照记录,历史ARR不混入新成立公司同期统计。
|
||||
它是定向试样,收入披露不足且统计时点不统一,不代表全部 AI 公司。
|
||||
界面将公司自述、第三方估算、融资、客户收入和履历收入分开,未知收入不记为零。
|
||||
保留被撤回的声明作为核查记录,并排除收入比较;并购后业务、管理层预测与历史里程碑单列。
|
||||
团队规模下界在列表中显示比较符,商业成熟度展示公开交付证据,不等于技术能力排名。
|
||||
Emergent的1亿美元ARR里程碑补足至2026年2月;Peec采用规模和Wonderful团队规模各保留其公告日期,不将较新非收入指标当作ARR更新。Wonderful按2026年9月官方综合平台定位归C05。
|
||||
公司披露的 ARR 尚未建立同期同口径比较组。新增平台 MRR 单独计算探索性样本统计,
|
||||
不能称行业平均 ARR、总体离散排名或财务造假概率。
|
||||
|
||||
收入分布:`IncomeDistribution.tsx` 从数据库返回的观测中筛选平台 USD MRR,
|
||||
每个产品只保留符合条件的最新值,默认数据日期距截止日不超过7天。
|
||||
按业务类别、支付来源及可选收费模式分组,显示均值、中位数、样本CV(标准差分母n−1)、
|
||||
均值/中位数和移除最大值后的均值敏感性。n<3标注样本不足;n=1不计算CV。
|
||||
MRR×12仅作为显示单位转换,不改写为公司ARR;公司ARR、估算与近30日收入均不混入。
|
||||
`ai-income-analysis.json` 是独立Python复算的7日/90日快照参考,用于检查统计选择和计算,
|
||||
页面本身从同一SQLite中的公司与观测记录计算,不读取第二个数据库。
|
||||
|
||||
平台同步时间、连接过期状态、业务描述的可编辑性和产品/法律主体未去重限制均保留。
|
||||
覆盖审计页位于 `/docs/other-research/ai-company-research/coverage`,分类链接 `overview?category=<id>` 的筛选数据。
|
||||
`python3 scripts/audit-ai-research.py --check` 检查候选池逐条计数与覆盖报告一致性;CI在ces-control运行此检查,未更改触发范围。
|
||||
收入快照报告位于 `/docs/other-research/ai-company-research/income-snapshot`,样本名称链接 `overview?company=<id>` 的公司明细。
|
||||
`python3 scripts/summarize-ai-income.py --check` 独立复核统计与报告一致性;不触发编译或构建。数据变更后先审查方法,再运行不带参数的命令更新固定快照。
|
||||
默认7日样本14个产品;90日窗口新增Gojiberry的8月历史点,营销样本的均值和CV随之明显变化。
|
||||
这提示先核查时间与样本构成,不能将高离散度直接解释为造假。两个近名营养应用按不同App Store ID分别建档。
|
||||
|
||||
覆盖核查:从 yc-oss 非官方镜像发现 2025—2026 八个 YC 批次的 1,428 条候选,
|
||||
逐条读取对应 YC 官方公开页面,以页面中的成立年份、团队字段和业务描述筛选。
|
||||
962 条初筛匹配,其中 34 条与原表 URL 重合,实际新增 928 条;268 条年份不符或缺失、
|
||||
192 条 AI 业务待确认、6 条成立年份晚于批次年份未纳入本批新增。
|
||||
`content/research/ai-companies.coverage.json` 保留逐条筛选结果、来源与响应摘要,
|
||||
数据快照中的 `coverage` 也随版本进入同一 SQLite 数据库。只覆盖该候选池,不能称全球全量。
|
||||
|
||||
新增业务分类优先匹配主营简介,再参考目录标签;未匹配者进入待分类组。
|
||||
初分规则位于 `scripts/classify-ai-profiles.py`,只生成待评审标签,不执行抓取或构建。
|
||||
目录基础项保留 `classificationBasis` 和 `directoryTags` 供复核。
|
||||
当前目录基础项中160条已人工复核主营与买家,Contrario及8条收入声明/排除案例已深化为个案。`directoryOrigin`保留原发现来源,`profileOnly`只表示当前资料深度;深化不改变原候选池新增928条的审计分母。
|
||||
分类按主要工作任务和买家判断,例如Paragon归客户与收入运营,Oki归消费者应用,Lucidic归Agent设施。市场支出、采用规模、性能主张及客户收益均不作为公司收入。
|
||||
其中121条原待分类记录已按YC官方业务介绍人工复核主营业务,分类理由保存在公司字段与对应证据摘要中;
|
||||
仍有3条因简介不足保留待分类。人工复核分类不代表独立验证技术水平、客户效果或财务数据。
|
||||
AI 关键词可能出现在业务背景中,收入、买家和交付状态仍须逐家深查,不能把目录项的未知收入当作未披露。
|
||||
技术专项评审用独立字段记录层级、日期、摘要与证据:未评审、已读技术自述、公开代码/模型材料可访问、有第三方独立评测记录。
|
||||
当前6条完成该字段的专项记录,其中2条确认材料可访问、1条记录第三方已发表评测(Gradium TTS Sep 2026);本轮未自行运行模型测试。缺字段不表示无技术能力,代码或模型卡可读不证明性能。
|
||||
界面支持按资料深度、记录团队规模和技术验证证据筛选;证据列表每页 30 条,避免一次渲染全部来源。
|
||||
|
||||
`pnpm test:research` 在远程 Runner 上验证同库持久化、重复读取、版本导入与回退、
|
||||
登录限流保留、引用完整性及收入排除规则。镜像冒烟检查会验证研究页面与 API 的认证、
|
||||
返回记录数量、只读方法和容器重启后的读取结果。页面筛选交互仍需实际浏览器验证。
|
||||
|
||||
## CI/CD 与缓存
|
||||
|
||||
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
|
||||
当前工作流仅在 main 推送或针对 main 的手动运行时执行。工作分支PR完成本地静态核查后,
|
||||
完整类型、测试、构建与容器检查在用户手动合并后的主线CI运行;报告须区分未运行与已通过。
|
||||
|
||||
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。
|
||||
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
|
||||
"test:auth": "node scripts/test-auth.mjs"
|
||||
"test:auth": "node scripts/test-auth.mjs",
|
||||
"test:research": "node scripts/test-research.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.3",
|
||||
|
||||
@@ -160,7 +160,7 @@ async function main() {
|
||||
path,
|
||||
);
|
||||
}
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search"])
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"])
|
||||
assert.equal((await request(path)).status, 401, path);
|
||||
assert.equal(
|
||||
(
|
||||
@@ -181,6 +181,21 @@ async function main() {
|
||||
const headers = {
|
||||
Authorization: "Bearer " + signSession(config, undefined, 600),
|
||||
};
|
||||
const researchResponse = await request("/api/research/ai-companies", { headers });
|
||||
assert.equal(researchResponse.status, 200, "authenticated research API");
|
||||
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
|
||||
const research = await researchResponse.json();
|
||||
assert.equal(research.companies.length, 1015);
|
||||
assert.equal(research.categories.length, 11);
|
||||
assert.equal(research.evidence.length, 1086);
|
||||
assert.equal(research.observations.length, 170);
|
||||
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
|
||||
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
|
||||
assert.equal(researchPage.status, 200);
|
||||
const researchHtml = await researchPage.text();
|
||||
assert.ok(researchHtml.includes('data-category="其他研究"'));
|
||||
assert.ok(researchHtml.includes("AI 公司研究"));
|
||||
assert.ok(researchHtml.includes("正在读取研究数据库"));
|
||||
const searchResponse = await request("/api/search", { headers });
|
||||
assert.equal(searchResponse.status, 200);
|
||||
const searchIndex = await searchResponse.json();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { build } from "esbuild";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), "tabularium-research-tests-"));
|
||||
try {
|
||||
const file = join(dir, "research.test.cjs");
|
||||
await build({ entryPoints: ["tests/research.test.ts"], bundle: true,
|
||||
platform: "node", target: "node22", format: "cjs", outfile: file });
|
||||
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import IncomeDistribution from "./IncomeDistribution";
|
||||
import {
|
||||
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
|
||||
} from "@/lib/ai-research";
|
||||
|
||||
const pageSize = 12;
|
||||
const evidencePageSize = 30;
|
||||
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
|
||||
const useLabels: Record<string, string> = {
|
||||
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
|
||||
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
|
||||
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
|
||||
};
|
||||
const money = (value: number, unit = "USD") => unit === "USD"
|
||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)
|
||||
: `${new Intl.NumberFormat("zh-CN").format(value)} ${unit}`;
|
||||
|
||||
function External({ url, children }: { url: string; children: React.ReactNode }) {
|
||||
const safe = evidenceUrl(url);
|
||||
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{children} ↗</a> : <span>{children}</span>;
|
||||
}
|
||||
function Evidence({ item, prefix = "evidence" }: { item: ResearchEvidence; prefix?: string }) {
|
||||
return <li id={`${prefix}-${item.id}`} className="research-evidence">
|
||||
<div><code>{item.id}</code><span className="research-tag">{item.kind}</span></div>
|
||||
<strong><External url={item.url}>{item.title}</External></strong>
|
||||
<p>{item.summary}</p><p className="research-muted">{item.limitation}</p>
|
||||
<small>发布:{item.publishedAt ?? "未注明"} 读取:{item.accessedAt}</small>
|
||||
</li>;
|
||||
}
|
||||
|
||||
export default function AICompanyResearch() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<ResearchSnapshot | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [query, setQuery] = useState("");
|
||||
const [categoryChoice, setCategoryChoice] = useState<{ route: string; id: string } | null>(null);
|
||||
const linkedCategory = router.query.category;
|
||||
const category = categoryChoice && categoryChoice.route === router.asPath ? categoryChoice.id
|
||||
: router.isReady && typeof linkedCategory === "string" && data?.categories.some((c) => c.id === linkedCategory)
|
||||
? linkedCategory : "";
|
||||
function setCategory(id: string) { setCategoryChoice({ route: router.asPath, id }); }
|
||||
const [cohort, setCohort] = useState("");
|
||||
const [income, setIncome] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [sourcePage, setSourcePage] = useState(0);
|
||||
const [depth, setDepth] = useState("");
|
||||
const [teamBand, setTeamBand] = useState("");
|
||||
const [technicalLevel, setTechnicalLevel] = useState("");
|
||||
const [selection, setSelection] = useState<{ route: string; id: string | null } | null>(null);
|
||||
const linkedCompany = router.query.company;
|
||||
const selected = selection && selection.route === router.asPath ? selection.id
|
||||
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
|
||||
? linkedCompany : null;
|
||||
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); }
|
||||
const [tab, setTab] = useState<"companies" | "revenue" | "sources" | "reviews">("companies");
|
||||
const companySection = useRef<HTMLElement>(null);
|
||||
const detailSection = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch("/api/research/ai-companies", { signal: controller.signal, cache: "no-store" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error("Research unavailable");
|
||||
const snapshot: ResearchSnapshot = await response.json();
|
||||
setData(snapshot);
|
||||
}).catch(() => { if (!controller.signal.aborted) setError(true); });
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
useEffect(() => { if (selected) detailSection.current?.focus(); }, [selected]);
|
||||
const summaries = useMemo(() => data ? categorySummary(data) : [], [data]);
|
||||
const filtered = useMemo(() => data?.companies.filter((c) => {
|
||||
const search = `${c.name} ${c.business} ${c.location ?? ""} ${c.buyer}`.toLocaleLowerCase();
|
||||
const yearMatch = !cohort || (cohort === "other" ? ![2025, 2026].includes(c.year ?? 0) : c.year === Number(cohort));
|
||||
const depthMatch = !depth || (depth === "profile" ? c.profileOnly : !c.profileOnly);
|
||||
const teamMatch = !teamBand || (teamBand === "unknown" ? c.team === null || (!!c.teamBound && c.team < 51)
|
||||
: c.team !== null && (teamBand === "xl" ? c.team >= 51 : !c.teamBound && (teamBand === "small" ? c.team <= 5 : teamBand === "medium" ? c.team >= 6 && c.team <= 20 : c.team >= 21 && c.team <= 50)));
|
||||
return (!technicalLevel || (c.technicalReview?.level ?? "unreviewed") === technicalLevel) && depthMatch && teamMatch && (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
|
||||
&& search.includes(query.trim().toLocaleLowerCase());
|
||||
}) ?? [], [data, query, category, cohort, income, depth, teamBand, technicalLevel]);
|
||||
if (error) return <div className="research-message" role="alert">研究数据库暂时不可用。<button onClick={() => { setError(false); setAttempt((n) => n + 1); }}>重新读取</button></div>;
|
||||
if (!data) return <p className="research-message" role="status">正在读取研究数据库…</p>;
|
||||
const categories = new Map(data.categories.map((c) => [c.id, c]));
|
||||
const selectedCompany = data.companies.find((c) => c.id === selected);
|
||||
const observed = data.observations.filter((o) => ["signal", "estimate"].includes(o.selection));
|
||||
const disclosedCompanies = new Set(observed.map((o) => o.company)).size;
|
||||
const revenueCompanies = data.companies.filter((c) => c.revenueStatus !== "unknown").length;
|
||||
const selectedObservations = data.observations.filter((o) => o.company === selected);
|
||||
const selectedEvidenceIds = new Set([
|
||||
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
|
||||
...(selectedCompany?.technicalReview?.evidenceIds ?? []),
|
||||
...selectedObservations.map((o) => o.evidence),
|
||||
]);
|
||||
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
|
||||
const reviewCompanies = (review: ResearchReview) => {
|
||||
const ids = new Set(reviewCompanyIds(data, review));
|
||||
return data.companies.filter((c) => ids.has(c.id));
|
||||
};
|
||||
const selectedReviews = data.reviews.filter((r) => selected && reviewCompanyIds(data, r).includes(selected));
|
||||
function chooseCategory(id: string) {
|
||||
setCategory(id); setPage(0); setTab("companies");
|
||||
companySection.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
return <div className="ai-research not-prose">
|
||||
<div className="research-meta"><span>资料截至 {data.asOf}</span><span>首批研究 · 持续补全</span></div>
|
||||
<div className="research-stats">
|
||||
<div><strong>{data.companies.length}</strong><span>公司/产品记录</span></div>
|
||||
<div><strong>{data.categories.length}</strong><span>业务类别</span></div>
|
||||
<div><strong>{revenueCompanies}</strong><span>有收入线索的记录</span></div>
|
||||
<div><strong>{data.evidence.length}</strong><span>证据记录</span></div>
|
||||
</div>
|
||||
<div className="research-scope"><p>{data.scope}</p>
|
||||
{data.coverage && <p>YC 候选池 {data.coverage.candidateRecords} 条,官方页面读取 {data.coverage.profilesRead} 条;初筛匹配 {data.coverage.matchingProfiles} 条,新增 {data.coverage.newProfiles} 条目录记录。年份不符或缺失 {data.coverage.otherOrMissingYear} 条、AI 业务待确认 {data.coverage.unclearAI} 条、日期冲突 {data.coverage.dateConflicts} 条。目录基础资料尚未逐家核验收入和交付能力。</p>}
|
||||
<details><summary>数据口径与覆盖限制</summary><ul>{data.limitations.map((note) => <li key={note}>{note}</li>)}</ul>
|
||||
<p>来源以 YC、公司官网、投资机构名录与经营案例为主。同一披露的转载不构成独立验证。团队人数包含公司自报的 Team Size,与正式员工口径可能不同。</p>
|
||||
</details>
|
||||
</div>
|
||||
<section aria-labelledby="research-categories-title" className="research-section">
|
||||
<h2 id="research-categories-title">业务分类</h2><p className="research-muted">点击类别查看对应公司。数量反映本次采集覆盖,不是市场份额。</p>
|
||||
<div className="research-categories">{summaries.map((c) => <button key={c.id} onClick={() => chooseCategory(c.id)} aria-label={`查看${c.name}的${c.count}条记录`}>
|
||||
<div><span>{c.name}</span><strong>{c.count}</strong></div>
|
||||
<span className="research-bar" aria-hidden="true"><i style={{ width: `${c.count / Math.max(1, ...summaries.map((s) => s.count)) * 100}%` }} /></span>
|
||||
<small>{c.description}</small>
|
||||
</button>)}</div>
|
||||
</section>
|
||||
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
|
||||
<h2 id="research-records-title">研究数据</h2>
|
||||
<div className="research-tabs" role="group" aria-label="研究数据视图">
|
||||
{([['companies','公司与产品'],['revenue','收入观测'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
|
||||
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
|
||||
</div>
|
||||
{tab === "companies" && <>
|
||||
<div className="research-filters">
|
||||
<label className="research-query">搜索公司<input type="search" value={query} placeholder="名称、业务、地区或买家" onChange={(e) => { setQuery(e.target.value); setPage(0); }} /></label>
|
||||
<label>业务类别<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}><option value="">全部类别</option>{data.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
|
||||
<label>成立或发布<select value={cohort} onChange={(e) => { setCohort(e.target.value); setPage(0); }}><option value="">全部组别</option><option value="2025">2025 年成立</option><option value="2026">2026 年成立</option><option value="other">公开发布与转型对照</option></select></label>
|
||||
<label>资料深度<select value={depth} onChange={(e) => { setDepth(e.target.value); setPage(0); }}><option value="">全部资料</option><option value="case">原有个案研究</option><option value="profile">目录基础资料</option></select></label>
|
||||
<label>记录团队规模<select value={teamBand} onChange={(e) => { setTeamBand(e.target.value); setPage(0); }}><option value="">全部规模</option><option value="small">1—5 人</option><option value="medium">6—20 人</option><option value="large">21—50 人</option><option value="xl">至少 51 人</option><option value="unknown">未披露或分组不确定</option></select></label>
|
||||
<label>技术验证证据<select value={technicalLevel} onChange={(e) => { setTechnicalLevel(e.target.value); setPage(0); }}><option value="">全部验证层级</option>{Object.entries(technicalReviewLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
|
||||
</div>
|
||||
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p>
|
||||
<div className="research-result-count" role="status">{filtered.length} 条匹配记录<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setDepth(""); setTeamBand(""); setTechnicalLevel(""); setPage(0); }}>清除筛选</button></div>
|
||||
<div className="research-table-wrap"><table><caption className="sr-only">公司/产品分类与经营资料。选择名称查看指标和原始证据。</caption><thead><tr><th>公司或产品 / 地区</th><th>业务与买家</th><th>成立 / 发布组别</th><th>团队规模</th><th>交付与技术验证证据</th><th>收入资料</th></tr></thead><tbody>
|
||||
{filtered.slice(page * pageSize, (page + 1) * pageSize).map((c) => <tr key={c.id} data-selected={selected === c.id || undefined}>
|
||||
<td><button className="research-company-name" aria-expanded={selected === c.id} aria-controls="research-company-detail" onClick={() => setSelected(c.id)}>{c.name}</button><small>{c.location ?? "地区未注明"}</small></td>
|
||||
<td><span className="research-tag">{categories.get(c.cat)?.name}</span>{c.profileOnly && <small>目录基础资料 · 分类依据见明细</small>}<p>{c.business}</p><small>买家:{c.buyer}</small></td>
|
||||
<td>{c.cohort}</td><td>{c.team !== null && c.teamBound}{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{c.stage}<small>{technicalReviewLabels[c.technicalReview?.level ?? "unreviewed"]}</small></td><td>{c.profileOnly ? "收入尚未逐一核验" : revenueLabels[c.revenueStatus]}</td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
|
||||
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
|
||||
</>}
|
||||
{tab === "revenue" && <>
|
||||
<IncomeDistribution data={data} onSelect={setSelected} />
|
||||
<h3>公司披露与估算的历史里程碑</h3>
|
||||
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
|
||||
<div className="research-revenue-chart" aria-label="不同时间的公开收入里程碑,线性长度,非同行排名">{observed.map((o) => <div key={o.id}>
|
||||
<div><button onClick={() => setSelected(o.company)}>{o.name}</button><strong>{o.bound !== "=" && o.bound}{money(o.value, o.unit)}</strong></div>
|
||||
{o.unit === "USD" && <span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(1, ...observed.filter((v) => v.unit === "USD").map((v) => v.value)) * 100}%`}} /></span>}
|
||||
<small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
|
||||
</div>)}</div>
|
||||
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>公司披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
|
||||
<p className="research-muted">离散度用于筛选值得核查的异常,还需排除公司年龄、口径和披露偏差;不能据此认定财务造假。</p>
|
||||
</>}
|
||||
{tab === "sources" && <><p className="research-muted">来源链接保留原始位置。相同 URL 的记录可能属于同一披露,不能按记录数计算独立证据数量。</p><ul className="research-evidence-list">{data.evidence.slice(sourcePage * evidencePageSize, (sourcePage + 1) * evidencePageSize).map((e) => <Evidence key={e.id} item={e} />)}</ul>{data.evidence.length > evidencePageSize && <nav className="research-pagination" aria-label="原始证据分页"><button disabled={sourcePage === 0} onClick={() => setSourcePage((n) => n - 1)}>上一页</button><span>{sourcePage + 1} / {Math.ceil(data.evidence.length / evidencePageSize)}</span><button disabled={(sourcePage + 1) * evidencePageSize >= data.evidence.length} onClick={() => setSourcePage((n) => n + 1)}>下一页</button></nav>}</>}
|
||||
{tab === "reviews" && <ul className="research-review-list">{data.reviews.map((r) => <li key={r.id}><span className="research-tag">{r.type}</span><h3>{r.company}</h3>{r.scope === "dataset" && <small>全局数据方法核查</small>}{r.scope === "excluded-candidate" && <small>未纳入公司表的候选</small>}<p>{r.summary}</p><p>{r.handling}</p><div>{reviewCompanies(r).map((c) => <button key={c.id} className="research-company-name" onClick={() => setSelected(c.id)}>查看 {c.name} 明细</button>)}</div><div>{r.evidenceIds.map((id) => { const e=data.evidence.find((item) => item.id === id); return e ? <External key={id} url={e.url}>{id}</External> : null; })}</div></li>)}</ul>}
|
||||
</section>
|
||||
{selectedCompany && <section id="research-company-detail" ref={detailSection} tabIndex={-1} className="research-detail research-section" aria-label={`${selectedCompany.name}资料明细`}>
|
||||
<div className="research-detail-heading"><h2>{selectedCompany.name}</h2><button onClick={() => setSelected(null)} aria-label="关闭公司明细">关闭</button></div>
|
||||
<p><External url={selectedCompany.url}>资料入口</External> {selectedCompany.extraUrl && <External url={selectedCompany.extraUrl}>额外核验入口</External>}</p>
|
||||
<dl className="research-company-facts"><div><dt>主营业务</dt><dd>{selectedCompany.business}</dd></div><div><dt>样本组别</dt><dd>{selectedCompany.cohort}</dd></div><div><dt>买家</dt><dd>{selectedCompany.buyer}</dd></div><div><dt>收费/交付模式</dt><dd>{selectedCompany.businessModel ?? "待进一步核验"}</dd></div><div><dt>收费方式</dt><dd>{selectedCompany.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>分类依据</dt><dd>{selectedCompany.classificationBasis ?? "原有个案分类;随研究复核"}</dd></div><div><dt>技术验证证据</dt><dd>{technicalReviewLabels[selectedCompany.technicalReview?.level ?? "unreviewed"]}<p>{selectedCompany.technicalReview?.summary ?? "本轮未形成专项技术评审记录;不表示公司没有技术能力。"}</p>{selectedCompany.technicalReview && <small>评审日期:{selectedCompany.technicalReview.reviewedAt}</small>}{selectedCompany.technicalReview?.testScope && <p>测试范围:{selectedCompany.technicalReview.testScope}</p>}<p>材料可访问仅确认可查看,不证明性能、可靠性或商业交付质量;不同任务不作统一能力排名。</p></dd></div><div><dt>观测主体</dt><dd>{selectedCompany.entityBasis ?? "公司或经营品牌,法律主体未逐一核验"}</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
|
||||
<h3>经营与技术观测</h3>
|
||||
{!!selectedReviews.length && <details><summary>关联核查记录({selectedReviews.length})</summary><ul>{selectedReviews.map((r) => <li key={r.id}><strong>{r.type}:{r.summary}</strong><p>{r.handling}</p><div>{r.evidenceIds.map((id) => { const item = data.evidence.find((e) => e.id === id); return item ? <External key={id} url={item.url}>{id}</External> : null; })}</div></li>)}</ul></details>}
|
||||
{selectedObservations.length ? <div className="research-table-wrap"><table><thead><tr><th>指标 / 归属</th><th>原始数值</th><th>数据时点</th><th>用途及限制</th><th>证据</th></tr></thead><tbody>{selectedObservations.map((o) => <tr key={o.id}><td>{o.kind}<small>{o.scope}</small></td><td>{o.bound !== "=" && o.bound}{money(o.value,o.unit)}</td><td>{o.timing}</td><td>{useLabels[o.selection] ?? o.selection}<small>{o.reason}</small></td><td><a href={`#detail-evidence-${o.evidence}`}>{o.evidence}</a></td></tr>)}</tbody></table></div> : <p>本轮尚未找到可记录的量化经营指标。收入保持未知。</p>}
|
||||
<h3>原始证据</h3><ul className="research-evidence-list">{selectedEvidence.map((e) => <Evidence key={e.id} prefix="detail-evidence" item={e} />)}</ul>
|
||||
</section>}
|
||||
<p className="research-footnote">数据版本 {data.version}。当前为首批资料,覆盖、收入定义、财务透明度与商业模式评估将随核验补充。</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ResearchSnapshot } from "@/lib/ai-research";
|
||||
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
|
||||
|
||||
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
const percent = (value: number | null) => value === null ? "—" : `${(value * 100).toFixed(1)}%`;
|
||||
|
||||
export default function IncomeDistribution({ data, onSelect }: {
|
||||
data: ResearchSnapshot; onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [dateWindow, setDateWindow] = useState("7");
|
||||
const [year, setYear] = useState("");
|
||||
const [annualize, setAnnualize] = useState(false);
|
||||
const [splitModels, setSplitModels] = useState(true);
|
||||
const [logScale, setLogScale] = useState(true);
|
||||
const sample = useMemo(() => platformIncomeSample(data, {
|
||||
windowDays: dateWindow === "all" ? null : Number(dateWindow), foundedYear: year ? Number(year) : undefined, annualize,
|
||||
}), [data, dateWindow, year, annualize]);
|
||||
const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) =>
|
||||
a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]);
|
||||
const categories = new Map(data.categories.map((category) => [category.id, category.name]));
|
||||
const metric = annualize ? "MRR×12参考年化" : "平台MRR";
|
||||
const values = sample.points.map((point) => point.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(1, ...values);
|
||||
const axisMin = logScale && minValue > 0 && Number.isFinite(minValue) ? 10 ** Math.floor(Math.log10(minValue)) : 0;
|
||||
const axisMax = Math.max(axisMin * 10, 10 ** Math.ceil(Math.log10(maxValue)));
|
||||
const scale = logScale ? (value: number) => Math.log10(value + 1) : (value: number) => value;
|
||||
const left = 250, right = 865;
|
||||
const x = (value: number) => left + (scale(value) - scale(axisMin)) / (scale(axisMax) - scale(axisMin)) * (right - left);
|
||||
const ticks = logScale ? [0, 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]
|
||||
.filter((value) => value >= axisMin && value <= axisMax) : [0, .25, .5, .75, 1].map((fraction) => fraction * axisMax);
|
||||
return <section className="research-distribution" aria-labelledby="income-distribution-title">
|
||||
<h3 id="income-distribution-title">收入分布与均值敏感性</h3>
|
||||
<p>单独比较支付平台显示的 USD MRR。每个点是一个产品或业务,法律主体与支付账户的独立性尚未全部核实;这不是行业总体或公司财报样本。</p>
|
||||
<div className="research-filters">
|
||||
<label>最后同步日期窗口<select value={dateWindow} onChange={(event) => setDateWindow(event.target.value)}><option value="7">截止日前 7 天内</option><option value="30">截止日前 30 天内</option><option value="90">截止日前 90 天内</option><option value="all">全部已知日期(混合历史)</option></select></label>
|
||||
<label>资料中的创立年<select value={year} onChange={(event) => setYear(event.target.value)}><option value="">2025 与 2026</option><option value="2025">2025</option><option value="2026">2026</option></select></label>
|
||||
<label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label>
|
||||
</div>
|
||||
<div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div>
|
||||
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
|
||||
{sample.points.length ? <>
|
||||
<figure>
|
||||
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
|
||||
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
|
||||
{groups.map((group, index) => {
|
||||
const y = index * 70 + 40;
|
||||
return <g key={group.key}>
|
||||
<text x={8} y={y - 8} fontSize="12" fill="#202724">{categories.get(group.category)}</text>
|
||||
<text x={8} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text>
|
||||
<line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} />
|
||||
<line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line>
|
||||
<path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path>
|
||||
{group.points.map((point, position) => <g key={point.company.id} role="button" tabIndex={0} aria-label={`查看${point.company.name},${metric} ${usd(point.value)}`} onClick={() => onSelect(point.company.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(point.company.id); } }}>
|
||||
<title>{point.company.name} · {usd(point.value)} · 数据日期 {point.observation.observedAt}{point.observation.connectionExpired ? " · 连接提示过期" : ""}</title>
|
||||
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={10} fill="transparent" />
|
||||
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={4.5} fill="#27745b" stroke="white" />
|
||||
</g>)}
|
||||
</g>;
|
||||
})}
|
||||
</svg></div>
|
||||
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。按业务类别与支付来源分组,点击点或下表产品名可看原始证据。</figcaption>
|
||||
</figure>
|
||||
<div className="research-table-wrap"><table><caption>{metric}样本统计;金额按显示值计算,CV使用样本标准差</caption><thead><tr><th>类别 / 来源 / 模式</th><th>n</th><th>均值</th><th>中位数</th><th>均值/中位数</th><th>CV</th><th>去除最大值后的均值 / 下降幅度</th><th>产品与限制</th></tr></thead><tbody>{groups.map((group) => <tr key={group.key}>
|
||||
<td>{categories.get(group.category)}<small>{group.provider} · {group.model}</small></td><td>{group.stats.n}</td><td>{usd(group.stats.mean)}</td><td>{usd(group.stats.median)}</td><td>{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}</td><td>{group.stats.cv?.toFixed(2) ?? "—"}</td><td>{usd(group.stats.meanWithoutLargest)}<small>{percent(group.stats.largestRemovalDrop)}</small></td>
|
||||
<td>{group.points.map((point) => <span key={point.company.id}><button className="research-company-name" onClick={() => onSelect(point.company.id)}>{point.company.name}</button>{" "}</span>)}<small>{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}</small></td>
|
||||
</tr>)}</tbody></table></div>
|
||||
</> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>}
|
||||
<details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details>
|
||||
{sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import seed from "../../../../content/research/ai-companies.seed.json";
|
||||
import { createHash } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
import { validateResearch, type ResearchSnapshot } from "./ai-research";
|
||||
|
||||
// Each release selects an immutable research revision. New revisions are added
|
||||
// transactionally, preserving both earlier research and all authentication rows.
|
||||
export function readResearchSnapshot(snapshot = seed as ResearchSnapshot): ResearchSnapshot {
|
||||
validateResearch(snapshot);
|
||||
const digest = createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
|
||||
const db = openSiteDatabase();
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS research_datasets (
|
||||
id TEXT PRIMARY KEY, as_of TEXT NOT NULL, metadata TEXT NOT NULL, content_hash TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_categories (
|
||||
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
|
||||
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_companies (
|
||||
dataset_id TEXT NOT NULL, id TEXT NOT NULL, category_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, founded_year INTEGER, team_size INTEGER,
|
||||
revenue_status TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
PRIMARY KEY(dataset_id,id),
|
||||
FOREIGN KEY(dataset_id,category_id) REFERENCES research_categories(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_evidence (
|
||||
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
|
||||
id TEXT NOT NULL, url TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
PRIMARY KEY(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_observations (
|
||||
dataset_id TEXT NOT NULL, id TEXT NOT NULL, company_id TEXT NOT NULL,
|
||||
evidence_id TEXT NOT NULL, kind TEXT NOT NULL, amount REAL NOT NULL,
|
||||
unit TEXT NOT NULL, record_use TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
PRIMARY KEY(dataset_id,id),
|
||||
FOREIGN KEY(dataset_id,company_id) REFERENCES research_companies(dataset_id,id),
|
||||
FOREIGN KEY(dataset_id,evidence_id) REFERENCES research_evidence(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_reviews (
|
||||
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
|
||||
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
|
||||
);
|
||||
`);
|
||||
// The database transaction also serializes first requests from two workers.
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const exists = db.prepare("SELECT content_hash FROM research_datasets WHERE id=?")
|
||||
.get(snapshot.version) as { content_hash: string } | undefined;
|
||||
if (exists && exists.content_hash !== digest)
|
||||
throw new Error("Research data changed without a new revision identifier");
|
||||
if (!exists) {
|
||||
const { categories, companies, observations, evidence, reviews, ...metadata } = snapshot;
|
||||
db.prepare("INSERT INTO research_datasets VALUES (?,?,?,?)")
|
||||
.run(snapshot.version, snapshot.asOf, JSON.stringify(metadata), digest);
|
||||
const insertCategory = db.prepare("INSERT INTO research_categories VALUES (?,?,?)");
|
||||
for (const row of categories) insertCategory.run(snapshot.version, row.id, JSON.stringify(row));
|
||||
const insertCompany = db.prepare("INSERT INTO research_companies VALUES (?,?,?,?,?,?,?,?)");
|
||||
for (const row of companies) insertCompany.run(snapshot.version, row.id, row.cat,
|
||||
row.name, row.year, row.team, row.revenueStatus, JSON.stringify(row));
|
||||
const insertEvidence = db.prepare("INSERT INTO research_evidence VALUES (?,?,?,?)");
|
||||
for (const row of evidence) insertEvidence.run(snapshot.version, row.id, row.url, JSON.stringify(row));
|
||||
const insertObservation = db.prepare("INSERT INTO research_observations VALUES (?,?,?,?,?,?,?,?,?)");
|
||||
for (const row of observations) insertObservation.run(snapshot.version, row.id, row.company,
|
||||
row.evidence, row.kind, row.value, row.unit, row.selection, JSON.stringify(row));
|
||||
const insertReview = db.prepare("INSERT INTO research_reviews VALUES (?,?,?)");
|
||||
for (const row of reviews) insertReview.run(snapshot.version, row.id, JSON.stringify(row));
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (error) {
|
||||
db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
const meta = db.prepare("SELECT metadata FROM research_datasets WHERE id=?").get(snapshot.version) as { metadata: string };
|
||||
// All displayed records come from the site database, not the seed arrays.
|
||||
const rows = (table: string) => (db.prepare(
|
||||
`SELECT payload FROM ${table} WHERE dataset_id=? ORDER BY id`,
|
||||
).all(snapshot.version) as { payload: string }[]).map((r) => JSON.parse(r.payload));
|
||||
const stored: ResearchSnapshot = { ...JSON.parse(meta.metadata),
|
||||
categories: rows("research_categories"), companies: rows("research_companies"),
|
||||
observations: rows("research_observations"), evidence: rows("research_evidence"),
|
||||
reviews: rows("research_reviews") };
|
||||
validateResearch(stored);
|
||||
return stored;
|
||||
} finally { db.close(); }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export type ResearchCategory = { id: string; name: string; description: string };
|
||||
export const technicalReviewLabels = {
|
||||
unreviewed: "技术证据尚未专项评审",
|
||||
description: "已读技术自述",
|
||||
"public-artifact": "公开代码/模型材料可访问",
|
||||
"independent-test": "有第三方独立评测记录",
|
||||
};
|
||||
export type ResearchCompany = {
|
||||
id: string; name: string; url: string; cat: string; year: number | null;
|
||||
team: number | null; teamBound?: ">" | "≥"; location: string | null; business: string; buyer: string;
|
||||
delivery: string; notes: string; cohort: string; teamBasis: string | null;
|
||||
price: string; stage: string; sourceType: string; evidenceIds: string[];
|
||||
revenueStatus: "self" | "estimate" | "platform" | "unknown";
|
||||
extraUrl?: string; pricingEvidence?: string;
|
||||
businessModel?: string; entityBasis?: string;
|
||||
profileOnly?: boolean; classificationBasis?: string; directoryTags?: string[];
|
||||
directoryOrigin?: "yc-2025-2026";
|
||||
technicalReview?: {
|
||||
level: "description" | "public-artifact" | "independent-test";
|
||||
reviewedAt: string; summary: string; evidenceIds: string[]; testScope?: string;
|
||||
};
|
||||
};
|
||||
export type ResearchObservation = {
|
||||
id: string; company: string; name: string; kind: string; value: number;
|
||||
unit: string; timing: string; evidence: string; scope: string;
|
||||
selection: string; reason: string; bound: string; comparisonGroup: string | null;
|
||||
metric?: "mrr" | "arr" | "revenue-30d" | "annualized-revenue";
|
||||
observedAt?: string; provider?: string; connectionExpired?: boolean; origin?: "payment-platform";
|
||||
};
|
||||
export type ResearchEvidence = {
|
||||
id: string; companyId: string | null; title: string; url: string;
|
||||
publishedAt: string | null; accessedAt: string; kind: string;
|
||||
summary: string; limitation: string;
|
||||
};
|
||||
export type ResearchReview = {
|
||||
id: string; type: string; company: string; summary: string;
|
||||
evidenceIds: string[]; handling: string;
|
||||
scope?: "companies" | "dataset" | "excluded-candidate"; companyIds?: string[];
|
||||
};
|
||||
export type ResearchCoverage = {
|
||||
source: string; accessedAt: string; candidateRecords: number; profilesRead: number;
|
||||
matchingProfiles: number; existingProfiles: number; newProfiles: number;
|
||||
otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number;
|
||||
discoverySha256: string;
|
||||
};
|
||||
export type ResearchSnapshot = {
|
||||
version: string; asOf: string; scope: string; limitations: string[];
|
||||
categories: ResearchCategory[]; companies: ResearchCompany[];
|
||||
observations: ResearchObservation[]; evidence: ResearchEvidence[];
|
||||
reviews: ResearchReview[]; coverage?: ResearchCoverage;
|
||||
};
|
||||
|
||||
export function evidenceUrl(value: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
export function validateResearch(snapshot: ResearchSnapshot) {
|
||||
const unique = (values: string[], label: string) => {
|
||||
if (new Set(values).size !== values.length || values.some((v) => !v))
|
||||
throw new Error(`Duplicate or missing research ${label}`);
|
||||
return new Set(values);
|
||||
};
|
||||
if (!snapshot.version || !/^\d{4}-\d{2}-\d{2}$/.test(snapshot.asOf))
|
||||
throw new Error("Invalid research dataset metadata");
|
||||
const categories = unique(snapshot.categories.map((c) => c.id), "categories");
|
||||
const companies = unique(snapshot.companies.map((c) => c.id), "companies");
|
||||
const evidence = unique(snapshot.evidence.map((e) => e.id), "evidence");
|
||||
unique(snapshot.observations.map((o) => o.id), "observations");
|
||||
unique(snapshot.reviews.map((r) => r.id), "reviews");
|
||||
for (const c of snapshot.companies) {
|
||||
const technical = c.technicalReview;
|
||||
if (technical && (!["description", "public-artifact", "independent-test"].includes(technical.level) ||
|
||||
!/^\d{4}-\d{2}-\d{2}$/.test(technical.reviewedAt) ||
|
||||
Number.isNaN(Date.parse(technical.reviewedAt)) ||
|
||||
new Date(technical.reviewedAt).toISOString().slice(0, 10) !== technical.reviewedAt ||
|
||||
technical.reviewedAt > snapshot.asOf || !technical.summary.trim() ||
|
||||
!technical.evidenceIds.length || technical.evidenceIds.some((id) => !evidence.has(id)) ||
|
||||
(technical.level === "independent-test" && !technical.testScope?.trim())))
|
||||
throw new Error(`Invalid technical review: ${c.id}`);
|
||||
if (c.profileOnly && (c.revenueStatus !== "unknown" || !c.classificationBasis))
|
||||
throw new Error(`Invalid profile-only research record: ${c.id}`);
|
||||
if (c.directoryOrigin && (c.directoryOrigin !== "yc-2025-2026" || !/^YC\d+$/.test(c.id)))
|
||||
throw new Error(`Invalid research directory origin: ${c.id}`);
|
||||
if (!categories.has(c.cat) || !evidenceUrl(c.url) ||
|
||||
c.evidenceIds.some((id) => !evidence.has(id)) ||
|
||||
(c.pricingEvidence && !evidence.has(c.pricingEvidence)))
|
||||
throw new Error(`Invalid research company reference: ${c.id}`);
|
||||
}
|
||||
for (const e of snapshot.evidence) {
|
||||
if (!evidenceUrl(e.url) || (e.companyId && !companies.has(e.companyId)))
|
||||
throw new Error(`Invalid research evidence: ${e.id}`);
|
||||
}
|
||||
for (const o of snapshot.observations) {
|
||||
if (!companies.has(o.company) || !evidence.has(o.evidence) ||
|
||||
!Number.isFinite(o.value))
|
||||
throw new Error(`Invalid research observation: ${o.id}`);
|
||||
}
|
||||
const coverage = snapshot.coverage;
|
||||
if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords ||
|
||||
coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles ||
|
||||
coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead ||
|
||||
snapshot.companies.filter((c) => c.profileOnly || c.directoryOrigin === "yc-2025-2026").length !== coverage.newProfiles || !evidenceUrl(coverage.source)))
|
||||
throw new Error("Invalid research coverage accounting");
|
||||
for (const r of snapshot.reviews)
|
||||
if (r.evidenceIds.some((id) => !evidence.has(id)) ||
|
||||
(r.companyIds && (new Set(r.companyIds).size !== r.companyIds.length ||
|
||||
r.companyIds.some((id) => !companies.has(id)))) ||
|
||||
(r.scope && !["companies", "dataset", "excluded-candidate"].includes(r.scope)) ||
|
||||
(r.scope === "companies" && !r.companyIds?.length) ||
|
||||
(r.scope && r.scope !== "companies" && !!r.companyIds?.length))
|
||||
throw new Error(`Invalid research review: ${r.id}`);
|
||||
}
|
||||
|
||||
/** Explicit reviewed associations take priority; older revisions use evidence ownership. */
|
||||
export function reviewCompanyIds(snapshot: ResearchSnapshot, review: ResearchReview): string[] {
|
||||
if (review.companyIds) return review.companyIds;
|
||||
const evidenceIds = new Set(review.evidenceIds);
|
||||
return [...new Set(snapshot.evidence.flatMap((e) =>
|
||||
e.companyId && evidenceIds.has(e.id) ? [e.companyId] : []))];
|
||||
}
|
||||
|
||||
export function categorySummary(snapshot: ResearchSnapshot) {
|
||||
return snapshot.categories.map((category) => {
|
||||
const companies = snapshot.companies.filter((c) => c.cat === category.id);
|
||||
return { ...category, count: companies.length,
|
||||
self: companies.filter((c) => c.revenueStatus === "self").length,
|
||||
platform: companies.filter((c) => c.revenueStatus === "platform").length,
|
||||
estimate: companies.filter((c) => c.revenueStatus === "estimate").length,
|
||||
unknown: companies.filter((c) => c.revenueStatus === "unknown").length };
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { authId, matchingStep, type AuthConfig } from "./auth";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
|
||||
function database() {
|
||||
const configFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("Authentication is not configured");
|
||||
const file = join(dirname(configFile), "login-state.sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
chmodSync(file, 0o600);
|
||||
const db = openSiteDatabase();
|
||||
db.exec(`PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS login_state (
|
||||
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
|
||||
|
||||
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
|
||||
export type IncomeStats = {
|
||||
n: number; mean: number; median: number; min: number; max: number;
|
||||
sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null;
|
||||
largestShare: number | null; meanWithoutLargest: number | null; largestRemovalDrop: number | null;
|
||||
};
|
||||
|
||||
/** Describes the supplied sample only; missing values never become zero. */
|
||||
export function describeIncome(values: number[]): IncomeStats | null {
|
||||
if (!values.length || values.some((value) => !Number.isFinite(value) || value < 0)) return null;
|
||||
const ordered = [...values].sort((a, b) => a - b);
|
||||
const n = ordered.length;
|
||||
const total = ordered.reduce((sum, value) => sum + value, 0);
|
||||
const mean = total / n;
|
||||
const median = n % 2 ? ordered[(n - 1) / 2] : (ordered[n / 2 - 1] + ordered[n / 2]) / 2;
|
||||
const sampleDeviation = n > 1 ? Math.sqrt(ordered.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1)) : null;
|
||||
const max = ordered[n - 1];
|
||||
const meanWithoutLargest = n > 1 ? (total - max) / (n - 1) : null;
|
||||
return { n, mean, median, min: ordered[0], max, sampleDeviation,
|
||||
cv: sampleDeviation !== null && mean > 0 ? sampleDeviation / mean : null,
|
||||
meanMedianRatio: median > 0 ? mean / median : null,
|
||||
largestShare: total > 0 ? max / total : null, meanWithoutLargest,
|
||||
largestRemovalDrop: meanWithoutLargest !== null && mean > 0 ? (mean - meanWithoutLargest) / mean : null };
|
||||
}
|
||||
|
||||
function calendarDay(value: string | undefined): number | null {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const time = Date.parse(`${value}T00:00:00Z`);
|
||||
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? time / 86400000 : null;
|
||||
}
|
||||
|
||||
/** Latest dated platform MRR per product, never ARR, cash collections or estimates. */
|
||||
export function platformIncomeSample(snapshot: ResearchSnapshot, options: {
|
||||
windowDays: number | null; foundedYear?: number; annualize?: boolean;
|
||||
}) {
|
||||
const end = calendarDay(snapshot.asOf);
|
||||
if (end === null || (options.windowDays !== null && (!Number.isInteger(options.windowDays) || options.windowDays < 0)))
|
||||
throw new Error("Invalid income sample window");
|
||||
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
|
||||
const latest = new Map<string, ResearchObservation>();
|
||||
const excluded: { observation: ResearchObservation; reason: string }[] = [];
|
||||
const candidates = snapshot.observations.filter((o) => o.selection === "platform" && o.metric === "mrr");
|
||||
for (const observation of candidates) {
|
||||
const company = companies.get(observation.company);
|
||||
const day = calendarDay(observation.observedAt);
|
||||
let reason = "";
|
||||
if (!company || ![2025, 2026].includes(company.year ?? 0)) reason = "创立年份未纳入2025—2026组";
|
||||
else if (options.foundedYear && company.year !== options.foundedYear) reason = "不属于所选创立年份";
|
||||
else if (observation.origin !== "payment-platform" || observation.unit !== "USD" || observation.bound !== "=" ||
|
||||
observation.comparisonGroup !== "trustmrr-usd-mrr" || !observation.provider || !Number.isFinite(observation.value) || observation.value < 0)
|
||||
reason = "来源、币种、指标或数值口径不满足条件";
|
||||
else if (day === null) reason = "缺少有效数据日期";
|
||||
else if (day > end) reason = "数据日期晚于研究截止日";
|
||||
else if (options.windowDays !== null && end - day > options.windowDays) reason = "最后同步日期超出所选窗口";
|
||||
if (reason) { excluded.push({ observation, reason }); continue; }
|
||||
const previous = latest.get(observation.company);
|
||||
if (!previous || observation.observedAt! > previous.observedAt!) {
|
||||
if (previous) excluded.push({ observation: previous, reason: "同一产品的较早观测" });
|
||||
latest.set(observation.company, observation);
|
||||
} else excluded.push({ observation, reason: "同一产品已选较新或同日观测" });
|
||||
}
|
||||
const points: IncomePoint[] = [...latest.values()].map((observation) => ({
|
||||
company: companies.get(observation.company)!, observation,
|
||||
value: observation.value * (options.annualize ? 12 : 1),
|
||||
}));
|
||||
return { points, excluded };
|
||||
}
|
||||
|
||||
export function groupIncome(points: IncomePoint[], splitModels = true) {
|
||||
const groups = new Map<string, { category: string; provider: string; model: string; points: IncomePoint[] }>();
|
||||
for (const point of points) {
|
||||
const category = point.company.cat;
|
||||
const provider = point.observation.provider!;
|
||||
const model = splitModels ? point.company.businessModel ?? "收费模式未核验" : "全部收费模式";
|
||||
const key = JSON.stringify([category, provider, model]);
|
||||
if (!groups.has(key)) groups.set(key, { category, provider, model, points: [] });
|
||||
groups.get(key)!.points.push(point);
|
||||
}
|
||||
return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! }));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Keep the historical filename so existing authentication state is preserved.
|
||||
// Research and authentication use different tables in this same site database.
|
||||
export function openSiteDatabase() {
|
||||
const configFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("Authentication is not configured");
|
||||
const file = join(dirname(configFile), "login-state.sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
try {
|
||||
chmodSync(file, 0o600);
|
||||
db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;");
|
||||
return db;
|
||||
} catch (error) {
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { readResearchSnapshot } from "@/lib/ai-research-store";
|
||||
|
||||
// The site's existing proxy protects this endpoint with the same login session.
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
try { return response.status(200).json(readResearchSnapshot()); }
|
||||
catch { return response.status(503).json({ error: "研究数据库暂时不可用,请稍后重试" }); }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InferGetStaticPropsType,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Children,
|
||||
@@ -24,6 +25,7 @@ import { Icon } from "@/components/Icon";
|
||||
import { headingsFor } from "@/lib/navigation";
|
||||
|
||||
type Props = { doc: Doc; docs: DocLink[] };
|
||||
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
|
||||
export const getStaticPaths: GetStaticPaths = async () => ({
|
||||
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
|
||||
fallback: false,
|
||||
@@ -88,7 +90,7 @@ export default function DocPage({
|
||||
<meta name="description" content={doc.description} />
|
||||
</Head>
|
||||
<Shell key={doc.slug} docs={docs} current={doc.slug}>
|
||||
<div className="reading-layout">
|
||||
<div className={`reading-layout${doc.collection === "AI 公司研究" ? " research-layout" : ""}`}>
|
||||
<aside className="sidebar">
|
||||
<Navigation docs={docs} current={doc.slug} />
|
||||
</aside>
|
||||
@@ -225,6 +227,7 @@ export default function DocPage({
|
||||
{doc.content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
{doc.slug === "other-research/ai-company-research/overview" && <AICompanyResearch />}
|
||||
<div className="article-end">
|
||||
|
||||
<a
|
||||
|
||||
@@ -1381,3 +1381,102 @@ svg { flex-shrink: 0; }
|
||||
.category-menu { position: static; }
|
||||
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
|
||||
}
|
||||
|
||||
/* Research keeps the documentation navigation and uses the remaining reading width. */
|
||||
.research-layout { grid-template-columns: 240px minmax(0, 1fr); }
|
||||
.research-layout .reading-main { max-width: 1440px; width: 100%; }
|
||||
.research-layout > .toc { display: none; }
|
||||
.ai-research { margin-top: 28px; font-size: 13px; line-height: 1.65; }
|
||||
.ai-research h2 { font-size: 20px; font-weight: 600; margin: 0 0 10px; letter-spacing: -.4px; }
|
||||
.ai-research h3 { font-size: 15px; font-weight: 600; margin: 18px 0 8px; }
|
||||
.ai-research p { margin: 6px 0 10px; }
|
||||
.ai-research a, .research-company-name { color: var(--green); }
|
||||
.ai-research a:hover, .research-company-name:hover { text-decoration: underline; }
|
||||
.ai-research button { text-align: left; }
|
||||
.research-meta { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--muted); font-size: 12px; }
|
||||
.research-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; margin: 22px 0; }
|
||||
.research-stats > div { border-top: 1px solid var(--line); padding: 14px 0; }
|
||||
.research-stats strong { display: block; font-size: 29px; line-height: 1.3; font-weight: 550; font-variant-numeric: tabular-nums; }
|
||||
.research-stats span { color: var(--muted); font-size: 12px; }
|
||||
.research-scope { padding: 14px 18px; background: var(--soft); border-radius: 7px; }
|
||||
.research-scope summary { color: var(--green); font-weight: 500; }
|
||||
.research-scope ul { padding-left: 20px; }
|
||||
.research-section { margin: 38px 0; scroll-margin-top: 92px; }
|
||||
.research-muted, .research-footnote { color: var(--muted); font-size: 12px; }
|
||||
.research-categories { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 26px; margin-top: 20px; }
|
||||
.research-categories button { padding: 12px 0; background: transparent; border: 0; border-bottom: 1px solid var(--line); }
|
||||
.research-categories button:hover { color: var(--green); }
|
||||
.research-categories button > div, .research-revenue-chart > div > div { display: flex; justify-content: space-between; gap: 16px; }
|
||||
.research-categories strong { font-variant-numeric: tabular-nums; }
|
||||
.research-categories small { font-size: 11px; color: var(--muted); }
|
||||
.research-bar { display: block; height: 5px; background: #f0f3f1; margin: 8px 0; border-radius: 3px; overflow: hidden; }
|
||||
.research-bar i { display: block; height: 100%; background: #47856d; border-radius: 3px; }
|
||||
.research-bar[data-estimate] i { background: #b98a40; }
|
||||
.research-tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin: 18px 0; overflow-x: auto; }
|
||||
.research-tabs button { padding: 10px 0; white-space: nowrap; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); }
|
||||
.research-tabs button[aria-pressed="true"] { border-bottom-color: var(--green); color: var(--green); font-weight: 600; }
|
||||
.research-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
|
||||
.research-filters label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 11px; flex: 1 1 140px; }
|
||||
.research-filters .research-query { flex: 2 1 210px; }
|
||||
.research-filters :is(input,select) { min-width: 0; width: 100%; padding: 8px 10px; font-size: 12px; color: var(--ink); background: #fff; border: 1px solid #dfe6e0; border-radius: 5px; height: 36px; }
|
||||
.research-result-count { display: flex; justify-content: space-between; margin: 14px 0 8px; color: var(--muted); font-size: 12px; }
|
||||
.research-result-count button { border: 0; background: none; color: var(--green); }
|
||||
.research-table-wrap { overflow-x: auto; max-width: 100%; }
|
||||
.ai-research table { width: 100%; border-collapse: collapse; min-width: 720px; text-align: left; font-size: 12px; }
|
||||
.ai-research caption { text-align: left; color: var(--muted); padding: 12px 0; font-size: 12px; }
|
||||
.ai-research th { color: var(--muted); background: #f7f9f7; font-weight: 500; }
|
||||
.ai-research :is(th,td) { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.ai-research td:first-child { min-width: 140px; }
|
||||
.ai-research td:nth-child(2) { min-width: 220px; }
|
||||
.ai-research td p { margin: 6px 0; }
|
||||
.ai-research td small { display: block; color: var(--muted); margin-top: 5px; }
|
||||
.ai-research tr[data-selected] { background: #f0f8f3; }
|
||||
.research-company-name { font-weight: 600; background: none; border: 0; padding: 0; }
|
||||
.research-tag { display: inline-block; font-size: 10px; line-height: 1.7; padding: 1px 6px; border-radius: 3px; background: #eef3ef; color: #596e60; }
|
||||
.research-pagination { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 16px; }
|
||||
.research-pagination button, .research-message button, .research-detail-heading button { padding: 6px 11px; border-radius: 4px; border: 1px solid var(--line); background: white; }
|
||||
.research-pagination button:disabled { color: #a8afa9; cursor: default; }
|
||||
.research-message { padding: 24px 0; color: var(--muted); }
|
||||
.research-message button { margin-left: 12px; color: var(--green); }
|
||||
.research-notice { border-left: 3px solid #c6a063; padding: 10px 16px; background: #faf8f2; }
|
||||
.research-revenue-chart { display: grid; gap: 18px; padding: 18px 0 30px; }
|
||||
.research-revenue-chart button { padding: 0; border: 0; background: none; color: var(--green); }
|
||||
.research-revenue-chart strong { font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||
.research-revenue-chart small { color: var(--muted); font-size: 11px; }
|
||||
.research-evidence-list, .research-review-list { list-style: none; padding: 0; margin: 0; }
|
||||
.research-evidence { padding: 18px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 92px; }
|
||||
.research-evidence > div { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.research-evidence code { font-size: 11px; color: var(--muted); }
|
||||
.research-evidence strong { font-weight: 500; }
|
||||
.research-evidence small { color: var(--muted); font-size: 11px; }
|
||||
.research-review-list > li { padding: 20px 0; border-bottom: 1px solid var(--line); }
|
||||
.research-review-list h3 { margin: 8px 0; }
|
||||
.research-review-list > li > div { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.research-detail { padding-top: 24px; border-top: 2px solid #dbe8df; }
|
||||
.research-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.research-company-facts { margin: 24px 0; }
|
||||
.research-company-facts > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
|
||||
.research-company-facts dt { color: var(--muted); }
|
||||
.research-company-facts dd { margin: 0; }
|
||||
.research-footnote { border-top: 1px solid var(--line); padding: 18px 0; }
|
||||
@media (max-width: 760px) {
|
||||
.research-layout { grid-template-columns: 1fr; }
|
||||
.research-layout .reading-main { padding: 26px 24px 0; }
|
||||
.research-section, .research-evidence { scroll-margin-top: 176px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.research-stats { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 18px; }
|
||||
.research-categories { grid-template-columns: 1fr; }
|
||||
.research-tabs { gap: 16px; }
|
||||
.research-layout .reading-main { padding: 22px 16px 0; }
|
||||
}
|
||||
|
||||
.research-distribution { margin: 24px 0 38px; }
|
||||
.research-distribution figure { margin: 20px 0; }
|
||||
.income-distribution-chart { display: block; width: 100%; min-width: 900px; height: auto; }
|
||||
.income-distribution-chart [role="button"] { cursor: pointer; outline: none; }
|
||||
.income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; }
|
||||
.research-distribution figcaption { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 10px 0; }
|
||||
.income-options { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0; font-size: 12px; }
|
||||
.income-options label { display: flex; gap: 6px; align-items: center; }
|
||||
.research-distribution details { margin: 16px 0; font-size: 12px; line-height: 1.8; }
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import seed from "../../../content/research/ai-companies.seed.json";
|
||||
import coverageAudit from "../../../content/research/ai-companies.coverage.json";
|
||||
import incomeReference from "../../../content/research/ai-income-analysis.json";
|
||||
import { readResearchSnapshot } from "../src/lib/ai-research-store";
|
||||
import { openSiteDatabase } from "../src/lib/site-database";
|
||||
import { categorySummary, evidenceUrl, reviewCompanyIds, validateResearch, type ResearchSnapshot } from "../src/lib/ai-research";
|
||||
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||
import { newAuthConfig } from "../src/lib/auth";
|
||||
import { describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics";
|
||||
|
||||
test("research revisions share the authentication database and preserve replay limits", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "site-research-"));
|
||||
const previous = process.env.AUTH_CONFIG_FILE;
|
||||
process.env.AUTH_CONFIG_FILE = join(dir, "config.json");
|
||||
try {
|
||||
initializeStore();
|
||||
const config = newAuthConfig();
|
||||
for (let i = 0; i < 20; i++) consumeCode(config, "invalid");
|
||||
assert.equal(consumeCode(config, "invalid"), "limited");
|
||||
const initial = readResearchSnapshot();
|
||||
assert.equal(initial.companies.length, 1015);
|
||||
assert.equal(initial.evidence.length, 1086);
|
||||
assert.equal(initial.observations.length, 170);
|
||||
assert.deepEqual(readResearchSnapshot(), initial, "reopening does not duplicate records");
|
||||
const db = openSiteDatabase();
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
||||
assert.ok(tables.some((r) => r.name === "login_state"));
|
||||
assert.ok(tables.some((r) => r.name === "research_companies"));
|
||||
assert.equal(db.prepare("PRAGMA foreign_keys").get()?.foreign_keys, 1);
|
||||
db.close();
|
||||
const next = structuredClone(seed) as ResearchSnapshot;
|
||||
next.version += "-revision-test";
|
||||
next.companies[0].name = "Revised company";
|
||||
assert.equal(readResearchSnapshot(next).companies[0].name, "Revised company");
|
||||
assert.deepEqual(readResearchSnapshot(), initial, "old release still reads its original revision");
|
||||
assert.equal(consumeCode(config, "invalid"), "limited", "research import must not reset auth limits");
|
||||
const collision = structuredClone(seed) as ResearchSnapshot;
|
||||
collision.companies[0].name = "Changed without revision";
|
||||
assert.throws(() => readResearchSnapshot(collision), /new revision/);
|
||||
assert.deepEqual(readResearchSnapshot(), initial, "failed import leaves valid revision intact");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
||||
else process.env.AUTH_CONFIG_FILE = previous;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("research validation rejects broken evidence and unsafe source links", () => {
|
||||
validateResearch(seed as ResearchSnapshot);
|
||||
const invalid = structuredClone(seed) as ResearchSnapshot;
|
||||
invalid.observations[0].evidence = "missing";
|
||||
assert.throws(() => validateResearch(invalid), /observation/);
|
||||
assert.equal(evidenceUrl("javascript:alert(1)"), undefined);
|
||||
assert.equal(evidenceUrl("/local-link"), undefined);
|
||||
assert.equal(evidenceUrl("https://example.com/evidence"), "https://example.com/evidence");
|
||||
});
|
||||
|
||||
test("reviews link explicit companies without turning global or excluded cases into companies", () => {
|
||||
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 37);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 3);
|
||||
assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 8);
|
||||
const review = snapshot.reviews.find((r) => r.id === "R4")!;
|
||||
assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]);
|
||||
assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []);
|
||||
const oldReview = structuredClone(snapshot.reviews.find((r) => r.id === "TECH-GRAD-R-SCOPE")!);
|
||||
delete oldReview.scope;
|
||||
delete oldReview.companyIds;
|
||||
assert.deepEqual(reviewCompanyIds(snapshot, oldReview), ["AI051"], "old revisions retain evidence-owner fallback");
|
||||
review.companyIds = ["unknown-company"];
|
||||
assert.throws(() => validateResearch(snapshot), /research review/);
|
||||
review.companyIds = ["AI007", "AI007"];
|
||||
assert.throws(() => validateResearch(snapshot), /research review/);
|
||||
review.companyIds = [];
|
||||
assert.throws(() => validateResearch(snapshot), /research review/);
|
||||
});
|
||||
|
||||
test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const summary = categorySummary(snapshot);
|
||||
assert.equal(summary.reduce((n, c) => n + c.count, 0), 1015);
|
||||
assert.equal(summary.reduce((n, c) => n + c.self, 0), 24);
|
||||
assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1);
|
||||
assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16);
|
||||
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 974);
|
||||
assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown");
|
||||
assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown");
|
||||
assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体"));
|
||||
});
|
||||
|
||||
test("technical reviews keep accessible materials separate from tested capabilities", () => {
|
||||
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 6);
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 2);
|
||||
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "independent-test").length, 1);
|
||||
assert.equal(snapshot.companies.find((c) => c.id === "AI051")?.revenueStatus, "unknown");
|
||||
assert.match(snapshot.companies.find((c) => c.id === "AI051")!.technicalReview!.testScope!, /Sep 2026/);
|
||||
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("TECH-GRAD-O-")).every((o) => o.selection === "technical" && o.comparisonGroup === null));
|
||||
const company = snapshot.companies.find((c) => c.id === "AI061")!;
|
||||
const review = company.technicalReview!;
|
||||
review.evidenceIds = ["missing-technical-evidence"];
|
||||
assert.throws(() => validateResearch(snapshot), /technical review/);
|
||||
review.evidenceIds = ["TECH-NAIVE-HF"];
|
||||
review.reviewedAt = "2026-02-30";
|
||||
assert.throws(() => validateResearch(snapshot), /technical review/);
|
||||
review.reviewedAt = "2099-01-01";
|
||||
assert.throws(() => validateResearch(snapshot), /technical review/);
|
||||
review.reviewedAt = snapshot.asOf;
|
||||
review.level = "independent-test";
|
||||
assert.throws(() => validateResearch(snapshot), /technical review/,
|
||||
"an accessible artifact cannot become a test record without a stated test scope");
|
||||
});
|
||||
|
||||
test("ambiguous ARR claims retain currency, precision and attribution limits", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const variant = snapshot.observations.filter((o) => o.company === "AI066");
|
||||
assert.deepEqual(variant.filter((o) => o.selection === "crosscheck").map((o) => o.value).sort(), [500000, 600000]);
|
||||
assert.equal(variant.filter((o) => o.selection === "signal").length, 0);
|
||||
assert.ok(variant.filter((o) => [2500000, 40000000].includes(o.value)).every((o) => o.selection === "excluded"));
|
||||
const codos = snapshot.observations.find((o) => o.id === "SR-O-AI065-arr")!;
|
||||
assert.equal(codos.bound, "≥");
|
||||
assert.equal(codos.unit, "币种未注明");
|
||||
assert.equal(codos.comparisonGroup, null);
|
||||
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("SR-O-")).every((o) => o.comparisonGroup === null && o.unit !== "USD"));
|
||||
assert.equal(snapshot.companies.find((c) => c.id === "AI071")?.year, 2021);
|
||||
const rocket = snapshot.observations.find((o) => o.id === "ROCKET-O-arr")!;
|
||||
assert.equal(rocket.selection, "historical");
|
||||
assert.equal(rocket.comparisonGroup, null);
|
||||
assert.notEqual(rocket.scope, "公司整体");
|
||||
assert.equal(snapshot.companies.find((c) => c.id === "AI070")?.revenueStatus, "unknown");
|
||||
assert.equal(snapshot.observations.filter((o) => o.company === "AI070").length, 0,
|
||||
"unreadable announcement summaries do not become numeric ARR");
|
||||
});
|
||||
|
||||
// Retractions and forecasts must stay visible for review without becoming revenue signals.
|
||||
test("retracted ARR and projected margins remain excluded", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const cluely = snapshot.companies.find((c) => c.name === "Cluely")!;
|
||||
assert.equal(cluely.revenueStatus, "unknown");
|
||||
assert.ok(snapshot.reviews.some((r) => r.company === "Cluely" && r.evidenceIds.includes("E100")));
|
||||
const retracted = snapshot.observations.filter((o) => o.company === cluely.id);
|
||||
assert.ok(retracted.length > 0);
|
||||
assert.ok(retracted.every((o) => o.selection === "excluded"));
|
||||
const forecast = snapshot.observations.find((o) => o.kind === "非GAAP毛利率预期")!;
|
||||
assert.equal(forecast.selection, "excluded");
|
||||
const peec = snapshot.companies.find((c) => c.name === "Peec AI")!;
|
||||
assert.equal(peec.teamBound, ">");
|
||||
assert.equal(snapshot.observations.filter((o) => o.company === peec.id && o.selection === "signal").length, 1);
|
||||
assert.ok(snapshot.observations.some((o) => o.company === peec.id && o.selection === "historical-estimate"));
|
||||
});
|
||||
|
||||
test("directory coverage reconciles every candidate and keeps profile-only income unknown", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const coverage = snapshot.coverage!;
|
||||
assert.equal(coverageAudit.records.length, coverage.candidateRecords);
|
||||
assert.equal(new Set(coverageAudit.records.map((r) => r.ycId)).size, coverage.candidateRecords);
|
||||
const count = (status: string) => coverageAudit.records.filter((r) => r.status === status).length;
|
||||
assert.equal(count("imported"), coverage.newProfiles);
|
||||
assert.equal(count("existing"), coverage.existingProfiles);
|
||||
assert.equal(count("date-conflict"), coverage.dateConflicts);
|
||||
assert.equal(count("year-outside-or-missing"), coverage.otherOrMissingYear);
|
||||
assert.equal(count("ai-unclear"), coverage.unclearAI);
|
||||
for (const row of coverageAudit.records.filter((r) => r.status === "imported")) {
|
||||
const company = snapshot.companies.find((c) => c.id === `YC${row.ycId}`)!;
|
||||
assert.ok(company.profileOnly || company.directoryOrigin === "yc-2025-2026");
|
||||
assert.equal(company.url, row.source);
|
||||
if (company.profileOnly) assert.equal(company.revenueStatus, "unknown");
|
||||
assert.equal(company.year, row.foundedYear);
|
||||
assert.ok([2025, 2026].includes(company.year!));
|
||||
}
|
||||
const invalid = structuredClone(snapshot);
|
||||
invalid.coverage!.newProfiles += 1;
|
||||
assert.throws(() => validateResearch(invalid), /coverage accounting/);
|
||||
const promoted = snapshot.companies.find((c) => c.id === "YC30215")!;
|
||||
assert.equal(promoted.profileOnly, false);
|
||||
assert.equal(promoted.directoryOrigin, "yc-2025-2026");
|
||||
assert.equal(promoted.revenueStatus, "self");
|
||||
const lostOrigin = structuredClone(snapshot);
|
||||
delete lostOrigin.companies.find((c) => c.id === promoted.id)!.directoryOrigin;
|
||||
assert.throws(() => validateResearch(lostOrigin), /coverage accounting/);
|
||||
});
|
||||
|
||||
test("income descriptions report sample dispersion and maximum-value sensitivity", () => {
|
||||
const stats = describeIncome([100, 200, 300])!;
|
||||
assert.equal(stats.mean, 200);
|
||||
assert.equal(stats.median, 200);
|
||||
assert.equal(stats.sampleDeviation, 100);
|
||||
assert.equal(stats.cv, .5);
|
||||
assert.equal(stats.largestShare, .5);
|
||||
assert.equal(stats.meanWithoutLargest, 150);
|
||||
assert.equal(stats.largestRemovalDrop, .25);
|
||||
assert.equal(describeIncome([5])!.cv, null);
|
||||
assert.equal(describeIncome([0, 0])!.mean, 0, "observed zero is distinct from missing");
|
||||
assert.equal(describeIncome([0, 0])!.cv, null);
|
||||
assert.equal(describeIncome([]), null);
|
||||
assert.equal(describeIncome([100, NaN]), null);
|
||||
assert.equal(describeIncome([-1]), null);
|
||||
assert.ok(describeIncome([1, 2, 100])!.largestRemovalDrop! > .95);
|
||||
});
|
||||
|
||||
test("platform samples exclude stale data and keep ARR, estimates and collections separate", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
const recent = platformIncomeSample(snapshot, { windowDays: 7 });
|
||||
assert.equal(recent.points.length, 14);
|
||||
assert.equal(recent.excluded.length, 3);
|
||||
assert.ok(recent.points.every((p) => p.observation.metric === "mrr" && p.observation.selection === "platform"));
|
||||
assert.deepEqual(new Set(recent.excluded.map((p) => p.observation.company)), new Set(["AI016", "MR013", "MR014"]));
|
||||
assert.ok(recent.points.some((p) => p.company.name === "TrackAI" && p.observation.connectionExpired), "dated recent value is not a claim of active connection");
|
||||
const older = platformIncomeSample(snapshot, { windowDays: 90 });
|
||||
assert.equal(older.points.length, 15);
|
||||
assert.ok(older.points.some((p) => p.company.id === "AI016"));
|
||||
const scaled = platformIncomeSample(snapshot, { windowDays: 7, annualize: true });
|
||||
const monthlyGroups = groupIncome(recent.points);
|
||||
const annualGroups = groupIncome(scaled.points);
|
||||
for (const group of monthlyGroups) {
|
||||
const annual = annualGroups.find((g) => g.key === group.key)!;
|
||||
assert.ok(Math.abs(annual.stats.mean - group.stats.mean * 12) < 1e-6);
|
||||
if (group.stats.cv !== null) assert.ok(Math.abs(annual.stats.cv! - group.stats.cv) < 1e-10);
|
||||
}
|
||||
const invalid = structuredClone(snapshot);
|
||||
invalid.observations = invalid.observations.filter((o) => o.company === "MR001" && o.metric === "mrr");
|
||||
const first = invalid.observations[0];
|
||||
invalid.observations.push({ ...first, id: "future", observedAt: "2026-11-01", value: 999999 });
|
||||
invalid.observations.push({ ...first, id: "bad-date", observedAt: "2026-02-30", value: 999999 });
|
||||
invalid.observations.push({ ...first, id: "currency", unit: "EUR", value: 999999 });
|
||||
invalid.observations.push({ ...first, id: "older", observedAt: "2026-10-28", value: 1 });
|
||||
const selected = platformIncomeSample(invalid, { windowDays: 7 });
|
||||
assert.equal(selected.points.length, 1);
|
||||
assert.equal(selected.points[0].observation.id, first.id, "bad newer data and older duplicate must not replace latest valid point");
|
||||
assert.equal(selected.excluded.length, 4);
|
||||
assert.throws(() => platformIncomeSample(snapshot, { windowDays: -1 }), /window/);
|
||||
});
|
||||
|
||||
test("distribution statistics match independently calculated snapshot references", () => {
|
||||
const snapshot = seed as ResearchSnapshot;
|
||||
assert.equal(incomeReference.sourceVersion, snapshot.version);
|
||||
for (const reference of incomeReference.windows) {
|
||||
const sample = platformIncomeSample(snapshot, { windowDays: reference.days });
|
||||
assert.deepEqual(new Set(sample.points.map((p) => p.company.id)), new Set(reference.products));
|
||||
const groups = groupIncome(sample.points);
|
||||
assert.equal(groups.length, reference.groups.length);
|
||||
for (const expected of reference.groups) {
|
||||
const actual = groups.find((g) => g.category === expected.category && g.provider === expected.provider && g.model === expected.model)!;
|
||||
assert.equal(actual.stats.n, expected.n);
|
||||
for (const metric of ["mean", "median", "sampleDeviation", "cv", "meanWithoutLargest", "largestRemovalDrop"] as const) {
|
||||
const value = expected[metric];
|
||||
if (value === null) assert.equal(actual.stats[metric], null);
|
||||
else assert.ok(Math.abs(actual.stats[metric]! - value) < 1e-6, metric);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,14 @@
|
||||
}
|
||||
],
|
||||
"documents": {
|
||||
"ai-company-research/00-overview": {
|
||||
"collection": "AI 公司研究",
|
||||
"group": "公司与市场",
|
||||
"description": "按业务分类查看 AI 公司、经营指标与原始证据,检查收入定义与数据缺项。",
|
||||
"slug": "other-research/ai-company-research/overview",
|
||||
"category": "其他研究",
|
||||
"featured": true
|
||||
},
|
||||
"00-endor-concepts-draft": {
|
||||
"group": "架构设计",
|
||||
"description": "Endor 的全球入口、区域运行时与对象数据。",
|
||||
@@ -211,6 +219,20 @@
|
||||
"pretext/12-chapter": {
|
||||
"slug": "source-studies/pretext/applying-the-design",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"ai-company-research/01-income-snapshot": {
|
||||
"collection": "AI 公司研究",
|
||||
"group": "公司与市场",
|
||||
"description": "查看14个产品的平台MRR分布、均值与离散度、历史点敏感性和逐项证据入口。",
|
||||
"slug": "other-research/ai-company-research/income-snapshot",
|
||||
"category": "其他研究"
|
||||
},
|
||||
"ai-company-research/02-coverage-and-gaps": {
|
||||
"collection": "AI 公司研究",
|
||||
"group": "公司与市场",
|
||||
"description": "核对候选池、来源偏差、分类与验证完成度,区分全量读取和定向补充。",
|
||||
"slug": "other-research/ai-company-research/coverage",
|
||||
"category": "其他研究"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# AI 公司研究
|
||||
|
||||
调查 2025—2026 年不同类型 AI 公司的业务、交付能力、规模与商业模式。分类表链接公司数据,公司数据链接经营指标与原始证据。
|
||||
|
||||
以下工作台读取研究数据库。首批为公开资料定向试样,成立、公开发布和转型发布分别记录。未披露收入的公司继续保留,避免把收入资料缺失误当成零收入。
|
||||
|
||||
[收入样本分布快照](/docs/other-research/ai-company-research/income-snapshot)列出当前平台MRR的分组结果、逐项证据入口和历史样本敏感性。
|
||||
|
||||
[资料覆盖与缺项](/docs/other-research/ai-company-research/coverage)区分候选池读取、定向样本、人工复核及尚未核验的字段。
|
||||
|
||||
## 商业模式初评:截至 2026-10-30
|
||||
|
||||
以下为研究假设,用于设计付费试验,尚不足以给行业排收益名次。规模、交付证据和收入来源必须一起看;目录条目数量不是市场需求,平台样本也存在自愿公开与幸存者偏差。
|
||||
|
||||
| 模式 | 工作台可搜索样本 | 当前证据 | 商业试验要测什么 | 主要缺项 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 企业获客与营销软件 | Gojiberry、Traxy AI、NotFair、ChatSEO | 有平台MRR记录,但同步日期不同 | 有效商机、续费与单客户贡献毛利 | 获客费、客户集中度、退款、留存 |
|
||||
| 消费者个人应用 | nutrIA、Calyra、Verbi、ABMAXX | 有平台收入与订阅记录;订阅份数不是独立人数 | 渠道成本、后续月份留存、平台抽成后利润 | 取消与退款、月/年套餐结构、重复订阅 |
|
||||
| 创意软件与制作服务 | Speel.co、Laper、GetEbook AI、White Glove Content | 软件订阅和交付服务必须分组 | 合格交付的计算与人工成本、返工率、重复购买 | 项目收入占比、人工工时、产能 |
|
||||
| 小企业综合Agent | Runable(AI059) | 公司披露历史ARR里程碑;[官方公告](https://runable.com/blogs/runable-grow) | 限定一个任务,测完整成功率、收费与执行成本 | 当前付费客户、续费、推理成本、人工接管 |
|
||||
| 行业专业Agent与服务 | LunaBill、Plena Health、Bizmark | 有合同ARR、数量级或ARR自述,日历截止日和交付拆分不完整 | 采购周期、上线工时、失败处理与验收标准 | 合同、经常性与实施费拆分、使用深度 |
|
||||
| 模型、算力与机器人 | Wafer、Induction Labs、Congruent | 可确定产品方向;团队小或融资多不证明单位经济 | 客户真实负载、性能成本、交付与售后成本 | 同口径收入、资本开支、独立性能测试 |
|
||||
|
||||
**对我们商业试验的推论:** 先选择能接触真实买家、结果可验收、成本可计量的工作流,比较软件订阅与服务交付的实际毛利,再评估是否扩成综合Agent。当前证据不足以断定某类必然更赚钱;平台数据更适合帮助选择访谈与试验对象。
|
||||
|
||||
收入里程碑还需结合成本核查:Runable创始人称毛利为负。[原始采访](https://techcrunch.com/2026/08/26/runable-hits-21m-to-bet-ai-agents-can-go-from-building-businesses-to-growing-them/)
|
||||
|
||||
## ARR 分布与财务声明核查
|
||||
|
||||
目前可绘制的是按日期窗口、支付平台及收费模式分组的产品 **MRR** 分布。公司自述ARR缺少统一截止日、定义和独立凭证,尚不能形成可靠的行业ARR排名。MRR乘12仅为参考年化;项目收入、融资、近30日收款与ARR分别保存。
|
||||
|
||||
| 现象 | 后续核查 | 推断边界 |
|
||||
| --- | --- | --- |
|
||||
| 平均值远高于中位数,移除最大值后均值明显下降 | 回查大样本的日期、业务边界和收费模式 | 高离散度不等于造假 |
|
||||
| ARR与近30日收款差距大 | 查年付、季付、退款、试用、币种及平台计算方法 | 不同指标不应机械相等 |
|
||||
| 用户很多,付费数缺失 | 取得付费客户、套餐与账期数据,再核ARPA | 不能用注册人数直接反推真实性 |
|
||||
| 公司、投资方与媒体重复一个数字 | 追到首发声明及独立底账 | 多篇转述不等于独立证据 |
|
||||
| 声明撤回、口径变更或含并购业务 | 保留历史版本,拆分边界并核对更正原因 | 单个异常无法生成可靠造假概率 |
|
||||
|
||||
### 从原文到可比较收入:新增核查实例
|
||||
|
||||
以下均为2026-10-30读取的公司自述,读取日不代表收入业务截止日。点击公司可查看分项观测、核查和原始来源。原文仅有美元符号时保留币种待核;未给币种的数字不补成美元。
|
||||
|
||||
| 公司 | 原始声明 | 数据表处理 | 对商业模式评估的影响 |
|
||||
| --- | --- | --- | --- |
|
||||
| [Risely AI](/docs/other-research/ai-company-research/overview?company=YC30724#research-company-detail) | 上线六周达到8千MRR,原文$ | 保存MRR;无日历截止日,不转换为同期ARR | 高校系统集成还需核实施成本、采购周期与续约 |
|
||||
| [Hyper](/docs/other-research/ai-company-research/overview?company=YC31548#research-company-detail) | 上线十二天达到1千MRR、采用团队50多 | 币种未列;采用团队不直接当付费客户 | 试点与持续付费需要分开;不直接算每团队收入 |
|
||||
| [Bizmark](/docs/other-research/ai-company-research/overview?company=YC32200#research-company-detail) | 工厂产品10.8万ARR,原文$ | 保留声明;创始人此前6000万业务收入排除 | 核对软件与定制集成收入、上线成本及客户集中度 |
|
||||
| [LunaBill](/docs/other-research/ai-company-research/overview?company=YC30997#research-company-detail) | 76.4万contracted ARR,其中42.8万称live revenue,原文$ | 合同承诺、已上线部分分别保存,不相加;后者未擅自补为ARR | 上线转化和已验收用量比总合同承诺更能检验交付;客户追回款项排除 |
|
||||
| [Plena Health](/docs/other-research/ai-company-research/overview?company=YC31493#research-company-detail) | 七位数committed/contracted ARR | 仅存数量级下界,不填精确100万;八个月/十个月表述保留 | 年度或多年合同需拆上线状态、取消条款和定制实施费 |
|
||||
| [Brickwise](/docs/other-research/ai-company-research/overview?company=YC30847#research-company-detail) | 销售管线中有超过100万cARR的企业合同,原文$ | 不确认成已签或已实现ARR;融资及前公司业绩排除 | 大合同集中度和签约到上线的转化需要单独核查 |
|
||||
| [Radley](/docs/other-research/ai-company-research/overview?company=YC33020#research-company-detail) | 收购诊所带来460万年化收入,另有220万合同,原文$ | 收购、交割与并表边界未核,不当纯软件ARR,不把两项相加 | 医疗服务收入需结合医生工时、收购成本与经营毛利,不能直接与SaaS比较 |
|
||||
| [Misprint](/docs/other-research/ai-company-research/overview?company=YC30302#research-company-detail) | 创始人此前卖卡50万ARR,原文$ | 发布帖明确早于平台构想,排除平台收入 | 卡牌销售额、交易额、平台佣金与软件订阅是不同口径 |
|
||||
|
||||
这批记录补充了收入定义差异,尚未增加同日同币种的可比ARR样本。合同承诺、并购业务或履历混入会使领域均值失真;资料有这些差异本身不证明财务造假。
|
||||
|
||||
下一轮优先取得统一业务截止日的ARR定义、付费客户变动、留存、收入与现金勾稽、直接成本及可独立复核的来源。缺项留在表内并保持未知。上述核查是研究流程,不是审计意见。
|
||||
@@ -0,0 +1,64 @@
|
||||
# AI 公司收入样本分布快照
|
||||
|
||||
截至 2026-10-30,7日窗口内有 14 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。
|
||||
|
||||
这是已采集资料的固定快照。[动态工作台](/docs/other-research/ai-company-research/overview)提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是1,015条公司/产品记录的收入普查。
|
||||
|
||||
## 7日窗口的分组结果
|
||||
|
||||
MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。
|
||||
|
||||
| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 客户与收入运营 · 软件订阅 | Stripe | 4 | 34,815 | 36,723 | 0.469 | 16.9% |
|
||||
| 内容与创意 · 消费者订阅 | RevenueCat | 1 | 53,101 | 53,101 | 不计算 | 不计算 |
|
||||
| 内容与创意 · 内容制作服务 | Stripe | 1 | 62,423 | 62,423 | 不计算 | 不计算 |
|
||||
| 内容与创意 · 软件订阅 | Stripe | 3 | 41,693 | 49,069 | 0.674 | 28.4% |
|
||||
| 消费者个人应用 · 消费者订阅 | RevenueCat | 5 | 25,349 | 20,483 | 0.471 | 20.9% |
|
||||
|
||||
在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。
|
||||
|
||||
均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。
|
||||
|
||||
## 样本与证据入口
|
||||
|
||||
点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。
|
||||
|
||||
| 产品 | 成立年 | MRR美元每月 | 最后同步 | 平台 | 连接状态 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| [Boothie](/docs/other-research/ai-company-research/overview?company=MR001#research-company-detail) | 2026 | 53,101 | 2026-10-29 | RevenueCat | 采集时未标过期 |
|
||||
| [White Glove Content](/docs/other-research/ai-company-research/overview?company=MR002#research-company-detail) | 2026 | 62,423 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [Speel.co](/docs/other-research/ai-company-research/overview?company=MR003#research-company-detail) | 2025 | 65,381 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [Laper](/docs/other-research/ai-company-research/overview?company=MR004#research-company-detail) | 2025 | 49,069 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [GETebook.ai](/docs/other-research/ai-company-research/overview?company=MR005#research-company-detail) | 2025 | 10,629 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [TrackAI](/docs/other-research/ai-company-research/overview?company=MR006#research-company-detail) | 2025 | 19,437 | 2026-10-27 | RevenueCat | 已过期的日期快照 |
|
||||
| [nutrIA(Nutria Inc)](/docs/other-research/ai-company-research/overview?company=MR007#research-company-detail) | 2025 | 18,198 | 2026-10-29 | RevenueCat | 采集时未标过期 |
|
||||
| [Calyra(平台名Nutria 9e3eaa)](/docs/other-research/ai-company-research/overview?company=MR008#research-company-detail) | 2025 | 46,562 | 2026-10-29 | RevenueCat | 采集时未标过期 |
|
||||
| [Verbi](/docs/other-research/ai-company-research/overview?company=MR009#research-company-detail) | 2026 | 22,066 | 2026-10-29 | RevenueCat | 采集时未标过期 |
|
||||
| [AbMaxx](/docs/other-research/ai-company-research/overview?company=MR010#research-company-detail) | 2026 | 20,483 | 2026-10-29 | RevenueCat | 采集时未标过期 |
|
||||
| [traxy.ai](/docs/other-research/ai-company-research/overview?company=MR011#research-company-detail) | 2026 | 52,438 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [NotFair](/docs/other-research/ai-company-research/overview?company=MR012#research-company-detail) | 2026 | 13,376 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [Rank Prompt](/docs/other-research/ai-company-research/overview?company=MR015#research-company-detail) | 2025 | 40,112 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
| [ChatSEO](/docs/other-research/ai-company-research/overview?company=MR016#research-company-detail) | 2025 | 33,334 | 2026-10-29 | Stripe | 采集时未标过期 |
|
||||
|
||||
## 扩大窗口后的敏感性
|
||||
|
||||
营销软件订阅组从7日窗口的4个产品扩大到90日窗口的5个产品,均值从34,815升至112,726美元每月,中位数从36,723变为40,112,CV从0.469升至1.551。
|
||||
|
||||
新增点来自[Gojiberry AI](/docs/other-research/ai-company-research/overview?company=AI016#research-company-detail)的8月历史快照,连接已过期。移除该最大值后,均值回到34,815美元,下降69.1%。这是日期与样本构成的敏感性,不能解释为行业风险变化。
|
||||
|
||||
## 默认窗口排除的历史记录
|
||||
|
||||
| 产品 | MRR美元每月 | 最后同步 | 排除原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| [Adspirer](/docs/other-research/ai-company-research/overview?company=MR013#research-company-detail) | 54,684 | 2026-06-29 | 超过7日窗口,保留历史观测 |
|
||||
| [PROSP](/docs/other-research/ai-company-research/overview?company=MR014#research-company-detail) | 128,005 | 2026-02-05 | 超过7日窗口,保留历史观测 |
|
||||
| [Gojiberry AI](/docs/other-research/ai-company-research/overview?company=AI016#research-company-detail) | 424,368 | 2026-08-14 | 超过7日窗口,保留历史观测 |
|
||||
|
||||
## 对 ARR 与商业模式的含义
|
||||
|
||||
MRR乘12只是参考年化。公司声明的ARR、合同管线、近30日收款、一次性额度和服务交付收入仍需分开;支付平台重建指标也不等于财务审计。方法与平台限制见[TrustMRR FAQ](https://trustmrr.com/faq)。
|
||||
|
||||
当前更适合选择访谈对象和设计收费试验,不能据最高均值直接决定业务方向。软件订阅优先核留存与推理成本,服务交付优先核人工工时和返工,消费者应用优先核渠道成本、退款和平台抽成。
|
||||
|
||||
行业ARR比较仍需补齐同一业务截止日、币种、经常性定义及业务边界。收入日期不完整或同页数值冲突的声明,继续保留在[核查工作台](/docs/other-research/ai-company-research/overview),不补成零值或精确数字。
|
||||
@@ -0,0 +1,71 @@
|
||||
# AI 公司资料覆盖与缺项
|
||||
|
||||
截至 2026-10-30,研究表含1,015条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。
|
||||
|
||||
[分类和数据工作台](/docs/other-research/ai-company-research/overview)链接原始证据,[收入样本快照](/docs/other-research/ai-company-research/income-snapshot)展示当前可计算的MRR样本。
|
||||
|
||||
## 候选池与公司表的关系
|
||||
|
||||
| 环节 | 记录数 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| YC批次候选 | 1,428 | 从公开镜像发现,随后读取YC官方页面 |
|
||||
| 官方页面读取 | 1,428 | 此候选池读取失败0条 |
|
||||
| 初筛匹配 | 962 | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |
|
||||
| 与原表重合 | 34 | URL匹配,未再次新增 |
|
||||
| 新增目录记录 | 928 | 原始入表来源,后续深化不改变发现计数 |
|
||||
| 年份不符或缺失 | 268 | 此轮不作为成立年匹配项 |
|
||||
| AI业务待确认 | 192 | 不强行把背景关键词当主营 |
|
||||
| 成立年与批次冲突 | 6 | 另保留核查记录 |
|
||||
|
||||
全表由919条目录基础项和96条个案或平台记录组成。候选池已匹配项之外另有53条记录,包含其他来源及发布/转型对照;其中已有9条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。
|
||||
|
||||
## 成立年与证据深度
|
||||
|
||||
| 维度 | 当前记录 | 解读 |
|
||||
| --- | --- | --- |
|
||||
| 记录为2025年成立 | 619 | 成立来源和精度见公司明细 |
|
||||
| 记录为2026年成立 | 391 | 不将产品发布日期代替成立日 |
|
||||
| 成立年未知或范围外 | 5 | 发布/转型对照单列,不进入两年成立分组 |
|
||||
| 目录业务分类人工复核 | 160 | 仅复核主营分类,不代表技术或财务审计 |
|
||||
| 其他目录基础项 | 759 | 规则初分或资料不足,继续待复核 |
|
||||
| 有团队数字 | 945 | 多为自报Team Size或Employees,时点与口径不统一 |
|
||||
| 专项技术证据记录 | 6 | 其中3条自述、2条公开材料、1条第三方评测 |
|
||||
| 公司披露收入线索 | 24 | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |
|
||||
| 仅平台收入线索 | 16 | 同时有公司披露者计入上一行,不重复计数 |
|
||||
| 仅第三方估算 | 1 | 不与直接披露混合 |
|
||||
| 暂无可用收入数字 | 974 | 包含尚未逐家搜索的目录项,不代表没有收入 |
|
||||
|
||||
## 业务分类与数据入口
|
||||
|
||||
| 业务类别 | 记录数 |
|
||||
| --- | --- |
|
||||
| [基础模型](/docs/other-research/ai-company-research/overview?category=C01) | 29 |
|
||||
| [算力与模型基础设施](/docs/other-research/ai-company-research/overview?category=C02) | 103 |
|
||||
| [Agent 开发与运行设施](/docs/other-research/ai-company-research/overview?category=C03) | 51 |
|
||||
| [软件开发](/docs/other-research/ai-company-research/overview?category=C04) | 109 |
|
||||
| [企业通用工作](/docs/other-research/ai-company-research/overview?category=C05) | 176 |
|
||||
| [客户与收入运营](/docs/other-research/ai-company-research/overview?category=C06) | 52 |
|
||||
| [行业专业应用](/docs/other-research/ai-company-research/overview?category=C07) | 321 |
|
||||
| [内容与创意](/docs/other-research/ai-company-research/overview?category=C08) | 45 |
|
||||
| [消费者个人应用](/docs/other-research/ai-company-research/overview?category=C09) | 45 |
|
||||
| [机器人及实体产业](/docs/other-research/ai-company-research/overview?category=C10) | 81 |
|
||||
| [业务待分类](/docs/other-research/ai-company-research/overview?category=C00) | 3 |
|
||||
|
||||
## 已使用来源的分布
|
||||
|
||||
1,086条证据记录对应1,072个不同URL。不同URL不自动构成独立验证;同一声明可能由公司、投资方和媒体重复转述。
|
||||
|
||||
| 来源 | 证据记录 | 不同URL | 用途及限制 |
|
||||
| --- | --- | --- | --- |
|
||||
| YC官方目录 | 984 | 975 | 公司自述的成立、团队与业务;英文及加速器样本偏多 |
|
||||
| TrustMRR | 18 | 18 | 支付平台重建指标及方法;公开者存在选择偏差,非财务审计 |
|
||||
| a16z Speedrun | 11 | 10 | 投资机构中的公司介绍及收入声明;存在利益关系 |
|
||||
| TechCrunch | 3 | 3 | 原始采访或报道;转述公司数字不等于查账 |
|
||||
| Sacra | 3 | 3 | 第三方估算;与直接披露分开 |
|
||||
| 其他官网、评测及公开资料 | 67 | 63 | 按每条来源限制理解,不能统一当作独立证据 |
|
||||
|
||||
## 尚未满足的研究条件
|
||||
|
||||
行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。
|
||||
|
||||
下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"sourceVersion": "2026-10-30-depth-3",
|
||||
"asOf": "2026-10-30",
|
||||
"metric": "platform-mrr-usd",
|
||||
"sampleUnit": "product-or-business",
|
||||
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
|
||||
"windows": [
|
||||
{
|
||||
"days": 7,
|
||||
"products": [
|
||||
"MR001",
|
||||
"MR002",
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005",
|
||||
"MR006",
|
||||
"MR007",
|
||||
"MR008",
|
||||
"MR009",
|
||||
"MR010",
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016"
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"category": "C06",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016"
|
||||
],
|
||||
"n": 4,
|
||||
"mean": 34815.0,
|
||||
"median": 36723.0,
|
||||
"sampleDeviation": 16334.54845820151,
|
||||
"cv": 0.46918134304758036,
|
||||
"meanWithoutLargest": 28940.666666666668,
|
||||
"largestRemovalDrop": 0.16872995356407675
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "RevenueCat",
|
||||
"model": "消费者订阅",
|
||||
"products": [
|
||||
"MR001"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 53101.0,
|
||||
"median": 53101,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "内容制作服务",
|
||||
"products": [
|
||||
"MR002"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 62423.0,
|
||||
"median": 62423,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005"
|
||||
],
|
||||
"n": 3,
|
||||
"mean": 41693.0,
|
||||
"median": 49069,
|
||||
"sampleDeviation": 28111.375064197768,
|
||||
"cv": 0.6742468775141575,
|
||||
"meanWithoutLargest": 29849.0,
|
||||
"largestRemovalDrop": 0.2840764636749574
|
||||
},
|
||||
{
|
||||
"category": "C09",
|
||||
"provider": "RevenueCat",
|
||||
"model": "消费者订阅",
|
||||
"products": [
|
||||
"MR006",
|
||||
"MR007",
|
||||
"MR008",
|
||||
"MR009",
|
||||
"MR010"
|
||||
],
|
||||
"n": 5,
|
||||
"mean": 25349.2,
|
||||
"median": 20483,
|
||||
"sampleDeviation": 11942.947278624319,
|
||||
"cv": 0.4711370488466823,
|
||||
"meanWithoutLargest": 20046.0,
|
||||
"largestRemovalDrop": 0.20920581320120557
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"days": 90,
|
||||
"products": [
|
||||
"MR001",
|
||||
"MR002",
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005",
|
||||
"MR006",
|
||||
"MR007",
|
||||
"MR008",
|
||||
"MR009",
|
||||
"MR010",
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016",
|
||||
"AI016"
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"category": "C06",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016",
|
||||
"AI016"
|
||||
],
|
||||
"n": 5,
|
||||
"mean": 112725.6,
|
||||
"median": 40112,
|
||||
"sampleDeviation": 174786.78744916618,
|
||||
"cv": 1.5505509613536426,
|
||||
"meanWithoutLargest": 34815.0,
|
||||
"largestRemovalDrop": 0.6911526751687284
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "RevenueCat",
|
||||
"model": "消费者订阅",
|
||||
"products": [
|
||||
"MR001"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 53101.0,
|
||||
"median": 53101,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "内容制作服务",
|
||||
"products": [
|
||||
"MR002"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 62423.0,
|
||||
"median": 62423,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005"
|
||||
],
|
||||
"n": 3,
|
||||
"mean": 41693.0,
|
||||
"median": 49069,
|
||||
"sampleDeviation": 28111.375064197768,
|
||||
"cv": 0.6742468775141575,
|
||||
"meanWithoutLargest": 29849.0,
|
||||
"largestRemovalDrop": 0.2840764636749574
|
||||
},
|
||||
{
|
||||
"category": "C09",
|
||||
"provider": "RevenueCat",
|
||||
"model": "消费者订阅",
|
||||
"products": [
|
||||
"MR006",
|
||||
"MR007",
|
||||
"MR008",
|
||||
"MR009",
|
||||
"MR010"
|
||||
],
|
||||
"n": 5,
|
||||
"mean": 25349.2,
|
||||
"median": 20483,
|
||||
"sampleDeviation": 11942.947278624319,
|
||||
"cv": 0.4711370488466823,
|
||||
"meanWithoutLargest": 20046.0,
|
||||
"largestRemovalDrop": 0.20920581320120557
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-2
@@ -13,7 +13,8 @@
|
||||
"lint": "pnpm --filter @endor/docs run lint",
|
||||
"typecheck": "pnpm --filter @endor/docs run typecheck",
|
||||
"figures": "uv run --locked --project tools/figures endor-figures",
|
||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
|
||||
"test:auth": "pnpm --filter @endor/docs run test:auth"
|
||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run test:research && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
|
||||
"test:auth": "pnpm --filter @endor/docs run test:auth",
|
||||
"test:research": "pnpm --filter @endor/docs run test:research"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Summarize observed research coverage; does not claim an exhaustive AI census."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
|
||||
AUDIT = json.loads((ROOT / 'content/research/ai-companies.coverage.json').read_text())
|
||||
TARGET = ROOT / 'content/docs/ai-company-research/02-coverage-and-gaps.md'
|
||||
BASE = '/docs/other-research/ai-company-research/overview'
|
||||
|
||||
|
||||
def render():
|
||||
cov = DATA['coverage']
|
||||
companies = DATA['companies']
|
||||
depth = sum(bool(c.get('profileOnly')) for c in companies)
|
||||
origin = sum(bool(c.get('profileOnly')) or c.get('directoryOrigin') == 'yc-2025-2026' for c in companies)
|
||||
promoted = origin - depth
|
||||
reviewed = sum(bool(c.get('profileOnly')) and '人工复核' in c.get('classificationBasis', '') for c in companies)
|
||||
years = Counter(c['year'] for c in companies)
|
||||
income = Counter(c['revenueStatus'] for c in companies)
|
||||
technical = Counter(c.get('technicalReview', {}).get('level', 'unreviewed') for c in companies)
|
||||
assert cov['profilesRead'] + cov['failedProfiles'] == cov['candidateRecords']
|
||||
assert cov['matchingProfiles'] == cov['existingProfiles'] + cov['newProfiles']
|
||||
assert cov['matchingProfiles'] + cov['otherOrMissingYear'] + cov['unclearAI'] + cov['dateConflicts'] == cov['profilesRead']
|
||||
assert origin == cov['newProfiles']
|
||||
assert AUDIT['accessedAt'] == cov['accessedAt']
|
||||
assert AUDIT['counts'] == cov
|
||||
assert len(AUDIT['records']) == cov['profilesRead']
|
||||
assert Counter(x['status'] for x in AUDIT['records']) == {
|
||||
'imported': cov['newProfiles'], 'existing': cov['existingProfiles'],
|
||||
'year-outside-or-missing': cov['otherOrMissingYear'],
|
||||
'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']}
|
||||
assert len({x['ycId'] for x in AUDIT['records']}) == len(AUDIT['records'])
|
||||
lines = ['# AI 公司资料覆盖与缺项', '',
|
||||
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
|
||||
f"[分类和数据工作台]({BASE})链接原始证据,[收入样本快照](/docs/other-research/ai-company-research/income-snapshot)展示当前可计算的MRR样本。", '',
|
||||
'## 候选池与公司表的关系', '',
|
||||
'| 环节 | 记录数 | 含义 |', '| --- | --- | --- |',
|
||||
f"| YC批次候选 | {cov['candidateRecords']:,} | 从公开镜像发现,随后读取YC官方页面 |",
|
||||
f"| 官方页面读取 | {cov['profilesRead']:,} | 此候选池读取失败{cov['failedProfiles']}条 |",
|
||||
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
|
||||
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
|
||||
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
|
||||
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |",
|
||||
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
|
||||
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
|
||||
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
|
||||
'## 成立年与证据深度', '',
|
||||
'| 维度 | 当前记录 | 解读 |', '| --- | --- | --- |',
|
||||
f"| 记录为2025年成立 | {years[2025]} | 成立来源和精度见公司明细 |",
|
||||
f"| 记录为2026年成立 | {years[2026]} | 不将产品发布日期代替成立日 |",
|
||||
f"| 成立年未知或范围外 | {sum(v for k,v in years.items() if k not in (2025,2026))} | 发布/转型对照单列,不进入两年成立分组 |",
|
||||
f"| 目录业务分类人工复核 | {reviewed} | 仅复核主营分类,不代表技术或财务审计 |",
|
||||
f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |",
|
||||
f"| 有团队数字 | {sum(c['team'] is not None for c in companies)} | 多为自报Team Size或Employees,时点与口径不统一 |",
|
||||
f"| 专项技术证据记录 | {len(companies)-technical['unreviewed']} | 其中{technical['description']}条自述、{technical['public-artifact']}条公开材料、{technical['independent-test']}条第三方评测 |",
|
||||
f"| 公司披露收入线索 | {income['self']} | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |",
|
||||
f"| 仅平台收入线索 | {income['platform']} | 同时有公司披露者计入上一行,不重复计数 |",
|
||||
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
|
||||
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
|
||||
'## 业务分类与数据入口', '',
|
||||
'| 业务类别 | 记录数 |', '| --- | --- |',
|
||||
]
|
||||
counts = Counter(c['cat'] for c in companies)
|
||||
for category in DATA['categories']:
|
||||
lines.append(f"| [{category['name']}]({BASE}?category={category['id']}) | {counts[category['id']]} |")
|
||||
lines += ['', '## 已使用来源的分布', '',
|
||||
f"{len(DATA['evidence']):,}条证据记录对应{len({e['url'] for e in DATA['evidence']}):,}个不同URL。不同URL不自动构成独立验证;同一声明可能由公司、投资方和媒体重复转述。", '',
|
||||
'| 来源 | 证据记录 | 不同URL | 用途及限制 |', '| --- | --- | --- | --- |',
|
||||
]
|
||||
families = [('YC官方目录', 'www.ycombinator.com', '公司自述的成立、团队与业务;英文及加速器样本偏多'),
|
||||
('TrustMRR', 'trustmrr.com', '支付平台重建指标及方法;公开者存在选择偏差,非财务审计'),
|
||||
('a16z Speedrun', 'speedrun.a16z.com', '投资机构中的公司介绍及收入声明;存在利益关系'),
|
||||
('TechCrunch', 'techcrunch.com', '原始采访或报道;转述公司数字不等于查账'),
|
||||
('Sacra', 'sacra.com', '第三方估算;与直接披露分开')]
|
||||
included = set()
|
||||
for name, host, note in families:
|
||||
entries = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc == host]
|
||||
included.add(host)
|
||||
lines.append(f"| {name} | {len(entries)} | {len({e['url'] for e in entries})} | {note} |")
|
||||
others = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc not in included]
|
||||
lines.append(f"| 其他官网、评测及公开资料 | {len(others)} | {len({e['url'] for e in others})} | 按每条来源限制理解,不能统一当作独立证据 |")
|
||||
lines += ['', '## 尚未满足的研究条件', '',
|
||||
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
|
||||
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true')
|
||||
args = parser.parse_args()
|
||||
content = render()
|
||||
if args.check:
|
||||
if not TARGET.exists() or TARGET.read_text() != content:
|
||||
sys.exit('Research coverage report differs; review and regenerate')
|
||||
print('Research coverage accounting and report are consistent')
|
||||
else:
|
||||
TARGET.write_text(content)
|
||||
print(TARGET.relative_to(ROOT))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -6,6 +6,7 @@ uv sync --project tools/figures --locked
|
||||
pnpm run lint
|
||||
pnpm run typecheck
|
||||
pnpm --filter @endor/docs run test:auth
|
||||
pnpm --filter @endor/docs run test:research
|
||||
pnpm --filter @endor/docs run images
|
||||
pnpm --filter @endor/docs run test:images
|
||||
cp -R apps/docs/public/figures /tmp/expected-figures
|
||||
|
||||
+4
-1
@@ -14,7 +14,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = "endorphin-tabularium-docs"
|
||||
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
|
||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures",
|
||||
"scripts/check-container.sh", "scripts/ci.py"]
|
||||
"scripts/check-container.sh", "scripts/ci.py", "scripts/summarize-ai-income.py",
|
||||
"scripts/audit-ai-research.py"]
|
||||
|
||||
|
||||
def run(*args: str, capture: bool = False) -> str:
|
||||
@@ -63,6 +64,8 @@ def smoke_image(image: str, name: str) -> None:
|
||||
|
||||
def main() -> None:
|
||||
os.chdir(ROOT)
|
||||
run(sys.executable, "scripts/summarize-ai-income.py", "--check")
|
||||
run(sys.executable, "scripts/audit-ai-research.py", "--check")
|
||||
run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py")
|
||||
scope = os.environ.get("CI_CACHE_SCOPE", "main")
|
||||
if not re.fullmatch(r"main|pr-[0-9]+", scope):
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Draft business labels for public company profiles, never financial verification.
|
||||
|
||||
Input is a directory of JSON records with a `company` object containing
|
||||
one_liner and tags, as retained by the official-profile research snapshot.
|
||||
Output is for review; unmatched records remain C00 rather than being forced.
|
||||
This tool does not fetch sources, modify the site database or run a build.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
def match(pattern,text):return bool(re.search(pattern,text,re.I))
|
||||
def classify(c):
|
||||
short=c.get('one_liner') or '';tags=' '.join(c.get('tags') or [])
|
||||
rules=[
|
||||
('C10',r'\brobot(?:s|ics|ic)?\b|embodied|physical intelligence|autonomous (?:driving|vehicle)|drones?|manufacturing hardware'),
|
||||
('C02',r'context compression|compression middleware|RNA intelligence|audio data for AI|synthetic data|data quality|\bGPU\b|\bGPUs\b|\bchips?\b|semiconductor|data centers?|datacenters?|inference|compute|training data|data labeling|data labelling|RL environments|reinforcement learning|model training'),
|
||||
('C07',r'precision medicine|ambulance|clinic|biology|manufacturers|distributors|fabrication|people-intelligence|deskless workers|health|medical|clinical|dental|physician|hospital|veterinar|therap|radiolog|pharma|legal|law firm|lawyer|litigation|accountant|accounting|audit\b|insurance|underwrit|financ|hedge funds?|bank|wealth|investment|investing|real estate|property|properties|construction|building (?:plan|code)|engineering projects|\bCAD\b|\bEDA\b|chip design|architecture|architects|logistics|supply chain|freight|global trade|compliance|government|govtech|lobby|energy|utilities|\bfleets?\b|education|edtech|\btutor|schools?|exam prep|tax\b|recruit|hiring|human resources|procurement|retail|e-commerce|ecommerce|merchants?|industrial'),
|
||||
('C03',r'AI evaluations|AI governance|voice agents|chat AI agents|harnesses|agent.{0,25}(?:infrastructure|tools|identity|identities|memory|evaluation|eval|observability|browser|payment|communication)|(?:infrastructure|tools|identity|identities|evaluation|eval|observability|browsers?|payments?).{0,25}agents?|\bMCP\b|agentmail|API.{0,20}agents?|developer.{0,15}agents?'),
|
||||
('C04',r'firmware|internal apps|coding|software engineer|code\b|codebase|developer|programming|app(?:lication)? (?:builder|creation|generation)|(?:build|building|make|create|generat).{0,25}(?:apps|applications|software)|\bDevOps\b|product docs|documentation engineer|\bIDE\b|logs and fix|testing|debug'),
|
||||
('C06',r'\bsales\b|marketing|customer support|customer service|call center|call centre|\bGTM\b|\bCRM\b|\bSEO\b|\bGEO\b|\bAEO\b|growth engine|outbound|telemarketing|advertis|\bads\b|contact center|customer success'),
|
||||
('C08',r'\bvideo\b|\bimage\b|\bphoto\b|\bfilm\b|\bdesign\b|animation|creative|creators?|content creation|\bmusic\b|media|audio edit|voiceover'),
|
||||
('C01',r'foundation models?|frontier models?|large language models?|\bLLMs?\b|voice model|speech model|world model'),
|
||||
('C09',r'consumer|personal|companions?|\bstor(?:y|ies)\b|entertainment|\bgam(?:e|es|ing)\b|social network|\bphone\b|smartphone|keyboard|your digital life'),
|
||||
('C05',r'\bB2B\b|enterprise|workflow|workspace|productivity|operations|\bSaaS\b|\bSOP\b|Word editor|spreadsheet|knowledge|company OS|company brain|work\b|teams?|business|employee|meeting|security|cyber|search'),
|
||||
]
|
||||
for label,text in [('主营简介',short),('目录标签',tags)]:
|
||||
for cat,pattern in rules:
|
||||
if match(pattern,text):return cat,label+'的规则初分'
|
||||
return 'C00','简介不足以确定主业务,待复核'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
result = []
|
||||
for path in sorted(args.input.glob("*.json")):
|
||||
record = json.loads(path.read_text())
|
||||
company = record["company"]
|
||||
category, basis = classify(company)
|
||||
result.append({"ycId": company["id"], "category": category,
|
||||
"basis": basis, "status": "provisional"})
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Render a fixed research snapshot; --check performs data analysis only, no build."""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import statistics as st
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
|
||||
REFERENCE = json.loads((ROOT / 'content/research/ai-income-analysis.json').read_text())
|
||||
TARGET = ROOT / 'content/docs/ai-company-research/01-income-snapshot.md'
|
||||
BASE = '/docs/other-research/ai-company-research/overview'
|
||||
COMPANIES = {c['id']: c for c in DATA['companies']}
|
||||
CATEGORIES = {c['id']: c['name'] for c in DATA['categories']}
|
||||
END = dt.date.fromisoformat(DATA['asOf'])
|
||||
|
||||
|
||||
def sample(days):
|
||||
latest, excluded = {}, []
|
||||
for o in DATA['observations']:
|
||||
if o.get('selection') != 'platform' or o.get('metric') != 'mrr':
|
||||
continue
|
||||
c = COMPANIES[o['company']]
|
||||
day = o.get('observedAt')
|
||||
try:
|
||||
parsed = dt.date.fromisoformat(day)
|
||||
valid_date = parsed.isoformat() == day
|
||||
except (TypeError, ValueError):
|
||||
valid_date = False
|
||||
valid = (c['year'] in (2025, 2026) and o.get('origin') == 'payment-platform'
|
||||
and o['unit'] == 'USD' and o['bound'] == '='
|
||||
and o.get('comparisonGroup') == 'trustmrr-usd-mrr'
|
||||
and o.get('provider') and isinstance(o['value'], (int, float))
|
||||
and 0 <= o['value'] < float('inf') and valid_date
|
||||
and 0 <= (END - parsed).days <= days)
|
||||
if not valid:
|
||||
excluded.append(o)
|
||||
continue
|
||||
prior = latest.get(c['id'])
|
||||
if prior is None or day > prior['observedAt']:
|
||||
latest[c['id']] = o
|
||||
return latest, excluded
|
||||
|
||||
|
||||
def groups(points):
|
||||
result = {}
|
||||
for cid, o in points.items():
|
||||
c = COMPANIES[cid]
|
||||
key = (c['cat'], o['provider'], c.get('businessModel', '收费模式未核验'))
|
||||
result.setdefault(key, []).append(o['value'])
|
||||
return result
|
||||
|
||||
|
||||
def stats(values):
|
||||
mean = st.mean(values)
|
||||
sd = st.stdev(values) if len(values) > 1 else None
|
||||
reduced = st.mean(sorted(values)[:-1]) if len(values) > 1 else None
|
||||
return dict(n=len(values), mean=mean, median=st.median(values),
|
||||
sampleDeviation=sd, cv=sd / mean if sd is not None and mean else None,
|
||||
meanWithoutLargest=reduced,
|
||||
largestRemovalDrop=(mean - reduced) / mean if reduced is not None and mean else None)
|
||||
|
||||
|
||||
def verify():
|
||||
assert REFERENCE['sourceVersion'] == DATA['version'], 'Reference revision mismatch'
|
||||
for window in REFERENCE['windows']:
|
||||
points, _ = sample(window['days'])
|
||||
assert set(points) == set(window['products']), 'Sample selection mismatch'
|
||||
computed = groups(points)
|
||||
assert len(computed) == len(window['groups'])
|
||||
for group in window['groups']:
|
||||
values = computed[(group['category'], group['provider'], group['model'])]
|
||||
for key, value in stats(values).items():
|
||||
expected = group[key]
|
||||
assert (value is None and expected is None) or (
|
||||
value is not None and expected is not None
|
||||
and abs(value - expected) <= 1e-8 * max(1, abs(expected))), key
|
||||
|
||||
|
||||
def money(value):
|
||||
return f'{value:,.0f}' if value is not None else '不计算'
|
||||
|
||||
|
||||
def link(cid):
|
||||
return f"[{COMPANIES[cid]['name']}]({BASE}?company={cid}#research-company-detail)"
|
||||
|
||||
|
||||
def render():
|
||||
points, excluded = sample(7)
|
||||
lines = [
|
||||
'# AI 公司收入样本分布快照', '',
|
||||
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。", '',
|
||||
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
|
||||
'## 7日窗口的分组结果', '',
|
||||
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
|
||||
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- |',
|
||||
]
|
||||
for key, values in sorted(groups(points).items()):
|
||||
cat, provider, model = key
|
||||
v = stats(values)
|
||||
cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算'
|
||||
drop = f"{v['largestRemovalDrop']:.1%}" if v['largestRemovalDrop'] is not None else '不计算'
|
||||
lines.append(f"| {CATEGORIES[cat]} · {model} | {provider} | {v['n']} | {money(v['mean'])} | {money(v['median'])} | {cv} | {drop} |")
|
||||
lines += ['',
|
||||
'在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '',
|
||||
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
|
||||
'## 样本与证据入口', '',
|
||||
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
|
||||
'| 产品 | 成立年 | MRR美元每月 | 最后同步 | 平台 | 连接状态 |',
|
||||
'| --- | --- | --- | --- | --- | --- |',
|
||||
]
|
||||
for cid, o in sorted(points.items()):
|
||||
status = '已过期的日期快照' if o.get('connectionExpired') else '采集时未标过期'
|
||||
lines.append(f"| {link(cid)} | {COMPANIES[cid]['year']} | {money(o['value'])} | {o['observedAt']} | {o['provider']} | {status} |")
|
||||
fresh = stats(groups(points)[('C06', 'Stripe', '软件订阅')])
|
||||
broad, _ = sample(90)
|
||||
older = stats(groups(broad)[('C06', 'Stripe', '软件订阅')])
|
||||
lines += ['', '## 扩大窗口后的敏感性', '',
|
||||
f"营销软件订阅组从7日窗口的{fresh['n']}个产品扩大到90日窗口的{older['n']}个产品,均值从{money(fresh['mean'])}升至{money(older['mean'])}美元每月,中位数从{money(fresh['median'])}变为{money(older['median'])},CV从{fresh['cv']:.3f}升至{older['cv']:.3f}。", '',
|
||||
f"新增点来自{link('AI016')}的8月历史快照,连接已过期。移除该最大值后,均值回到{money(older['meanWithoutLargest'])}美元,下降{older['largestRemovalDrop']:.1%}。这是日期与样本构成的敏感性,不能解释为行业风险变化。", '',
|
||||
'## 默认窗口排除的历史记录', '',
|
||||
'| 产品 | MRR美元每月 | 最后同步 | 排除原因 |',
|
||||
'| --- | --- | --- | --- |',
|
||||
]
|
||||
for o in excluded:
|
||||
lines.append(f"| {link(o['company'])} | {money(o['value'])} | {o.get('observedAt', '未知')} | 超过7日窗口,保留历史观测 |")
|
||||
lines += ['', '## 对 ARR 与商业模式的含义', '',
|
||||
'MRR乘12只是参考年化。公司声明的ARR、合同管线、近30日收款、一次性额度和服务交付收入仍需分开;支付平台重建指标也不等于财务审计。方法与平台限制见[TrustMRR FAQ](https://trustmrr.com/faq)。', '',
|
||||
'当前更适合选择访谈对象和设计收费试验,不能据最高均值直接决定业务方向。软件订阅优先核留存与推理成本,服务交付优先核人工工时和返工,消费者应用优先核渠道成本、退款和平台抽成。', '',
|
||||
f"行业ARR比较仍需补齐同一业务截止日、币种、经常性定义及业务边界。收入日期不完整或同页数值冲突的声明,继续保留在[核查工作台]({BASE}),不补成零值或精确数字。", '',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true')
|
||||
args = parser.parse_args()
|
||||
verify()
|
||||
content = render()
|
||||
if args.check:
|
||||
if not TARGET.exists() or TARGET.read_text() != content:
|
||||
sys.exit('Income snapshot differs; regenerate after reviewing data changes')
|
||||
print('Income reference, samples and report are consistent')
|
||||
else:
|
||||
TARGET.write_text(content)
|
||||
print(TARGET.relative_to(ROOT))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user