Compare commits

...

193 Commits

Author SHA1 Message Date
codex-bot 2545dfe636 Classify consumer learning and health products with preorder and revenue-scope exclusions 2026-11-01 03:30:39 +08:00
codex-bot db70ef32da Classify robotics and evaluation infrastructure with historical-test and future-pricing limits 2026-11-01 03:24:07 +08:00
codex-bot 55b8fea7a8 Classify financial workflow AI and preserve payment-volume and current-product gaps 2026-11-01 03:18:23 +08:00
Zayden-Jung 701ef632ff Merge pull request '核对 Speedrun 七批与226档案,分类四十家公司并分离签约及非软件收入' (#45) from codex/ai-research-sr-rosters into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #45
2026-10-31 19:13:52 +00:00
codex-bot b6e44625b6 Classify enterprise memory and industry workflows while excluding contracted revenue from ARR 2026-11-01 03:12:52 +08:00
codex-bot c22739d4f8 Classify legal and eldercare AI delivery with separate cost and revenue boundaries 2026-11-01 03:06:02 +08:00
codex-bot 46bff91af9 Classify three SR006 businesses and preserve rebrand and revenue ambiguities 2026-11-01 02:55:28 +08:00
codex-bot fe6b3b5951 Classify six SR006 AI businesses and separate research scores from product validation 2026-11-01 02:47:53 +08:00
codex-bot e0806362f0 Classify five SR005 businesses and distinguish physical supply from SaaS 2026-11-01 02:41:25 +08:00
codex-bot bef80fba4d Classify six SR005 AI businesses and inspect Dex official accounts 2026-11-01 02:35:01 +08:00
codex-bot 9c32f77722 Classify two SR004 businesses and preserve unresolved brand founding dates 2026-11-01 02:28:52 +08:00
codex-bot d631be2617 Audit 226 Speedrun profiles and classify six current AI businesses 2026-11-01 02:16:52 +08:00
codex-bot 51d6364ddd Register all seven public Speedrun cohorts with verifiable boundaries 2026-11-01 01:55:27 +08:00
Zayden-Jung 17e9306c97 Merge pull request '完成 SR007 名单逐项档案读取,补充二十一家公司并区分历史收入归属' (#44) from codex/ai-research-sr007-more into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m36s
Reviewed-on: #44
2026-10-31 17:48:52 +00:00
codex-bot 6e3a505d24 Complete SR007 profile reconciliation and qualify historical revenue categories 2026-11-01 01:44:35 +08:00
codex-bot cd3e0c695c Reconcile remaining SR007 profiles and separate contracts from revenue 2026-11-01 01:36:14 +08:00
codex-bot 5514875213 Separate SR007 company years, managed services and legacy revenue claims 2026-11-01 01:23:00 +08:00
codex-bot ec50b3302e Reconcile SR007 contracts, free SDK terms and provider reach 2026-11-01 01:16:48 +08:00
Zayden-Jung 68780f1f2b Merge pull request '补核 SR007 三十六项候选及三十家公司,分类收入并增加两项商业模式初评' (#43) from codex/ai-research-sr007-followup into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m58s
Reviewed-on: #43
2026-10-31 17:11:28 +00:00
codex-bot 34f7cae0fe Distinguish live ARR, contracted demand and professional service run rates 2026-11-01 01:09:45 +08:00
codex-bot 76aa80fedc Classify SR007 deployments and assess local free to cloud subscription economics 2026-11-01 01:03:48 +08:00
codex-bot 7901ab940e Reconcile SR007 AI scope and separate travel revenue from ARR claims 2026-11-01 00:56:54 +08:00
codex-bot d52dac64e5 Classify six SR007 companies and exclude projected revenue and client assets 2026-11-01 00:50:40 +08:00
codex-bot b3fcb9ad3a Separate contracted pipeline, pilot demand and cloud usage in SR007 research 2026-11-01 00:40:39 +08:00
codex-bot 91d6c22e6b Classify six SR007 follow-ups and preserve revenue attribution limits 2026-11-01 00:31:49 +08:00
Zayden-Jung e04ab96187 Merge pull request '统一 AI 公司研究:ARR 补证、商业模式初评与来源核查' (#42) from codex/ai-research-ph-all26 into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #42
2026-10-31 16:30:24 +00:00
codex-bot eb7e758570 Register public SR007 roster and verify three new AI companies 2026-11-01 00:25:46 +08:00
codex-bot 5ba790eec2 Reconcile current YC coverage accounting with source boundaries 2026-11-01 00:14:51 +08:00
codex-bot ac9ddd9c27 Expose source candidates and unresolved classification gaps 2026-11-01 00:10:23 +08:00
codex-bot ea31287924 Keep webpage maintenance dates out of ARR financial cutoffs 2026-11-01 00:06:41 +08:00
codex-bot 5b31fa5b77 Connect evidence-backed business model assessments to company categories 2026-11-01 00:00:35 +08:00
codex-bot c833aa56c1 Trace DUIX and EZsite legacy businesses and licensing boundaries 2026-10-31 23:55:03 +08:00
codex-bot 0a640d75d4 Resolve Ada company registration and shared ChartGen identity 2026-10-31 23:50:49 +08:00
codex-bot 0bd2ce6b02 Cross-check Sacra candidates and classify General Intuition 2026-10-31 23:45:58 +08:00
codex-bot cce2fd8581 Expose ARR disclosure gaps before statistical comparison 2026-10-31 23:36:33 +08:00
codex-bot ae84304e16 Keep explicit ARR labels while guarding ambiguous income samples 2026-10-31 23:31:29 +08:00
codex-bot c6e5a7b05f Complete Product Hunt All snapshot and classify StoreClaw 2026-10-31 23:28:03 +08:00
Zayden-Jung 9bfd6891c3 Merge pull request '统一 AI 公司研究:ARR 分类表、名单补查与 CI 统计修复' (#41) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 4m0s
Reviewed-on: #41
2026-10-31 15:25:36 +00:00
codex-bot 3557ee7e87 Audit Product Hunt All archive and preserve company boundaries 2026-10-31 23:18:18 +08:00
codex-bot cc6993de2a Separate asset returns, funding and material pilot evidence 2026-10-31 23:10:05 +08:00
codex-bot 462fe01df0 Clarify game tooling, wallet fees and hardware evidence 2026-10-31 23:05:52 +08:00
codex-bot 838c9f1bad Audit teleoperation, product continuity and trading fee boundaries 2026-10-31 22:56:48 +08:00
codex-bot 0d7c24c529 Separate research, prototypes and current AI product evidence 2026-10-31 22:52:07 +08:00
codex-bot 74cd21c6fa Verify HEVN AI assistant and separate mixed service revenue 2026-10-31 22:46:33 +08:00
codex-bot 3c14c6fdca Audit Andustry sourcing AI and distinguish transaction fee models 2026-10-31 22:36:42 +08:00
codex-bot a1587e86bb Audit physical product stages and SpotPay entity boundaries 2026-10-31 22:29:58 +08:00
codex-bot 17d6589db1 Classify Fort and Sequence with training and revenue boundaries 2026-10-31 22:25:18 +08:00
codex-bot 708f136163 Verify ZeroSettle hosted AI and separate mixed billing economics 2026-10-31 22:20:06 +08:00
codex-bot f74b268da4 research: retain unqualified MRR claims and robotics product-stage gaps 2026-10-31 22:10:17 +08:00
codex-bot 2f525f97e3 research: link Voltair inspection service and review post-acquisition billing 2026-10-31 22:05:44 +08:00
codex-bot 92f7bcffbd research: link Sava trust workflows and review service revenue boundaries 2026-10-31 21:59:37 +08:00
codex-bot 1ca8ab72e8 research: separate trial evidence capacity and payment pricing from ARR 2026-10-31 21:54:50 +08:00
codex-bot 2c1f35f000 research: link DeepGrove model artifacts and separate candidate revenue boundaries 2026-10-31 21:51:18 +08:00
codex-bot 5cbaa013a7 research: distinguish infrastructure delivery and customer earnings 2026-10-31 21:46:35 +08:00
codex-bot f72043a4ed research: revisit AI scope and corporate lineage for YC candidates 2026-10-31 21:43:16 +08:00
codex-bot 21fe8ded32 feat: explain income dispersion within comparable sample groups 2026-10-31 21:34:38 +08:00
codex-bot 3c631a43a6 research: distinguish Chinese startup and institution evidence 2026-10-31 21:31:41 +08:00
codex-bot 53df8a4a2b research: link ARR definitions to subscription economics 2026-10-31 21:27:13 +08:00
codex-bot 5b86f08959 research: verify ARR currency and current contract models 2026-10-31 21:23:02 +08:00
codex-bot 4330817138 feat: show ARR disclosure coverage by business category 2026-10-31 21:18:11 +08:00
codex-bot 72f0928efa research: verify Perfingo historical roster identity 2026-10-31 21:15:24 +08:00
codex-bot e113b74c41 research: resolve Nunatak unlabeled roster link 2026-10-31 21:11:24 +08:00
codex-bot a1184b31b8 research: reconcile all 2026 named roster URLs 2026-10-31 21:08:50 +08:00
codex-bot 14e0c8ac76 research: distinguish treatment, diagnostic and audio revenue 2026-10-31 21:05:14 +08:00
codex-bot ea3433bd60 research: separate health analysis fees and clinic collections 2026-10-31 21:01:57 +08:00
codex-bot dc17a4d802 research: verify health devices and bereavement AI boundaries 2026-10-31 20:59:22 +08:00
codex-bot f49b5b96e7 research: audit first four 2026 health roster entries 2026-10-31 20:56:05 +08:00
codex-bot f3d83901a1 research: audit 2026 biotech roster and evidence boundaries 2026-10-31 20:47:46 +08:00
codex-bot cb803662c3 research: audit 2026 manufacturing roster and delivery gaps 2026-10-31 20:39:27 +08:00
codex-bot 7fc6742129 research: audit 2026 space roster and delivery evidence 2026-10-31 20:33:14 +08:00
codex-bot 91d4734763 research: audit 2026 energy roster and industrial revenue scope 2026-10-31 20:27:48 +08:00
codex-bot 4269d05b2e research: audit 2026 consumer roster and hardware revenue scope 2026-10-31 20:23:38 +08:00
codex-bot 4be984fa0a research: audit 2026 education roster and revenue boundaries 2026-10-31 20:18:17 +08:00
codex-bot 643c0df7aa research: audit 2026 infrastructure and social impact roster 2026-10-31 20:11:19 +08:00
codex-bot 8eb3b888d1 补核2026零售媒体八项并隔离实物佣金与按篇服务收入 2026-10-31 20:02:39 +08:00
codex-bot e9f48d6d9c 补核2026营销八项并拆分公关合作成果与软件净收入 2026-10-31 19:57:28 +08:00
codex-bot a67a3b3b81 补核2026房地产六项并保留成立冲突与机器人交付边界 2026-10-31 19:51:15 +08:00
codex-bot d7251e892b 补核2026物流六项并拆分运输硬件与软件净收入 2026-10-31 19:45:30 +08:00
codex-bot c0f9ee50c1 Merge remote-tracking branch 'origin/main' into codex/ai-research-consolidated 2026-10-31 19:39:46 +08:00
codex-bot ad682df1cc 补核2026人才招聘五项并区分按次评估与订阅收入 2026-10-31 19:39:46 +08:00
codex-bot cf47c1af31 补核2026法律公共事务七项并保留主体与签名验证缺项 2026-10-31 19:34:59 +08:00
codex-bot 06a6883be3 补核2026金融十项并拆分客户资产与软件净收入 2026-10-31 19:24:13 +08:00
codex-bot 9c08d9dc0f 补核2026安全组13项并核实 HODOR 法国登记 2026-10-31 19:17:04 +08:00
codex-bot c0924df7c4 补齐2026企业组候选并核实 Optise 登记与收入边界 2026-10-31 19:09:34 +08:00
codex-bot 0e1e127d4b 补核2026企业组第二批并区分成果合同与平台净费 2026-10-31 19:00:57 +08:00
codex-bot a9e5ac6a87 补核2026企业组八项并拆分任务交易与软件收入 2026-10-31 18:54:23 +08:00
codex-bot b81e1ad76e 补核2026汽车交通六项及软硬件与租赁收入边界 2026-10-31 18:46:19 +08:00
codex-bot 1f87628076 建立2026竞赛名单对账并补核农业食品七项 2026-10-31 18:42:59 +08:00
codex-bot a6a1860ef1 核实七项原网址对应并保留 SaveFirst 沿革缺项 2026-10-31 18:37:54 +08:00
codex-bot 94dcac4e95 补齐健康最后八项并对账2025总入口候选 2026-10-31 18:33:58 +08:00
codex-bot ec0d9abb34 补核健康七项及设备会员与原始验证边界 2026-10-31 18:29:50 +08:00
codex-bot d2e83cd33b 补核健康第三批及研究验证与收购边界 2026-10-31 18:25:30 +08:00
codex-bot 69c3cc8e64 补核健康名单第二批及 Eos 既有主体关联 2026-10-31 18:21:57 +08:00
codex-bot cdb84376cb 补核健康名单首六项及软硬件收入口径 2026-10-31 18:17:42 +08:00
codex-bot fabfce1f4c 补核娱乐物流制造24项并收录 CosmicBrain 2026-10-31 18:10:52 +08:00
codex-bot 142fa62d10 补核清洁能源22项并对账2025竞赛总名单 2026-10-31 18:00:37 +08:00
codex-bot 868594c79a 补核航天防务7项与 HANCE 后继 Rapidly 主体 2026-10-31 17:50:19 +08:00
codex-bot 83f91f1b1e 补核农业食品14项并收录按店计费的 Clave 2026-10-31 17:42:18 +08:00
codex-bot f99a354db2 增加按分类与成立年份浏览的 ARR 披露表 2026-10-31 17:36:03 +08:00
codex-bot 6410535ed9 补核TechCrunch消费教育26项并拆分交易硬件与软件收入 2026-10-31 17:27:07 +08:00
codex-bot db3b21bf54 补核TechCrunch安全名单9项并区分旧主体与收入口径 2026-10-31 17:14:55 +08:00
codex-bot 7140ea0469 完成54项缺年池补查并按最新官方字段纳入Graphide 2026-10-31 17:07:43 +08:00
codex-bot 241c82aa88 同步研究快照的核查与技术统计测试断言 2026-10-31 16:47:31 +08:00
codex-bot 5d62dee4e9 补核6项缺年候选并拆分推理代收与客户价值 2026-10-31 16:44:22 +08:00
Zayden-Jung 543d6ed527 Merge pull request '补核18项AI公司缺年候选、主体沿革与收费边界' (#40) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 4m1s
Reviewed-on: #40
2026-10-31 08:43:48 +00:00
codex-bot 445bfb37d0 补核6项缺年候选并区分旧业务与资金示例 2026-10-31 16:41:30 +08:00
codex-bot baaea4fc61 补核6项缺年候选并以登记证据纳入Zymbly 2026-10-31 16:38:01 +08:00
codex-bot ac04839571 补核6项缺年候选并保留旧品牌与服务收费边界 2026-10-31 16:34:12 +08:00
Zayden-Jung 7cecb9f753 Merge pull request '补核12项缺年AI候选,区分旧主体与成立缺项' (#39) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 4m15s
Reviewed-on: #39
2026-10-31 08:33:16 +00:00
codex-bot a2a5958fc4 补核12项缺年候选并关联官方主体登记 2026-10-31 16:29:35 +08:00
Zayden-Jung 7cc2397103 Merge pull request '统一 AI 公司研究:504项主营、192项AI候选与6项成立冲突补核' (#38) from codex/ai-research-consolidated into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 5m35s
Reviewed-on: #38
2026-10-31 08:27:07 +00:00
codex-bot feb4de220d 补核6项成立冲突并拆分旧年份与缺年候选 2026-10-31 04:48:47 +08:00
codex-bot 652b0fd539 完成192项AI不明名单补核并核对预订与交付口径 2026-10-31 04:37:52 +08:00
codex-bot 5ee04f26ae 补核24项AI候选并区分实体销售与预付收入 2026-10-31 04:26:29 +08:00
codex-bot 812749823b 补核24项AI不明候选并区分同名主体与流失收入 2026-10-31 04:16:25 +08:00
codex-bot 8a4df5c7ba Review fifth uncertain AI cohort and distinguish ARR from service and contract claims 2026-10-31 04:03:33 +08:00
codex-bot 8d886ad3cc Review fourth uncertain AI cohort and separate managed services from transaction flows 2026-10-31 03:51:44 +08:00
codex-bot 790dda09de Review third uncertain AI cohort and retain shutdown and mixed service evidence 2026-10-31 03:44:51 +08:00
codex-bot 5182eca961 Review second uncertain AI cohort and separate client and legacy financials 2026-10-31 03:38:16 +08:00
codex-bot 5cd8f831be Review uncertain AI candidates and preserve unresolved business scope 2026-10-31 03:31:34 +08:00
codex-bot a520694f9e Merge developer tag review into consolidated AI research 2026-10-31 03:17:15 +08:00
codex-bot dc373bad09 Merge model tag review into consolidated AI research 2026-10-31 03:17:08 +08:00
codex-bot dc2c3a22f3 Merge industry review into consolidated AI research 2026-10-31 03:17:02 +08:00
codex-bot fefa7c5ebd Merge enterprise review into consolidated AI research 2026-10-31 03:16:57 +08:00
codex-bot 5c2d9fd06c Complete remaining tag review and distinguish intent from revenue 2026-10-31 03:13:45 +08:00
codex-bot 85a0c534aa Review mixed directory tags and separate historical Mixy income 2026-10-31 03:06:48 +08:00
codex-bot d4736f7477 Complete enterprise-tag review and exclude prospective income claims 2026-10-31 02:59:56 +08:00
codex-bot f41d9e0d53 Review first enterprise-tag cohort and separate client metrics 2026-10-31 02:51:26 +08:00
codex-bot 1736912ecb Complete developer tag review and distinguish agent hosting cost models 2026-10-31 02:39:58 +08:00
codex-bot 45f1d1dba3 Review developer tag candidates and separate runtime and compute businesses 2026-10-31 02:32:24 +08:00
codex-bot 8d7b10df19 Review model tag candidates and separate licensing and revenue evidence 2026-10-31 02:24:38 +08:00
codex-bot d9d675ce56 Complete industry tag review and distinguish service revenue run rates 2026-10-31 02:17:19 +08:00
codex-bot b8a82359e3 Review 20 industry tag candidates and Litmus historical ARR 2026-10-31 02:05:45 +08:00
codex-bot 4682e3513e Review 20 tag-classified industry candidates and current AI relevance 2026-10-31 02:00:16 +08:00
codex-bot c0ff99e640 Complete remaining 39 industry classifications and revenue boundary checks 2026-10-31 01:53:42 +08:00
codex-bot b5e4f817fb Review 20 industry companies and separate services from model claims 2026-10-31 01:44:23 +08:00
codex-bot df227df18c Review 30 industry companies and distinguish revenue commitments 2026-10-31 01:37:48 +08:00
codex-bot 1e81887682 research: review regulated services and separate client assets from income 2026-10-31 01:23:41 +08:00
codex-bot c25fb79eee research: separate industry software from brokerage and operations services 2026-10-31 01:18:08 +08:00
codex-bot a8b054381f research: distinguish industry services and model training inputs 2026-10-31 01:12:51 +08:00
codex-bot 8e5df06fdd research: review industry workflows and historical ARR boundaries 2026-10-31 01:07:22 +08:00
codex-bot 4942b86a4a research: finish enterprise classification and separate Tasklet product history 2026-10-31 00:57:16 +08:00
codex-bot 7e4f4fe3e6 research: separate enterprise workflows and speech model evidence 2026-10-31 00:37:13 +08:00
codex-bot 1e716c36b2 research: review enterprise workflows and product pivots 2026-10-31 00:29:52 +08:00
Zayden-Jung ba042ed5ae Merge pull request '扩展 AI 公司研究并修复收入统计表布局' (#31) from codex/ai-research-cn-sme into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 9m0s
Reviewed-on: #31
2026-10-30 16:11:48 +00:00
codex-bot 0cc3815bd8 Review sales delivery models and Native founding conflict 2026-10-31 00:11:28 +08:00
codex-bot f49aac2778 Review creative and consumer classifications and historical income 2026-10-31 00:00:28 +08:00
codex-bot d588f2e1fb Review remaining robotics classifications and pilot contract boundaries 2026-10-30 23:49:14 +08:00
codex-bot ad1f351a6b Review software development classifications and delivery models 2026-10-30 23:40:12 +08:00
codex-bot f73d2631fb Review remaining model infrastructure classifications 2026-10-30 23:31:15 +08:00
codex-bot 6dca32dc66 Review compute infrastructure category boundaries 2026-10-30 23:21:43 +08:00
codex-bot 992275560f Resolve candidate years and preserve seller and legacy business boundaries 2026-10-30 23:12:15 +08:00
codex-bot 74c74ddda9 Expand platform income samples and derive report conclusions from current data 2026-10-30 22:58:47 +08:00
codex-bot 61201c1bb0 Review agent infrastructure classifications and financial service boundaries 2026-10-30 22:47:04 +08:00
codex-bot 4eb08c0857 Research 2025 Speedrun companies and separate pilot and client revenue 2026-10-30 22:35:50 +08:00
codex-bot 3e7792191e Audit biotech candidates and separate research milestones from revenue 2026-10-30 22:19:52 +08:00
codex-bot fa09a01421 Audit financial and real-estate candidates without treating asset values as ARR 2026-10-30 22:12:07 +08:00
Zayden-Jung 87987f6cc6 Merge pull request '支持基于内部摘要的章节级文章编辑' (#30) from codex/document-sections into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 8m39s
Reviewed-on: #30
2026-10-30 14:05:53 +00:00
codex-bot c9497ccc6b Audit visible government and legal startup candidates with source gaps 2026-10-30 22:04:11 +08:00
codex-bot 05b500e911 Refine physical industry classifications and preserve pivot revenue limits 2026-10-30 21:54:16 +08:00
codex-bot ea09b414a2 Merge income statistics layout fix into AI research delivery 2026-10-30 21:40:21 +08:00
codex-bot 945e7eadb2 Keep income statistics columns readable with grouped rows 2026-10-30 21:36:38 +08:00
codex-bot f86a4a75a0 Expand Speedrun companies and separate contract revenue claims 2026-10-30 21:20:12 +08:00
codex-bot ccba2c546a Expand Chinese SME AI delivery models and physical-AI candidates 2026-10-30 21:11:14 +08:00
Zayden-Jung 56b0ee60c0 Merge pull request '扩展中英文 AI 公司分类与收入核查' (#29) from codex/ai-research-source-lineage into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m33s
Reviewed-on: #29
2026-10-30 13:05:46 +00:00
codex-bot f7cdf16933 Audit four further TechCrunch candidates and bundled-service boundaries 2026-10-30 20:58:01 +08:00
codex-bot deadd24dce Verify AiMOGA founding and preserve interim revenue disclosure 2026-10-30 20:50:58 +08:00
codex-bot 4f1d3caafd Expand Chinese candidates and audit data-vendor revenue evidence 2026-10-30 20:40:17 +08:00
codex-bot 28ced713dc Preserve unspecified period of the AfterQuery run-rate claim 2026-10-30 20:22:00 +08:00
codex-bot c8c8bad563 Separate revenue run-rate claims from recurring contract evidence 2026-10-30 20:19:03 +08:00
codex-bot 13f2b31e8a Review model-category business boundaries and reconcile Eka history 2026-10-30 20:08:28 +08:00
codex-bot 9ee2bd219a Add chapter editing API with internal summaries and revision control 2026-10-30 19:58:40 +08:00
codex-bot 20e5097783 Record Nirva founder-reported team size and planned price 2026-10-30 19:55:45 +08:00
codex-bot ae4bd203a1 Add three model and wearable companies from archive discovery 2026-10-30 19:50:05 +08:00
codex-bot 5b6d3d9bfd Reconcile four pre-2025 businesses with original source evidence 2026-10-30 19:36:51 +08:00
Zayden-Jung 6b4fa711c1 Merge pull request '补核六项既有业务并保留 Sponstar 年份冲突' (#28) from codex/ai-research-year-proofs into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 8m34s
Reviewed-on: #28
2026-10-30 11:32:34 +00:00
codex-bot 76e32490e9 Verify six company histories and retain conflicting founding dates 2026-10-30 19:27:55 +08:00
Zayden-Jung fa0bcb15fb Merge pull request '补齐32项企业竞赛登记并核查19项既有业务' (#26) from codex/ai-research-2025-coverage into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #26
2026-10-30 11:20:47 +00:00
codex-bot 12123c278f Resolve fourteen older Battlefield businesses with source evidence 2026-10-30 19:17:43 +08:00
codex-bot 4273dd77e0 Register all 32 enterprise Battlefield discovery entries 2026-10-30 19:09:13 +08:00
Zayden-Jung 3ab2f8e122 Merge pull request '汇总 AI 公司核查更新与文档上传 API' (#25) from codex/research-api-integration into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m33s
Reviewed-on: #25
2026-10-30 10:28:45 +00:00
codex-bot 15024c3908 merge: include latest company research updates from PR #23 2026-10-30 18:20:34 +08:00
codex-bot 770be83692 Backfill 2025 discovery ledger and verify public archive scope 2026-10-30 18:19:29 +08:00
codex-bot 083f532609 merge: integrate document upload API from PR #24 2026-10-30 18:17:53 +08:00
codex-bot 2b3706c90d merge: integrate company categorization and deployment fixes from PR #23 2026-10-30 18:17:53 +08:00
codex-bot f156406265 Classify Thrive services rollup and exclude Brila existing operator 2026-10-30 18:12:52 +08:00
codex-bot e65d90c78b Resolve Utopai founding history and preserve Fundraisly entity gaps 2026-10-30 18:10:35 +08:00
codex-bot 96f1417cda Add Goldfish with investor-backed founding-year evidence 2026-10-30 18:08:01 +08:00
codex-bot 347b9587c6 Reconcile Tapflow history and Atoms company-year evidence 2026-10-30 18:04:11 +08:00
codex-bot 677cd73945 Fix Peec team bounds regression assertion 2026-10-30 17:54:08 +08:00
codex-bot e075eee8a5 Separate legacy launches from unresolved Happycapy entity lineage 2026-10-30 17:41:15 +08:00
codex-bot d971362a97 Resolve four launch candidates through operator and app history evidence 2026-10-30 17:31:44 +08:00
codex-bot 0e5ccafa30 Classify remaining directory profiles using official business evidence 2026-10-30 17:21:06 +08:00
24 changed files with 104851 additions and 6117 deletions
+1
View File
@@ -4,6 +4,7 @@
- Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。
- 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。
- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用文档 API 或 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。
- 编辑已有文章时优先使用章节 API:先读取章节目录与内部摘要,据此选择需要读取正文的章节,再提交单章或多章操作。摘要缺失时先读对应正文并补充;修改观点前必须核对该章原文,检查读取版本与提交 revision 一致。摘要只写入内部章节数据,不放入网页正文、目录摘要或 Git;改写正文时同步更新摘要。完整操作与 Python 客户端用法见 README 的「先读摘要,再按章节编辑」。
- 未确认的架构提议明确标注为草案,不能表述为现有实现。
- 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18135,可通过独立 production.env 配置。
- 对 gitea.entroia.com 仅使用 codex-bot,提交身份为 Codex Bot <codex-bot@gitea.entroia.local>。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。
+50
View File
@@ -75,6 +75,10 @@ sudo /opt/endorphin-tabularium-docs/docs save < "$doc_work_dir/document.json"
| `GET /api/documents/item/<slug>?revision=1` | 读取指定修订的完整正文 |
| `POST /api/documents` | 保存一个完整文档 JSON;字段与 `docs save` 相同 |
| `POST /api/documents/batch` | 保存 `{ "documents": [...] }`,每个请求 1—100 篇 |
| `GET /api/documents/sections/<slug>` | 读取章节 ID、标题和内部摘要,不返回正文 |
| `GET /api/documents/sections/<slug>?section=<id>` | 读取选中章节正文;重复 `section` 可选多章 |
| `GET /api/documents/sections/<slug>?revision=2` | 读取对应修订的章节目录;也可搭配 `section` |
| `POST /api/documents/sections/<slug>` | 原子执行章节新增、更新、删除和移动 |
写入使用 `Authorization: Bearer <文档 API Key>`。新建传 `revision: 0`;更新先读取当前全文,再使用读到的 revision。每篇正文最多 2,000,000 个 JavaScript 字符单位;每次请求最多 8 MiB。目录和修订分页的 `limit` 范围为 1—100,响应包含 `total` 和 `nextOffset`。
@@ -82,6 +86,52 @@ sudo /opt/endorphin-tabularium-docs/docs save < "$doc_work_dir/document.json"
建议写入时提供 `Idempotency-Key`,由 1—128 个字母、数字或 `._:-` 组成。同一个 Key、相同请求 ID 和相同输入重试时,返回原回执并标记 `replayed: true`,不追加修订;相同 ID 改换输入会冲突。回执描述原请求,不代表之后没有其他修改;需要当前内容时重新读取文档。改过正文或 revision 后,应使用新的请求 ID。
### 先读摘要,再按章节编辑
章节接口使用相同的文档 Key 和读写权限。编辑流程为:先读章节目录及摘要,选择需要深入的章节,再读取这些章节的正文,最后提交一组操作。不要只凭摘要修改未读正文;目录与正文响应的 `revision` 应一致,否则重新读取。接口不调用模型生成摘要:由编辑 Agent 阅读正文、撰写摘要,并在编辑请求中保存。
章节按行首 Markdown ATX 二级标题(`##`)划分,三级至六级标题随所属章节一起编辑;代码围栏内的标题不会切章。二级标题之前的文档标题及导言属于固定 ID `preamble`,可以修改但不能删除或移动。Setext 标题、带缩进或引用、列表标记的标题不作为章节边界。正文无行首二级标题时,全文作为导言处理。拆分及组合保持换行和未编辑内容原样,不自动格式化 Markdown。
目录中的每章包含 `id`、`title`、`position`、`summary`、`summaryStatus` 和 `contentHash`。摘要为编辑内部数据,单独保存在 SQLite 的 `document_sections` 和 `document_section_revisions` 中,不加入普通文档响应、网页 props、搜索索引或页面正文。完整数据库备份包含这些表;`docs export` 仍只导出当前文档,不包含内部摘要和修订。
旧文章无需重写,首次读取即可按原文返回目录,初始摘要为空,`summaryStatus` 为 `missing`。Agent 应按需读取这些章节,生成摘要并通过 `update` 保存;保存后状态为 `current`。这里的 `current` 仅表示摘要与该版正文一起保存,不保证模型的总结正确。全文接口仍兼容;未变化章节保留摘要与 ID,正文变化的章节清空摘要,等待更新。全文改名且改写章节时可能产生新 ID,因此全文保存后应重新读取目录。
章节编辑请求示例(示例 ID 需要替换为目录实际返回的值):
```json
{
"revision": 3,
"operations": [
{
"op": "update",
"id": "s_existing",
"content": "## 原有章节\n\n修改后的正文。\n\n",
"summary": "本章说明修改后的核心观点与边界。"
},
{
"op": "insert",
"after": "s_existing",
"content": "## 新章节\n\n新增论证。\n\n",
"summary": "补充上一章尚未覆盖的论证。"
}
]
}
```
`update` 可只改 `summary`,也可改正文;正文改变时若不提供新摘要,旧摘要会清空。`insert` 必须提供正文和非空摘要;摘要最多 8000 个 JavaScript 字符单位。`delete` 使用 `{ "op": "delete", "id": "s_existing" }`;`move` 使用 `{ "op": "move", "id": "s_existing", "after": "preamble" }`。按数组顺序执行,最多 100 个操作;新章 ID 由服务器生成,在回执目录中返回。章正文应从自己的 `##` 标题开始,只包含一个二级标题,并保留与下一章之间的换行;跨章代码围栏或章节边界改变会被拒绝,拆章请使用多个操作。
章节变更、内部摘要、组合后的全文和历史快照在同一事务内提交。每批操作只增加一次文章 `revision`;摘要单独更新也增加版本号。后续操作失败或全文版本过期时全部回滚。写入沿用幂等规则,回执包含新版本、章节目录和 `replayed`;章节接口与全文接口的幂等记录分开。修改章节不会改变其 ID,移动亦保留 ID;删除后的正文和摘要仍可从对应的旧修订读取。启用前的历史修订只有原文,摘要显示缺失。
Python 客户端对应命令:
```sh
python3 scripts/documents-client.py outline endor/endor-purpose-draft
python3 scripts/documents-client.py sections endor/endor-purpose-draft preamble s_existing
python3 scripts/documents-client.py edit-sections endor/endor-purpose-draft /private/path/edit.json --request-id chapter-edit-1
```
`outline` 和 `sections` 支持 `--revision`;请求文件只包含 `revision` 和 `operations`,不要把内部摘要文件提交到 Git。
### 创建、查看与撤销 Key
管理员只需通过已验证的服务器连接进行 Key 管理:
+3 -3
View File
@@ -182,10 +182,10 @@ async function main() {
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, 1021);
assert.equal(research.companies.length, 1307);
assert.equal(research.categories.length, 11);
assert.equal(research.evidence.length, 1215);
assert.equal(research.observations.length, 189);
assert.equal(research.evidence.length, 5331);
assert.equal(research.observations.length, 495);
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);
+14 -8
View File
@@ -1,15 +1,18 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router";
import IncomeDistribution from "./IncomeDistribution";
import ArrDisclosures from "./ArrDisclosures";
import BusinessModelAssessment from "./BusinessModelAssessment";
import ResearchCandidates from "./ResearchCandidates";
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 revenueLabels = { self: "企业披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
const useLabels: Record<string, string> = {
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
platform: "支付平台记录", signal: "企业披露信号", estimate: "第三方估算", historical: "历史里程碑",
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
};
@@ -55,7 +58,7 @@ export default function AICompanyResearch() {
: 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 [tab, setTab] = useState<"companies" | "candidates" | "revenue" | "models" | "sources" | "reviews">("companies");
const companySection = useRef<HTMLElement>(null);
const detailSection = useRef<HTMLElement>(null);
useEffect(() => {
@@ -127,7 +130,7 @@ export default function AICompanyResearch() {
<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]) =>
{([['companies','公司与产品'],['candidates','候选与缺项'],['revenue','收入观测'],['models','商业模式初评'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
</div>
{tab === "companies" && <>
@@ -138,7 +141,7 @@ export default function AICompanyResearch() {
<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>
<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>
@@ -152,16 +155,19 @@ export default function AICompanyResearch() {
{!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 === "models" && <BusinessModelAssessment data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
{tab === "candidates" && <ResearchCandidates data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
{tab === "revenue" && <>
<IncomeDistribution data={data} onSelect={setSelected} />
<h3>公司披露与估算的历史里程碑</h3>
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
<ArrDisclosures 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>
<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>}</>}
@@ -0,0 +1,69 @@
import { useState } from "react";
import { evidenceUrl, type ResearchSnapshot } from "@/lib/ai-research";
import { arrDisclosureCoverage, arrDisclosures, arrFieldGaps, type ArrFieldGap } from "@/lib/research-statistics";
const labels: Record<string, string> = {
signal: "企业或当事方披露", estimate: "第三方估算",
historical: "历史披露", "historical-estimate": "历史估算",
};
const gapLabels: Record<ArrFieldGap, string> = {
date: "缺有效财务截至日", currency: "USD 未确认(含其他币种)",
amount: "下界、近似或非精确金额", estimate: "第三方估算",
};
export default function ArrDisclosures({ data, onSelect }: { data: ResearchSnapshot; onSelect: (id: string) => void }) {
const [category, setCategory] = useState("");
const [year, setYear] = useState("");
const [gap, setGap] = useState<ArrFieldGap | "">("");
const claims = arrDisclosures(data, { category, foundedYear: year ? Number(year) : undefined });
const rows = claims.filter(({ observation }) => !gap || arrFieldGaps(observation, data.asOf).includes(gap));
const gapCounts = (Object.keys(gapLabels) as ArrFieldGap[]).map((id) => ({
id, count: claims.filter(({ observation }) => arrFieldGaps(observation, data.asOf).includes(id)).length,
}));
const completeFields = claims.filter(({ observation }) => !arrFieldGaps(observation, data.asOf).length).length;
const coverage = arrDisclosureCoverage(data, year ? Number(year) : undefined);
const companies = new Set(claims.map((row) => row.company.id));
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
return <section aria-label="ARR披露分类表">
<h3>2025—2026 年成立公司的 ARR 披露</h3>
<p className="research-notice">这是跨时点证据表,共 {companies.size} 家公司、{claims.length} 条记录;同一公司的历史里程碑逐条保留,不能相加或视为独立公司。ARR 标签沿用原始披露,未证明经常性收入定义或审计状态;不纳入 MRR 图表,不计算同期行业均值或造假概率。仅收录明确标注的 ARR;定义冲突、目标、承诺、客户和前雇主收入排除,缺失不作零。</p>
<div className="research-filters">
<label>业务分类<select value={category} onChange={(event) => setCategory(event.target.value)}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</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={gap} onChange={(event) => setGap(event.target.value as ArrFieldGap | "")}><option value="">全部记录</option>{gapCounts.map((item) => <option key={item.id} value={item.id}>{gapLabels[item.id]}({item.count} 条)</option>)}</select></label>
</div>
<div className="research-table-wrap"><table>
<caption>ARR 披露覆盖:按公司当前分类及所选成立年份、每家公司计一次。点击分类筛选下方证据表。</caption>
<thead><tr><th>业务分类</th><th>已采集公司</th><th>企业或当事方披露</th><th>仅第三方估算</th><th>缺少 ARR</th><th>披露覆盖率</th></tr></thead>
<tbody>{coverage.filter((item) => item.companies > 0).map((item) => <tr key={item.id}>
<td><button onClick={() => setCategory(item.id)} aria-pressed={category === item.id}>{item.name}</button></td>
<td>{item.companies}</td><td>{item.reported}</td><td>{item.estimatedOnly}</td><td>{item.missing}</td>
<td>{new Intl.NumberFormat("zh-CN", { style: "percent", maximumFractionDigits: 1 }).format(item.disclosed / item.companies)}</td>
</tr>)}</tbody>
</table></div>
<p className="research-muted">分母含仅有目录资料的公司,覆盖率反映本次采集深度,不代表行业披露率。分类及收费模式采用公司当前资料;公司转向或同时经营多个产品时,历史 ARR 未必属于当前业务,需打开公司核对产品、主体与期间,不能据当前分类重归历史收入。企业披露含历史里程碑,同一公司兼有披露和估算时归企业披露;缺少 ARR 的公司仍可能有其他收入。披露时点、币种与合同定义未对齐,不能将已披露少数公司的均值外推至整个分类,也不能由离散性判断财务造假。</p>
<aside className="research-notice" aria-label="ARR比较前的补证检查">
<strong>ARR 比较前的补证检查</strong>
<p>当前分类及成立年下有 {claims.length} 条披露,{completeFields} 条同时具有有效日期、明确 USD、精确金额且不是第三方估算。补证筛选后显示 {rows.length} 条。</p>
<div className="income-options">{gapCounts.map((item) => <button key={item.id} aria-pressed={gap === item.id} onClick={() => setGap(gap === item.id ? "" : item.id)}>{gapLabels[item.id]}:{item.count}</button>)}</div>
<p>各项可重叠,不能相加为独立公司数。日期必须是经营指标截至日,网页访问、发布或更新时间不能代替;其他币种保留原值,不擅自换算。基础字段齐全也不证明可比,还需对齐收入定义、业务期间、收费模式、主体与独立验证;这些缺项不作为造假判定。</p>
</aside>
<div className="research-table-wrap"><table>
<caption>金额保留原币种、下界和估算身份;日期栏保留业务截至日缺项</caption>
<thead><tr><th>公司 / 当前分类</th><th>当前收费与交付模式</th><th>原始 ARR 声明</th><th>时点</th><th>身份 / 限制</th><th>证据</th></tr></thead>
<tbody>{rows.map(({ company, observation: item }) => {
const source = evidence.get(item.evidence);
const url = source && evidenceUrl(source.url);
return <tr key={item.id}>
<td><button onClick={() => onSelect(company.id)}>{company.name}</button><small>{categories.get(company.cat)} · {company.year}</small></td>
<td>{company.businessModel ?? company.price}</td>
<td>{item.bound !== "=" && item.bound}{new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(item.value)} {item.unit}<small>{item.kind}</small></td>
<td>{item.timing}</td><td>{labels[item.selection]}<small>{item.reason}</small><small>{arrFieldGaps(item, data.asOf).map((id) => gapLabels[id]).join(";") || "基础字段齐全;定义、期间与主体仍须核实"}</small></td>
<td>{url ? <a href={url} target="_blank" rel="noopener noreferrer">{item.evidence} ↗</a> : item.evidence}</td>
</tr>;
})}</tbody>
</table></div>
{!rows.length && <p>当前分类、成立年与补证筛选没有符合条件的已记录 ARR 声明;不代表这些公司收入为零。</p>}
</section>;
}
@@ -0,0 +1,32 @@
import { businessModelReviewType, evidenceUrl, reviewCompanyIds, type ResearchSnapshot } from "@/lib/ai-research";
export default function BusinessModelAssessment({ data, onSelect, onChooseCategory }: {
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
}) {
const reviews = data.reviews.filter((review) => review.type === businessModelReviewType && review.scope === "companies");
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
return <section aria-labelledby="research-models-title">
<h3 id="research-models-title">商业模式初评</h3>
<p>从已核个案提炼收费方式、成本约束与下一步验证条件。已知事实与研究判断分列;同一种收费方式可以跨业务分类,以下个案不能代表整个行业。</p>
<p className="research-muted">这是基于当前证据的初步评估,不是收入预测或盈利排名。判断能否用于我们的业务,还需用实际客户任务、付费留存与交付成本验证;ARR 或平台 MRR 单独不能证明利润。</p>
{reviews.length ? <div className="research-table-wrap"><table>
<caption>收费模式与业务分类、公司明细及原始证据相互关联</caption>
<thead><tr><th scope="col">模式 / 业务分类</th><th scope="col">已核事实</th><th scope="col">初步判断 / 验证条件</th><th scope="col">参考个案与证据</th></tr></thead>
<tbody>{reviews.map((review) => {
const companyIds = new Set(reviewCompanyIds(data, review));
const companies = data.companies.filter((company) => companyIds.has(company.id));
const categories = data.categories.filter((category) => companies.some((company) => company.cat === category.id));
return <tr key={review.id}>
<td><strong>{review.company}</strong><div>{categories.map((category) => <button key={category.id} onClick={() => onChooseCategory(category.id)}>{category.name}</button>)}</div></td>
<td>{review.summary}</td><td>{review.handling}</td>
<td><div>{companies.map((company) => <button key={company.id} className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button>)}</div>
<div>{review.evidenceIds.map((id) => {
const item = evidence.get(id); const url = item && evidenceUrl(item.url);
return item && url ? <p key={id}><a href={url} target="_blank" rel="noopener noreferrer">{item.title} ↗</a></p> : null;
})}</div>
</td>
</tr>;
})}</tbody>
</table></div> : <p>当前数据版本尚未记录商业模式初评。</p>}
</section>;
}
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import type { ResearchSnapshot } from "@/lib/ai-research";
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
import { compareIncomeGroups, groupIncome, platformIncomeSample } from "@/lib/research-statistics";
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
style: "currency", currency: "USD", maximumFractionDigits: 0,
@@ -23,6 +23,7 @@ export default function IncomeDistribution({ data, onSelect }: {
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
category, rows: groups.filter((group) => group.category === category),
}));
const comparisons = compareIncomeGroups(groups);
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);
@@ -47,6 +48,20 @@ export default function IncomeDistribution({ data, onSelect }: {
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
<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 ? <>
<aside aria-label="当前窗口样本解读" className="research-notice">
<strong>当前窗口的样本解读</strong>
{comparisons.length ? comparisons.map((comparison) => <div key={comparison.key}>
<p>{comparison.provider} · {comparison.model}(仅比较各组 n≥3):
{comparison.leastDispersed.stats.cv === comparison.mostDispersed.stats.cv
? <>各组 CV 相同({comparison.leastDispersed.stats.cv?.toFixed(3)}),没有离散度高低差异。</>
: <>{categories.get(comparison.mostDispersed.category)}更离散(CV {comparison.mostDispersed.stats.cv?.toFixed(3)}),{categories.get(comparison.leastDispersed.category)}较集中(CV {comparison.leastDispersed.stats.cv?.toFixed(3)})。</>}
{comparison.leastSensitive.stats.largestRemovalDrop === comparison.mostSensitive.stats.largestRemovalDrop
? <>各组移除最大值后的均值下降幅度相同({percent(comparison.leastSensitive.stats.largestRemovalDrop)})。</>
: <>{categories.get(comparison.mostSensitive.category)}的均值更受最大值影响,移除后下降 {percent(comparison.mostSensitive.stats.largestRemovalDrop)};{categories.get(comparison.leastSensitive.category)}下降 {percent(comparison.leastSensitive.stats.largestRemovalDrop)}。</>}
</p>
</div>) : <p>当前没有至少两个可对照的统计组。需要相同支付平台、明确的收费模式标签,且各组至少 3 个产品;可调整同步窗口或创立年,不能将单个产品或两点均值当作领域典型。</p>}
<p>这些是所选产品样本内的描述,较集中或较不敏感也不证明行业均值可靠。最后同步日并非统一财务结账日,产品年龄、主体独立性、成本和留存仍待核;参考年化也不是已披露 ARR。</p>
</aside>
<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>)}
@@ -75,7 +90,7 @@ export default function IncomeDistribution({ data, onSelect }: {
</svg></div>
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
</figure>
<div className="research-table-wrap"><table><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
<div className="research-table-wrap"><table className="income-statistics"><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
{index === 0 && <th scope="rowgroup" rowSpan={rows.length}>{categories.get(category)}<small>{rows.length}个统计组</small></th>}
<td>{group.provider}<small>{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>
@@ -0,0 +1,75 @@
import { useMemo, useState } from "react";
import { evidenceUrl, type ResearchCandidateBatch, type ResearchSnapshot } from "@/lib/ai-research";
type CandidateStatus = ResearchCandidateBatch["records"][number]["status"];
const statusLabels: Record<CandidateStatus, string> = {
"pending-year": "成立年或主体待核", "pending-ai": "AI 主营待核", linked: "已关联公司",
"existing-outside-cohort": "已关联范围外对照", "outside-cohort": "范围外候选", promotion: "推广活动",
};
const pageSize = 20;
const sourceNames: Record<string, string> = {
S01: "Y Combinator", S02: "TechCrunch AI", S04: "Product Hunt", S05: "Sacra",
S10: "机器之心", S11: "36氪", S13: "TrustMRR", S14: "a16z Speedrun",
};
const sourceLabel = (id: string) => `${id} · ${sourceNames[id] ?? "其他来源"}`;
function SourceLink({ url, label }: { url: string | null; label: string }) {
const safe = url && evidenceUrl(url);
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{label} ↗</a> : <span>{label}(链接未取得)</span>;
}
export default function ResearchCandidates({ data, onSelect, onChooseCategory }: {
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
}) {
const [source, setSource] = useState("");
const [batchId, setBatchId] = useState("");
const [category, setCategory] = useState("");
const [status, setStatus] = useState<CandidateStatus | "pending" | "">("pending");
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const all = useMemo(() => (data.candidateBatches ?? []).flatMap((batch) => batch.records.map((record) => ({ batch, record }))), [data.candidateBatches]);
const sources = [...new Set(all.map(({ batch }) => batch.sourceId))].sort();
const batches = (data.candidateBatches ?? []).filter((batch) => !source || batch.sourceId === source);
const matched = all.filter(({ batch, record }) => (!source || batch.sourceId === source)
&& (!batchId || batch.id === batchId) && (!category || record.categoryId === category)
&& `${record.name} ${record.id} ${record.note}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const pending = (value: CandidateStatus) => value === "pending-year" || value === "pending-ai";
const rows = matched.filter(({ record }) => !status || (status === "pending" ? pending(record.status) : record.status === status));
const companies = new Map(data.companies.map((company) => [company.id, company]));
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
return <section aria-labelledby="research-candidates-title">
<h3 id="research-candidates-title">候选与缺项</h3>
<p>展示已登记的有限来源批次及后续核查。默认显示成立年、主体或 AI 主营待核的条目;待核不是确认排除,也不计入公司表。每行是一次发现位置,同一公司或产品可以重复出现。</p>
<p className="research-muted">候选初分类与公司表数量分开,来源批次不等全年或全站名单。原始发现范围及后续结论见每行说明;暂无收入资料不作零,推广活动也不算公司。</p>
<div className="research-filters">
<label className="research-query">搜索候选<input type="search" value={query} placeholder="名称、编号或核查说明" onChange={(event) => { setQuery(event.target.value); setPage(0); }} /></label>
<label>发现来源<select value={source} onChange={(event) => { setSource(event.target.value); setBatchId(""); setPage(0); }}><option value="">全部已登记来源</option>{sources.map((id) => <option key={id} value={id}>{sourceLabel(id)}</option>)}</select></label>
<label>发现批次<select value={batchId} onChange={(event) => { setBatchId(event.target.value); setPage(0); }}><option value="">全部批次</option>{batches.map((batch) => <option key={batch.id} value={batch.id}>{batch.id}</option>)}</select></label>
<label>候选初分类<select value={category} onChange={(event) => { setCategory(event.target.value); setPage(0); }}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>核查状态<select value={status} onChange={(event) => { setStatus(event.target.value as typeof status); setPage(0); }}>
<option value="">全部状态({matched.length})</option><option value="pending">全部待核({matched.filter(({ record }) => pending(record.status)).length})</option>
{Object.entries(statusLabels).map(([id, label]) => <option key={id} value={id}>{label}({matched.filter(({ record }) => record.status === id).length})</option>)}
</select></label>
</div>
<p role="status">{rows.length} 个发现位置匹配;不是独立公司数。</p>
<div className="research-table-wrap"><table>
<caption>有限来源候选与处理记录;选择关联公司查看主数据,原始证据直接链接来源</caption>
<thead><tr><th scope="col">候选</th><th scope="col">初分类 / 状态</th><th scope="col">来源及范围</th><th scope="col">核查结论 / 缺项</th><th scope="col">关联公司 / 证据</th></tr></thead>
<tbody>{rows.slice(page * pageSize, (page + 1) * pageSize).map(({ batch, record }) => {
const company = record.companyId && companies.get(record.companyId);
return <tr key={record.id}>
<td><SourceLink url={record.url} label={record.name} /><small>{record.id}</small></td>
<td><button onClick={() => onChooseCategory(record.categoryId)} aria-label={`查看${categories.get(record.categoryId)}分类内的公司`}>{categories.get(record.categoryId)}</button><small>{statusLabels[record.status]}</small></td>
<td><SourceLink url={batch.url} label={sourceLabel(batch.sourceId)} /><small>{batch.id} · 读取 {batch.accessedAt}</small><details><summary>发现批次范围</summary><p>{batch.scope}</p><p>{batch.complete ? "该批次标记已完成;不代表来源全站覆盖。" : "该批次仍保留覆盖缺项。"}</p></details></td>
<td>{record.note}</td>
<td>{company ? <button className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button> : <small>尚未关联公司主记录</small>}
{record.evidenceIds?.map((id) => { const item = evidence.get(id); return item ? <p key={id}><SourceLink url={item.url} label={id} /></p> : null; })}
</td>
</tr>;
})}</tbody>
</table></div>
{!rows.length && <p>当前筛选没有已登记的候选记录;不代表该来源或分类没有公司。</p>}
{rows.length > pageSize && <nav className="research-pagination" aria-label="候选与缺项分页"><button disabled={page === 0} onClick={() => setPage((value) => value - 1)}>上一页</button><span>{page + 1} / {Math.ceil(rows.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= rows.length} onClick={() => setPage((value) => value + 1)}>下一页</button></nav>}
</section>;
}
+4 -3
View File
@@ -1,4 +1,5 @@
export type ResearchCategory = { id: string; name: string; description: string };
export const businessModelReviewType = "商业模式初评";
export const technicalReviewLabels = {
unreviewed: "技术证据尚未专项评审",
description: "已读技术自述",
@@ -48,7 +49,7 @@ export type ResearchCandidateBatch = {
records: {
id: string; name: string; url: string | null; categoryId: string;
kind: "product" | "company" | "promotion"; profileStatus: "readable" | "unavailable" | "archive-only";
status: "pending-year" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
status: "pending-year" | "pending-ai" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
companyId?: string; evidenceIds?: string[]; note: string;
}[];
};
@@ -125,13 +126,13 @@ export function validateResearch(snapshot: ResearchSnapshot) {
if (!row.name.trim() || !row.note.trim() || !categories.has(row.categoryId) ||
!["product", "company", "promotion"].includes(row.kind) ||
!["readable", "unavailable", "archive-only"].includes(row.profileStatus) ||
!["pending-year", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) ||
!["pending-year", "pending-ai", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) ||
(row.kind !== "promotion" && (!row.url || !evidenceUrl(row.url))) ||
(row.url !== null && !evidenceUrl(row.url)) ||
linked !== !!row.companyId || (row.companyId && !companies.has(row.companyId)) ||
(row.kind === "promotion") !== (row.status === "promotion") ||
(row.evidenceIds ?? []).some((id) => !evidence.has(id)) ||
(row.status === "outside-cohort" && !row.evidenceIds?.length))
(["outside-cohort", "pending-ai"].includes(row.status) && !row.evidenceIds?.length))
throw new Error(`Invalid research candidate: ${row.id}`);
}
}
+75 -5
View File
@@ -1,5 +1,9 @@
import { randomUUID, createHash } from "node:crypto";
import { openSiteDatabase } from "./site-database";
import { DocumentValidationError, DocumentConflictError } from "./document-errors";
import { sectionSchema, loadSections, reconcileSections, persistSections, splitSections, outline,
applySectionOperations, parseSectionEdit, type DocumentSection } from "./document-sections";
export { DocumentValidationError, DocumentConflictError } from "./document-errors";
export type DocLink = {
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
@@ -9,8 +13,6 @@ export type DocLink = {
export type Doc = DocLink & { content: string };
export type DocInput = Omit<Doc, "revision" | "searchText">;
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
export class DocumentValidationError extends Error {}
export class DocumentConflictError extends Error {}
export type DocumentReceipt = Pick<Doc, "slug" | "sourceSlug" | "revision">;
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
@@ -36,6 +38,7 @@ function database() {
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
);
`);
sectionSchema(db);
return db;
}
@@ -57,12 +60,13 @@ export function validateDocument(input: DocInput): void {
throw new DocumentValidationError("Use compressed WebP images instead of original rasters");
}
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number, editedSections?: DocumentSection[]): Doc {
validateDocument(input);
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
const existing = db.prepare("SELECT id, revision, payload FROM documents WHERE source_slug=?")
.get(input.sourceSlug) as { id: string; revision: number; payload: string } | undefined;
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
const id = existing?.id ?? randomUUID();
const previous = existing ? loadSections(db, id, (JSON.parse(existing.payload) as Doc).content) : [];
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
title: input.title, group: input.group, collection: input.collection, description: input.description,
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
@@ -80,9 +84,75 @@ function write(db: ReturnType<typeof database>, input: DocInput, expectedRevisio
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
.run(id, doc.revision, payload, new Date().toISOString());
persistSections(db, id, doc.revision, editedSections ?? reconcileSections(doc.content, previous));
return doc;
}
// Internal summaries never enter Doc, catalog, search or browser page props.
export function readDocumentSections(slug: string, revision?: number) {
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1)) throw new DocumentValidationError("Invalid revision");
const db = database();
try {
db.exec("BEGIN");
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
if (!row) { db.exec("COMMIT"); return null; }
let doc = JSON.parse(row.payload) as Doc;
let sections: DocumentSection[];
if (revision === undefined || revision === doc.revision) sections = loadSections(db, row.id, doc.content);
else {
const prior = db.prepare("SELECT payload FROM document_revisions WHERE document_id=? AND revision=?")
.get(row.id, revision) as { payload: string } | undefined;
if (!prior) { db.exec("COMMIT"); return null; }
doc = JSON.parse(prior.payload) as Doc;
const snapshot = db.prepare("SELECT payload FROM document_section_revisions WHERE document_id=? AND revision=?")
.get(row.id, revision) as { payload: string } | undefined;
sections = snapshot ? JSON.parse(snapshot.payload) as DocumentSection[] : splitSections(doc.content);
}
db.exec("COMMIT");
return { slug: doc.slug, sourceSlug: doc.sourceSlug, title: doc.title, revision: doc.revision, sections };
} finally { db.close(); }
}
export function editDocumentSections(slug: string, value: unknown, request?: { namespace: string; id: string }) {
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
const edit = parseSectionEdit(value);
if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id))) throw new DocumentValidationError("Invalid Idempotency-Key");
const digest = createHash("sha256").update(JSON.stringify({ slug, edit })).digest("hex");
const namespace = request ? request.namespace + ":sections" : undefined;
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try {
if (request) {
const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?")
.get(namespace!, request.id) as { digest: string; response: string } | undefined;
if (prior) {
if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content");
db.exec("COMMIT");
return { ...JSON.parse(prior.response) as { slug: string; sourceSlug: string; revision: number; sections: ReturnType<typeof outline> }, replayed: true };
}
}
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
if (!row) { db.exec("COMMIT"); return null; }
const doc = JSON.parse(row.payload) as Doc;
if (doc.revision !== edit.revision) throw new DocumentConflictError("Document revision conflict");
const previous = loadSections(db, row.id, doc.content);
const sections = applySectionOperations(previous, edit.operations);
// Preserve the initial outline when upgrading an existing document for the first time.
db.prepare("INSERT OR IGNORE INTO document_section_revisions VALUES (?,?,?)").run(row.id, doc.revision, JSON.stringify(previous));
const saved = write(db, { ...doc, content: sections.map((s) => s.content).join("") }, doc.revision, sections);
const receipt = { slug: saved.slug, sourceSlug: saved.sourceSlug, revision: saved.revision, sections: outline(sections) };
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
.run(namespace!, request.id, digest, JSON.stringify(receipt));
db.exec("COMMIT");
return { ...receipt, replayed: false };
} catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
const db = database();
+2
View File
@@ -0,0 +1,2 @@
export class DocumentValidationError extends Error {}
export class DocumentConflictError extends Error {}
+144
View File
@@ -0,0 +1,144 @@
import { createHash, randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { DocumentValidationError } from "./document-errors";
export type DocumentSection = {
id: string; title: string; content: string; summary: string;
};
export type SectionOperation =
| { op: "update"; id: string; content?: string; summary?: string }
| { op: "insert"; after: string; content: string; summary: string }
| { op: "delete"; id: string }
| { op: "move"; id: string; after: string };
export type SectionEdit = { revision: number; operations: SectionOperation[] };
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
// Preserve every byte, including CRLF and separators. H3-H6 belong to their H2 chapter.
export function splitSections(content: string): DocumentSection[] {
const starts: { offset: number; title: string }[] = [{ offset: 0, title: "导言" }];
let offset = 0;
let fence: { marker: string; length: number } | undefined;
for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
const opening = line.match(/^ {0,3}(`{3,}|~{3,})(.*?)(?:\r?\n)?$/);
if (fence) {
if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*\\r?\\n?$`).test(line)) fence = undefined;
} else if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
fence = { marker: opening[1][0], length: opening[1].length };
} else {
const heading = line.match(/^##(?:[ \t]+(.*?))?[ \t]*\r?\n?$/);
if (heading) starts.push({ offset, title: (heading[1] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim() });
}
offset += line.length;
}
const seen = new Map<string, number>();
return starts.map((start, index) => {
const occurrence = seen.get(start.title) ?? 0;
seen.set(start.title, occurrence + 1);
return { id: index === 0 ? "preamble" : "s_" + hash(`${start.title}\0${occurrence}`).slice(0, 24),
title: start.title, content: content.slice(start.offset, starts[index + 1]?.offset), summary: "" };
});
}
export function sectionSchema(db: DatabaseSync) {
db.exec(`CREATE TABLE IF NOT EXISTS document_sections (
document_id TEXT PRIMARY KEY REFERENCES documents(id), payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS document_section_revisions (
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
payload TEXT NOT NULL, PRIMARY KEY(document_id, revision)
);`);
}
export function loadSections(db: DatabaseSync, documentId: string, content: string): DocumentSection[] {
const row = db.prepare("SELECT payload FROM document_sections WHERE document_id=?")
.get(documentId) as { payload: string } | undefined;
if (!row) return splitSections(content);
const sections = JSON.parse(row.payload) as DocumentSection[];
// A legacy writer must not leave an apparently current outline over different Markdown.
return sections.map((s) => s.content).join("") === content ? sections : reconcileSections(content, sections);
}
export function reconcileSections(content: string, previous: DocumentSection[]): DocumentSection[] {
const unused = new Set(previous);
return splitSections(content).map((section) => {
const prior = section.id === "preamble" ? previous.find((p) => p.id === "preamble") :
previous.find((p) => p.id !== "preamble" && unused.has(p) && p.content === section.content) ??
previous.find((p) => p.id !== "preamble" && unused.has(p) && p.title === section.title);
if (prior) unused.delete(prior);
return { ...section, id: prior?.id ?? (section.id === "preamble" ? "preamble" : "s_" + randomUUID()),
summary: prior?.content === section.content ? prior.summary : "" };
});
}
export function persistSections(db: DatabaseSync, documentId: string, revision: number, sections: DocumentSection[]) {
const payload = JSON.stringify(sections);
db.prepare(`INSERT INTO document_sections VALUES (?,?) ON CONFLICT(document_id)
DO UPDATE SET payload=excluded.payload`).run(documentId, payload);
db.prepare("INSERT INTO document_section_revisions VALUES (?,?,?)").run(documentId, revision, payload);
}
export function outline(sections: DocumentSection[]) {
return sections.map(({ content, ...section }, position) => ({ ...section, position,
contentHash: hash(content), summaryStatus: section.summary.trim() ? "current" : "missing" }));
}
export function parseSectionEdit(value: unknown): SectionEdit {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected section edit object");
const row = value as Record<string, unknown>;
if (Object.keys(row).some((key) => !["revision", "operations"].includes(key)) ||
!Number.isSafeInteger(row.revision) || (row.revision as number) < 1 || !Array.isArray(row.operations) ||
!row.operations.length || row.operations.length > 100) throw new DocumentValidationError("Provide revision and 1 to 100 operations");
for (const raw of row.operations) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DocumentValidationError("Invalid operation");
const op = raw as Record<string, unknown>;
const fields: Record<string, string[]> = { update: ["op", "id", "content", "summary"],
insert: ["op", "after", "content", "summary"], delete: ["op", "id"], move: ["op", "id", "after"] };
if (typeof op.op !== "string" || !Object.hasOwn(fields, op.op) || Object.keys(op).some((key) => !fields[op.op as string].includes(key)))
throw new DocumentValidationError("Unknown operation or field");
for (const key of fields[op.op].filter((key) => !["content", "summary", "op"].includes(key)))
if (typeof op[key] !== "string" || !(op[key] as string).length) throw new DocumentValidationError("Missing section identifier");
for (const key of ["content", "summary"])
if (op[key] !== undefined && typeof op[key] !== "string") throw new DocumentValidationError(`Invalid ${key}`);
if (op.op === "update" && op.content === undefined && op.summary === undefined) throw new DocumentValidationError("Empty update");
if (op.op === "insert" && (typeof op.content !== "string" || typeof op.summary !== "string" || !op.summary.trim()))
throw new DocumentValidationError("Insert requires content and summary");
if (typeof op.summary === "string" && (!op.summary.trim() || op.summary.length > 8000))
throw new DocumentValidationError("Summary must contain 1 to 8000 characters");
}
return { revision: row.revision as number, operations: row.operations as SectionOperation[] };
}
export function applySectionOperations(previous: DocumentSection[], operations: SectionOperation[]) {
const sections = previous.map((section) => ({ ...section }));
function index(id: string) {
const result = sections.findIndex((s) => s.id === id);
if (result < 0) throw new DocumentValidationError("Unknown section id");
return result;
}
for (const op of operations) {
if (op.op === "insert") {
sections.splice(index(op.after) + 1, 0, { id: "s_" + randomUUID(), title: "", content: op.content, summary: op.summary });
} else {
const at = index(op.id);
if (op.op === "update") {
if (op.content !== undefined && op.content !== sections[at].content) sections[at].summary = "";
if (op.content !== undefined) sections[at].content = op.content;
if (op.summary !== undefined) sections[at].summary = op.summary;
} else {
if (op.id === "preamble") throw new DocumentValidationError("Cannot delete or move the preamble");
if (op.op === "delete") sections.splice(at, 1);
else {
if (op.id === op.after) throw new DocumentValidationError("Cannot move section after itself");
index(op.after);
const [section] = sections.splice(at, 1);
sections.splice(index(op.after) + 1, 0, section);
}
}
}
}
const content = sections.map((s) => s.content).join("");
const parsed = splitSections(content);
if (parsed.length !== sections.length || parsed.some((s, i) => s.content !== sections[i].content))
throw new DocumentValidationError("Each chapter must start with one H2 heading; preserve newline boundaries and balanced fences");
return sections.map((s, i) => ({ ...s, title: parsed[i].title }));
}
+65
View File
@@ -1,6 +1,50 @@
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
/** An evidence ledger, never a comparable sample: retain historical claims and estimates separately. */
export function arrDisclosures(snapshot: ResearchSnapshot, options: { category?: string; foundedYear?: number } = {}) {
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
return snapshot.observations.flatMap((observation) => {
const company = companies.get(observation.company);
if (!company || ![2025, 2026].includes(company.year ?? 0) || observation.metric !== "arr" ||
!["signal", "estimate", "historical", "historical-estimate"].includes(observation.selection) ||
!Number.isFinite(observation.value) || observation.value < 0 ||
(options.category && company.cat !== options.category) ||
(options.foundedYear && company.year !== options.foundedYear)) return [];
return [{ company, observation }];
});
}
/** Count companies once; missing ARR is a coverage gap, never zero revenue. */
export function arrDisclosureCoverage(snapshot: ResearchSnapshot, foundedYear?: number) {
const rows = arrDisclosures(snapshot, { foundedYear });
return snapshot.categories.map((category) => {
const companies = snapshot.companies.filter((company) => company.cat === category.id &&
[2025, 2026].includes(company.year ?? 0) && (!foundedYear || company.year === foundedYear));
const claims = rows.filter((row) => row.company.cat === category.id);
const disclosed = new Set(claims.map((row) => row.company.id));
const reported = new Set(claims.filter((row) =>
["signal", "historical"].includes(row.observation.selection)).map((row) => row.company.id));
return { id: category.id, name: category.name, companies: companies.length,
disclosed: disclosed.size, reported: reported.size, estimatedOnly: disclosed.size - reported.size,
missing: companies.length - disclosed.size, records: claims.length };
});
}
export type ArrFieldGap = "date" | "currency" | "amount" | "estimate";
/** Prerequisites for inspecting exact, dated USD disclosures; never a comparability or audit verdict. */
export function arrFieldGaps(observation: ResearchObservation, asOf: string): ArrFieldGap[] {
const gaps: ArrFieldGap[] = [];
const day = calendarDay(observation.observedAt);
const end = calendarDay(asOf);
if (day === null || end === null || day > end) gaps.push("date");
if (observation.unit !== "USD") gaps.push("currency");
if (observation.bound !== "=" || !Number.isFinite(observation.value) || observation.value < 0) gaps.push("amount");
if (["estimate", "historical-estimate"].includes(observation.selection)) gaps.push("estimate");
return gaps;
}
export type IncomeStats = {
n: number; mean: number; median: number; min: number; max: number;
sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null;
@@ -80,3 +124,24 @@ export function groupIncome(points: IncomePoint[], splitModels = true) {
}
return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! }));
}
/** Compare descriptive statistics only within the same provider and delivery model. */
export function compareIncomeGroups(groups: ReturnType<typeof groupIncome>) {
const buckets = new Map<string, typeof groups>();
for (const group of groups) {
if (group.stats.n < 3 || group.stats.cv === null || group.stats.largestRemovalDrop === null ||
group.model === "全部收费模式" || group.model === "收费模式未核验") continue;
const key = JSON.stringify([group.provider, group.model]);
const bucket = buckets.get(key) ?? [];
bucket.push(group);
buckets.set(key, bucket);
}
return [...buckets.entries()].flatMap(([key, rows]) => {
if (rows.length < 2) return [];
const byCV = [...rows].sort((a, b) => a.stats.cv! - b.stats.cv! || a.key.localeCompare(b.key));
const bySensitivity = [...rows].sort((a, b) => a.stats.largestRemovalDrop! - b.stats.largestRemovalDrop! || a.key.localeCompare(b.key));
return [{ key, provider: rows[0].provider, model: rows[0].model,
leastDispersed: byCV[0], mostDispersed: byCV[byCV.length - 1],
leastSensitive: bySensitivity[0], mostSensitive: bySensitivity[bySensitivity.length - 1] }];
});
}
@@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { DocumentValidationError, readDocumentSections, editDocumentSections } from "@/lib/docs";
import { outline } from "@/lib/document-sections";
import { authorizeDocuments, documentApiError, idempotency } from "@/lib/documents-api";
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
const key = authorizeDocuments(request, response, request.method === "POST");
if (!key) return;
if (request.method !== "GET" && request.method !== "POST") {
response.setHeader("Allow", "GET, POST");
return response.status(405).end();
}
if (!Array.isArray(request.query.slug)) throw new DocumentValidationError("Invalid document slug");
const slug = request.query.slug.join("/");
if (Object.keys(request.query).some((k) => !["slug", "section", "revision"].includes(k)))
throw new DocumentValidationError("Unknown section query");
if (request.method === "POST") {
if (request.query.section !== undefined || request.query.revision !== undefined)
throw new DocumentValidationError("POST uses revision and operations in the body");
const saved = editDocumentSections(slug, request.body, idempotency(request, key.id));
return saved ? response.status(200).json(saved) : response.status(404).json({ code: "not_found" });
}
const revision = request.query.revision;
if (revision !== undefined && (typeof revision !== "string" || !/^[1-9]\d*$/.test(revision)))
throw new DocumentValidationError("Invalid revision");
const doc = readDocumentSections(slug, revision === undefined ? undefined : Number(revision));
if (!doc) return response.status(404).json({ code: "not_found" });
const selected = request.query.section;
if (selected === undefined) return response.status(200).json({ ...doc, sections: outline(doc.sections) });
const ids = typeof selected === "string" ? [selected] : selected;
if (!ids.length || ids.length > 100 || ids.some((id) => !id)) throw new DocumentValidationError("Select 1 to 100 sections");
if (ids.some((id) => !doc.sections.some((s) => s.id === id))) return response.status(404).json({ code: "not_found" });
return response.status(200).json({ ...doc, sections: doc.sections.filter((s) => ids.includes(s.id)) });
} catch (error) { return documentApiError(response, error); }
}
+7
View File
@@ -1473,6 +1473,13 @@ svg { flex-shrink: 0; }
.research-distribution { margin: 24px 0 38px; }
.research-distribution figure { margin: 20px 0; }
.ai-research .income-statistics { min-width: 1200px; }
/* Row-spanning category headings shift cell indexes between rows. */
.ai-research .income-statistics td { min-width: 0; }
.ai-research .income-statistics th[scope="rowgroup"] { min-width: 150px; white-space: nowrap; }
.ai-research .income-statistics th[scope="rowgroup"] small { display: block; margin-top: 5px; }
.ai-research .income-statistics td:not(:last-child) { white-space: nowrap; }
.ai-research .income-statistics td:last-child { min-width: 220px; }
.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; }
+123 -1
View File
@@ -7,8 +7,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import documents from "../src/pages/api/documents/index";
import batch from "../src/pages/api/documents/batch";
import item from "../src/pages/api/documents/item/[...slug]";
import sections from "../src/pages/api/documents/sections/[...slug]";
import { splitSections } from "../src/lib/document-sections";
import { openSiteDatabase } from "../src/lib/site-database";
import { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys";
import { allDocuments, documentHistory, readDocument, saveDocument, type Doc } from "../src/lib/docs";
import { allDocuments, documentHistory, readDocument, saveDocument, readDocumentSections, type Doc } from "../src/lib/docs";
import { newAuthConfig, signSession } from "../src/lib/auth";
const input: Doc = { slug: "endor/example", sourceSlug: "00-example", title: "Example", category: "Endor",
@@ -134,3 +137,122 @@ test("invalid batch members, duplicate identifiers, raw images and malformed fie
assert.equal(call(documents, { method: "DELETE", headers }).status, 405);
assert.equal(call(item, { headers, query: { slug: ["missing"] } }).status, 404);
}));
const chapterContent = "# Example\n\nIntroduction.\n\n## First\n\nFirst body.\n\n### Detail\n\nChild stays in chapter.\n\n## Second\n\nSecond body.\n";
type SectionResponse = { revision: number; replayed?: boolean;
sections: { id: string; title: string; summary: string; summaryStatus?: string; content?: string }[] };
const chapterQuery = { slug: input.slug.split("/") };
test("chapter parser preserves bytes, fences, duplicate headings and CRLF", () => {
const content = "# Title\r\n\r\n## Repeated\r\n~~~text\r\n## Inside fence\r\n~~~\r\n### Nested\r\n```js\r\n## Also inside\r\n````\r\n\r\n## Repeated\r\nTail";
const parts = splitSections(content);
assert.equal(parts.length, 3);
assert.notEqual(parts[1].id, parts[2].id);
assert.equal(parts.map((p) => p.content).join(""), content);
assert.equal(parts[1].title, "Repeated");
assert.ok(parts[1].content.includes("### Nested"));
assert.equal(splitSections("").length, 1);
assert.equal(splitSections("# Title\n\n- Item\n ## List detail\n\n> ## Quoted\n").length, 1);
});
test("outline reads existing Markdown without changing it and returns only selected bodies", () => isolated(({ headers }) => {
saveDocument({ ...input, content: chapterContent }, 0);
const db = openSiteDatabase();
db.exec("DELETE FROM document_sections; DELETE FROM document_section_revisions;"); db.close();
const result = call(sections, { headers, query: chapterQuery });
assert.equal(result.status, 200);
const outline = result.body as SectionResponse;
assert.equal(outline.revision, 1); assert.equal(outline.sections.length, 3);
assert.deepEqual(outline.sections.map((s) => s.title), ["导言", "First", "Second"]);
assert.ok(outline.sections.every((s) => s.content === undefined && s.summaryStatus === "missing"));
const selected = call(sections, { headers, query: { ...chapterQuery, section: outline.sections[1].id } }).body as SectionResponse;
assert.equal(selected.sections.length, 1);
assert.ok(selected.sections[0].content!.includes("### Detail"));
assert.equal(readDocument(input.slug)!.content, chapterContent);
assert.equal(call(sections, { headers, query: { ...chapterQuery, section: "missing" } }).status, 404);
assert.equal(call(sections, { headers, query: { ...chapterQuery, revision: "0" } }).status, 400);
}));
test("internal summaries persist separately and chapter updates leave other bytes untouched", () => isolated(({ headers }) => {
saveDocument({ ...input, content: chapterContent }, 0);
const before = readDocumentSections(input.slug)!;
const first = before.sections[1]; const second = before.sections[2];
const changed = first.content.replace("First body.", "Revised body.");
const edited = call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations: [
{ op: "update", id: first.id, content: changed, summary: "INTERNAL_SUMMARY_FIRST" },
{ op: "update", id: second.id, summary: "INTERNAL_SUMMARY_SECOND" },
] } });
assert.equal(edited.status, 200);
const after = readDocumentSections(input.slug)!;
assert.equal(after.revision, 2); assert.equal(after.sections[1].id, first.id);
assert.equal(after.sections[0].content, before.sections[0].content);
assert.equal(after.sections[2].content, before.sections[2].content);
assert.equal(after.sections[1].summary, "INTERNAL_SUMMARY_FIRST");
assert.equal(readDocument(input.slug)!.content, chapterContent.replace("First body.", "Revised body."));
assert.ok(!JSON.stringify(allDocuments()).includes("INTERNAL_SUMMARY"));
assert.ok(!JSON.stringify(call(item, { headers, query: chapterQuery }).body).includes("INTERNAL_SUMMARY"));
assert.ok(!JSON.stringify(call(documents, { headers }).body).includes("INTERNAL_SUMMARY"));
const history = readDocumentSections(input.slug, 1)!;
assert.equal(history.sections[1].content, first.content); assert.equal(history.sections[1].summary, "");
assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "INTERNAL_SUMMARY_FIRST");
}));
test("insert, move and delete chapters commit together with replay and conflict protection", () => isolated(({ headers }) => {
saveDocument({ ...input, content: chapterContent }, 0);
const before = readDocumentSections(input.slug)!;
const first = before.sections[1]; const second = before.sections[2];
const body = { revision: 1, operations: [
{ op: "move", id: second.id, after: "preamble" },
{ op: "delete", id: first.id },
{ op: "insert", after: second.id, content: "## New chapter\n\nNew body.\n", summary: "New material" },
] };
const options = { method: "POST", headers: { ...headers, "idempotency-key": "chapters-1" }, query: chapterQuery, body };
assert.equal(call(sections, options).status, 200);
const repeated = call(sections, options);
assert.equal(repeated.status, 200); assert.equal((repeated.body as SectionResponse).replayed, true);
const after = readDocumentSections(input.slug)!;
assert.deepEqual(after.sections.map((s) => s.title), ["导言", "Second", "New chapter"]);
assert.equal(after.sections[1].content, second.content);
assert.equal(after.revision, 2); assert.equal(documentHistory(input.sourceSlug).length, 2);
assert.equal(call(sections, { ...options, body: { ...body, revision: 2 } }).status, 409);
assert.equal(call(sections, { ...options, headers }).status, 409);
assert.equal(saveDocument({ ...readDocument(input.slug)!, description: "Changed metadata" }, 2).revision, 3);
assert.equal(readDocumentSections(input.slug)!.sections[2].summary, "New material");
}));
test("invalid late operation rolls back content, summary and history; read-only keys cannot edit", () => isolated(({ headers }) => {
saveDocument({ ...input, content: chapterContent }, 0);
const before = readDocumentSections(input.slug)!;
const invalid = [
[{ op: "update", id: before.sections[1].id, summary: "Should roll back" }, { op: "delete", id: "missing" }],
[{ op: "delete", id: "preamble" }],
[{ op: "move", id: before.sections[1].id, after: before.sections[1].id }],
[{ op: "update", id: before.sections[1].id, content: "## First\n\n## Extra\n" }],
[{ op: "update", id: before.sections[1].id, content: "## First\n![raw](/figures/raw.png)\n" }],
[{ op: "insert", after: "preamble", content: "## Missing summary\n" }],
[{ op: "delete", id: before.sections[1].id, unexpected: true }],
];
for (const operations of invalid) {
assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations } }).status, 400);
assert.deepEqual(readDocumentSections(input.slug), before);
assert.equal(documentHistory(input.sourceSlug).length, 1);
}
assert.equal(call(sections, { query: chapterQuery }).status, 401);
const reader = createDocumentKey("chapter reader", "read");
const readHeaders = { authorization: "Bearer " + reader.token };
assert.equal(call(sections, { headers: readHeaders, query: chapterQuery }).status, 200);
assert.equal(call(sections, { method: "POST", headers: readHeaders, query: chapterQuery, body: { revision: 1, operations: [] } }).status, 403);
}));
test("full-document saves invalidate changed chapter summaries and preserve unaffected summaries and ids", () => isolated(({ headers }) => {
saveDocument({ ...input, content: chapterContent }, 0);
const before = readDocumentSections(input.slug)!;
assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations:
before.sections.map((s) => ({ op: "update", id: s.id, summary: "Summary of " + s.title })) } }).status, 200);
saveDocument({ ...readDocument(input.slug)!, content: chapterContent.replace("First body.", "Changed externally.") }, 2);
const after = readDocumentSections(input.slug)!;
assert.equal(after.sections[1].id, before.sections[1].id);
assert.equal(after.sections[1].summary, "");
assert.equal(after.sections[2].summary, "Summary of Second");
assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "Summary of First");
}));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+52 -28
View File
@@ -1,6 +1,6 @@
{
"sourceVersion": "2026-10-30-ph-history-1",
"asOf": "2026-10-30",
"sourceVersion": "2026-11-01-sr-learning-health-1",
"asOf": "2026-11-01",
"metric": "platform-mrr-usd",
"sampleUnit": "product-or-business",
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
@@ -21,7 +21,9 @@
"MR011",
"MR012",
"MR015",
"MR016"
"MR016",
"MR018",
"MR019"
],
"groups": [
{
@@ -35,7 +37,7 @@
"MR016"
],
"n": 4,
"mean": 34815.0,
"mean": 34815,
"median": 36723.0,
"sampleDeviation": 16334.54845820151,
"cv": 0.46918134304758036,
@@ -50,7 +52,7 @@
"MR001"
],
"n": 1,
"mean": 53101.0,
"mean": 53101,
"median": 53101,
"sampleDeviation": null,
"cv": null,
@@ -65,7 +67,7 @@
"MR002"
],
"n": 1,
"mean": 62423.0,
"mean": 62423,
"median": 62423,
"sampleDeviation": null,
"cv": null,
@@ -79,15 +81,17 @@
"products": [
"MR003",
"MR004",
"MR005"
"MR005",
"MR018",
"MR019"
],
"n": 3,
"mean": 41693.0,
"median": 49069,
"sampleDeviation": 28111.375064197768,
"cv": 0.6742468775141575,
"meanWithoutLargest": 29849.0,
"largestRemovalDrop": 0.2840764636749574
"n": 5,
"mean": 51674.8,
"median": 65381,
"sampleDeviation": 24138.82269705795,
"cv": 0.4671294847209461,
"meanWithoutLargest": 47627.5,
"largestRemovalDrop": 0.07832250923080501
},
{
"category": "C09",
@@ -105,7 +109,7 @@
"median": 20483,
"sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0,
"meanWithoutLargest": 20046,
"largestRemovalDrop": 0.20920581320120557
}
]
@@ -127,9 +131,27 @@
"MR012",
"MR015",
"MR016",
"AI016"
"AI016",
"MR017",
"MR018",
"MR019"
],
"groups": [
{
"category": "C02",
"provider": "Stripe",
"model": "模型网关订阅与用量",
"products": [
"MR017"
],
"n": 1,
"mean": 78764,
"median": 78764,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C06",
"provider": "Stripe",
@@ -146,7 +168,7 @@
"median": 40112,
"sampleDeviation": 174786.78744916618,
"cv": 1.5505509613536426,
"meanWithoutLargest": 34815.0,
"meanWithoutLargest": 34815,
"largestRemovalDrop": 0.6911526751687284
},
{
@@ -157,7 +179,7 @@
"MR001"
],
"n": 1,
"mean": 53101.0,
"mean": 53101,
"median": 53101,
"sampleDeviation": null,
"cv": null,
@@ -172,7 +194,7 @@
"MR002"
],
"n": 1,
"mean": 62423.0,
"mean": 62423,
"median": 62423,
"sampleDeviation": null,
"cv": null,
@@ -186,15 +208,17 @@
"products": [
"MR003",
"MR004",
"MR005"
"MR005",
"MR018",
"MR019"
],
"n": 3,
"mean": 41693.0,
"median": 49069,
"sampleDeviation": 28111.375064197768,
"cv": 0.6742468775141575,
"meanWithoutLargest": 29849.0,
"largestRemovalDrop": 0.2840764636749574
"n": 5,
"mean": 51674.8,
"median": 65381,
"sampleDeviation": 24138.82269705795,
"cv": 0.4671294847209461,
"meanWithoutLargest": 47627.5,
"largestRemovalDrop": 0.07832250923080501
},
{
"category": "C09",
@@ -212,7 +236,7 @@
"median": 20483,
"sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0,
"meanWithoutLargest": 20046,
"largestRemovalDrop": 0.20920581320120557
}
]
+167 -9
View File
@@ -4,7 +4,7 @@ from collections import Counter
import json
from pathlib import Path
import sys
from urllib.parse import urlsplit
from urllib.parse import parse_qs, urlsplit
ROOT = Path(__file__).resolve().parents[1]
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
@@ -16,6 +16,20 @@ SOURCE_SLUG = 'ai-company-research/02-coverage-and-gaps'
BASE = '/docs/other-research/ai-company-research/overview'
def yc_coverage_summary():
companies = {row['id']: row for row in DATA['companies']}
imported = [companies[f"YC{row['ycId']}"] for row in AUDIT['records'] if row['status'] == 'imported']
profile_only = sum(bool(row.get('profileOnly')) for row in imported)
groups = {}
for label, prefix in [('unclearAI', 'YC-AI-UNCLEAR-'), ('missingYear', 'YC-MISSING-YEAR-'),
('dateConflict', 'YC-DATE-CONFLICT-')]:
rows = [row for batch in DATA.get('candidateBatches', []) if batch['id'].startswith(prefix)
for row in batch['records']]
groups[label] = {'records': len(rows), 'statuses': dict(sorted(Counter(row['status'] for row in rows).items()))}
return {'initialProfiles': len(AUDIT['records']), 'originalImported': len(imported),
'profileOnly': profile_only, 'deepenedOriginal': len(imported) - profile_only, **groups}
def source_register():
sources = SOURCES['sources']
@@ -26,6 +40,63 @@ def source_register():
for source in sources:
assert source['status'] in {'candidate-audit', 'targeted', 'pending', 'context', 'access-limited', 'per-company'}
assert source['boundary'] and source['nextCheck']
if source['id'] == 'S01':
assert source.get('coverageSummary') == yc_coverage_summary(), 'YC source summary is stale'
if source['id'] == 'S14' and source.get('rosterAudit'):
roster = source['rosterAudit']
batch = next(batch for batch in DATA['candidateBatches'] if batch['id'] == roster['candidateBatchId'])
assert batch['sourceId'] == 'S14' and not batch['complete']
assert roster['defaultCohort'] == 'SR007' and roster['next'] is None
assert len(batch['records']) == roster['recordCount'] == roster['reportedCount']
assert len({row['url'] for row in batch['records']}) == roster['recordCount']
if source['id'] == 'S14' and source.get('publicRosterAudits'):
audits = source['publicRosterAudits']
assert {a['cohort'] for a in audits} == {f'SR{i:03d}' for i in range(1, 8)}
assert len(audits) == 7
profile_urls = []
for audit in audits:
batch = next(b for b in DATA['candidateBatches'] if b['id'] == audit['candidateBatchId'])
query = parse_qs(urlsplit(audit['url']).query)
assert urlsplit(audit['url']).netloc == 'speedrun.a16z.com'
assert query == {'limit': ['100'], 'offset': ['0'], 'cohort': [audit['cohort']]}
assert urlsplit(audit['url']).path == '/api/companies/companyparams'
assert audit['httpStatus'] == 200 and audit['accessedAt'] <= DATA['asOf']
assert audit['next'] is None and audit['previous'] is None
assert len(batch['records']) == audit['recordCount'] == audit['reportedCount']
assert audit['uniquePublicIds'] == audit['uniqueSlugs'] == audit['recordCount']
assert len(audit['responseSha256']) == 64 and all(c in '0123456789abcdef' for c in audit['responseSha256'])
assert batch['sourceId'] == 'S14' and not batch['complete']
proof = next(e for e in DATA['evidence'] if e['id'] == audit['evidenceId'])
assert proof['url'] == audit['url'] and proof['companyId'] is None
profile_urls.extend(row['url'] for row in batch['records'])
assert len(profile_urls) == len(set(profile_urls)), 'duplicate profiles across public cohorts'
if source['id'] == 'S14' and source.get('profileAudit'):
audit = source['profileAudit']
entries = audit['records']
candidates = {row['id']: row for b in DATA['candidateBatches'] for row in b['records']}
evidence_by_id = {row['id']: row for row in DATA['evidence']}
assert len(entries) == audit['recordCount'] == audit['readable']
assert audit['failed'] == 0 and len({e['candidateId'] for e in entries}) == len(entries)
years = Counter(str(e['foundedYear']) for e in entries)
assert dict(years) == audit['yearCounts']
for entry in entries:
row = candidates[entry['candidateId']]
proof = evidence_by_id[entry['evidenceId']]
assert row['profileStatus'] == 'readable' and proof['url'] == row['url']
assert entry['evidenceId'] in row['evidenceIds']
assert isinstance(entry['foundedYear'], int) and 1800 <= entry['foundedYear'] <= int(DATA['asOf'][:4])
assert isinstance(entry['teamSize'], int) and entry['teamSize'] >= 0
assert len(entry['htmlSha256']) == 64 and all(c in '0123456789abcdef' for c in entry['htmlSha256'])
if entry['foundedYear'] not in [2025, 2026]:
assert row['status'] == 'outside-cohort' and not row.get('companyId')
assert sum(e['foundedYear'] not in [2025, 2026] for e in entries) == audit['outsideTargetYears']
assert sum(e['foundedYear'] in [2025, 2026] for e in entries) == audit['targetYearSelfReports']
assert sum(candidates[e['candidateId']]['status'] == 'linked' for e in entries) == audit['newLinkedCompanies']
assert sum(candidates[e['candidateId']]['status'] == 'pending-ai' for e in entries) == audit['pendingCurrentAIOrIdentity']
assert sum(candidates[e['candidateId']]['status'] == 'pending-year' for e in entries) == audit['pendingFoundingIdentity']
assert audit['targetYearSelfReports'] == (
audit['newLinkedCompanies'] + audit['pendingCurrentAIOrIdentity'] + audit['pendingFoundingIdentity']
), 'self-reported years must retain unresolved brand/entity dates'
if source['url']:
assert urlsplit(source['url']).scheme in {'http', 'https'} and urlsplit(source['url']).netloc
for host in source['hosts']:
@@ -49,7 +120,18 @@ def source_register():
company_ids = {e['companyId'] for e in evidence if e['companyId']}
observations = sum(o['evidence'] in evidence_ids for o in DATA['observations'])
name = f"[{source['name']}]({source['url']})" if source['url'] else source['name']
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {source['boundary']} | {source['nextCheck']} |")
boundary = source['boundary']
if source['id'] == 'S01':
summary = source['coverageSummary']
boundary += (f" 当前原候选池{summary['initialProfiles']}项,原新增{summary['originalImported']}项中"
f"{summary['profileOnly']}项目录基础资料、{summary['deepenedOriginal']}项深化个案。")
for key, label in [('unclearAI', '原AI不明'), ('missingYear', '原缺年'), ('dateConflict', '原日期冲突')]:
group = summary[key]
labels = {'linked': '关联公司', 'pending-ai': 'AI主营待核',
'pending-year': '成立/主体待核', 'outside-cohort': '范围外'}
counts = '、'.join(f'{labels.get(status, status)} {count}' for status, count in group['statuses'].items())
boundary += f" {label}{group['records']}项当前:{counts}。"
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {boundary} | {source['nextCheck']} |")
lines += ['', 'Dealroom、Product Hunt、机器之心与36氪入口于2026-10-30重新访问。官网首页可读不证明已取得完整筛选结果;安全检测页面不作为文章内容。候选池之外的来源边界尚需建立可回查条目,完成前不宣布来源清单已全部覆盖。', '']
return lines
@@ -85,7 +167,7 @@ def candidate_register():
all_ids = [r['id'] for b in batches for r in b['records']]
assert len(set(all_ids)) == len(all_ids)
lines = []
statuses = {'pending-year': '成立年待核', 'linked': '已关联公司',
statuses = {'pending-year': '成立年待核', 'pending-ai': 'AI主营待核', 'linked': '已关联公司',
'existing-outside-cohort': '已有关联,年份范围外',
'outside-cohort': '范围外或既有业务,不新增公司', 'promotion': '推广活动,非公司新增'}
for batch in batches:
@@ -97,13 +179,13 @@ def candidate_register():
assert sum(counts.values()) == len(rows)
lines += ['', f"## {batch['sourceId']} 候选核对({batch['id']})", '',
f"[发现页面]({batch['url']}),读取于{batch['accessedAt']}。{batch['scope']}", '',
f"本批{len(rows)}条:{counts['linked']}条关联公司、{counts['existing-outside-cohort']}条关联范围外对照、{counts['outside-cohort']}条范围外不新增公司、{counts['pending-year']}条成立年待核、{counts['promotion']}条推广活动。完整来源覆盖标记:{'已完成' if batch['complete'] else '未完成'}。主营或产品任务的初分类不计入主公司表数量,只有已关联的公司记录进入对应公司明细。候选与缺项随研究版本保存在文档站同一SQLite的研究元数据中。", '',
f"本批{len(rows)}条:{counts['linked']}条关联公司、{counts['existing-outside-cohort']}条关联范围外对照、{counts['outside-cohort']}条范围外不新增公司、{counts['pending-year']}条成立年待核、{counts['pending-ai']}条AI主营待核、{counts['promotion']}条推广活动。完整来源覆盖标记:{'已完成' if batch['complete'] else '未完成'}。主营或产品任务的初分类不计入主公司表数量,只有已关联的公司记录进入对应公司明细。候选与缺项随研究版本保存在文档站同一SQLite的研究元数据中。", '',
'| 发现条目 | 主营或产品任务初分 | 核对状态 | 关联公司 | 核对证据 | 限制 |', '| --- | --- | --- | --- | --- | --- |']
for row in rows:
assert row['categoryId'] in categories
assert row['status'] in statuses
assert all(eid in evidence for eid in row.get('evidenceIds', []))
if row['status'] == 'outside-cohort':
if row['status'] in {'outside-cohort', 'pending-ai'}:
assert row.get('evidenceIds') and not row.get('companyId')
name = f"[{row['name']}]({row['url']})" if row['url'] else row['name']
company = '—'
@@ -121,6 +203,73 @@ def candidate_register():
return lines
def uncertain_ai_followups():
original = {row['source']: row for row in AUDIT['records'] if row['status'] == 'ai-unclear'}
rows = [row for batch in DATA.get('candidateBatches', [])
if batch['id'].startswith('YC-AI-UNCLEAR-') for row in batch['records']]
if not rows:
return []
urls = [row['url'] for row in rows]
assert len(set(urls)) == len(urls), 'Duplicate AI-relevance follow-up URL'
rechecks = {row['ycId']: row for row in DATA.get('directoryRechecks', [])}
counts = Counter(row['status'] for row in rows)
assert set(counts) <= {'linked', 'pending-ai'}
for row in rows:
assert row['url'] in original and row.get('evidenceIds')
baseline = original[row['url']]
if row['status'] == 'linked':
assert rechecks[baseline['ycId']]['companyId'] == row['companyId']
else:
assert not row.get('companyId') and baseline['ycId'] not in rechecks
unread = len(original) - len(rows)
assert unread >= 0
return ['', '## 原AI主营不明名单的后续覆盖', '',
f"原名单{len(original)}项,已有后续记录{len(rows)}项:{counts['linked']}项关联公司、{counts['pending-ai']}项仍待核AI主营,{unread}项尚无本批系列的后续记录。计数按原始YC页面去重;EveryApp此前的独立复核与末批记录不重复相加。", '',
'读完名单只证明每项有可回查的处理记录,不证明待核项均为AI公司,也不证明法律主体、收入或技术效果已独立验证。原始发现审计保持不变,成立年范围外/缺失、成立冲突及其他来源的缺项仍单列。', '']
def founding_year_followups():
original = {row['source']: row for row in AUDIT['records'] if row['status'] == 'date-conflict'}
rows = [row for batch in DATA.get('candidateBatches', [])
if batch['id'].startswith('YC-DATE-CONFLICT-') for row in batch['records']]
urls = [row['url'] for row in rows]
assert len(set(urls)) == len(urls), 'Duplicate founding-conflict follow-up URL'
assert set(urls) <= set(original), 'Unknown founding-conflict follow-up URL'
evidence = {row['id']: row for row in DATA['evidence']}
for row in rows:
assert row['status'] == 'pending-year' and not row.get('companyId')
assert row.get('evidenceIds') and all(eid in evidence for eid in row['evidenceIds'])
assert all(evidence[eid]['companyId'] is None for eid in row['evidenceIds'])
unread = len(original) - len(rows)
return ['', '## 原成立年冲突名单的后续覆盖', '',
f"原名单{len(original)}项,已有后续记录{len(rows)}项,均保留成立年待核,{unread}项尚无本系列后续记录。当前业务初分类见候选表,不计入主公司表及收入统计。", '',
'2025旧品牌发布可证明当时已有产品材料;当前目录的2026字段、批次、条款日期和运营者名称不能单独证明法律主体成立或新旧主体连续。未据此强改成立年。Layers当前AI核心仍另待核。', '']
def missing_year_followups():
original = {row['source'] for row in AUDIT['records']
if row['status'] == 'year-outside-or-missing' and row['foundedYear'] is None}
rows = [row for batch in DATA.get('candidateBatches', [])
if batch['id'].startswith('YC-MISSING-YEAR-') for row in batch['records']]
urls = [row['url'] for row in rows]
assert len(set(urls)) == len(urls) and set(urls) <= original
evidence = {e['id']: e for e in DATA['evidence']}
for row in rows:
assert row['status'] in {'outside-cohort', 'pending-year', 'pending-ai', 'linked'}
assert row.get('evidenceIds')
if row['status'] == 'linked':
matches = [r for r in DATA.get('directoryRechecks', []) if r['companyId'] == row.get('companyId')]
assert len(matches) == 1 and matches[0]['originalStatus'] == 'year-outside-or-missing'
assert all(eid in evidence and evidence[eid]['companyId'] == row['companyId'] for eid in row['evidenceIds'])
else:
assert not row.get('companyId')
assert all(eid in evidence and evidence[eid]['companyId'] is None for eid in row['evidenceIds'])
counts = Counter(row['status'] for row in rows)
return ['', '## 原缺少成立年名单的后续覆盖', '',
f"原快照缺年{len(original)}项,外部补核已有{len(rows)}项:{counts['linked']}项关联公司、{counts['outside-cohort']}项既有主体/业务范围外、{counts['pending-year']}项成立年待核、{counts['pending-ai']}项AI主营待核,{len(original)-len(rows)}项尚无本系列外部补核记录。", '',
'当前YC缺年字段复读与外部主体核查分别统计。登记创建日、公司自述年、产品发布、条款更新和批次不相互替代;已查英国或法国主体不代表完整集团沿革已核。原54项发现计数不回写,也不以Inactive补零收入。', '']
def directory_rechecks():
rows = DATA.get('directoryRechecks', [])
if not rows:
@@ -167,6 +316,11 @@ def render():
'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'])
excluded_years = Counter(x['foundedYear'] for x in AUDIT['records'] if x['status'] == 'year-outside-or-missing')
outside_years = {year: count for year, count in excluded_years.items() if year is not None}
assert all(year not in (2025, 2026) for year in outside_years)
assert sum(outside_years.values()) + excluded_years[None] == cov['otherOrMissingYear']
year_detail = '、'.join(f'{year}年{count}项' for year, count in sorted(outside_years.items()))
lines = ['# AI 公司资料覆盖与缺项', '',
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
'验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '',
@@ -178,7 +332,8 @@ def render():
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |",
f"| 原目录列为范围外年份 | {sum(outside_years.values())} | {year_detail};原快照自述,不是独立登记核验 |",
f"| 原目录未给成立年 | {excluded_years[None]} | 与已列范围外年份分开,仍待补证 |",
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
@@ -191,8 +346,8 @@ def render():
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['self']} | 含公司或交易当事方披露、历史、下界及币种不明,不等于可比ARR |",
f"| 仅平台收入线索 | {income['platform']} | 同时有企业披露者计入上一行,不重复计数 |",
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
'## 业务分类与数据入口', '',
@@ -217,13 +372,16 @@ def render():
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 += uncertain_ai_followups()
lines += founding_year_followups()
lines += missing_year_followups()
lines += directory_rechecks()
lines += source_register()
lines += context_methods()
lines += candidate_register()
lines += ['', '## 尚未满足的研究条件', '',
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
'下一轮优先补收入线索的日期及合同定义、成立年冲突、AI主营仍待核项、非YC和非英文来源,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
]
return '\n'.join(lines)
+29
View File
@@ -116,6 +116,17 @@ def main():
mode.add_argument('--revision', type=int)
reading.add_argument('--limit', type=int, default=50)
reading.add_argument('--offset', type=int, default=0)
sections = sub.add_parser('outline', help='Read chapter titles and internal summaries without bodies')
sections.add_argument('slug')
sections.add_argument('--revision', type=int)
section = sub.add_parser('sections', help='Read selected chapter bodies')
section.add_argument('slug')
section.add_argument('ids', nargs='+')
section.add_argument('--revision', type=int)
editing = sub.add_parser('edit-sections', help='Atomically apply chapter operations from private JSON')
editing.add_argument('slug')
editing.add_argument('file')
editing.add_argument('--request-id')
saving = sub.add_parser('save')
saving.add_argument('file', help='Private JSON file or - for stdin; one document or {documents: [...]}')
saving.add_argument('--request-id', help='Reuse this id only with the same input when retrying')
@@ -135,6 +146,24 @@ def main():
elif args.revision is not None:
path += '?' + urllib.parse.urlencode({'revision': args.revision})
result = request(base, token, 'GET', path)
elif args.command in ('outline', 'sections', 'edit-sections'):
path = '/api/documents/sections/' + urllib.parse.quote(args.slug, safe='/')
if args.command == 'edit-sections':
payload = json.load(sys.stdin) if args.file == '-' else json.loads(Path(args.file).read_text())
request_id = args.request_id or str(uuid.uuid4())
if not request_id or len(request_id) > 100 or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-' for c in request_id):
raise ValueError('Invalid request id')
print('Request id: ' + request_id, file=sys.stderr)
result = request(base, token, 'POST', path, payload, request_id)
else:
query = []
if args.revision is not None:
query.append(('revision', args.revision))
if args.command == 'sections':
query.extend(('section', section_id) for section_id in args.ids)
if query:
path += '?' + urllib.parse.urlencode(query)
result = request(base, token, 'GET', path)
else:
payload = json.load(sys.stdin) if args.file == '-' else json.loads(Path(args.file).read_text())
request_id = args.request_id or str(uuid.uuid4())
+22 -3
View File
@@ -89,23 +89,42 @@ def link(cid):
def render():
points, excluded = sample(7)
grouped = groups(points)
described = [(key, stats(values)) for key, values in sorted(grouped.items())]
sizes = [value['n'] for _, value in described]
eligible = [(key, value) for key, value in described if value['n'] >= 3]
interpretation = '各组均不足3个样本,暂不比较分组离散度或收入高低。'
if eligible:
highest_key, highest = max(eligible, key=lambda item: item[1]['mean'])
sensitive_key, sensitive = max(eligible, key=lambda item: item[1]['largestRemovalDrop'] or 0)
cvs = [value['cv'] for _, value in eligible if value['cv'] is not None]
cv_text = f"CV范围为{min(cvs):.3f}—{max(cvs):.3f}。" if cvs else '各组CV均不可计算。'
interpretation = (
f"在至少3个样本的组内,当前均值最高的是{CATEGORIES[highest_key[0]]} · {highest_key[2]}"
f"({highest_key[1]},{money(highest['mean'])}美元每月)。{cv_text}"
f"剔除最大值后均值下降最多的是{CATEGORIES[sensitive_key[0]]} · {sensitive_key[2]}"
f"({sensitive_key[1]},下降{sensitive['largestRemovalDrop'] or 0:.1%})。"
'这些只是当前采集点的描述,不据微小CV差异宣称某领域更稳定。'
)
size_text = f"各组目前只有{min(sizes)}至{max(sizes)}个产品" if sizes else '当前没有可比较的产品'
lines = [
'# AI 公司收入样本分布快照', '',
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。", '',
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。按业务、支付平台和收费模式分别计算均值、中位数与离散度。{size_text},尚不足以估计行业总体或造假概率。", '',
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
'## 7日窗口的分组结果', '',
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
'| --- | --- | --- | --- | --- | --- | --- |',
]
for key, values in sorted(groups(points).items()):
for key, values in sorted(grouped.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仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '',
interpretation, '',
'单个样本不计算离散度,也不能作为典型收入或领域排名;不同收费模式和支付平台的分组保留各自含义。', '',
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
'## 样本与证据入口', '',
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
+25
View File
@@ -4,6 +4,7 @@ import io
import json
from pathlib import Path
import tempfile
import sys
import unittest
from unittest.mock import patch
import urllib.error
@@ -15,6 +16,30 @@ spec.loader.exec_module(client)
class ClientTests(unittest.TestCase):
def test_section_reads_select_bodies_and_revision_without_full_document_fetch(self):
argv = ['client', '--url', 'https://docs.example', '--key-file', '/private/key',
'sections', 'endor/example', 'preamble', 's_one', '--revision', '3']
with patch.object(sys, 'argv', argv), patch.object(client, 'read_token', return_value='test-token'), \
patch.object(client, 'request', return_value={'revision': 3, 'sections': []}) as request, \
patch.object(sys, 'stdout', io.StringIO()):
client.main()
request.assert_called_once_with('https://docs.example', 'test-token', 'GET',
'/api/documents/sections/endor/example?revision=3&section=preamble&section=s_one')
def test_section_edit_keeps_payload_and_retry_id(self):
payload = {'revision': 3, 'operations': [{'op': 'update', 'id': 's_one', 'summary': 'Internal summary'}]}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'edit.json'
path.write_text(json.dumps(payload))
argv = ['client', '--url', 'https://docs.example', '--key-file', '/private/key',
'edit-sections', 'endor/example', str(path), '--request-id', 'edit-1']
with patch.object(sys, 'argv', argv), patch.object(client, 'read_token', return_value='test-token'), \
patch.object(client, 'request', return_value={'revision': 4}) as request, \
patch.object(sys, 'stdout', io.StringIO()), patch.object(sys, 'stderr', io.StringIO()):
client.main()
request.assert_called_once_with('https://docs.example', 'test-token', 'POST',
'/api/documents/sections/endor/example', payload, 'edit-1')
def test_only_tls_or_loopback_origins_are_accepted(self):
self.assertEqual(client.server_url('https://docs.example/'), 'https://docs.example')
self.assertEqual(client.server_url('http://127.0.0.1:18135'), 'http://127.0.0.1:18135')