Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 157144b3f9 | |||
| 3cfce44a25 | |||
| e91255856c | |||
| 1250b674b0 | |||
| 074881b650 | |||
| 1966d7a0a7 | |||
| 319de8a3a7 | |||
| 787491622b | |||
| e448acb904 |
@@ -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 配置。
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
|
||||
"test:auth": "node scripts/test-auth.mjs"
|
||||
"test:auth": "node scripts/test-auth.mjs",
|
||||
"test:research": "node scripts/test-research.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.3",
|
||||
|
||||
@@ -160,7 +160,7 @@ async function main() {
|
||||
path,
|
||||
);
|
||||
}
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search"])
|
||||
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"])
|
||||
assert.equal((await request(path)).status, 401, path);
|
||||
assert.equal(
|
||||
(
|
||||
@@ -181,6 +181,21 @@ async function main() {
|
||||
const headers = {
|
||||
Authorization: "Bearer " + signSession(config, undefined, 600),
|
||||
};
|
||||
const researchResponse = await request("/api/research/ai-companies", { headers });
|
||||
assert.equal(researchResponse.status, 200, "authenticated research API");
|
||||
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
|
||||
const research = await researchResponse.json();
|
||||
assert.equal(research.companies.length, 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();
|
||||
@@ -203,8 +218,11 @@ async function main() {
|
||||
if (route === "/") home = html;
|
||||
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
|
||||
if (route.startsWith("/docs/")) {
|
||||
assert.ok(html.includes('aria-label="文档选择"'), route);
|
||||
assert.ok(html.includes('<optgroup label="语言与运行时">'), route);
|
||||
assert.ok(html.includes('aria-label="文档分类"'), route);
|
||||
assert.ok(html.includes('data-category="语言与运行时"'), route);
|
||||
assert.ok(html.includes('class="category-panel" hidden=""'), route);
|
||||
assert.ok(!html.includes('class="collection-picker"'), route);
|
||||
assert.ok(!html.includes("<select"), route);
|
||||
assert.ok(!html.includes('class="nav-back"'), route);
|
||||
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
||||
import type { DocLink } from "@/lib/docs";
|
||||
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export function CategoryNavigation({ docs, current }: { docs: DocLink[]; current?: string }) {
|
||||
const navigation = useRef<HTMLElement>(null);
|
||||
const triggers = useRef(new Map<string, HTMLButtonElement>());
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const id = useId();
|
||||
const categories = [...new Set(docs.map((doc) => doc.category))];
|
||||
const collections = collectionEntries(docs);
|
||||
const selected = docs.find((doc) => doc.slug === current);
|
||||
|
||||
useEffect(() => {
|
||||
function outside(event: PointerEvent) {
|
||||
if (event.target instanceof Node && !navigation.current?.contains(event.target)) setOpen(null);
|
||||
}
|
||||
function escape(event: globalThis.KeyboardEvent) {
|
||||
if (event.key !== "Escape" || !open) return;
|
||||
if (navigation.current?.contains(document.activeElement)) {
|
||||
event.preventDefault();
|
||||
triggers.current.get(open)?.focus();
|
||||
}
|
||||
setOpen(null);
|
||||
}
|
||||
document.addEventListener("pointerdown", outside);
|
||||
document.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside);
|
||||
document.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function focusItem(panel: string, last = false) {
|
||||
requestAnimationFrame(() => {
|
||||
const links = document.getElementById(panel)?.querySelectorAll<HTMLAnchorElement>("a");
|
||||
links?.[last ? links.length - 1 : 0]?.focus();
|
||||
});
|
||||
}
|
||||
function move(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||||
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>("a")];
|
||||
const index = links.indexOf(document.activeElement as HTMLAnchorElement);
|
||||
if (index < 0) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? links.length - 1 :
|
||||
(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length;
|
||||
links[next]?.focus();
|
||||
}
|
||||
|
||||
return <nav ref={navigation} className="top-nav category-navigation" aria-label="文档分类"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(null);
|
||||
}}>
|
||||
<ul>
|
||||
<li><Link className="category-home" href="/" aria-current={!current ? "page" : undefined} onClick={() => setOpen(null)}>文档中心</Link></li>
|
||||
{categories.map((category, index) => {
|
||||
const panel = `${id}-category-${index}`;
|
||||
const expanded = open === category;
|
||||
return <li className="category-menu" key={category}
|
||||
onPointerEnter={(event) => { if (event.pointerType === "mouse") setOpen(category); }}
|
||||
onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(null); }}>
|
||||
<button ref={(element) => { if (element) triggers.current.set(category, element); else triggers.current.delete(category); }}
|
||||
type="button" className="category-trigger" data-category={category}
|
||||
data-active={selected?.category === category || undefined}
|
||||
aria-expanded={expanded} aria-controls={panel}
|
||||
onClick={() => setOpen(expanded ? null : category)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpen(category);
|
||||
focusItem(panel, event.key === "ArrowUp");
|
||||
}
|
||||
}}>
|
||||
{category}<Icon name="chevron" size={11} />
|
||||
</button>
|
||||
<div id={panel} className="category-panel" hidden={!expanded} onKeyDown={move}>
|
||||
<p className="category-panel-title">{category}</p>
|
||||
{collections.filter(({entry}) => entry.category === category).map(({name, entry}) => <Link
|
||||
key={name} href={`/docs/${entry.slug}`} data-tone={collectionTone(name)}
|
||||
aria-current={current === entry.slug ? "page" : selected?.collection === name ? "true" : undefined}
|
||||
onClick={() => setOpen(null)}>
|
||||
<Icon name={collectionIcon(name)} size={19} />
|
||||
<span><strong>{collectionLabel(name)}</strong><small>{entry.description}</small></span>
|
||||
</Link>)}
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</nav>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { CategoryNavigation } from "./CategoryNavigation";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { DocLink } from "@/lib/docs";
|
||||
@@ -69,7 +69,6 @@ export function Shell({
|
||||
current?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
@@ -80,7 +79,6 @@ export function Shell({
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const collection = docs.find((d) => d.slug === current)?.collection;
|
||||
const collections = collectionEntries(docs);
|
||||
const results = searchDocs.filter((d) =>
|
||||
(!collection || d.collection === collection) &&
|
||||
`${d.title} ${d.description} ${d.searchText ?? ""}`
|
||||
@@ -125,22 +123,7 @@ export function Shell({
|
||||
</a>
|
||||
<header className="site-header">
|
||||
<Brand />
|
||||
<nav className="top-nav" aria-label="文档选择">
|
||||
<label className="collection-picker">
|
||||
<span className="picker-category">{docs.find((d) => d.slug === current)?.category ?? "全部分类"}</span>
|
||||
<span className="sr-only">选择分类与文档合集</span>
|
||||
<select value={collection ?? ""} onChange={(event) => {
|
||||
const entry = collections.find((c) => c.name === event.target.value)?.entry;
|
||||
void router.push(entry ? `/docs/${entry.slug}` : "/");
|
||||
}}>
|
||||
<option value="">文档中心</option>
|
||||
{[...new Set(docs.map((d) => d.category))].map((category) => <optgroup label={category} key={category}>
|
||||
{collections.filter((c) => c.entry.category === category).map(({name}) => <option value={name} key={name}>{collectionLabel(name)}</option>)}
|
||||
</optgroup>)}
|
||||
</select>
|
||||
<Icon name="chevron" size={13} />
|
||||
</label>
|
||||
</nav>
|
||||
<CategoryNavigation docs={docs} current={current} />
|
||||
<button
|
||||
ref={trigger}
|
||||
className="search-trigger"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import seed from "../../../../content/research/ai-companies.seed.json";
|
||||
import { createHash } from "node:crypto";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
import { validateResearch, type ResearchSnapshot } from "./ai-research";
|
||||
|
||||
// Each release selects an immutable research revision. New revisions are added
|
||||
// transactionally, preserving both earlier research and all authentication rows.
|
||||
export function readResearchSnapshot(snapshot = seed as ResearchSnapshot): ResearchSnapshot {
|
||||
validateResearch(snapshot);
|
||||
const digest = createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
|
||||
const db = openSiteDatabase();
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS research_datasets (
|
||||
id TEXT PRIMARY KEY, as_of TEXT NOT NULL, metadata TEXT NOT NULL, content_hash TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_categories (
|
||||
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
|
||||
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_companies (
|
||||
dataset_id TEXT NOT NULL, id TEXT NOT NULL, category_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, founded_year INTEGER, team_size INTEGER,
|
||||
revenue_status TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
PRIMARY KEY(dataset_id,id),
|
||||
FOREIGN KEY(dataset_id,category_id) REFERENCES research_categories(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_evidence (
|
||||
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
|
||||
id TEXT NOT NULL, url TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
PRIMARY KEY(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_observations (
|
||||
dataset_id TEXT NOT NULL, id TEXT NOT NULL, company_id TEXT NOT NULL,
|
||||
evidence_id TEXT NOT NULL, kind TEXT NOT NULL, amount REAL NOT NULL,
|
||||
unit TEXT NOT NULL, record_use TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
PRIMARY KEY(dataset_id,id),
|
||||
FOREIGN KEY(dataset_id,company_id) REFERENCES research_companies(dataset_id,id),
|
||||
FOREIGN KEY(dataset_id,evidence_id) REFERENCES research_evidence(dataset_id,id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS research_reviews (
|
||||
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
|
||||
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
|
||||
);
|
||||
`);
|
||||
// The database transaction also serializes first requests from two workers.
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const exists = db.prepare("SELECT content_hash FROM research_datasets WHERE id=?")
|
||||
.get(snapshot.version) as { content_hash: string } | undefined;
|
||||
if (exists && exists.content_hash !== digest)
|
||||
throw new Error("Research data changed without a new revision identifier");
|
||||
if (!exists) {
|
||||
const { categories, companies, observations, evidence, reviews, ...metadata } = snapshot;
|
||||
db.prepare("INSERT INTO research_datasets VALUES (?,?,?,?)")
|
||||
.run(snapshot.version, snapshot.asOf, JSON.stringify(metadata), digest);
|
||||
const insertCategory = db.prepare("INSERT INTO research_categories VALUES (?,?,?)");
|
||||
for (const row of categories) insertCategory.run(snapshot.version, row.id, JSON.stringify(row));
|
||||
const insertCompany = db.prepare("INSERT INTO research_companies VALUES (?,?,?,?,?,?,?,?)");
|
||||
for (const row of companies) insertCompany.run(snapshot.version, row.id, row.cat,
|
||||
row.name, row.year, row.team, row.revenueStatus, JSON.stringify(row));
|
||||
const insertEvidence = db.prepare("INSERT INTO research_evidence VALUES (?,?,?,?)");
|
||||
for (const row of evidence) insertEvidence.run(snapshot.version, row.id, row.url, JSON.stringify(row));
|
||||
const insertObservation = db.prepare("INSERT INTO research_observations VALUES (?,?,?,?,?,?,?,?,?)");
|
||||
for (const row of observations) insertObservation.run(snapshot.version, row.id, row.company,
|
||||
row.evidence, row.kind, row.value, row.unit, row.selection, JSON.stringify(row));
|
||||
const insertReview = db.prepare("INSERT INTO research_reviews VALUES (?,?,?)");
|
||||
for (const row of reviews) insertReview.run(snapshot.version, row.id, JSON.stringify(row));
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (error) {
|
||||
db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
const meta = db.prepare("SELECT metadata FROM research_datasets WHERE id=?").get(snapshot.version) as { metadata: string };
|
||||
// All displayed records come from the site database, not the seed arrays.
|
||||
const rows = (table: string) => (db.prepare(
|
||||
`SELECT payload FROM ${table} WHERE dataset_id=? ORDER BY id`,
|
||||
).all(snapshot.version) as { payload: string }[]).map((r) => JSON.parse(r.payload));
|
||||
const stored: ResearchSnapshot = { ...JSON.parse(meta.metadata),
|
||||
categories: rows("research_categories"), companies: rows("research_companies"),
|
||||
observations: rows("research_observations"), evidence: rows("research_evidence"),
|
||||
reviews: rows("research_reviews") };
|
||||
validateResearch(stored);
|
||||
return stored;
|
||||
} finally { db.close(); }
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { authId, matchingStep, type AuthConfig } from "./auth";
|
||||
import { openSiteDatabase } from "./site-database";
|
||||
|
||||
function database() {
|
||||
const configFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("Authentication is not configured");
|
||||
const file = join(dirname(configFile), "login-state.sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
chmodSync(file, 0o600);
|
||||
const db = openSiteDatabase();
|
||||
db.exec(`PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS login_state (
|
||||
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Keep the historical filename so existing authentication state is preserved.
|
||||
// Research and authentication use different tables in this same site database.
|
||||
export function openSiteDatabase() {
|
||||
const configFile = process.env.AUTH_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("Authentication is not configured");
|
||||
const file = join(dirname(configFile), "login-state.sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
try {
|
||||
chmodSync(file, 0o600);
|
||||
db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;");
|
||||
return db;
|
||||
} catch (error) {
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { readResearchSnapshot } from "@/lib/ai-research-store";
|
||||
|
||||
// The site's existing proxy protects this endpoint with the same login session.
|
||||
export default function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
return response.status(405).end();
|
||||
}
|
||||
try { return response.status(200).json(readResearchSnapshot()); }
|
||||
catch { return response.status(503).json({ error: "研究数据库暂时不可用,请稍后重试" }); }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InferGetStaticPropsType,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Children,
|
||||
@@ -24,6 +25,7 @@ import { Icon } from "@/components/Icon";
|
||||
import { headingsFor } from "@/lib/navigation";
|
||||
|
||||
type Props = { doc: Doc; docs: DocLink[] };
|
||||
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
|
||||
export const getStaticPaths: GetStaticPaths = async () => ({
|
||||
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
|
||||
fallback: false,
|
||||
@@ -88,7 +90,7 @@ export default function DocPage({
|
||||
<meta name="description" content={doc.description} />
|
||||
</Head>
|
||||
<Shell key={doc.slug} docs={docs} current={doc.slug}>
|
||||
<div className="reading-layout">
|
||||
<div className={`reading-layout${doc.collection === "AI 公司研究" ? " research-layout" : ""}`}>
|
||||
<aside className="sidebar">
|
||||
<Navigation docs={docs} current={doc.slug} />
|
||||
</aside>
|
||||
@@ -225,6 +227,7 @@ export default function DocPage({
|
||||
{doc.content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
{doc.slug === "other-research/ai-company-research/overview" && <AICompanyResearch />}
|
||||
<div className="article-end">
|
||||
|
||||
<a
|
||||
|
||||
@@ -1319,11 +1319,6 @@ svg { flex-shrink: 0; }
|
||||
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
|
||||
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
|
||||
}
|
||||
.collection-picker { display: flex; flex-wrap: wrap; align-items: center; position: relative; min-width: 180px; max-width: 280px; }
|
||||
.collection-picker select { appearance: none; width: 100%; cursor: pointer; padding: 8px 30px 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font: inherit; font-size: 13px; color: var(--ink); text-overflow: ellipsis; }
|
||||
.picker-category { width: 100%; font-size: 10px; line-height: 1.4; color: var(--muted); margin-bottom: 2px; }
|
||||
.collection-picker > svg { position: absolute; right: 10px; bottom: 12px; pointer-events: none; transform: rotate(90deg); color: var(--muted); }
|
||||
.collection-picker select:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
|
||||
.top-nav { margin-left: 0; }
|
||||
.sidebar { padding: 22px 16px 32px; }
|
||||
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
|
||||
@@ -1347,8 +1342,131 @@ svg { flex-shrink: 0; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.top-nav { overflow: visible; }
|
||||
.collection-picker { width: 100%; max-width: 100%; }
|
||||
.picker-category { display: none; }
|
||||
.collection-picker select { padding-block: 6px; }
|
||||
.mobile-navigation .nav-group { margin-top: 16px; }
|
||||
}
|
||||
|
||||
/* Categories stay visible across the header, each with its own disclosure panel. */
|
||||
.top-nav.category-navigation { position: relative; display: flex; margin-left: 0; gap: 0; }
|
||||
.category-navigation > ul { display: flex; align-items: stretch; gap: 22px; height: 100%; list-style: none; padding: 0; margin: 0; }
|
||||
.category-navigation > ul > li { display: flex; align-items: stretch; }
|
||||
.category-menu { position: relative; }
|
||||
.category-navigation .category-home, .category-trigger { display: flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; white-space: nowrap; }
|
||||
.category-trigger { position: relative; padding: 0; background: transparent; cursor: pointer; }
|
||||
.category-trigger > svg { transform: rotate(90deg); transition: transform 160ms; }
|
||||
.category-trigger[aria-expanded="true"] > svg { transform: rotate(-90deg); }
|
||||
.category-trigger:hover, .category-trigger[aria-expanded="true"], .category-trigger[data-active="true"] { color: var(--ink); }
|
||||
.category-trigger[data-active="true"]::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--green); }
|
||||
.category-trigger:focus-visible, .category-navigation a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: 3px; }
|
||||
.category-panel { position: absolute; top: 100%; left: -16px; width: 320px; padding: 12px; background: #fdfefd; border: 1px solid var(--line); border-radius: 0 0 10px 10px; box-shadow: 0 16px 40px #24352b1a; z-index: 50; }
|
||||
.category-panel[hidden] { display: none; }
|
||||
.category-menu:last-child .category-panel { left: auto; right: -16px; }
|
||||
.category-panel-title { margin: 3px 10px 8px; font-size: 11px; color: var(--muted); }
|
||||
.top-nav .category-panel a { display: flex; align-items: flex-start; gap: 12px; padding: 12px 10px; white-space: normal; border-radius: 6px; color: var(--ink); }
|
||||
.top-nav .category-panel a::after { display: none; }
|
||||
.category-panel a:hover, .category-panel a[aria-current] { background: var(--accent-wash); }
|
||||
.category-panel a > svg { margin-top: 2px; color: var(--green); }
|
||||
.category-panel strong { display: block; font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||
.category-panel small { display: block; margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--muted); }
|
||||
@media (min-width: 761px) and (max-width: 1100px) {
|
||||
.site-header { gap: 20px; }
|
||||
.category-navigation > ul { gap: 16px; }
|
||||
.category-navigation .category-home, .category-trigger { font-size: 12px; }
|
||||
.search-trigger { width: 36px; height: 34px; padding: 8px; justify-content: center; }
|
||||
.search-trigger span, .search-trigger kbd { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.top-nav.category-navigation { overflow: visible; }
|
||||
.category-navigation > ul { width: 100%; gap: 18px; }
|
||||
.category-navigation .category-home, .category-trigger { font-size: 12px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -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 === "公司整体"));
|
||||
});
|
||||
@@ -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 的全球入口、区域运行时与对象数据。",
|
||||
@@ -58,6 +66,22 @@
|
||||
"slug": "language-and-runtime/object-relations/object-graphs-gc",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"06-agent-programming-draft": {
|
||||
"collection": "Agent 编程",
|
||||
"group": "研究与提案",
|
||||
"description": "从指令重解释、组合边界与灵活性取舍,讨论 Agent 编程抽象。",
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/agent-programming/from-instructions-to-composition",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"07-composable-judgments-draft": {
|
||||
"collection": "Agent 编程",
|
||||
"group": "研究与提案",
|
||||
"description": "比较 LangGraph 与 DSPy,探索判断对象、依据变化与局部重算。",
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/agent-programming/composable-judgments",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"pretext/00-overview": {
|
||||
"description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。",
|
||||
"featured": true,
|
||||
|
||||
@@ -332,4 +332,6 @@ Endor 也不应仅凭对象之间可连接,就宣称用户可以无损迁移
|
||||
|
||||
这一版先把程序入口、对象、操作权、AI 与语言之间的关系串起来。后续可以用真实工作流修订这些描述,进一步确定对象身份、执行与挂载的语义。
|
||||
|
||||
[从给 AI 写教程,到组合 AI 能力](/docs/language-and-runtime/agent-programming/from-instructions-to-composition)进一步记录指令重解释、组合边界与灵活性的三个问题;[LangGraph、DSPy 与可接续的判断对象](/docs/language-and-runtime/agent-programming/composable-judgments)比较现有探索,并提出依据变化后复核与局部重算的原型方向。两篇均为讨论草案,不表示现有语言或运行时已经实现。
|
||||
|
||||
[系统架构](/docs/00-endor-concepts-draft)讨论部署与存储,[节点与对象挂载](/docs/02-mounting-draft)讨论运行位置和挂载边界;本文关注它们为何能给用户带来值得验证的变化。
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 从给 AI 写教程,到组合 AI 能力
|
||||
|
||||
记录日期:2026 年 10 月 28 日。本文保存关于 Agent 编程抽象的讨论,重点是指令的重新解释、能力的组合边界,以及约束与灵活性的取舍。
|
||||
|
||||
> **讨论草案**:文中的分析和候选机制尚未成为 Endor 语言规范,也不表示 Aster、Hestia 或其他组件已经实现。对外部项目的事实描述附官方来源;设计判断与后续提案分别说明。
|
||||
|
||||
## 我们真正追问的是什么
|
||||
|
||||
为什么很多 Agent 扩展仍像是给 AI 的教程?传统编程发展出函数、对象、函数式组合和状态管理方法,Agent 是否也应该发展出可以理解、测试和组合的能力单元?
|
||||
|
||||
**关键区别在于:代码中的抽象有运行机制支撑,而很多 Agent 抽象的效果,仍然依赖模型理解并遵守一段话。**
|
||||
|
||||
函数的局部变量受到作用域规则约束,模块可以控制导出内容,React Hooks 的状态与调用有框架机制支撑。这些机制并不能自动证明业务逻辑正确,却让开发者有明确的执行规则可依赖。
|
||||
|
||||
如果在 skill 中写“这段规则只对当前任务有效,不得影响其他任务”,它首先还是一条需要模型遵守的指令。只有宿主系统真正隔离了上下文、存储或权限,才形成相应的执行边界。**把一段话放进文件夹,获得了组织和复用能力,却不等于获得了封装。**
|
||||
|
||||
这里不能把 skill 与 MCP 完全等同:Agent Skills 用 Markdown 指导、可选脚本和资源打包任务知识;MCP 还规范工具发现、调用、资源与消息交换,具有真实的协议机制。但协议不替宿主决定模型怎样组织推理。[Agent Skills 规范](https://agentskills.io/specification)、[MCP 架构](https://modelcontextprotocol.io/docs/learn/architecture)
|
||||
|
||||
## 第一,结构在每次推理中被重新解释
|
||||
|
||||
自然语言把大量原来需要开发者提前定义的步骤,交给模型临时组织。用户说“检查合同,找出对我不利的条款”,模型能够补出搜索、比较、解释等步骤,不必先穷举每个分支。这使教程式扩展具有实际价值:便宜、灵活,也能承载尚未形式化的经验。
|
||||
|
||||
**但代价是,结构没有明确保存在程序里,而是在每次推理时由模型重新解释。因此,增加一个 skill,可能改变另一个 skill 的使用方式;换一个模型,原来有效的指令可能表现不同。**
|
||||
|
||||
这里说的是仅由自然语言承担的结构。skill 随附脚本、宿主的工具权限和明确的数据流,仍可以由普通代码实现硬边界,不能把整个 Agent 系统都说成没有结构。
|
||||
|
||||
需要区分三件事:指导内容是否被正确加载,模型是否按预期理解,执行环境是否强制实施约束。改进第一件事,不自动完成后两件事。
|
||||
|
||||
一个值得探索的方向是把“能力的公开输入与输出”“本次可见的数据”“允许产生的外部效果”显式表示。自然语言继续描述意图,系统承担能够检查的边界。模型升级时,对相同任务重新评估,而不是假定文件格式相同就行为兼容。
|
||||
|
||||
## 第二,AI 能力还缺少容易验证的组合边界
|
||||
|
||||
假设有三个能力:
|
||||
|
||||
1. A:查找资料。
|
||||
2. B:判断资料是否可信。
|
||||
3. C:写成报告。
|
||||
|
||||
把它们串起来很容易。难的是确保:
|
||||
|
||||
- B 拒绝的资料不会又被 C 使用。
|
||||
- C 能区分“原文事实”和“模型推断”。
|
||||
- A 没找到证据时,后续不会把空缺补成事实。
|
||||
|
||||
传统代码也不能自动保证业务正确,但可以控制显式数据流。例如 C 只收到 B 允许传递的证据引用,检索工具也只能读取这一集合。如果三个阶段只是向同一个上下文添加文字,“通过”和“拒绝”可能都只是模型看得到的文本。
|
||||
|
||||
**模块之间存在名称上的边界,却未必存在执行上的边界。**
|
||||
|
||||
可验证边界不等于“保证模型不知道被拒绝的事情”。模型可能在预训练中已经接触过相关信息,也可能凭一般知识推断。可实现的约束是限制本次输入、检索权限和可接受输出;要求报告中的事实主张指向获准证据,再检查关联是否有效。证据是否真正支持主张,仍需要独立评估或人工判断,不能只检查引用 ID 存在。
|
||||
|
||||
还要区分“来源可信”和“支持某个结论”:一份可靠文件可以被错误解读,也可能对另一个问题毫无证明力。审核应该说明对象、版本、问题范围与判断依据。
|
||||
|
||||
## 第三,把 AI 约束得足够明确,又可能损失它最有价值的灵活性
|
||||
|
||||
把流程完全固定成状态机,可以获得更明确的行为,但也要求开发者提前定义更多场景。反过来,让模型自由决定,就重新依赖它对指令的解释。
|
||||
|
||||
这不是无解,而是需要找出:哪些地方应该由系统确定,哪些地方值得留给模型判断。不同任务的答案还不一样,因此很难很快出现一套统一范式。
|
||||
|
||||
固定状态机只是取舍的一端。图执行也可以动态分支,模型也可以在受限环境中自主选择路径;不能把所有框架描述成僵硬流程。
|
||||
|
||||
一个候选划分是:**把可用材料、权限、预算和结果接纳条件固定下来,把寻找解法的路径留给模型。** 模型可以换检索策略、拆分问题和请求更多材料,但不能通过修改提示词给自己新增写权限,也不能把尚未审核的结果自行标成业务事实。
|
||||
|
||||
这仍需要具体任务检验:接纳条件过强可能拒绝有用结果,过弱可能放过错误;某些开放式创作没有唯一验收答案,应保留人工评价和不确定状态。
|
||||
|
||||
## 从指导语句到可执行的规则
|
||||
|
||||
| 常见指导 | 可以落实的机制 | 仍未解决的部分 |
|
||||
| --- | --- | --- |
|
||||
| 不要把资料当成指令 | 分开传递资料与控制配置,资料不能授予工具权限 | 提示注入仍可能影响内容判断,不能只靠分隔符解决 |
|
||||
| 只记住当前项目 | 按项目划分存储与检索范围,读取时检查身份 | 共享模型本身不因此遗忘已有知识 |
|
||||
| 交给另一个 Agent 做 | 显式传入对象、权限、预算和返回契约 | 子任务是否理解业务目标仍需评估 |
|
||||
| 确认以后才能发布 | 发布接口检查有效审批及对应内容版本 | 人的审批是否充分,不能由接口自动证明 |
|
||||
| 每个结论都要有证据 | 结论关联证据版本并检查引用有效性 | 证据是否充分、结论是否成立仍需判断 |
|
||||
|
||||
这些机制有些可以直接用现有语言实现。研究重点应是把它们变成一致、可复用的运行约定,而不是只增加新术语或语法。
|
||||
|
||||
## 对 Endor 的意义
|
||||
|
||||
Endor 已在设计讨论中把对象池、对象身份和 Agent 任务联系起来。这为表达证据、判断和结果之间的关系提供了切入点,但不能从“一切皆对象”推导出隔离、授权、持久化和业务正确性已经成立。
|
||||
|
||||
下一步可以讨论:一个判断是否应成为关联输入版本、检查记录与适用范围的对象?依据变化时,下游工作怎样知道需要复核?同一能力更换模型后,哪些行为需要重新验证?
|
||||
|
||||
更深入的外部方案比较、候选机制与原型验收,见[LangGraph、DSPy 与可接续的判断对象](/docs/language-and-runtime/agent-programming/composable-judgments)。项目整体定位见[Endor 的意义](/docs/endor/endor-purpose-draft)。
|
||||
@@ -0,0 +1,180 @@
|
||||
# LangGraph、DSPy 与可接续的判断对象
|
||||
|
||||
记录日期:2026 年 10 月 28 日。本文从[Agent 编程的三个问题](/docs/language-and-runtime/agent-programming/from-instructions-to-composition)继续讨论:已有探索为什么有价值,边界在哪里,以及 Endor 可以验证什么。
|
||||
|
||||
> **研究与设计草案**:官方机制、本文的分析和候选设计在各节中区分。下文没有确认新语言语法、对象内置类型或权限规范;没有修改现有 271 项语言设计议题的确认状态,也没有实现运行时功能。资料按本文日期查阅,具体 API 会演进。
|
||||
|
||||
## 两条路线分别解决了什么
|
||||
|
||||
LangGraph 把状态、节点和转移显式组织起来;DSPy 把模型任务表示为签名和模块,并根据指标优化程序。它们分别使执行过程更可管理、模型行为更可测量。它们也可以组合使用,并不是相互排斥的完整替代品。
|
||||
|
||||
| 维度 | LangGraph | DSPy | 本文进一步提出的问题 |
|
||||
| --- | --- | --- | --- |
|
||||
| 组织单位 | 状态、节点、边与子图 | 任务签名、模块与模型程序 | 一项判断能否跨流程保留身份、依据与适用范围 |
|
||||
| 关键进步 | 显式组织路径、状态与恢复 | 分离任务声明、调用策略与优化 | 如何接纳、复用及作废一个结果 |
|
||||
| 可获得的检查 | 图结构、状态约束、应用代码检查 | 输出结构、约束验证、数据集上的指标 | 证据版本、权限、业务条件与变化后的复核 |
|
||||
| 不会自动成立的事情 | 节点执行过不代表业务判断正确 | 得分提高不代表每次都满足真实需求 | 有来源也不代表内容真实或推理正确 |
|
||||
|
||||
## LangGraph 为什么好
|
||||
|
||||
### 从聊天历史走向明确的执行状态
|
||||
|
||||
官方 Graph API 把工作流组织为 State、Nodes 和 Edges,并用 reducer 规定更新怎样合入状态。它还支持输入、输出及内部状态的不同组织方式。这让开发者可以明确决定数据传到哪里,而不必把所有东西拼接成一段聊天。[Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api)
|
||||
|
||||
其价值是把一部分程序结构从模型的临时理解中拿回来:例如审核节点产生的允许集合,由普通代码选择后再提供给写作节点。
|
||||
|
||||
### 让中断与长期工作可以被管理
|
||||
|
||||
checkpointer 保存线程的图状态,store 保存跨线程数据;持久化实现的选择决定重启后是否还能接续。interrupt 支持等待外部输入,恢复时可能重新执行节点开头的代码,因此调用者必须处理重入和副作用。[Persistence](https://docs.langchain.com/oss/python/langgraph/persistence)、[Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts)
|
||||
|
||||
这是真实的执行能力,不只是“请记住进度”的指导。它让人可以在工作中间介入,而不必完全重开任务。
|
||||
|
||||
### 保留动态决策空间
|
||||
|
||||
LangGraph 有条件转移、动态路由和子图,节点里也可以包含开放式 Agent。不能把它批评成“只能预先画死全部步骤”。其强项在于让确定性逻辑和模型判断出现在一个可组织的执行过程里。[Graph API 的 Command](https://docs.langchain.com/oss/python/langgraph/graph-api#command)
|
||||
|
||||
## LangGraph 为什么还没有回答完这个问题
|
||||
|
||||
以下是对职责边界的分析,并不是说 LangGraph 无法用应用代码实现这些能力。
|
||||
|
||||
**流程状态不自动等于业务世界的有效状态。** checkpoint 可以保存“审核完成”,但被审核文件之后可能已经修改,或者用户已经撤回了授权。恢复执行时是否重读、复核和拒绝过期结果,需要应用定义。
|
||||
|
||||
**状态结构不自动等于信息安全边界。** 节点输入过滤有实际价值,但不能仅凭字段叫 private 就假设它不会进入日志、流输出或其他路径;数据访问、工具访问和输出通道仍需检查。一个图也可以做严格隔离,只是隔离不能由图形名称推导。
|
||||
|
||||
**检查节点不能天然证明上游结论。** 模型审核也可能犯错,与生成者共享偏差。增加一个 reviewer 节点是一种策略,实际效果要用独立样例和人工核验评估。
|
||||
|
||||
**跨任务的判断复用需要共同约定。** 两张图都产生“供应商通过审核”,是否针对同一公司、同一资料版本、同一政策和时间范围?这些业务关系可以加入 state 和数据库,但各应用往往还需自行定义。
|
||||
|
||||
因此,应继续利用图的执行能力,同时把跨流程可用的结果关系设计清楚。新做一个流程编辑器,未必解决这些问题。
|
||||
|
||||
## DSPy 为什么好
|
||||
|
||||
### 把任务声明与求解策略分开
|
||||
|
||||
Signature 声明输入、输出及任务意图;Module 组织调用策略。同一任务可以更换求解方式而保留接口。这让“我们要什么结果”和“模型如何尝试完成”更容易分别修改。[Signatures in depth](https://dspy.ai/3.4.1/diving-deeper/signatures-in-depth/)
|
||||
|
||||
例如,可以把证据列表到候选结论表示为一个模块,在相同输入集上比较不同策略,而不必重写整个应用。
|
||||
|
||||
### 让改进从手调措辞走向可测量的优化
|
||||
|
||||
DSPy 的优化器接受程序、指标和样例,根据反馈调整指令、示例,某些优化路径还涉及模型权重。这让提示词成为可优化的实现,而不是只能凭直觉维护的字符串。[DSPy 优化器官方文档源码](https://github.com/stanfordnlp/dspy/blob/main/docs/docs/learn/optimization/optimizers.md)
|
||||
|
||||
真正值得借鉴的是对“好不好”的显式追问:必须给出数据和评估方法,才能比较优化前后的程序。
|
||||
|
||||
## DSPy 为什么还没有回答完这个问题
|
||||
|
||||
**结构类型和业务成立条件不同。** 输出符合字段与类型要求,不说明结论受证据支持。“供应商可靠”可以是合法字符串,却没有包含可靠性的范围、依据和时效。增加业务字段有帮助,字段内容仍需要检验。
|
||||
|
||||
**指标是目标的可测量代理。** 如果只奖励引用数量,系统可能更愿意多加引用,而没有提高引用的支持程度。需要分别评估正确性、覆盖、证据支持、拒答、成本和耗时,并使用独立保留样本,避免只追逐一个总分。
|
||||
|
||||
**离线优化与在线条件之间存在距离。** 真实对象会变化,工具可能失败,权限可能收回,用户目标也会调整。验证集上的改进不能自动替代每次行动前的状态与授权检查。
|
||||
|
||||
**换模型后的可替换性仍需验证。** 接口兼容不等于行为兼容;优化产物需要连同模型配置、数据集与评估记录版本化。更强模型可能减少某些提示策略的必要性,也可能改变结果分布。
|
||||
|
||||
这些并不削弱 DSPy 的价值。它提供了进行比较和优化的机制;把它当作业务真理的证明器,才是越过了其能力边界。
|
||||
|
||||
## 我们可以补上的一层:让判断成为可接续的对象
|
||||
|
||||
候选方向是让 Agent 产出的判断,成为后续程序可以检查、组合和更新的工作对象。重点不在新的提示词容器,而在输出被其他任务使用时仍保留必要关系。
|
||||
|
||||
一项判断可以包含以下信息。字段仅用于原型讨论,不是已确认的内置类型。
|
||||
|
||||
| 信息 | 例子 | 能检查什么 |
|
||||
| --- | --- | --- |
|
||||
| 主张与范围 | 供应商 A 满足本次采购的材料要求 | 区分不同问题,避免把局部结论扩成全面认可 |
|
||||
| 输入对象及版本 | 营业文件 v3、报价单 v8 | 判断产生时实际使用了哪些材料 |
|
||||
| 证据关联 | 某项要求对应文件的哪些位置 | 引用是否存在,是否为允许使用的材料 |
|
||||
| 判断性质 | 原文摘录、计算结果、推断、人工确认 | 不把不同可靠性来源压成一个字符串 |
|
||||
| 生成与检查记录 | 模型配置、程序版本、检查规则与结果 | 复核、模型替换和实验追溯 |
|
||||
| 适用条件 | 政策版本、对象版本、复核期限 | 条件变化时识别需要重新检查的结果 |
|
||||
| 后续使用关系 | 报告段落、采购建议、候选任务 | 沿已记录依赖定位可能受影响的工作 |
|
||||
|
||||
这里的“可接纳”始终相对于明确政策与检查过程,不命名为无条件的“已证实事实”。模型自报的置信度也不能当作校准后的正确率。
|
||||
|
||||
### 把研究、提案与真实修改分开
|
||||
|
||||
模型可以自主探索、重试和比较方案,但先在隔离的候选工作区里产生结果与变更意图。准备提交时,由运行环境检查材料版本、当前授权、接纳条件和影响范围,再实际调用修改能力。
|
||||
|
||||
候选工作区可以采用写时复制、事件记录或暂存对象实现,选型尚未确定。它不是整个互联网的快照,也不能回滚已经发出的邮件或外部付款。外部读取可能计费或写日志,也应纳入效果清单;无法模拟的动作需要单独定义提交方式。
|
||||
|
||||
## 一个更有吸引力的体验:修改依据,工作能够局部更新
|
||||
|
||||
把前文的 A 查资料、B 审核、C 写报告,继续推进到一个用户可感知的场景:
|
||||
|
||||
1. AI 用三份资料形成报告,每个需要依据的结论关联具体来源版本。
|
||||
2. 用户发现第二份资料有误,将其从本次允许集合移除。
|
||||
3. 系统沿记录的依赖标出相关结论、报告段落和待提交任务;旧结果保留为历史,但不再作为当前有效依据。
|
||||
4. Agent 可以寻找替代证据、缩小结论或报告材料不足;与这份资料无关且仍满足条件的结果可以复用。
|
||||
5. 用户看到需要复核的内容、新旧结论和候选变更,确认后再更新正式对象。
|
||||
|
||||
**用户感知到的变化是:改一份依据,就能知道哪些判断需要复核,并接着完成这些工作。** 对开发者而言,可复用单元从一段生成文本,变成带输入、条件和检查记录的判断过程。
|
||||
|
||||
这借用了电子表格重算、增量构建、数据血缘和版本控制的成熟思想,不声称发明了依赖跟踪。需要验证的新价值是:这些关系能否在用户、模型与不同流程之间共同使用,减少每个应用手写一套更新逻辑的成本。
|
||||
|
||||
### 必须承认的限制
|
||||
|
||||
- 系统只能沿已记录的依赖传播失效,不能自动理解并发现全部语义影响。模型偷偷使用了未记录输入时,依赖图会不完整;原型应限制数据入口并记录读取,必要时保守地复核整个产物。
|
||||
- “读取过”不等于“真正依赖”。粗粒度跟踪可能重算过多,细粒度声明可能漏记,需要衡量漏失效与过度失效。
|
||||
- 版本变化只说明旧条件不再完全匹配,不代表旧结论必然错误。应标为待复核,而不是宣称系统已发现事实错误。
|
||||
- 重算不保证得到相同结果。回放历史记录与重新调用模型是两回事,外部服务和模型行为也可能变化。
|
||||
- 本地候选分支可丢弃,不等于全部业务效果可撤销。跨系统提交需要各自支持版本检查、幂等或补偿,否则必须暴露部分完成状态。
|
||||
|
||||
## 怎样形成真正的组合边界
|
||||
|
||||
下面的概念接口只表达职责,不是 Endor 已有或拟定语法,也不是可运行代码。
|
||||
|
||||
```text
|
||||
查找(问题, 允许的数据源) -> 资料引用与版本 | 材料不足
|
||||
审核(资料, 问题, 审核政策) -> 本次允许的证据集合 | 需要人工判断
|
||||
生成(问题, 允许的证据集合) -> 候选结论与证据关联
|
||||
接纳(候选结论, 检查政策) -> 接纳记录 | 拒绝原因 | 无法确定
|
||||
准备(接纳记录, 目标对象版本) -> 候选变更
|
||||
提交(候选变更, 当前授权, 版本前置条件) -> 实际效果记录 | 冲突 | 部分完成
|
||||
```
|
||||
|
||||
这些阶段不要求固定成六次模型调用;查找与生成可以动态循环,审核也可以由确定性规则、模型和人工共同完成。受限的是数据与效果边界,解题路径仍可自主选择。
|
||||
|
||||
“接纳记录”不能只是让模型在 JSON 中填写 `accepted: true`。原型可让受信任的检查服务生成记录,并在每次使用时验证记录所属项目、对象版本、政策与有效期。它能证明检查过程发生过,不能证明自然语言结论永远正确。权限应沿用项目既有授权体系,不能通过候选结果自动扩大。
|
||||
|
||||
## 与 Endor 对象模型怎样衔接
|
||||
|
||||
这项提案与[Endor 的对象操作定位](/docs/endor/endor-purpose-draft)有连接点:材料、判断、候选变更、任务可以保留独立身份和关系,而不只存在于某个聊天上下文或图执行记录中。
|
||||
|
||||
候选职责划分如下,仍需逐项设计和确认:
|
||||
|
||||
| 层次 | 候选职责 | 不应声称自动完成的事情 |
|
||||
| --- | --- | --- |
|
||||
| 语言与类型 | 表达输入、结果分支、对象引用和可调用能力 | 仅靠类型名证明自然语言主张 |
|
||||
| 对象与存储 | 保存身份、版本、关系和历史 | 自动发现所有语义依赖 |
|
||||
| 运行环境 | 限制效果、检查当前授权与提交前置条件 | 跨任意外部系统提供统一事务 |
|
||||
| 模型与模块 | 解释目标、探索方案、生成候选判断 | 自行授予权限或给自己签发可信结论 |
|
||||
| 检查与人 | 定义接纳条件、核验内容、处理不能确定的情况 | 用一次总分覆盖全部业务风险 |
|
||||
|
||||
先用现有语言和接口验证这些机制,再决定哪些值得进入 Endor 语言。不要先把假设写成语法,再用语法存在证明系统有价值。
|
||||
|
||||
## 最小原型与公平比较
|
||||
|
||||
第一版只选“资料到报告,依据撤回后继续修订”这一条工作流。使用有限的版本化资料集和一个可检查版本的报告存储,不接真实付款或邮件,也不扩展成万能 Agent 平台。
|
||||
|
||||
对照组采用认真实现的 LangGraph + DSPy 应用,允许它使用显式输入过滤、持久化、同样的检查器及自定义依赖记录;实验组使用相同模型、材料、预算和检查政策,增加可复用的判断对象与依赖处理层。对照组也能实现相同行为,比较重点是重复代码、集成工作量和变更后的可靠性,而不是把对照组故意降成一个长提示词。
|
||||
|
||||
| 实验 | 需要观察的结果 |
|
||||
| --- | --- |
|
||||
| 撤回一份材料 | 下游是否还有引用被撤回依据的当前有效结果;受影响项是否漏标 |
|
||||
| 修改材料中的一个事实 | 哪些产物需要复核,哪些可以复用;是否产生过度失效 |
|
||||
| 模型替换 | 固定任务上的质量、拒答、成本与耗时怎样变化;既有检查能否复用 |
|
||||
| 中断与重试 | 是否继续正确任务,重复提交是否被目标端拒绝或去重 |
|
||||
| 提交前版本变化 | 是否报告冲突或重新验证,避免覆盖用户新修改 |
|
||||
| 收回写权限 | 接续任务是否重新检查并拒绝写入;历史审批不能覆盖当前授权 |
|
||||
| 来源不支持结论 | 结构检查能否通过,独立内容审核能否识别错误;如实记录漏检 |
|
||||
| 增加第二个工作流 | 需要重复编写多少字段、失效传播、权限和接纳逻辑 |
|
||||
|
||||
先保存基线数据,再预先确定可接受门槛和独立评估集,不凭最顺利的演示设定标准。分别记录内容质量、来源支持、失效覆盖、过度重算、人工介入、模型与工具成本、延迟和开发工作量;不要把它们合成一个无法解释的“智能分数”。
|
||||
|
||||
原型如果不能降低第二条工作流的实现成本,或只是增加很多对象却没有减少错误和复核负担,就说明这层抽象还没有证明价值。如果 LangGraph + DSPy 的直接实现已经足够清晰,则优先沉淀薄库和约定,不必新造完整运行时。
|
||||
|
||||
## 当前建议
|
||||
|
||||
**保留 LangGraph 对执行的组织能力、DSPy 对模型程序的优化能力;探索让判断的依据、适用条件和后续影响成为共同的对象关系。**
|
||||
|
||||
最值得做的演示不是更多 Agent 同时对话,而是用户修改一份依据后,系统解释哪些工作受到影响、哪些仍可复用,并让 AI 在受约束的空间里继续完成修订。这项体验能否用更少的应用专用代码实现,才是继续投入的依据。
|
||||
@@ -0,0 +1,5 @@
|
||||
# AI 公司研究
|
||||
|
||||
调查 2025—2026 年不同类型 AI 公司的业务、交付能力、规模与商业模式。分类表链接公司数据,公司数据链接经营指标与原始证据。
|
||||
|
||||
以下工作台读取研究数据库。首批为公开资料定向试样,成立、公开发布和转型发布分别记录。未披露收入的公司继续保留,避免把收入资料缺失误当成零收入。
|
||||
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -13,7 +13,8 @@
|
||||
"lint": "pnpm --filter @endor/docs run lint",
|
||||
"typecheck": "pnpm --filter @endor/docs run typecheck",
|
||||
"figures": "uv run --locked --project tools/figures endor-figures",
|
||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
|
||||
"test:auth": "pnpm --filter @endor/docs run test:auth"
|
||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run test:research && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
|
||||
"test:auth": "pnpm --filter @endor/docs run test:auth",
|
||||
"test:research": "pnpm --filter @endor/docs run test:research"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user