跳转至

实验 3-13:从结构化数据中提取隐性知识——以司法判例分析为例

配套《深入理解 AI Agent》第 3 章。演示如何让 Agent 不把知识库当成"只能检索的静态仓库", 而是先把数据读懂、从数据本身归纳出结构化的决策逻辑,再基于这套逻辑回答问题

以三类罪名(盗窃罪 / 故意伤害罪 / 诈骗罪)的判例为例,完整走通四段流水线:

判例文本 ──①自下而上因子发现──▶ 模块化 schema(核心+各罪名扩展)
                            ②结构化抽取(用发现的 schema 抽因子)
                            ③各罪名内聚类 ──▶ 案件原型 + 层次因子重要性
        新案情 ──④对话 Agent(匹配最近原型、按重要性追问、给出建议)◀──┘

与"预定义僵化 schema + 回归黑箱"的做法相反,本实验的两个关键创新是: 因子不预设、由 LLM 从数据里自由归纳判决经验不靠回归拟合刑期、而靠聚类出可解释的案件原型

四段流水线

① 自下而上因子发现(discovery.py 不预先定义任何字段。把判例文本分批喂给 LLM,让它自由列出每一批案例中所有可能影响判决的 因素;再用一次 LLM 调用把各批发现的原始因子归并、去重、规范化成一个模块化 schema: core(适用所有罪名的通用因子:自首、赔偿、认罪认罚、前科累犯……)+ extensions (各罪名特有扩展因子:盗窃→涉案金额/入户/团伙,故意伤害→伤害等级/持械/预谋,诈骗→金额/受害人数)。 产出 data/schema.json(带缓存)。

② 结构化抽取(extractor.py 用发现出来的 schema,从每条判例抽取「核心 + 该罪名扩展」因子(LLM 结构化输出, response_format=json_object)。文本未提及的因子返回 null。抽取结果缓存到 data/extracted.jsonl,一次性抽取后重跑几乎免费。

③ 聚类成案件原型 + 层次因子重要性(archetypes.py 把因子翻译成数值向量:罪名 / 分类因子(如伤害等级)用 one-hot 开关位(不用 1/2/3, 避免暗示大小关系);金额 / 人数取 ln 压缩量纲;是非情节取 0/1。在每个罪名内部用 KMeans 聚类(k 由轮廓系数自动挑选),得到若干「案件原型」——例如故意伤害罪会自动聚出 "轻微伤"、"轻伤"、"持械预谋致重伤" 等典型模式。再算两级重要性: - 全局因子重要性:每个因子在所有原型之间的区分度(簇间方差占比)→ 全局排序; - 原型内定义性因子:每个原型相对全局最突出的因子 + 该原型典型刑期分布(中位 / 区间)。

产出可读、自洽的 data/archetypes.json(含标准化参数与簇心)。

④ 对话式量刑建议 Agent(advisor_agent.py 把「案件原型 + 层次因子重要性」当决策逻辑:从用户口语描述抽取已知因子 → 对照全局因子 重要性追问仍缺失的关键因子 → 把案件匹配到最近的案件原型(先按罪名圈定候选,再只在 已知维度上比距离)→ 让 LLM 基于该原型的统计数据(典型刑期区间、定义性因子)给出一段 有判例支持、可解释的建议(附法律免责声明)。所有刑期数字均来自原型统计,LLM 只负责讲清楚。

运行

pip install -r requirements.txt
cp env.example .env        # 填入 OPENAI_API_KEY(默认模型 gpt-5.6-luna)
python generate_data.py    # 可选:重新生成合成判例数据集(已自带 data/cases.jsonl)
python demo.py             # 跑通 因子发现 → 抽取 → 聚类 → 对话建议 全流程

首次运行会调用 LLM 做因子发现(约 7 次)与逐条抽取(约 66 次),结果分别写入 data/schema.jsondata/extracted.jsonl;再次运行直接命中缓存,几乎免费。

真实运行输出(节选)

阶段 1 自下而上发现的 schema:
  核心通用因子: prior_record 前科 / self_surrender 自首 / compensation 赔偿 /
               guilty_plea 认罪认罚 / victim_reconciliation 谅解 ...
  扩展·盗窃罪:  amount_stolen 盗窃金额 / gang_involvement 团伙 / use_of_weapon 持械
  扩展·故意伤害罪: injury_level 伤害等级[轻微伤/轻伤二级/重伤二级] / premeditation 预谋 ...
  扩展·诈骗罪:  amount_defrauded 诈骗金额 / victim_count 受害人数 / group_crime 团伙

阶段 3 各罪名内聚类(k 由轮廓系数自动选)→ 共 12 个案件原型;全局因子重要性排序:
  1. 罪名  2. 伤害等级=重伤  3. 诈骗金额  4. 盗窃金额  5. 团伙作案  6. 是否预谋 ...
  ▸ 原型#0 [故意伤害罪] 中位 2 月:伤害等级=轻微伤(z=+2.5)
  ▸ 原型#1 [故意伤害罪] 中位 42 月:伤害等级=重伤二级(z=+3.9)、预谋(z=+1.8) —— "持械预谋重伤"型
  ▸ 原型#5 [盗窃罪]     中位 51 月:盗窃金额高、前科/累犯 100% ...

阶段 4 对话:识别到盗窃案缺金额 → 按重要性追问金额/认罪/谅解 → 补全后匹配到 原型#6
         (典型刑期中位 40 月、区间 24~50 月),并引用该原型的关键因子给出建议。

数据说明

data/cases.jsonl自带的小样本合成数据(66 条,覆盖 3 类罪名),由 generate_data.py 用已知量刑公式加噪声生成:每条含自然语言 fact、结构化真值 gold、刑期 label_months。 关键点是因子在生成时被"写进"案情文本,发现阶段再从文本里把它们"读"回来——因子发现完全 不依赖生成时的字段列表,因此学到的模式来自数据本身。

真实目标数据集是 CAIL2018(中文刑事判决,数百万条)。因体量太大不便随仓库分发才用合成 小样本;换成真实数据只需把 generate_data.py 换成读取 CAIL 的 data_*.json (每行含 factmeta.accusationmeta.term_of_imprisonment),产出同结构的 cases.jsonl 即可,发现 / 抽取 / 聚类 / 对话四段代码无需改动。

文件

文件 作用
generate_data.py 合成多罪名小样本判例数据集
discovery.py 阶段 ①:自下而上因子发现 → 模块化 schema
extractor.py 阶段 ②:用发现的 schema 做结构化抽取(带缓存)
archetypes.py 阶段 ③:各罪名内聚类成案件原型 + 层次因子重要性
advisor_agent.py 阶段 ④:对话式量刑建议 Agent(匹配最近原型)
demo.py 全流程演示入口
config.py OpenAI 客户端与模型配置

局限与免责声明

  • 本项目仅用于教学,演示"从结构化数据中提取隐性知识"这一技术范式。
  • 数据为合成、因子集经简化,聚类也无法刻画真实司法量刑的复杂性与非线性。
  • 本项目的任何输出都不构成法律意见。 真实案件量刑受法律条文、司法解释、 地域政策与大量具体情节影响,请务必咨询专业律师,切勿据此做任何法律决策。

OpenRouter 通用回退 / Universal OpenRouter fallback

This experiment now supports a universal OpenRouter fallback for its chat LLM.

  • If the primary provider key (e.g. MOONSHOT_API_KEY / KIMI_API_KEY / OPENAI_API_KEY / DOUBAO_API_KEY …) is present, behavior is unchanged.
  • Else if OPENROUTER_API_KEY is set, the chat LLM is automatically routed through OpenRouter (https://openrouter.ai/api/v1). Model names are mapped automatically: gpt-*/o1-*openai/…, claude-*anthropic/claude-opus-4.8, kimi-*moonshotai/kimi-k2.6, ids already containing / are kept as-is, and other provider-native ids (e.g. doubao-*) fall back to openai/gpt-5.6-luna. Set OPENROUTER_MODEL to force a specific OpenRouter model id.
  • Else a clear error lists the accepted keys.

Add OPENROUTER_API_KEY=... to your .env (see env.example) to enable it.