Compare commits

...

5 Commits

Author SHA1 Message Date
codex-bot 157144b3f9 Correct research review references and advance the data revision
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 6m46s
2026-10-30 03:35:17 +08:00
codex-bot 3cfce44a25 Align research workbench breakpoints with document navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Waiting to run
2026-10-30 03:34:05 +08:00
Zayden-Jung e91255856c Merge pull request '新增 AI 公司研究工作台并共用文档站数据库' (#6) from codex/ai-company-research into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 2m26s
Reviewed-on: #6
2026-10-29 19:31:59 +00:00
codex-bot 1250b674b0 Add AI company research workbench backed by the shared site database
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m57s
2026-10-30 03:25:38 +08:00
Zayden-Jung 074881b650 Merge pull request '补充 Agent 编程边界与 LangGraph / DSPy 研究草案' (#5) from codex/agent-composition into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m7s
Reviewed-on: #5
2026-10-28 14:28:52 +00:00
18 changed files with 3993 additions and 13 deletions
+27
View File
@@ -80,6 +80,33 @@ AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=fal
`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。
## AI 公司研究
导航入口为 **其他研究 → AI 公司研究**,页面路径是
`/docs/other-research/ai-company-research/overview`。工作台提供业务分类、公司搜索与筛选、
公司明细、收入观测、原始证据和数据核查记录。所有页面与
`GET /api/research/ai-companies` 均使用文档站现有的登录认证。
研究数据与登录状态**共用文档站现有的 SQLite 数据库文件**:
`AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`。该文件名是历史名称,未创建第二个数据库。
`login_state` 保留登录限流与重放状态,新增的 `research_*` 表保存数据版本、分类、
公司、经营指标、证据与核查记录。沿用现有 `/auth` 持久挂载,数据库不会写入镜像或公开目录。
`content/research/ai-companies.seed.json` 是可评审的研究导入快照,
服务端首次读取该版本时将它以事务导入同一数据库,之后从数据库查询记录。
导入使用外键校验,不删除登录数据,也不覆盖旧研究版本。每次更新快照必须修改 `version`;
同一版本内容变化会拒绝导入,避免静默读取旧数据。回退代码时继续读取对应旧版本。
所有修改都经过工作分支和 PR,部署仍等待人工合并主线。
首批资料包含 54 家公司、10 类业务、87 条证据记录和 63 条经营观测。
它是定向试样,收入披露不足且统计时点不统一,不代表全部 AI 公司。
界面将公司自述、第三方估算、融资、客户收入和履历收入分开,未知收入不记为零。
当前尚不能可靠计算领域 ARR 均值、离散排名或财务造假概率。
`pnpm test:research` 在远程 Runner 上验证同库持久化、重复读取、版本导入与回退、
登录限流保留、引用完整性及收入排除规则。镜像冒烟检查会验证研究页面与 API 的认证、
返回记录数量、只读方法和容器重启后的读取结果。页面筛选交互仍需实际浏览器验证。
## CI/CD 与缓存
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
+2 -1
View File
@@ -10,7 +10,8 @@
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
"lint": "eslint . --max-warnings=0",
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
"test:auth": "node scripts/test-auth.mjs"
"test:auth": "node scripts/test-auth.mjs",
"test:research": "node scripts/test-research.mjs"
},
"dependencies": {
"next": "16.4.3",
+16 -1
View File
@@ -160,7 +160,7 @@ async function main() {
path,
);
}
for (const path of ["/api/private", "/api/unknown", "/api/search"])
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"])
assert.equal((await request(path)).status, 401, path);
assert.equal(
(
@@ -181,6 +181,21 @@ async function main() {
const headers = {
Authorization: "Bearer " + signSession(config, undefined, 600),
};
const researchResponse = await request("/api/research/ai-companies", { headers });
assert.equal(researchResponse.status, 200, "authenticated research API");
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
const research = await researchResponse.json();
assert.equal(research.companies.length, 54);
assert.equal(research.categories.length, 10);
assert.equal(research.evidence.length, 87);
assert.equal(research.observations.length, 63);
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
assert.equal(researchPage.status, 200);
const researchHtml = await researchPage.text();
assert.ok(researchHtml.includes('data-category="其他研究"'));
assert.ok(researchHtml.includes("AI 公司研究"));
assert.ok(researchHtml.includes("正在读取研究数据库"));
const searchResponse = await request("/api/search", { headers });
assert.equal(searchResponse.status, 200);
const searchIndex = await searchResponse.json();
+14
View File
@@ -0,0 +1,14 @@
import { build } from "esbuild";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-research-tests-"));
try {
const file = join(dir, "research.test.cjs");
await build({ entryPoints: ["tests/research.test.ts"], bundle: true,
platform: "node", target: "node22", format: "cjs", outfile: file });
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
process.exitCode = result.status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); }
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
categorySummary, evidenceUrl, type ResearchEvidence, type ResearchSnapshot,
} from "@/lib/ai-research";
const pageSize = 12;
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", unknown: "本轮未找到收入数字" };
const useLabels: Record<string, string> = {
signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
"historical-estimate": "历史估算", excluded: "排除收入比较", scale: "团队规模",
funding: "融资", crosscheck: "同一里程碑的其他资料",
};
const money = (value: number, unit = "USD") => unit === "USD"
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)
: `${new Intl.NumberFormat("zh-CN").format(value)} ${unit}`;
function External({ url, children }: { url: string; children: React.ReactNode }) {
const safe = evidenceUrl(url);
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{children} ↗</a> : <span>{children}</span>;
}
function Evidence({ item, prefix = "evidence" }: { item: ResearchEvidence; prefix?: string }) {
return <li id={`${prefix}-${item.id}`} className="research-evidence">
<div><code>{item.id}</code><span className="research-tag">{item.kind}</span></div>
<strong><External url={item.url}>{item.title}</External></strong>
<p>{item.summary}</p><p className="research-muted">{item.limitation}</p>
<small>发布:{item.publishedAt ?? "未注明"} 读取:{item.accessedAt}</small>
</li>;
}
export default function AICompanyResearch() {
const [data, setData] = useState<ResearchSnapshot | null>(null);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [cohort, setCohort] = useState("");
const [income, setIncome] = useState("");
const [page, setPage] = useState(0);
const [selected, setSelected] = useState<string | null>(null);
const [tab, setTab] = useState<"companies" | "revenue" | "sources" | "reviews">("companies");
const companySection = useRef<HTMLElement>(null);
const detailSection = useRef<HTMLElement>(null);
useEffect(() => {
const controller = new AbortController();
fetch("/api/research/ai-companies", { signal: controller.signal, cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("Research unavailable");
const snapshot: ResearchSnapshot = await response.json();
setData(snapshot);
}).catch(() => { if (!controller.signal.aborted) setError(true); });
return () => controller.abort();
}, [attempt]);
useEffect(() => { if (selected) detailSection.current?.focus(); }, [selected]);
const summaries = useMemo(() => data ? categorySummary(data) : [], [data]);
const filtered = useMemo(() => data?.companies.filter((c) => {
const search = `${c.name} ${c.business} ${c.location ?? ""} ${c.buyer}`.toLocaleLowerCase();
const yearMatch = !cohort || (cohort === "other" ? ![2025, 2026].includes(c.year ?? 0) : c.year === Number(cohort));
return (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
&& search.includes(query.trim().toLocaleLowerCase());
}) ?? [], [data, query, category, cohort, income]);
if (error) return <div className="research-message" role="alert">研究数据库暂时不可用。<button onClick={() => { setError(false); setAttempt((n) => n + 1); }}>重新读取</button></div>;
if (!data) return <p className="research-message" role="status">正在读取研究数据库…</p>;
const categories = new Map(data.categories.map((c) => [c.id, c]));
const selectedCompany = data.companies.find((c) => c.id === selected);
const observed = data.observations.filter((o) => ["signal", "estimate"].includes(o.selection));
const revenueCompanies = new Set(observed.map((o) => o.company)).size;
const selectedObservations = data.observations.filter((o) => o.company === selected);
const selectedEvidenceIds = new Set([
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
...selectedObservations.map((o) => o.evidence),
]);
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
function chooseCategory(id: string) {
setCategory(id); setPage(0); setTab("companies");
companySection.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
return <div className="ai-research not-prose">
<div className="research-meta"><span>资料截至 {data.asOf}</span><span>首批研究 · 持续补全</span></div>
<div className="research-stats">
<div><strong>{data.companies.length}</strong><span>已收录公司</span></div>
<div><strong>{data.categories.length}</strong><span>业务类别</span></div>
<div><strong>{revenueCompanies}</strong><span>找到收入信号的公司</span></div>
<div><strong>{data.evidence.length}</strong><span>证据记录</span></div>
</div>
<div className="research-scope"><p>{data.scope}</p>
<details><summary>数据口径与覆盖限制</summary><ul>{data.limitations.map((note) => <li key={note}>{note}</li>)}</ul>
<p>来源以 YC、公司官网、投资机构名录与经营案例为主。同一披露的转载不构成独立验证。团队人数包含公司自报的 Team Size,与正式员工口径可能不同。</p>
</details>
</div>
<section aria-labelledby="research-categories-title" className="research-section">
<h2 id="research-categories-title">业务分类</h2><p className="research-muted">点击类别查看对应公司。数量反映本次采集覆盖,不是市场份额。</p>
<div className="research-categories">{summaries.map((c) => <button key={c.id} onClick={() => chooseCategory(c.id)} aria-label={`查看${c.name}的${c.count}家公司`}>
<div><span>{c.name}</span><strong>{c.count}</strong></div>
<span className="research-bar" aria-hidden="true"><i style={{ width: `${c.count / Math.max(1, ...summaries.map((s) => s.count)) * 100}%` }} /></span>
<small>{c.description}</small>
</button>)}</div>
</section>
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
<h2 id="research-records-title">研究数据</h2>
<div className="research-tabs" role="group" aria-label="研究数据视图">
{([['companies','公司数据'],['revenue','收入观测'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
</div>
{tab === "companies" && <>
<div className="research-filters">
<label className="research-query">搜索公司<input type="search" value={query} placeholder="名称、业务、地区或买家" onChange={(e) => { setQuery(e.target.value); setPage(0); }} /></label>
<label>业务类别<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}><option value="">全部类别</option>{data.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
<label>成立或发布<select value={cohort} onChange={(e) => { setCohort(e.target.value); setPage(0); }}><option value="">全部组别</option><option value="2025">2025 年成立</option><option value="2026">2026 年成立</option><option value="other">公开发布与转型对照</option></select></label>
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="unknown">本轮未找到数字</option></select></label>
</div>
<div className="research-result-count" role="status">{filtered.length} 家匹配公司<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setPage(0); }}>清除筛选</button></div>
<div className="research-table-wrap"><table><caption className="sr-only">公司分类与经营资料。选择公司名称查看指标和原始证据。</caption><thead><tr><th>公司 / 地区</th><th>业务与买家</th><th>成立 / 发布组别</th><th>团队规模</th><th>收入资料</th></tr></thead><tbody>
{filtered.slice(page * pageSize, (page + 1) * pageSize).map((c) => <tr key={c.id} data-selected={selected === c.id || undefined}>
<td><button className="research-company-name" aria-expanded={selected === c.id} aria-controls="research-company-detail" onClick={() => setSelected(c.id)}>{c.name}</button><small>{c.location ?? "地区未注明"}</small></td>
<td><span className="research-tag">{categories.get(c.cat)?.name}</span><p>{c.business}</p><small>买家:{c.buyer}</small></td>
<td>{c.cohort}</td><td>{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{revenueLabels[c.revenueStatus]}</td>
</tr>)}
</tbody></table></div>
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
</>}
{tab === "revenue" && <>
<p className="research-notice">目前找到 {revenueCompanies} 家公司的收入信号。大多是缺少日历时点的历史里程碑,且 ARR 与年化收入尚未统一。领域均值和离散度暂不计算。</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>{money(o.value, o.unit)}</strong></div>
<span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(...observed.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></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.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.map((e) => <Evidence key={e.id} item={e} />)}</ul></>}
{tab === "reviews" && <ul className="research-review-list">{data.reviews.map((r) => <li key={r.id}><span className="research-tag">{r.type}</span><h3>{r.company}</h3><p>{r.summary}</p><p>{r.handling}</p><div>{r.evidenceIds.map((id) => { const e=data.evidence.find((item) => item.id === id); return e ? <External key={id} url={e.url}>{id}</External> : null; })}</div></li>)}</ul>}
</section>
{selectedCompany && <section id="research-company-detail" ref={detailSection} tabIndex={-1} className="research-detail research-section" aria-label={`${selectedCompany.name}公司明细`}>
<div className="research-detail-heading"><h2>{selectedCompany.name}</h2><button onClick={() => setSelected(null)} aria-label="关闭公司明细">关闭</button></div>
<p><External url={selectedCompany.url}>公司资料入口</External> {selectedCompany.extraUrl && <External url={selectedCompany.extraUrl}>额外核验入口</External>}</p>
<dl className="research-company-facts"><div><dt>主营业务</dt><dd>{selectedCompany.business}</dd></div><div><dt>样本组别</dt><dd>{selectedCompany.cohort}</dd></div><div><dt>买家</dt><dd>{selectedCompany.buyer}</dd></div><div><dt>收费方式</dt><dd>{selectedCompany.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>技术水平</dt><dd>尚未独立测试,不据融资或收入作能力排名。</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
<h3>经营指标与排除记录</h3>
{selectedObservations.length ? <div className="research-table-wrap"><table><thead><tr><th>指标 / 归属</th><th>原始数值</th><th>数据时点</th><th>用途及限制</th><th>证据</th></tr></thead><tbody>{selectedObservations.map((o) => <tr key={o.id}><td>{o.kind}<small>{o.scope}</small></td><td>{o.bound !== "=" && o.bound}{money(o.value,o.unit)}</td><td>{o.timing}</td><td>{useLabels[o.selection] ?? o.selection}<small>{o.reason}</small></td><td><a href={`#detail-evidence-${o.evidence}`}>{o.evidence}</a></td></tr>)}</tbody></table></div> : <p>本轮尚未找到可记录的量化经营指标。收入保持未知。</p>}
<h3>原始证据</h3><ul className="research-evidence-list">{selectedEvidence.map((e) => <Evidence key={e.id} prefix="detail-evidence" item={e} />)}</ul>
</section>}
<p className="research-footnote">数据版本 {data.version}。当前为首批资料,覆盖、收入定义、财务透明度与商业模式评估将随核验补充。</p>
</div>;
}
+87
View File
@@ -0,0 +1,87 @@
import seed from "../../../../content/research/ai-companies.seed.json";
import { createHash } from "node:crypto";
import { openSiteDatabase } from "./site-database";
import { validateResearch, type ResearchSnapshot } from "./ai-research";
// Each release selects an immutable research revision. New revisions are added
// transactionally, preserving both earlier research and all authentication rows.
export function readResearchSnapshot(snapshot = seed as ResearchSnapshot): ResearchSnapshot {
validateResearch(snapshot);
const digest = createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
const db = openSiteDatabase();
try {
db.exec(`
CREATE TABLE IF NOT EXISTS research_datasets (
id TEXT PRIMARY KEY, as_of TEXT NOT NULL, metadata TEXT NOT NULL, content_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS research_categories (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_companies (
dataset_id TEXT NOT NULL, id TEXT NOT NULL, category_id TEXT NOT NULL,
name TEXT NOT NULL, founded_year INTEGER, team_size INTEGER,
revenue_status TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id),
FOREIGN KEY(dataset_id,category_id) REFERENCES research_categories(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_evidence (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, url TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_observations (
dataset_id TEXT NOT NULL, id TEXT NOT NULL, company_id TEXT NOT NULL,
evidence_id TEXT NOT NULL, kind TEXT NOT NULL, amount REAL NOT NULL,
unit TEXT NOT NULL, record_use TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id),
FOREIGN KEY(dataset_id,company_id) REFERENCES research_companies(dataset_id,id),
FOREIGN KEY(dataset_id,evidence_id) REFERENCES research_evidence(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_reviews (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
);
`);
// The database transaction also serializes first requests from two workers.
db.exec("BEGIN IMMEDIATE");
try {
const exists = db.prepare("SELECT content_hash FROM research_datasets WHERE id=?")
.get(snapshot.version) as { content_hash: string } | undefined;
if (exists && exists.content_hash !== digest)
throw new Error("Research data changed without a new revision identifier");
if (!exists) {
const { categories, companies, observations, evidence, reviews, ...metadata } = snapshot;
db.prepare("INSERT INTO research_datasets VALUES (?,?,?,?)")
.run(snapshot.version, snapshot.asOf, JSON.stringify(metadata), digest);
const insertCategory = db.prepare("INSERT INTO research_categories VALUES (?,?,?)");
for (const row of categories) insertCategory.run(snapshot.version, row.id, JSON.stringify(row));
const insertCompany = db.prepare("INSERT INTO research_companies VALUES (?,?,?,?,?,?,?,?)");
for (const row of companies) insertCompany.run(snapshot.version, row.id, row.cat,
row.name, row.year, row.team, row.revenueStatus, JSON.stringify(row));
const insertEvidence = db.prepare("INSERT INTO research_evidence VALUES (?,?,?,?)");
for (const row of evidence) insertEvidence.run(snapshot.version, row.id, row.url, JSON.stringify(row));
const insertObservation = db.prepare("INSERT INTO research_observations VALUES (?,?,?,?,?,?,?,?,?)");
for (const row of observations) insertObservation.run(snapshot.version, row.id, row.company,
row.evidence, row.kind, row.value, row.unit, row.selection, JSON.stringify(row));
const insertReview = db.prepare("INSERT INTO research_reviews VALUES (?,?,?)");
for (const row of reviews) insertReview.run(snapshot.version, row.id, JSON.stringify(row));
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
const meta = db.prepare("SELECT metadata FROM research_datasets WHERE id=?").get(snapshot.version) as { metadata: string };
// All displayed records come from the site database, not the seed arrays.
const rows = (table: string) => (db.prepare(
`SELECT payload FROM ${table} WHERE dataset_id=? ORDER BY id`,
).all(snapshot.version) as { payload: string }[]).map((r) => JSON.parse(r.payload));
const stored: ResearchSnapshot = { ...JSON.parse(meta.metadata),
categories: rows("research_categories"), companies: rows("research_companies"),
observations: rows("research_observations"), evidence: rows("research_evidence"),
reviews: rows("research_reviews") };
validateResearch(stored);
return stored;
} finally { db.close(); }
}
+79
View File
@@ -0,0 +1,79 @@
export type ResearchCategory = { id: string; name: string; description: string };
export type ResearchCompany = {
id: string; name: string; url: string; cat: string; year: number | null;
team: number | null; location: string | null; business: string; buyer: string;
delivery: string; notes: string; cohort: string; teamBasis: string | null;
price: string; stage: string; sourceType: string; evidenceIds: string[];
revenueStatus: "self" | "estimate" | "unknown";
extraUrl?: string; pricingEvidence?: string;
};
export type ResearchObservation = {
id: string; company: string; name: string; kind: string; value: number;
unit: string; timing: string; evidence: string; scope: string;
selection: string; reason: string; bound: string; comparisonGroup: string | null;
};
export type ResearchEvidence = {
id: string; companyId: string | null; title: string; url: string;
publishedAt: string | null; accessedAt: string; kind: string;
summary: string; limitation: string;
};
export type ResearchReview = {
id: string; type: string; company: string; summary: string;
evidenceIds: string[]; handling: string;
};
export type ResearchSnapshot = {
version: string; asOf: string; scope: string; limitations: string[];
categories: ResearchCategory[]; companies: ResearchCompany[];
observations: ResearchObservation[]; evidence: ResearchEvidence[];
reviews: ResearchReview[];
};
export function evidenceUrl(value: string): string | undefined {
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
} catch { return undefined; }
}
export function validateResearch(snapshot: ResearchSnapshot) {
const unique = (values: string[], label: string) => {
if (new Set(values).size !== values.length || values.some((v) => !v))
throw new Error(`Duplicate or missing research ${label}`);
return new Set(values);
};
if (!snapshot.version || !/^\d{4}-\d{2}-\d{2}$/.test(snapshot.asOf))
throw new Error("Invalid research dataset metadata");
const categories = unique(snapshot.categories.map((c) => c.id), "categories");
const companies = unique(snapshot.companies.map((c) => c.id), "companies");
const evidence = unique(snapshot.evidence.map((e) => e.id), "evidence");
unique(snapshot.observations.map((o) => o.id), "observations");
unique(snapshot.reviews.map((r) => r.id), "reviews");
for (const c of snapshot.companies) {
if (!categories.has(c.cat) || !evidenceUrl(c.url) ||
c.evidenceIds.some((id) => !evidence.has(id)) ||
(c.pricingEvidence && !evidence.has(c.pricingEvidence)))
throw new Error(`Invalid research company reference: ${c.id}`);
}
for (const e of snapshot.evidence) {
if (!evidenceUrl(e.url) || (e.companyId && !companies.has(e.companyId)))
throw new Error(`Invalid research evidence: ${e.id}`);
}
for (const o of snapshot.observations) {
if (!companies.has(o.company) || !evidence.has(o.evidence) ||
!Number.isFinite(o.value))
throw new Error(`Invalid research observation: ${o.id}`);
}
for (const r of snapshot.reviews)
if (r.evidenceIds.some((id) => !evidence.has(id)))
throw new Error(`Invalid research review: ${r.id}`);
}
export function categorySummary(snapshot: ResearchSnapshot) {
return snapshot.categories.map((category) => {
const companies = snapshot.companies.filter((c) => c.cat === category.id);
return { ...category, count: companies.length,
self: companies.filter((c) => c.revenueStatus === "self").length,
estimate: companies.filter((c) => c.revenueStatus === "estimate").length,
unknown: companies.filter((c) => c.revenueStatus === "unknown").length };
});
}
+2 -8
View File
@@ -1,14 +1,8 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { authId, matchingStep, type AuthConfig } from "./auth";
import { openSiteDatabase } from "./site-database";
function database() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
chmodSync(file, 0o600);
const db = openSiteDatabase();
db.exec(`PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS login_state (
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
+20
View File
@@ -0,0 +1,20 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
// Keep the historical filename so existing authentication state is preserved.
// Research and authentication use different tables in this same site database.
export function openSiteDatabase() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
try {
chmodSync(file, 0o600);
db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;");
return db;
} catch (error) {
db.close();
throw error;
}
}
@@ -0,0 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { readResearchSnapshot } from "@/lib/ai-research-store";
// The site's existing proxy protects this endpoint with the same login session.
export default function handler(request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "private, no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
try { return response.status(200).json(readResearchSnapshot()); }
catch { return response.status(503).json({ error: "研究数据库暂时不可用,请稍后重试" }); }
}
+4 -1
View File
@@ -4,6 +4,7 @@ import type {
InferGetStaticPropsType,
} from "next";
import Head from "next/head";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
Children,
@@ -24,6 +25,7 @@ import { Icon } from "@/components/Icon";
import { headingsFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
fallback: false,
@@ -88,7 +90,7 @@ export default function DocPage({
<meta name="description" content={doc.description} />
</Head>
<Shell key={doc.slug} docs={docs} current={doc.slug}>
<div className="reading-layout">
<div className={`reading-layout${doc.collection === "AI 公司研究" ? " research-layout" : ""}`}>
<aside className="sidebar">
<Navigation docs={docs} current={doc.slug} />
</aside>
@@ -225,6 +227,7 @@ export default function DocPage({
{doc.content}
</ReactMarkdown>
</article>
{doc.slug === "other-research/ai-company-research/overview" && <AICompanyResearch />}
<div className="article-end">
<a
+89
View File
@@ -1381,3 +1381,92 @@ svg { flex-shrink: 0; }
.category-menu { position: static; }
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
}
/* Research keeps the documentation navigation and uses the remaining reading width. */
.research-layout { grid-template-columns: 240px minmax(0, 1fr); }
.research-layout .reading-main { max-width: 1440px; width: 100%; }
.research-layout > .toc { display: none; }
.ai-research { margin-top: 28px; font-size: 13px; line-height: 1.65; }
.ai-research h2 { font-size: 20px; font-weight: 600; margin: 0 0 10px; letter-spacing: -.4px; }
.ai-research h3 { font-size: 15px; font-weight: 600; margin: 18px 0 8px; }
.ai-research p { margin: 6px 0 10px; }
.ai-research a, .research-company-name { color: var(--green); }
.ai-research a:hover, .research-company-name:hover { text-decoration: underline; }
.ai-research button { text-align: left; }
.research-meta { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--muted); font-size: 12px; }
.research-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; margin: 22px 0; }
.research-stats > div { border-top: 1px solid var(--line); padding: 14px 0; }
.research-stats strong { display: block; font-size: 29px; line-height: 1.3; font-weight: 550; font-variant-numeric: tabular-nums; }
.research-stats span { color: var(--muted); font-size: 12px; }
.research-scope { padding: 14px 18px; background: var(--soft); border-radius: 7px; }
.research-scope summary { color: var(--green); font-weight: 500; }
.research-scope ul { padding-left: 20px; }
.research-section { margin: 38px 0; scroll-margin-top: 92px; }
.research-muted, .research-footnote { color: var(--muted); font-size: 12px; }
.research-categories { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 26px; margin-top: 20px; }
.research-categories button { padding: 12px 0; background: transparent; border: 0; border-bottom: 1px solid var(--line); }
.research-categories button:hover { color: var(--green); }
.research-categories button > div, .research-revenue-chart > div > div { display: flex; justify-content: space-between; gap: 16px; }
.research-categories strong { font-variant-numeric: tabular-nums; }
.research-categories small { font-size: 11px; color: var(--muted); }
.research-bar { display: block; height: 5px; background: #f0f3f1; margin: 8px 0; border-radius: 3px; overflow: hidden; }
.research-bar i { display: block; height: 100%; background: #47856d; border-radius: 3px; }
.research-bar[data-estimate] i { background: #b98a40; }
.research-tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin: 18px 0; overflow-x: auto; }
.research-tabs button { padding: 10px 0; white-space: nowrap; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); }
.research-tabs button[aria-pressed="true"] { border-bottom-color: var(--green); color: var(--green); font-weight: 600; }
.research-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
.research-filters label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 11px; flex: 1 1 140px; }
.research-filters .research-query { flex: 2 1 210px; }
.research-filters :is(input,select) { min-width: 0; width: 100%; padding: 8px 10px; font-size: 12px; color: var(--ink); background: #fff; border: 1px solid #dfe6e0; border-radius: 5px; height: 36px; }
.research-result-count { display: flex; justify-content: space-between; margin: 14px 0 8px; color: var(--muted); font-size: 12px; }
.research-result-count button { border: 0; background: none; color: var(--green); }
.research-table-wrap { overflow-x: auto; max-width: 100%; }
.ai-research table { width: 100%; border-collapse: collapse; min-width: 720px; text-align: left; font-size: 12px; }
.ai-research caption { text-align: left; color: var(--muted); padding: 12px 0; font-size: 12px; }
.ai-research th { color: var(--muted); background: #f7f9f7; font-weight: 500; }
.ai-research :is(th,td) { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
.ai-research td:first-child { min-width: 140px; }
.ai-research td:nth-child(2) { min-width: 220px; }
.ai-research td p { margin: 6px 0; }
.ai-research td small { display: block; color: var(--muted); margin-top: 5px; }
.ai-research tr[data-selected] { background: #f0f8f3; }
.research-company-name { font-weight: 600; background: none; border: 0; padding: 0; }
.research-tag { display: inline-block; font-size: 10px; line-height: 1.7; padding: 1px 6px; border-radius: 3px; background: #eef3ef; color: #596e60; }
.research-pagination { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 16px; }
.research-pagination button, .research-message button, .research-detail-heading button { padding: 6px 11px; border-radius: 4px; border: 1px solid var(--line); background: white; }
.research-pagination button:disabled { color: #a8afa9; cursor: default; }
.research-message { padding: 24px 0; color: var(--muted); }
.research-message button { margin-left: 12px; color: var(--green); }
.research-notice { border-left: 3px solid #c6a063; padding: 10px 16px; background: #faf8f2; }
.research-revenue-chart { display: grid; gap: 18px; padding: 18px 0 30px; }
.research-revenue-chart button { padding: 0; border: 0; background: none; color: var(--green); }
.research-revenue-chart strong { font-variant-numeric: tabular-nums; font-weight: 500; }
.research-revenue-chart small { color: var(--muted); font-size: 11px; }
.research-evidence-list, .research-review-list { list-style: none; padding: 0; margin: 0; }
.research-evidence { padding: 18px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 92px; }
.research-evidence > div { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.research-evidence code { font-size: 11px; color: var(--muted); }
.research-evidence strong { font-weight: 500; }
.research-evidence small { color: var(--muted); font-size: 11px; }
.research-review-list > li { padding: 20px 0; border-bottom: 1px solid var(--line); }
.research-review-list h3 { margin: 8px 0; }
.research-review-list > li > div { display: flex; gap: 14px; flex-wrap: wrap; }
.research-detail { padding-top: 24px; border-top: 2px solid #dbe8df; }
.research-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.research-company-facts { margin: 24px 0; }
.research-company-facts > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
.research-company-facts dt { color: var(--muted); }
.research-company-facts dd { margin: 0; }
.research-footnote { border-top: 1px solid var(--line); padding: 18px 0; }
@media (max-width: 760px) {
.research-layout { grid-template-columns: 1fr; }
.research-layout .reading-main { padding: 26px 24px 0; }
.research-section, .research-evidence { scroll-margin-top: 176px; }
}
@media (max-width: 560px) {
.research-stats { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 18px; }
.research-categories { grid-template-columns: 1fr; }
.research-tabs { gap: 16px; }
.research-layout .reading-main { padding: 22px 16px 0; }
}
+70
View File
@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import seed from "../../../content/research/ai-companies.seed.json";
import { readResearchSnapshot } from "../src/lib/ai-research-store";
import { openSiteDatabase } from "../src/lib/site-database";
import { categorySummary, evidenceUrl, validateResearch, type ResearchSnapshot } from "../src/lib/ai-research";
import { consumeCode, initializeStore } from "../src/lib/auth-store";
import { newAuthConfig } from "../src/lib/auth";
test("research revisions share the authentication database and preserve replay limits", () => {
const dir = mkdtempSync(join(tmpdir(), "site-research-"));
const previous = process.env.AUTH_CONFIG_FILE;
process.env.AUTH_CONFIG_FILE = join(dir, "config.json");
try {
initializeStore();
const config = newAuthConfig();
for (let i = 0; i < 20; i++) consumeCode(config, "invalid");
assert.equal(consumeCode(config, "invalid"), "limited");
const initial = readResearchSnapshot();
assert.equal(initial.companies.length, 54);
assert.equal(initial.evidence.length, 87);
assert.equal(initial.observations.length, 63);
assert.deepEqual(readResearchSnapshot(), initial, "reopening does not duplicate records");
const db = openSiteDatabase();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
assert.ok(tables.some((r) => r.name === "login_state"));
assert.ok(tables.some((r) => r.name === "research_companies"));
assert.equal(db.prepare("PRAGMA foreign_keys").get()?.foreign_keys, 1);
db.close();
const next = structuredClone(seed) as ResearchSnapshot;
next.version += "-revision-test";
next.companies[0].name = "Revised company";
assert.equal(readResearchSnapshot(next).companies[0].name, "Revised company");
assert.deepEqual(readResearchSnapshot(), initial, "old release still reads its original revision");
assert.equal(consumeCode(config, "invalid"), "limited", "research import must not reset auth limits");
const collision = structuredClone(seed) as ResearchSnapshot;
collision.companies[0].name = "Changed without revision";
assert.throws(() => readResearchSnapshot(collision), /new revision/);
assert.deepEqual(readResearchSnapshot(), initial, "failed import leaves valid revision intact");
} finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(dir, { recursive: true, force: true });
}
});
test("research validation rejects broken evidence and unsafe source links", () => {
validateResearch(seed as ResearchSnapshot);
const invalid = structuredClone(seed) as ResearchSnapshot;
invalid.observations[0].evidence = "missing";
assert.throws(() => validateResearch(invalid), /observation/);
assert.equal(evidenceUrl("javascript:alert(1)"), undefined);
assert.equal(evidenceUrl("/local-link"), undefined);
assert.equal(evidenceUrl("https://example.com/evidence"), "https://example.com/evidence");
});
test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => {
const snapshot = seed as ResearchSnapshot;
const summary = categorySummary(snapshot);
assert.equal(summary.reduce((n, c) => n + c.count, 0), 54);
assert.equal(summary.reduce((n, c) => n + c.self, 0), 5);
assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1);
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 48);
assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown");
assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown");
assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体"));
});
+8
View File
@@ -17,6 +17,14 @@
}
],
"documents": {
"ai-company-research/00-overview": {
"collection": "AI 公司研究",
"group": "公司与市场",
"description": "按业务分类查看 AI 公司、经营指标与原始证据,检查收入定义与数据缺项。",
"slug": "other-research/ai-company-research/overview",
"category": "其他研究",
"featured": true
},
"00-endor-concepts-draft": {
"group": "架构设计",
"description": "Endor 的全球入口、区域运行时与对象数据。",
@@ -0,0 +1,5 @@
# AI 公司研究
调查 2025—2026 年不同类型 AI 公司的业务、交付能力、规模与商业模式。分类表链接公司数据,公司数据链接经营指标与原始证据。
以下工作台读取研究数据库。首批为公开资料定向试样,成立、公开发布和转型发布分别记录。未披露收入的公司继续保留,避免把收入资料缺失误当成零收入。
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -13,7 +13,8 @@
"lint": "pnpm --filter @endor/docs run lint",
"typecheck": "pnpm --filter @endor/docs run typecheck",
"figures": "uv run --locked --project tools/figures endor-figures",
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth"
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run test:research && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth",
"test:research": "pnpm --filter @endor/docs run test:research"
}
}
+1
View File
@@ -6,6 +6,7 @@ uv sync --project tools/figures --locked
pnpm run lint
pnpm run typecheck
pnpm --filter @endor/docs run test:auth
pnpm --filter @endor/docs run test:research
pnpm --filter @endor/docs run images
pnpm --filter @endor/docs run test:images
cp -R apps/docs/public/figures /tmp/expected-figures