跳转至

structured-knowledge-extraction

第3章 · 用户记忆和知识库 · 配套项目 chapter3/structured-knowledge-extraction

项目说明

实验 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.

源代码

advisor_agent.py

"""
阶段 4:对话式量刑建议 Agent。

把「案件原型 + 层次因子重要性」当决策逻辑来用:
  1. 从用户口语描述里抽取已知因子(复用抽取器,含罪名判定);
  2. 按**全局因子重要性顺序**,找出仍缺失、但很重要的因子,生成引导性追问;
  3. 信息补全后,把案件**匹配到最近的案件原型**;
  4. 用 LLM 把该原型的统计数据(典型刑期区间、定义性关键因子)组织成一段
     有判例支持、可解释的中文建议(附法律免责声明)。

所有刑期数字都来自原型统计,LLM 只负责"把数字讲清楚",不自行编造。
"""
from config import MODEL, get_client
from archetypes import nearest_archetype
from discovery import all_factors

DISCLAIMER = (
    "【免责声明】本回答由教学实验中的统计模型自动生成,仅用于演示"
    "『从结构化数据中提取隐性知识』这一技术,不构成任何法律意见。真实案件量刑受"
    "法律条文、司法解释、地域与具体情节等大量因素影响,请务必咨询专业律师。"
)


class LegalAdvisorAgent:
    def __init__(self, schema, model):
        self.schema = schema
        self.model = model  # archetypes.fit() 产出的模型
        self.client = get_client()
        self._factor = {f["key"]: f for f in all_factors(schema)}

    # --- 步骤 1:抽取已知因子 ---
    def extract_known(self, case_text):
        from extractor import extract_one
        return extract_one(case_text, schema=self.schema, client=self.client)

    # --- 步骤 2:按全局重要性顺序,追问缺失的重要因子 ---
    def missing_important_questions(self, known):
        questions, asked = [], set()
        for item in self.model["global_importance"]:
            col = item["feature"]
            # 从列名解析出因子 key(跳过罪名维——已判定)
            if col.startswith("charge="):
                continue
            key = col.split(":", 1)[1].split("=", 1)[0]
            if key in asked or key not in known:
                continue
            if known.get(key) is None:  # 该因子适用于本罪名但用户尚未提供
                f = self._factor.get(key, {})
                questions.append({
                    "factor": key,
                    "name_cn": f.get("name_cn", key),
                    "importance": item["score"],
                    "question": f.get("question") or f"请补充:{f.get('name_cn', key)}?",
                })
                asked.add(key)
        return questions

    # --- 步骤 3+4:匹配最近原型并给出建议 ---
    def advise(self, known):
        arch, dist = nearest_archetype(self.model, known)
        m = arch["months"]
        defining = ";".join(
            f"{d['label']}{d['direction']},典型 {d['typical']})"
            for d in arch["defining"][:4]
        )
        evidence = (
            f"- 命中案件原型 #{arch['id']}{arch['charge']},该原型含 {arch['size']} 例),"
            f"匹配距离 {dist:.2f}\n"
            f"- 该原型典型刑期:中位 {m['median']:.0f} 个月,区间 {m['min']:.0f}~{m['max']:.0f} 个月\n"
            f"- 定义该原型的关键因子:{defining}"
        )
        known_desc = self._describe_known(known)

        system = (
            "你是一名严谨的司法数据分析助手。下面给出一个数据驱动模型把某案件匹配到的"
            "『案件原型』及其统计数据(数字均来自模型,不得改动)。请用中文写一段 160 字"
            "以内、条理清晰的量刑参考:先说明命中的原型及其典型刑期区间,再点明本案与该"
            "原型共有的关键因子如何影响结果。不要编造模型未给出的数字,不要给确定性承诺,"
            "不要重复免责声明(系统会另附)。"
        )
        user = f"本案已知因子:\n{known_desc}\n\n模型匹配依据:\n{evidence}"
        resp = self.client.chat.completions.create(
            model=MODEL, temperature=0.3,
            messages=[{"role": "system", "content": system},
                      {"role": "user", "content": user}],
        )
        return arch, resp.choices[0].message.content.strip() + "\n\n" + DISCLAIMER

    def _describe_known(self, known):
        parts = [f"罪名:{known.get('charge')}"]
        for key, v in known.items():
            if key == "charge":
                continue
            f = self._factor.get(key, {})
            if v is None:
                tag = "未知"
            elif isinstance(v, bool):
                tag = "是" if v else "否"
            else:
                tag = str(v)
            parts.append(f"{f.get('name_cn', key)}{tag}")
        return "\n".join("  " + p for p in parts)

archetypes.py

"""
阶段 3:聚类 + 层次重要性 —— 从结构化因子里发现「案件原型」与「因子重要性层次」。

不做刑期回归(那会得到一个说不清理由的黑箱),而是:
  1. 把每条案例的因子翻译成数值特征向量:
       - 罪名 / 分类因子(如伤害等级) 用 one-hot 开关位(不用 1/2/3,避免暗示大小关系);
       - 数值因子(金额/人数)取 ln 压缩量纲;是非情节取 0/1。
     (某因子若同时落在 core 与某罪名扩展里,按 key 去重,特征列不重复。)
  2. 标准化后用 KMeans 聚类,k 由轮廓系数(silhouette)自动挑选,得到若干「案件原型」;
  3. 计算两级重要性:
       - 全局重要性:每个因子在原型之间的区分度(簇间方差占比)→ 全局因子重要性排序;
       - 原型内重要性:每个原型相对全局均值最突出的因子 → 定义该原型的关键特征。
     并统计每个原型的刑期分布(均值/中位/区间)作为「数据驱动的判决经验」。

产出 data/archetypes.json(可读、自洽,含标准化参数与簇心),供对话 Agent 直接引用。
"""
import json
import math
import os

import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

from discovery import all_factors

DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
MODEL_PATH = os.path.join(DATA_DIR, "archetypes.json")


# --- 特征空间:自描述列名,训练与推理共用 -----------------------------------
def build_columns(schema, results):
    """根据 schema + 抽取结果确定有序特征列(列名自描述其含义)。"""
    factors = all_factors(schema)
    kind = {f["key"]: f["kind"] for f in factors}
    charges = sorted({r["extracted"]["charge"] for r in results})

    cols = [f"charge={c}" for c in charges]
    for f in factors:
        k = f["key"]
        if kind[k] == "numeric":
            cols.append(f"num:{k}")
        elif kind[k] == "bool":
            cols.append(f"bool:{k}")
        else:  # categorical:取值集合来自 schema 与实际数据的并集
            vals = set(f.get("values") or [])
            for r in results:
                v = r["extracted"].get(k)
                if v is not None:
                    vals.add(str(v))
            for v in sorted(vals):
                cols.append(f"cat:{k}={v}")
    return cols


def vectorize(extraction, columns):
    """把一条抽取结果转成特征向量,并返回 known 掩码(该维是否有已知取值)。"""
    charge = extraction.get("charge")
    vec, known = [], []
    for col in columns:
        if col.startswith("charge="):
            vec.append(1.0 if charge == col[len("charge="):] else 0.0)
            known.append(True)  # 罪名一旦判定即视为已知
        elif col.startswith("num:"):
            v = extraction.get(col[len("num:"):])
            vec.append(math.log(v) if v else 0.0)
            known.append(v is not None)
        elif col.startswith("bool:"):
            v = extraction.get(col[len("bool:"):])
            vec.append(1.0 if v else 0.0)
            known.append(v is not None)
        else:  # cat:key=value
            body = col[len("cat:"):]
            key, val = body.split("=", 1)
            v = extraction.get(key)
            vec.append(1.0 if (v is not None and str(v) == val) else 0.0)
            known.append(v is not None)
    return np.array(vec), np.array(known)


def column_label(col, schema):
    """列名 -> 中文可读标签。"""
    name_cn = {f["key"]: f["name_cn"] for f in all_factors(schema)}
    if col.startswith("charge="):
        return "罪名=" + col[len("charge="):]
    if col.startswith("num:"):
        return name_cn.get(col[len("num:"):], col[len("num:"):]) + "(对数)"
    if col.startswith("bool:"):
        k = col[len("bool:"):]
        return name_cn.get(k, k)
    body = col[len("cat:"):]
    key, val = body.split("=", 1)
    return f"{name_cn.get(key, key)}={val}"


# --- 聚类 + 层次重要性 ------------------------------------------------------
def fit(schema, results, k_range=range(2, 5), save=True, verbose=True):
    """在**每个罪名内部**聚类出案件原型(书中:在某罪名内自动聚出典型模式),
    再跨全部原型算全局因子重要性。"""
    columns = build_columns(schema, results)
    X_raw = np.array([vectorize(r["extracted"], columns)[0] for r in results])
    months = np.array([r["label_months"] for r in results], dtype=float)
    charges = np.array([r["extracted"]["charge"] for r in results])
    is_charge_col = np.array([c.startswith("charge=") for c in columns])

    scaler = StandardScaler().fit(X_raw)
    Z = scaler.transform(X_raw)

    archetypes, aid, sils = [], 0, []
    for ch in sorted(set(charges)):
        idx = np.where(charges == ch)[0]
        Zc = Z[idx]
        # 该罪名内用轮廓系数挑 k
        best = None
        for k in k_range:
            if k >= len(idx):
                break
            km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Zc)
            sil = silhouette_score(Zc, km.labels_)
            if best is None or sil > best[0]:
                best = (sil, k, km)
        sil, k, km = best
        sils.append(sil)
        if verbose:
            print(f"    {ch}: n={len(idx)}  自动选定 k={k}  轮廓系数={sil:.3f}")
        for c in range(k):
            sub = idx[km.labels_ == c]
            z = km.cluster_centers_[c]         # 标准化空间簇心(全维)
            mth = months[sub]
            # 定义性特征:|簇心| 最大的**非罪名**列(罪名在同一罪名内是常量,不算)
            cand = [j for j in np.argsort(-np.abs(z)) if not is_charge_col[j]][:6]
            defining = [{
                "feature": columns[j],
                "label": column_label(columns[j], schema),
                "z": float(z[j]),
                "direction": "高于平均" if z[j] > 0 else "低于平均",
                "typical": _typical_value(columns[j], float(X_raw[sub, j].mean())),
            } for j in cand]
            archetypes.append({
                "id": aid,
                "charge": ch,
                "size": int(len(sub)),
                "months": {"mean": float(mth.mean()), "median": float(np.median(mth)),
                           "min": float(mth.min()), "max": float(mth.max())},
                "defining": defining,
                "centroid_std": z.tolist(),
            })
            aid += 1

    # 全局重要性:跨全部原型的簇间方差占比(簇心加权方差)
    cents = np.array([a["centroid_std"] for a in archetypes])
    sizes = np.array([a["size"] for a in archetypes])
    weights = sizes / sizes.sum()
    between_var = (weights[:, None] * cents ** 2).sum(axis=0)  # 标准化后总均值≈0
    global_importance = [
        {"feature": columns[j], "label": column_label(columns[j], schema),
         "score": float(between_var[j])}
        for j in np.argsort(-between_var)
    ]

    archetypes.sort(key=lambda a: (a["charge"], a["months"]["median"]))

    model = {
        "columns": columns,
        "scaler_mean": scaler.mean_.tolist(),
        "scaler_scale": scaler.scale_.tolist(),
        "n_archetypes": len(archetypes),
        "silhouette_mean": float(np.mean(sils)),
        "global_importance": global_importance,
        "archetypes": archetypes,
        "n_samples": int(len(results)),
    }
    if save:
        os.makedirs(DATA_DIR, exist_ok=True)
        with open(MODEL_PATH, "w", encoding="utf-8") as fh:
            json.dump(model, fh, ensure_ascii=False, indent=2)
    return model


def _typical_value(col, raw_mean):
    """把某列在簇内的原始均值翻译成人话。"""
    if col.startswith("num:"):
        return f"约 {math.exp(raw_mean):,.0f}" if raw_mean else "多为缺失"
    if col.startswith("charge="):
        return f"{raw_mean*100:.0f}% 为该罪名"
    if col.startswith("cat:"):
        return f"{raw_mean*100:.0f}% 命中"
    return f"{raw_mean*100:.0f}% 具备此情节"  # bool


def load_model():
    with open(MODEL_PATH, encoding="utf-8") as fh:
        return json.load(fh)


def nearest_archetype(model, extraction):
    """把一条(可能不完整的)案件匹配到最近的案件原型:先按罪名圈定候选,
    再只在**已知维度**上比距离(避免用缺失维=0 误导匹配)。"""
    vec, known = vectorize(extraction, model["columns"])
    z = (vec - np.array(model["scaler_mean"])) / np.array(model["scaler_scale"])

    charge = extraction.get("charge")
    cands = [a for a in model["archetypes"] if a["charge"] == charge] \
        or model["archetypes"]
    best = None
    for a in cands:
        diff = (np.array(a["centroid_std"]) - z) * known
        d = float(np.linalg.norm(diff))
        if best is None or d < best[1]:
            best = (a, d)
    return best


# --- 打印 -------------------------------------------------------------------
def print_model(model):
    print(f"  样本数={model['n_samples']}  共发现 {model['n_archetypes']} 个案件原型"
          f"(各罪名内聚类,平均轮廓系数={model['silhouette_mean']:.3f})")
    print("\n  全局因子重要性排序(原型间区分度,越大越是划分原型的关键因子):")
    for i, item in enumerate(model["global_importance"][:10], 1):
        print(f"    {i:>2}. {item['label']:<22} 区分度={item['score']:.3f}")
    print("\n  案件原型(按罪名 + 典型刑期中位数排序):")
    for a in model["archetypes"]:
        m = a["months"]
        print(f"\n    ▸ 原型#{a['id']} [{a['charge']}]  规模 {a['size']} 例"
              f"  典型刑期 中位 {m['median']:.0f} 月 / 区间 {m['min']:.0f}~{m['max']:.0f} 月")
        for d in a["defining"][:4]:
            print(f"        · {d['label']:<20} {d['direction']}(z={d['z']:+.2f})  典型:{d['typical']}")

config.py

"""
全局配置:加载环境变量、提供 OpenAI 客户端与默认模型名。

只依赖官方 OpenAI SDK,读取 OPENAI_API_KEY。
默认模型 gpt-5.6-luna(便宜、够用于因子发现、结构化抽取与文案生成)。
"""
import os

from openai import OpenAI

try:
    # 可选:如果安装了 python-dotenv,则自动加载同目录 .env
    from dotenv import load_dotenv

    load_dotenv()
except Exception:  # pragma: no cover - dotenv 是可选依赖
    pass

def _openrouter_model_id(model) -> str:
    """将供应商原生模型名映射为 OpenRouter 模型 id(通用 OpenRouter 回退用)。
    显式的 OPENROUTER_MODEL 环境变量优先。"""
    override = os.getenv("OPENROUTER_MODEL")
    if override:
        return override
    m = (model or "").strip()
    if not m:
        return "openai/gpt-5.6-luna"
    if "/" in m:
        return m
    ml = m.lower()
    if ml.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
        return "openai/" + m
    if ml.startswith("claude-"):
        return "anthropic/claude-opus-4.8"
    if ml.startswith("kimi"):
        # kimi-k3 is not on OpenRouter; moonshotai/kimi-k2.6 is the closest hosted id.
        return "moonshotai/kimi-k2.6"
    return "openai/gpt-5.6-luna"


# 默认模型,可用环境变量覆盖
MODEL = os.getenv("OPENAI_MODEL", "gpt-5.6-luna")

# 通用 OpenRouter 回退:若没有 OPENAI_API_KEY 但设置了 OPENROUTER_API_KEY,
# 则把聊天模型路由到 OpenRouter,并把模型名映射为 OpenRouter 的 id。
_OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
_OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
# gpt-5.x(含 gpt-5.6*)在 OpenAI 直连 API 上需要组织实名认证;只要设置了
# OPENROUTER_API_KEY,就优先把这类 id 走 OpenRouter。
_PREFER_OPENROUTER = bool(_OPENROUTER_API_KEY) and MODEL.lower().startswith("gpt-5")
_USE_OPENROUTER = _PREFER_OPENROUTER or ((not _OPENAI_API_KEY) and bool(_OPENROUTER_API_KEY))
if _USE_OPENROUTER:
    MODEL = _openrouter_model_id(MODEL)


def get_client() -> OpenAI:
    """返回一个配置好的 OpenAI 客户端。

    优先使用官方端点(读取 OPENAI_API_KEY);若缺失则在存在 OPENROUTER_API_KEY 时
    回退到 OpenRouter(OpenAI 兼容端点)。"""
    # timeout + 自动重试:发现/抽取阶段要连续发几十次请求,单次瞬时错误
    # (网络抖动 / 限流 / 5xx)不应中断整条流水线。
    if _OPENAI_API_KEY and not _PREFER_OPENROUTER:
        return OpenAI(api_key=_OPENAI_API_KEY, timeout=60.0, max_retries=5)
    if _OPENROUTER_API_KEY:
        return OpenAI(
            api_key=_OPENROUTER_API_KEY,
            base_url="https://openrouter.ai/api/v1",
            timeout=60.0,
            max_retries=5,
        )
    raise RuntimeError(
        "未找到 OPENAI_API_KEY 或 OPENROUTER_API_KEY,请先 `cp env.example .env` "
        "并填入你的 OpenAI Key(或 OpenRouter Key 作为回退)。"
    )

demo.py

"""
实验 3-13 全流程演示:从司法判例中提取隐性知识。

运行:
    python demo.py

依次执行四个阶段:
  阶段 1  自下而上因子发现:让 LLM 自由归纳因子,归并成模块化 schema(核心+各罪名扩展);
  阶段 2  结构化抽取:用发现的 schema 从每条判例抽取因子(带缓存);
  阶段 3  聚类 + 层次重要性:把因子向量聚成「案件原型」,算全局与原型内因子重要性;
  阶段 4  对话式建议 Agent:把新案情匹配到最近原型,按重要性追问缺失因子,给出建议。
"""
import json
import os
import sys

import archetypes
import discovery
from advisor_agent import LegalAdvisorAgent
from extractor import extract_dataset, load_dataset


def section(title):
    print("\n" + "=" * 74)
    print(title)
    print("=" * 74)


def main():
    cases = load_dataset()

    # ---------- 阶段 1:自下而上因子发现 ----------
    section("阶段 1 / 自下而上因子发现(LLM 自由归纳 → 模块化 schema)")
    schema = discovery.discover_schema(cases, batch_size=12, use_cache=True)
    discovery.print_schema(schema)

    # ---------- 阶段 2:结构化抽取 ----------
    section("阶段 2 / 结构化抽取(用发现的 schema 抽取每条判例的因子)")
    results = extract_dataset(schema, use_cache=True, verbose=True)
    print("\n抽取样例(前 2 条):")
    for r in results[:2]:
        print(f"\n[{r['id']}] {r['fact'][:56]}...")
        print(f"  抽取: {json.dumps(r['extracted'], ensure_ascii=False)}")

    # ---------- 阶段 3:聚类 + 层次重要性 ----------
    section("阶段 3 / 聚类成案件原型 + 层次因子重要性")
    model = archetypes.fit(schema, results, save=True)
    archetypes.print_model(model)
    print(f"\n  模型已保存 -> {os.path.join('data', 'archetypes.json')}")

    # ---------- 阶段 4:对话式量刑建议 Agent ----------
    section("阶段 4 / 对话式量刑建议 Agent(匹配最近原型 + 按重要性追问)")
    agent = LegalAdvisorAgent(schema, model)

    user_turn1 = (
        "我朋友之前因为盗窃被判过刑,这次他撬门进了别人家里偷东西,被抓的时候没反抗。"
        "这种情况大概会判多久?"
    )
    print(f"\n用户: {user_turn1}")
    known = agent.extract_known(user_turn1)
    print(f"\nAgent 已识别因子: {json.dumps(known, ensure_ascii=False)}")

    questions = agent.missing_important_questions(known)
    print("\nAgent 追问(按全局因子重要性排序,只问缺失且重要的):")
    for q in questions[:5]:
        print(f"  - [{q['name_cn']} 重要度{q['importance']:.3f}] {q['question']}")

    user_turn2 = (
        "补充一下:这次偷的东西价值大概 5 万元,事后他没有退赃,"
        "作案时也没带凶器,是他一个人干的,到了法庭上他认罪认罚了。"
    )
    print(f"\n用户: {user_turn2}")
    known2 = agent.extract_known(user_turn1 + " " + user_turn2)
    print(f"\nAgent 更新后的因子: {json.dumps(known2, ensure_ascii=False)}")

    arch, advice = agent.advise(known2)
    print(f"\nAgent 匹配到 原型#{arch['id']}(典型刑期中位 {arch['months']['median']:.0f} 月)")
    print("\nAgent 量刑建议:\n")
    print(advice)


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as exc:
        print(f"启动失败:{exc}", file=sys.stderr)
        sys.exit(1)

discovery.py

"""
阶段 1:自下而上的因子发现(bottom-up factor discovery)。

不预先定义任何僵化的数据模式,而是:
  1. 把判例文本分批喂给 LLM,让它**自由列出**每一批案例中所有可能影响判决的因素;
  2. 汇总各批发现的原始因子,再用一次 LLM 调用做**归并与规范化**,产出一个
     「模块化数据模式」:
        - core        —— 适用于所有罪名的通用因子(自首、赔偿、认罪、前科……);
        - extensions  —— 各罪名特有的扩展因子(盗窃→涉案金额/入户;伤害→伤害等级……)。

产出的 schema 落盘到 data/schema.json,供后续抽取 / 聚类 / 对话三段复用。
schema 里每个因子含:key(英文)、name_cn、kind(numeric/bool/categorical)、
values(categorical 取值)、direction(aggravating/mitigating/neutral)、question(引导性追问)。
"""
import json
import os

from config import MODEL, get_client

DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
SCHEMA_PATH = os.path.join(DATA_DIR, "schema.json")

_BATCH_SYS = """你是协助司法数据分析的专家。下面给你若干条刑事判决书的「事实」段落。
请你**自由归纳**出其中所有可能影响法院量刑/判决的因素(不要局限于任何预设清单)。
对每个因素给出:
  - key: 简短英文 snake_case 标识
  - name_cn: 中文名
  - charge: 该因素主要适用的罪名(若各类案件通用则填 "通用")
  - kind: numeric(数值,如金额/人数)| bool(是非情节)| categorical(多取值,如伤害等级)
  - values: 若 kind 为 categorical,列出观察到的取值数组;否则为空数组
只输出 JSON:{"factors": [ {factor...}, ... ]}"""

_CONSOLIDATE_SYS = """你是司法数据建模专家。下面是从多批判例中分别发现的**原始因子清单**
(可能有重复、同义、命名不一致)。请把它们**归并、去重、规范化**成一个模块化数据模式:
  - core: 适用于所有罪名的通用因子(如自首、赔偿谅解、认罪认罚、前科累犯)
  - extensions: 一个对象,键为罪名(如 "盗窃罪"/"故意伤害罪"/"诈骗罪"),值为该罪名特有的因子数组
规范化要求:
  - 合并同义因子(如"自首/主动投案"、"认罪认罚/认罪/如实供述"、"赔偿/退赔/退赃"、
    "累犯/前科"、同一罪名下的"涉案金额/物品价值/诈骗金额"只保留一个),
    每组只保留一个最清晰的 key 与中文名;
  - 剔除与量刑无实质关系的因素(如被告人性别、案发地点这类描述性信息);
  - "是否否认指控/辩称正当防卫"这类与"认罪认罚"互为反面的,不要重复保留。
每个因子输出字段:
  key, name_cn, kind(numeric|bool|categorical), values(categorical 的取值数组,否则[]),
  direction(aggravating 从重 | mitigating 从轻 | neutral 中性),
  question(当该因子缺失时,向当事人提出的一句中文引导性问题)
只输出 JSON:{"core": [...], "extensions": {"罪名": [...], ...}}"""


def _chat_json(client, system, user):
    resp = client.chat.completions.create(
        model=MODEL,
        temperature=0,
        response_format={"type": "json_object"},
        messages=[{"role": "system", "content": system},
                  {"role": "user", "content": user}],
    )
    return json.loads(resp.choices[0].message.content)


def discover_schema(cases, batch_size=12, use_cache=True, verbose=True):
    """自下而上发现因子并归并成模块化 schema。带磁盘缓存(避免重复花钱)。"""
    if use_cache and os.path.exists(SCHEMA_PATH):
        with open(SCHEMA_PATH, encoding="utf-8") as fh:
            if verbose:
                print(f"  命中缓存 schema -> {SCHEMA_PATH}")
            return json.load(fh)

    client = get_client()

    # --- 第 1 步:分批自由发现 ---
    raw_factors = []
    for start in range(0, len(cases), batch_size):
        batch = cases[start:start + batch_size]
        facts = "\n\n".join(f"[案例{start + j + 1}]({c['charge']}{c['fact']}"
                            for j, c in enumerate(batch))
        out = _chat_json(client, _BATCH_SYS, facts)
        got = out.get("factors", [])
        raw_factors.extend(got)
        if verbose:
            print(f"  批次 {start // batch_size + 1}:发现 {len(got)} 个候选因子")

    # --- 第 2 步:归并 / 规范化成模块化 schema ---
    if verbose:
        print(f"  汇总 {len(raw_factors)} 个原始因子,做归并与规范化 ...")
    schema = _chat_json(client, _CONSOLIDATE_SYS,
                        "原始因子清单:\n" + json.dumps(raw_factors, ensure_ascii=False))
    schema.setdefault("core", [])
    schema.setdefault("extensions", {})

    os.makedirs(DATA_DIR, exist_ok=True)
    with open(SCHEMA_PATH, "w", encoding="utf-8") as fh:
        json.dump(schema, fh, ensure_ascii=False, indent=2)
    if verbose:
        print(f"  发现的模块化 schema 已保存 -> {SCHEMA_PATH}")
    return schema


# --- schema 便捷访问 ---------------------------------------------------------
def load_schema():
    with open(SCHEMA_PATH, encoding="utf-8") as fh:
        return json.load(fh)


def factors_for_charge(schema, charge):
    """返回某罪名适用的因子列表:核心通用因子 + 该罪名扩展因子(按 key 去重)。"""
    seen, out = set(), []
    for f in schema.get("core", []) + schema.get("extensions", {}).get(charge, []):
        if f["key"] in seen:  # 去重:某因子同时落在 core 和扩展里时只保留一次
            continue
        seen.add(f["key"])
        out.append(f)
    return out


def all_factors(schema):
    """全部因子(core + 所有扩展),按 key 去重。"""
    seen, out = set(), []
    lists = [schema.get("core", [])] + list(schema.get("extensions", {}).values())
    for lst in lists:
        for f in lst:
            if f["key"] in seen:
                continue
            seen.add(f["key"])
            out.append(f)
    return out


def print_schema(schema):
    print("  核心通用因子 (core):")
    for f in schema.get("core", []):
        vals = f"={f['values']}" if f.get("values") else ""
        print(f"    - {f['key']:<16} {f['name_cn']}  [{f['kind']}{vals}] {f.get('direction','')}")
    for charge, lst in schema.get("extensions", {}).items():
        print(f"  扩展因子 · {charge}:")
        for f in lst:
            vals = f"={f['values']}" if f.get("values") else ""
            print(f"    - {f['key']:<16} {f['name_cn']}  [{f['kind']}{vals}] {f.get('direction','')}")

extractor.py

"""
阶段 2:结构化抽取 —— 用发现出来的 schema 从判例文本抽取结构化因子。

流程:
  1. 先判定案件罪名(从 schema 已知的罪名里选);
  2. 按「核心通用因子 + 该罪名扩展因子」逐项抽取,输出结构化 JSON;
  3. 文本未提及的因子返回 null(供对话 Agent 判断"还缺什么信息");
  4. 带磁盘缓存(data/extracted.jsonl),一次性抽取后重跑几乎免费。

输出统一为 {"charge": <罪名>, <factor_key>: <值|null>, ...}。
"""
import json
import os

from config import MODEL, get_client
from discovery import factors_for_charge, load_schema

DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
CACHE_PATH = os.path.join(DATA_DIR, "extracted.jsonl")


def _factor_lines(factors):
    lines = []
    for f in factors:
        if f["kind"] == "numeric":
            t = "数值(整数,去掉单位)"
        elif f["kind"] == "bool":
            t = "true/false"
        else:
            t = "取值之一:" + "/".join(f.get("values", [])) if f.get("values") else "分类取值"
        lines.append(f'  - "{f["key"]}": {t}  # {f["name_cn"]}')
    return "\n".join(lines)


def _charges(schema):
    return list(schema.get("extensions", {}).keys())


def extract_one(fact_text, schema=None, client=None, charge=None):
    """从单条判例文本抽取 {charge, factors...}。缺失因子取 null。

    charge 已知时(数据集抽取)直接沿用,省一次调用;未知时(对话新案情)先让 LLM 判定。
    """
    schema = schema or load_schema()
    client = client or get_client()
    charges = _charges(schema)

    # 第 1 步:判定罪名(仅在未提供时调用 LLM)
    if charge is None:
        charge_resp = client.chat.completions.create(
            model=MODEL, temperature=0,
            response_format={"type": "json_object"},
            messages=[
                {"role": "system", "content":
                    "判断下述刑事案件属于哪个罪名,只能从这些里选:"
                    + "/".join(charges) + '。只输出 JSON:{"charge": "..."}。'},
                {"role": "user", "content": fact_text},
            ],
        )
        charge = json.loads(charge_resp.choices[0].message.content).get("charge")
    if charge not in charges:  # 兜底:默认第一个罪名
        charge = charges[0]

    # 第 2 步:按该罪名适用的因子抽取
    factors = factors_for_charge(schema, charge)
    sys = (
        "你是协助司法数据分析的信息抽取助手。请从判决书「事实」段落中抽取以下因子,"
        "只输出一个 JSON 对象:\n" + _factor_lines(factors) + "\n\n规则:\n"
        "1. 数值因子输出整数(去掉'元''人民币''名'等字样)。\n"
        "2. 是非因子:文本明确支持则 true,明确否定则 false。\n"
        "3. 分类因子只能取给定取值之一。\n"
        "4. 文本完全没有相关信息的因子取 null(不要臆测)。\n"
        "5. 只输出 JSON,不要解释。"
    )
    resp = client.chat.completions.create(
        model=MODEL, temperature=0,
        response_format={"type": "json_object"},
        messages=[{"role": "system", "content": sys},
                  {"role": "user", "content": f"判决书事实段落:\n{fact_text}"}],
    )
    raw = json.loads(resp.choices[0].message.content)
    return _normalize(raw, charge, factors)


def _normalize(raw, charge, factors):
    out = {"charge": charge}
    for f in factors:
        v = raw.get(f["key"])
        if v is None or v == "":
            out[f["key"]] = None
        elif f["kind"] == "numeric":
            if isinstance(v, str):
                digits = "".join(ch for ch in v if ch.isdigit())
                out[f["key"]] = int(digits) if digits else None
            else:
                try:
                    out[f["key"]] = int(v)
                except (TypeError, ValueError):
                    out[f["key"]] = None
        elif f["kind"] == "bool":
            out[f["key"]] = bool(v) if isinstance(v, bool) else str(v).lower() in ("true", "1", "是")
        else:  # categorical
            out[f["key"]] = str(v)
    return out


def load_dataset():
    path = os.path.join(DATA_DIR, "cases.jsonl")
    with open(path, encoding="utf-8") as fh:
        return [json.loads(line) for line in fh if line.strip()]


def extract_dataset(schema, use_cache=True, verbose=True):
    """对整个数据集抽取,带缓存。返回 list,每项含原案例字段 + `extracted`。"""
    cases = load_dataset()
    cache = {}
    if use_cache and os.path.exists(CACHE_PATH):
        with open(CACHE_PATH, encoding="utf-8") as fh:
            for line in fh:
                if line.strip():
                    rec = json.loads(line)
                    cache[rec["id"]] = rec["extracted"]

    client = get_client()
    results, n_called = [], 0
    for c in cases:
        if c["id"] in cache:
            extracted = cache[c["id"]]
        else:
            extracted = extract_one(c["fact"], schema=schema, client=client,
                                    charge=c.get("charge"))
            cache[c["id"]] = extracted
            n_called += 1
            if verbose:
                print(f"  抽取 {c['id']} ({extracted.get('charge')}) ... 完成")
        results.append({**c, "extracted": extracted})

    with open(CACHE_PATH, "w", encoding="utf-8") as fh:
        for r in results:
            fh.write(json.dumps({"id": r["id"], "extracted": r["extracted"]},
                                ensure_ascii=False) + "\n")
    if verbose:
        print(f"  本次实际调用 LLM {n_called} 次,其余命中缓存。")
    return results

generate_data.py

"""
合成一个小样本、多罪名的刑事判例数据集。

CAIL2018(真实目标数据集)体量太大(数百万条),不便随仓库分发;本实验自带一个
可离线运行的小样本,覆盖三类罪名:盗窃罪、故意伤害罪、诈骗罪。

每条案例包含:
  - `charge`  罪名(生成时已知,仅作参考;抽取阶段会由 LLM 自行判定);
  - `fact`    一段自然语言判决书事实描述;
  - `gold`    生成时使用的因子真值(仅供人工核对,抽取不依赖它);
  - `label_months` 刑期(月),由一个「已知」的量刑公式加噪声生成。

关键点:**因子在生成时被"写进"案情文本,发现阶段再从文本里把它们"读"回来**。
生成用的字段名(英文 key)只服务于本文件,下游的因子发现完全不依赖它——发现阶段
让 LLM 自由归纳因子,因此学到的模式来自数据本身,而非这里的先验字段列表。

真实迁移:把本文件替换为读取 CAIL2018 的 `data_*.json`(每行含 `fact` 与
`meta.term_of_imprisonment` 与 `meta.accusation`),产出同样结构的 `cases.jsonl` 即可。
"""
import json
import math
import os
import random

random.seed(42)

DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
OUT_PATH = os.path.join(DATA_DIR, "cases.jsonl")

NAMES = list("赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹")
LOCATIONS = ["某小区", "某商场", "某写字楼", "某菜市场", "某手机专卖店", "某电动车棚", "某网吧"]


# ---------------------------------------------------------------------------
# 盗窃罪
# ---------------------------------------------------------------------------
def gen_theft(i: int) -> dict:
    amount = int(round(random.uniform(1500, 400000), -1))
    f = {
        "prior_record": random.random() < 0.5,
        "surrender": random.random() < 0.45,
        "restitution": random.random() < 0.5,
        "confession": random.random() < 0.6,
        "burglary": random.random() < 0.45,
        "carry_weapon": random.random() < 0.25,
        "gang": random.random() < 0.4,
    }
    m = -18 + 5.2 * math.log(amount)
    m += f["prior_record"] * 11 + f["burglary"] * 7 + f["carry_weapon"] * 5 + f["gang"] * 3
    m += -f["surrender"] * 9 - f["restitution"] * 6 - f["confession"] * 3
    m += random.gauss(0, 1.2)
    months = int(max(1, min(180, round(m))))

    name = "被告人" + random.choice(NAMES) + "某"
    prior = "曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。" if f["prior_record"] else "此前无违法犯罪记录。"
    scene = f"翻窗入户进入被害人位于{random.choice(LOCATIONS)}的住宅内" if f["burglary"] else f"在{random.choice(LOCATIONS)}内"
    weapon = ",作案时随身携带匕首一把" if f["carry_weapon"] else ""
    gang = "伙同他人结伙" if f["gang"] else "单独"
    surrender = "案发后主动到公安机关投案自首," if f["surrender"] else "后被公安机关抓获归案,"
    restitution = "已退赔全部赃款并取得谅解。" if f["restitution"] else "赃款已被挥霍,未退赔。"
    confession = "当庭认罪认罚。" if f["confession"] else "当庭对指控予以否认。"
    fact = (
        f"{name},男。{prior}经审理查明:{name}{gang}{scene}{weapon}窃取他人财物,"
        f"经鉴定价值人民币{amount}元。{surrender}{restitution}{confession}"
    )
    return {"id": f"theft_{i:02d}", "charge": "盗窃罪", "fact": fact,
            "gold": {"amount": amount, **f}, "label_months": months}


# ---------------------------------------------------------------------------
# 故意伤害罪
# ---------------------------------------------------------------------------
_INJURY_BASE = {"轻微伤": 2.0, "轻伤": 12.0, "重伤": 40.0}


def gen_assault(i: int) -> dict:
    injury = random.choice(["轻微伤", "轻微伤", "轻伤", "轻伤", "重伤"])
    f = {
        "prior_record": random.random() < 0.35,
        "surrender": random.random() < 0.4,
        "restitution": random.random() < 0.55,  # 赔偿谅解在伤害案中权重很大
        "confession": random.random() < 0.6,
        "injury_level": injury,
        "armed": random.random() < 0.45,
        "premeditated": random.random() < 0.3,
        "gang": random.random() < 0.35,
    }
    m = _INJURY_BASE[injury]
    m += f["prior_record"] * 8 + f["armed"] * 10 + f["premeditated"] * 8 + f["gang"] * 4
    m += -f["surrender"] * 6 - f["restitution"] * 10 - f["confession"] * 3
    m += random.gauss(0, 1.0)
    months = int(max(1, min(180, round(m))))

    name = "被告人" + random.choice(NAMES) + "某"
    prior = "曾因寻衅滋事被判刑,系累犯。" if f["prior_record"] else "平时表现尚可,无前科。"
    plan = "因积怨已久、事先预谋," if f["premeditated"] else "因琐事发生口角后,"
    gang = "纠集多人" if f["gang"] else "持"
    weapon = ("持械(砍刀)" if f["armed"] else "赤手空拳") if not f["gang"] else ("并持械" if f["armed"] else "")
    injury_desc = {"轻微伤": "经鉴定为轻微伤", "轻伤": "经鉴定为轻伤二级", "重伤": "经鉴定为重伤二级"}[injury]
    surrender = "案发后主动投案自首," if f["surrender"] else "作案后逃离现场,后被抓获,"
    restitution = "已赔偿被害人损失并取得谅解。" if f["restitution"] else "未赔偿被害人损失。"
    confession = "当庭认罪认罚。" if f["confession"] else "当庭辩称系正当防卫。"
    fact = (
        f"{name},男。{prior}经审理查明:{name}{plan}{gang}{weapon}殴打被害人,"
        f"致其{injury_desc}{surrender}{restitution}{confession}"
    )
    return {"id": f"assault_{i:02d}", "charge": "故意伤害罪", "fact": fact,
            "gold": f, "label_months": months}


# ---------------------------------------------------------------------------
# 诈骗罪
# ---------------------------------------------------------------------------
def gen_fraud(i: int) -> dict:
    amount = int(round(random.uniform(8000, 800000), -1))
    scam = random.choice(["电信网络", "电信网络", "合同", "普通"])
    victims = random.randint(1, 40) if scam == "电信网络" else random.randint(1, 5)
    f = {
        "prior_record": random.random() < 0.35,
        "surrender": random.random() < 0.4,
        "restitution": random.random() < 0.45,
        "confession": random.random() < 0.6,
        "scam_type": scam,
        "victim_count": victims,
        "gang": random.random() < 0.5,
    }
    m = -22 + 6.0 * math.log(amount)
    m += f["prior_record"] * 10 + f["gang"] * 4
    m += {"电信网络": 8.0, "合同": 3.0, "普通": 0.0}[scam]
    m += math.log(victims + 1) * 3.0
    m += -f["surrender"] * 8 - f["restitution"] * 7 - f["confession"] * 3
    m += random.gauss(0, 1.2)
    months = int(max(1, min(180, round(m))))

    name = "被告人" + random.choice(NAMES) + "某"
    prior = "曾因诈骗被判刑,系累犯。" if f["prior_record"] else "此前无犯罪记录。"
    method = {
        "电信网络": f"通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取{victims}名被害人",
        "合同": "在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人",
        "普通": "以帮忙办事为由,虚构事实骗取被害人",
    }[scam]
    gang = "伙同他人组成团伙," if f["gang"] else ""
    surrender = "案发后主动投案自首," if f["surrender"] else "后被公安机关抓获,"
    restitution = "已退赔全部赃款。" if f["restitution"] else "赃款未追回。"
    confession = "当庭认罪认罚。" if f["confession"] else "当庭否认诈骗故意。"
    fact = (
        f"{name},男。{prior}经审理查明:{name}{gang}{method}钱财,"
        f"骗取财物共计人民币{amount}元。{surrender}{restitution}{confession}"
    )
    return {"id": f"fraud_{i:02d}", "charge": "诈骗罪", "fact": fact,
            "gold": {"amount": amount, **f}, "label_months": months}


def main():
    os.makedirs(DATA_DIR, exist_ok=True)
    cases = []
    for i in range(1, 25):   # 24 盗窃
        cases.append(gen_theft(i))
    for i in range(1, 23):   # 22 故意伤害
        cases.append(gen_assault(i))
    for i in range(1, 21):   # 20 诈骗  -> 共 66 条
        cases.append(gen_fraud(i))
    random.shuffle(cases)

    with open(OUT_PATH, "w", encoding="utf-8") as fh:
        for c in cases:
            fh.write(json.dumps(c, ensure_ascii=False) + "\n")

    months = [c["label_months"] for c in cases]
    print(f"已生成 {len(cases)} 条案例(盗窃/故意伤害/诈骗)-> {OUT_PATH}")
    print(f"刑期范围: {min(months)}~{max(months)} 个月,均值 {sum(months)/len(months):.1f}")


if __name__ == "__main__":
    main()

test_normalize_numeric.py

"""Regression tests: _normalize must coerce non-scalar LLM-provided JSON values
(e.g. lists/dicts for a numeric factor) to None instead of raising TypeError."""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from extractor import _normalize

FACTORS = [{"key": "amount", "kind": "numeric", "values": []}]


def test_numeric_list_value_becomes_none():
    out = _normalize({"amount": ["约10万元"]}, "盗窃罪", FACTORS)
    assert out == {"charge": "盗窃罪", "amount": None}


def test_numeric_dict_value_becomes_none():
    out = _normalize({"amount": {"value": 5}}, "盗窃罪", FACTORS)
    assert out["amount"] is None


def test_numeric_string_extracts_digits():
    out = _normalize({"amount": "约105000元"}, "盗窃罪", FACTORS)
    assert out["amount"] == 105000


def test_numeric_plain_int_unchanged():
    out = _normalize({"amount": 5000}, "盗窃罪", FACTORS)
    assert out["amount"] == 5000


def test_numeric_null_stays_none():
    out = _normalize({"amount": None}, "盗窃罪", FACTORS)
    assert out["amount"] is None

data/cases.jsonl

{"id": "theft_24", "charge": "盗窃罪", "fact": "被告人冯某,男。此前无违法犯罪记录。经审理查明:被告人冯某伙同他人结伙在某电动车棚内窃取他人财物,经鉴定价值人民币34170元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 34170, "prior_record": false, "surrender": false, "restitution": true, "confession": true, "burglary": false, "carry_weapon": false, "gang": true}, "label_months": 30}
{"id": "assault_06", "charge": "故意伤害罪", "fact": "被告人冯某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人冯某因琐事发生口角后,纠集多人殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭认罪认罚。", "gold": {"prior_record": true, "surrender": false, "restitution": false, "confession": true, "injury_level": "轻微伤", "armed": false, "premeditated": false, "gang": true}, "label_months": 12}
{"id": "assault_07", "charge": "故意伤害罪", "fact": "被告人严某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人严某因积怨已久、事先预谋,纠集多人殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭辩称系正当防卫。", "gold": {"prior_record": true, "surrender": false, "restitution": false, "confession": false, "injury_level": "轻微伤", "armed": false, "premeditated": true, "gang": true}, "label_months": 23}
{"id": "assault_18", "charge": "故意伤害罪", "fact": "被告人严某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人严某因琐事发生口角后,纠集多人殴打被害人,致其经鉴定为轻伤二级。案发后主动投案自首,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": true, "surrender": true, "restitution": true, "confession": true, "injury_level": "轻伤", "armed": false, "premeditated": false, "gang": true}, "label_months": 5}
{"id": "theft_11", "charge": "盗窃罪", "fact": "被告人郑某,男。此前无违法犯罪记录。经审理查明:被告人郑某伙同他人结伙在某网吧内窃取他人财物,经鉴定价值人民币107830元。案发后主动到公安机关投案自首,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 107830, "prior_record": false, "surrender": true, "restitution": true, "confession": true, "burglary": false, "carry_weapon": false, "gang": true}, "label_months": 27}
{"id": "theft_23", "charge": "盗窃罪", "fact": "被告人孙某,男。此前无违法犯罪记录。经审理查明:被告人孙某单独在某手机专卖店内窃取他人财物,经鉴定价值人民币24170元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 24170, "prior_record": false, "surrender": false, "restitution": true, "confession": true, "burglary": false, "carry_weapon": false, "gang": false}, "label_months": 24}
{"id": "assault_19", "charge": "故意伤害罪", "fact": "被告人华某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人华某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为重伤二级。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": true, "surrender": false, "restitution": true, "confession": true, "injury_level": "重伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 43}
{"id": "theft_13", "charge": "盗窃罪", "fact": "被告人姜某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人姜某单独翻窗入户进入被害人位于某网吧的住宅内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币130680元。后被公安机关抓获归案,赃款已被挥霍,未退赔。当庭对指控予以否认。", "gold": {"amount": 130680, "prior_record": true, "surrender": false, "restitution": false, "confession": false, "burglary": true, "carry_weapon": true, "gang": false}, "label_months": 67}
{"id": "assault_11", "charge": "故意伤害罪", "fact": "被告人朱某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人朱某因琐事发生口角后,持赤手空拳殴打被害人,致其经鉴定为轻伤二级。案发后主动投案自首,未赔偿被害人损失。当庭认罪认罚。", "gold": {"prior_record": true, "surrender": true, "restitution": false, "confession": true, "injury_level": "轻伤", "armed": false, "premeditated": false, "gang": false}, "label_months": 11}
{"id": "theft_17", "charge": "盗窃罪", "fact": "被告人郑某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人郑某伙同他人结伙在某小区内窃取他人财物,经鉴定价值人民币206390元。后被公安机关抓获归案,赃款已被挥霍,未退赔。当庭对指控予以否认。", "gold": {"amount": 206390, "prior_record": true, "surrender": false, "restitution": false, "confession": false, "burglary": false, "carry_weapon": false, "gang": true}, "label_months": 58}
{"id": "theft_19", "charge": "盗窃罪", "fact": "被告人郑某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人郑某单独翻窗入户进入被害人位于某小区的住宅内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币344880元。案发后主动到公安机关投案自首,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 344880, "prior_record": true, "surrender": true, "restitution": true, "confession": true, "burglary": true, "carry_weapon": true, "gang": false}, "label_months": 51}
{"id": "theft_21", "charge": "盗窃罪", "fact": "被告人姜某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人姜某单独翻窗入户进入被害人位于某商场的住宅内窃取他人财物,经鉴定价值人民币67110元。案发后主动到公安机关投案自首,赃款已被挥霍,未退赔。当庭对指控予以否认。", "gold": {"amount": 67110, "prior_record": true, "surrender": true, "restitution": false, "confession": false, "burglary": true, "carry_weapon": false, "gang": false}, "label_months": 49}
{"id": "fraud_03", "charge": "诈骗罪", "fact": "被告人邹某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人邹某以帮忙办事为由,虚构事实骗取被害人钱财,骗取财物共计人民币686530元。案发后主动投案自首,赃款未追回。当庭否认诈骗故意。", "gold": {"amount": 686530, "prior_record": true, "surrender": true, "restitution": false, "confession": false, "scam_type": "普通", "victim_count": 4, "gang": false}, "label_months": 64}
{"id": "theft_12", "charge": "盗窃罪", "fact": "被告人赵某,男。此前无违法犯罪记录。经审理查明:被告人赵某单独翻窗入户进入被害人位于某手机专卖店的住宅内窃取他人财物,经鉴定价值人民币250680元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 250680, "prior_record": false, "surrender": false, "restitution": true, "confession": true, "burglary": true, "carry_weapon": false, "gang": false}, "label_months": 45}
{"id": "fraud_16", "charge": "诈骗罪", "fact": "被告人谢某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人谢某伙同他人组成团伙,通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取6名被害人钱财,骗取财物共计人民币362950元。后被公安机关抓获,已退赔全部赃款。当庭否认诈骗故意。", "gold": {"amount": 362950, "prior_record": true, "surrender": false, "restitution": true, "confession": false, "scam_type": "电信网络", "victim_count": 6, "gang": true}, "label_months": 74}
{"id": "fraud_02", "charge": "诈骗罪", "fact": "被告人钱某,男。此前无犯罪记录。经审理查明:被告人钱某在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币336580元。案发后主动投案自首,赃款未追回。当庭认罪认罚。", "gold": {"amount": 336580, "prior_record": false, "surrender": true, "restitution": false, "confession": true, "scam_type": "合同", "victim_count": 5, "gang": false}, "label_months": 50}
{"id": "theft_02", "charge": "盗窃罪", "fact": "被告人赵某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人赵某单独翻窗入户进入被害人位于某网吧的住宅内窃取他人财物,经鉴定价值人民币202880元。案发后主动到公安机关投案自首,赃款已被挥霍,未退赔。当庭认罪认罚。", "gold": {"amount": 202880, "prior_record": true, "surrender": true, "restitution": false, "confession": true, "burglary": true, "carry_weapon": false, "gang": false}, "label_months": 52}
{"id": "fraud_10", "charge": "诈骗罪", "fact": "被告人许某,男。此前无犯罪记录。经审理查明:被告人许某伙同他人组成团伙,以帮忙办事为由,虚构事实骗取被害人钱财,骗取财物共计人民币464030元。案发后主动投案自首,赃款未追回。当庭否认诈骗故意。", "gold": {"amount": 464030, "prior_record": false, "surrender": true, "restitution": false, "confession": false, "scam_type": "普通", "victim_count": 4, "gang": true}, "label_months": 59}
{"id": "fraud_05", "charge": "诈骗罪", "fact": "被告人魏某,男。此前无犯罪记录。经审理查明:被告人魏某以帮忙办事为由,虚构事实骗取被害人钱财,骗取财物共计人民币581550元。后被公安机关抓获,赃款未追回。当庭认罪认罚。", "gold": {"amount": 581550, "prior_record": false, "surrender": false, "restitution": false, "confession": true, "scam_type": "普通", "victim_count": 2, "gang": false}, "label_months": 59}
{"id": "fraud_13", "charge": "诈骗罪", "fact": "被告人魏某,男。此前无犯罪记录。经审理查明:被告人魏某以帮忙办事为由,虚构事实骗取被害人钱财,骗取财物共计人民币621520元。后被公安机关抓获,已退赔全部赃款。当庭认罪认罚。", "gold": {"amount": 621520, "prior_record": false, "surrender": false, "restitution": true, "confession": true, "scam_type": "普通", "victim_count": 4, "gang": false}, "label_months": 53}
{"id": "fraud_17", "charge": "诈骗罪", "fact": "被告人卫某,男。此前无犯罪记录。经审理查明:被告人卫某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币280260元。案发后主动投案自首,已退赔全部赃款。当庭认罪认罚。", "gold": {"amount": 280260, "prior_record": false, "surrender": true, "restitution": true, "confession": true, "scam_type": "合同", "victim_count": 3, "gang": true}, "label_months": 48}
{"id": "theft_01", "charge": "盗窃罪", "fact": "被告人沈某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人沈某伙同他人结伙在某商场内窃取他人财物,经鉴定价值人民币256310元。案发后主动到公安机关投案自首,已退赔全部赃款并取得谅解。当庭对指控予以否认。", "gold": {"amount": 256310, "prior_record": true, "surrender": true, "restitution": true, "confession": false, "burglary": false, "carry_weapon": false, "gang": true}, "label_months": 46}
{"id": "assault_02", "charge": "故意伤害罪", "fact": "被告人谢某,男。平时表现尚可,无前科。经审理查明:被告人谢某因琐事发生口角后,持赤手空拳殴打被害人,致其经鉴定为轻伤二级。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": true, "confession": true, "injury_level": "轻伤", "armed": false, "premeditated": false, "gang": false}, "label_months": 1}
{"id": "assault_21", "charge": "故意伤害罪", "fact": "被告人褚某,男。平时表现尚可,无前科。经审理查明:被告人褚某因琐事发生口角后,持赤手空拳殴打被害人,致其经鉴定为重伤二级。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭辩称系正当防卫。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": false, "injury_level": "重伤", "armed": false, "premeditated": false, "gang": false}, "label_months": 41}
{"id": "theft_18", "charge": "盗窃罪", "fact": "被告人吕某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人吕某伙同他人结伙在某网吧内窃取他人财物,经鉴定价值人民币264100元。案发后主动到公安机关投案自首,赃款已被挥霍,未退赔。当庭认罪认罚。", "gold": {"amount": 264100, "prior_record": true, "surrender": true, "restitution": false, "confession": true, "burglary": false, "carry_weapon": false, "gang": true}, "label_months": 49}
{"id": "fraud_08", "charge": "诈骗罪", "fact": "被告人魏某,男。此前无犯罪记录。经审理查明:被告人魏某通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取2名被害人钱财,骗取财物共计人民币760090元。案发后主动投案自首,赃款未追回。当庭认罪认罚。", "gold": {"amount": 760090, "prior_record": false, "surrender": true, "restitution": false, "confession": true, "scam_type": "电信网络", "victim_count": 2, "gang": false}, "label_months": 58}
{"id": "fraud_04", "charge": "诈骗罪", "fact": "被告人曹某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人曹某通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取32名被害人钱财,骗取财物共计人民币641500元。后被公安机关抓获,已退赔全部赃款。当庭认罪认罚。", "gold": {"amount": 641500, "prior_record": true, "surrender": false, "restitution": true, "confession": true, "scam_type": "电信网络", "victim_count": 32, "gang": false}, "label_months": 78}
{"id": "fraud_09", "charge": "诈骗罪", "fact": "被告人许某,男。此前无犯罪记录。经审理查明:被告人许某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币561980元。后被公安机关抓获,赃款未追回。当庭否认诈骗故意。", "gold": {"amount": 561980, "prior_record": false, "surrender": false, "restitution": false, "confession": false, "scam_type": "合同", "victim_count": 2, "gang": true}, "label_months": 67}
{"id": "theft_03", "charge": "盗窃罪", "fact": "被告人孙某,男。此前无违法犯罪记录。经审理查明:被告人孙某伙同他人结伙翻窗入户进入被害人位于某电动车棚的住宅内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币322620元。案发后主动到公安机关投案自首,已退赔全部赃款并取得谅解。当庭对指控予以否认。", "gold": {"amount": 322620, "prior_record": false, "surrender": true, "restitution": true, "confession": false, "burglary": true, "carry_weapon": true, "gang": true}, "label_months": 49}
{"id": "theft_22", "charge": "盗窃罪", "fact": "被告人谢某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人谢某单独翻窗入户进入被害人位于某商场的住宅内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币77170元。案发后主动到公安机关投案自首,赃款已被挥霍,未退赔。当庭认罪认罚。", "gold": {"amount": 77170, "prior_record": true, "surrender": true, "restitution": false, "confession": true, "burglary": true, "carry_weapon": true, "gang": false}, "label_months": 50}
{"id": "fraud_19", "charge": "诈骗罪", "fact": "被告人姜某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人姜某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币242630元。后被公安机关抓获,已退赔全部赃款。当庭认罪认罚。", "gold": {"amount": 242630, "prior_record": true, "surrender": false, "restitution": true, "confession": true, "scam_type": "合同", "victim_count": 2, "gang": true}, "label_months": 62}
{"id": "assault_09", "charge": "故意伤害罪", "fact": "被告人陶某,男。平时表现尚可,无前科。经审理查明:被告人陶某因积怨已久、事先预谋,持持械(砍刀)殴打被害人,致其经鉴定为轻伤二级。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭辩称系正当防卫。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": false, "injury_level": "轻伤", "armed": true, "premeditated": true, "gang": false}, "label_months": 30}
{"id": "theft_14", "charge": "盗窃罪", "fact": "被告人华某,男。此前无违法犯罪记录。经审理查明:被告人华某单独在某商场内窃取他人财物,经鉴定价值人民币29080元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 29080, "prior_record": false, "surrender": false, "restitution": true, "confession": true, "burglary": false, "carry_weapon": false, "gang": false}, "label_months": 26}
{"id": "theft_07", "charge": "盗窃罪", "fact": "被告人何某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人何某单独在某商场内窃取他人财物,经鉴定价值人民币275730元。案发后主动到公安机关投案自首,赃款已被挥霍,未退赔。当庭认罪认罚。", "gold": {"amount": 275730, "prior_record": true, "surrender": true, "restitution": false, "confession": true, "burglary": false, "carry_weapon": false, "gang": false}, "label_months": 49}
{"id": "fraud_12", "charge": "诈骗罪", "fact": "被告人邹某,男。此前无犯罪记录。经审理查明:被告人邹某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币91590元。后被公安机关抓获,赃款未追回。当庭否认诈骗故意。", "gold": {"amount": 91590, "prior_record": false, "surrender": false, "restitution": false, "confession": false, "scam_type": "合同", "victim_count": 4, "gang": true}, "label_months": 60}
{"id": "assault_22", "charge": "故意伤害罪", "fact": "被告人金某,男。平时表现尚可,无前科。经审理查明:被告人金某因琐事发生口角后,纠集多人殴打被害人,致其经鉴定为轻伤二级。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": true, "injury_level": "轻伤", "armed": false, "premeditated": false, "gang": true}, "label_months": 12}
{"id": "assault_04", "charge": "故意伤害罪", "fact": "被告人李某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人李某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为重伤二级。案发后主动投案自首,未赔偿被害人损失。当庭辩称系正当防卫。", "gold": {"prior_record": true, "surrender": true, "restitution": false, "confession": false, "injury_level": "重伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 49}
{"id": "assault_14", "charge": "故意伤害罪", "fact": "被告人戚某,男。平时表现尚可,无前科。经审理查明:被告人戚某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为轻伤二级。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭辩称系正当防卫。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": false, "injury_level": "轻伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 18}
{"id": "fraud_07", "charge": "诈骗罪", "fact": "被告人钱某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人钱某通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取31名被害人钱财,骗取财物共计人民币207800元。后被公安机关抓获,赃款未追回。当庭认罪认罚。", "gold": {"amount": 207800, "prior_record": true, "surrender": false, "restitution": false, "confession": true, "scam_type": "电信网络", "victim_count": 31, "gang": false}, "label_months": 77}
{"id": "fraud_14", "charge": "诈骗罪", "fact": "被告人吕某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人吕某以帮忙办事为由,虚构事实骗取被害人钱财,骗取财物共计人民币459320元。后被公安机关抓获,已退赔全部赃款。当庭认罪认罚。", "gold": {"amount": 459320, "prior_record": true, "surrender": false, "restitution": true, "confession": true, "scam_type": "普通", "victim_count": 3, "gang": false}, "label_months": 62}
{"id": "assault_05", "charge": "故意伤害罪", "fact": "被告人赵某,男。平时表现尚可,无前科。经审理查明:被告人赵某因琐事发生口角后,纠集多人并持械殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": true, "confession": true, "injury_level": "轻微伤", "armed": true, "premeditated": false, "gang": true}, "label_months": 2}
{"id": "assault_01", "charge": "故意伤害罪", "fact": "被告人冯某,男。平时表现尚可,无前科。经审理查明:被告人冯某因琐事发生口角后,纠集多人殴打被害人,致其经鉴定为轻伤二级。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": true, "injury_level": "轻伤", "armed": false, "premeditated": false, "gang": true}, "label_months": 12}
{"id": "assault_10", "charge": "故意伤害罪", "fact": "被告人曹某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人曹某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭辩称系正当防卫。", "gold": {"prior_record": true, "surrender": false, "restitution": true, "confession": false, "injury_level": "轻微伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 8}
{"id": "fraud_20", "charge": "诈骗罪", "fact": "被告人陶某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人陶某伙同他人组成团伙,通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取37名被害人钱财,骗取财物共计人民币89250元。后被公安机关抓获,已退赔全部赃款。当庭否认诈骗故意。", "gold": {"amount": 89250, "prior_record": true, "surrender": false, "restitution": true, "confession": false, "scam_type": "电信网络", "victim_count": 37, "gang": true}, "label_months": 75}
{"id": "assault_16", "charge": "故意伤害罪", "fact": "被告人金某,男。平时表现尚可,无前科。经审理查明:被告人金某因琐事发生口角后,持赤手空拳殴打被害人,致其经鉴定为轻伤二级。案发后主动投案自首,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": true, "restitution": true, "confession": true, "injury_level": "轻伤", "armed": false, "premeditated": false, "gang": false}, "label_months": 1}
{"id": "fraud_01", "charge": "诈骗罪", "fact": "被告人周某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人周某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币69370元。案发后主动投案自首,赃款未追回。当庭认罪认罚。", "gold": {"amount": 69370, "prior_record": true, "surrender": true, "restitution": false, "confession": true, "scam_type": "合同", "victim_count": 2, "gang": true}, "label_months": 54}
{"id": "assault_15", "charge": "故意伤害罪", "fact": "被告人许某,男。平时表现尚可,无前科。经审理查明:被告人许某因琐事发生口角后,持持械(砍刀)殴打被害人,致其经鉴定为轻伤二级。案发后主动投案自首,未赔偿被害人损失。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": true, "restitution": false, "confession": true, "injury_level": "轻伤", "armed": true, "premeditated": false, "gang": false}, "label_months": 13}
{"id": "assault_03", "charge": "故意伤害罪", "fact": "被告人尤某,男。平时表现尚可,无前科。经审理查明:被告人尤某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": true, "injury_level": "轻微伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 7}
{"id": "assault_12", "charge": "故意伤害罪", "fact": "被告人戚某,男。平时表现尚可,无前科。经审理查明:被告人戚某因琐事发生口角后,持持械(砍刀)殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": true, "confession": true, "injury_level": "轻微伤", "armed": true, "premeditated": false, "gang": false}, "label_months": 1}
{"id": "theft_08", "charge": "盗窃罪", "fact": "被告人华某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人华某伙同他人结伙翻窗入户进入被害人位于某手机专卖店的住宅内窃取他人财物,经鉴定价值人民币262690元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 262690, "prior_record": true, "surrender": false, "restitution": true, "confession": true, "burglary": true, "carry_weapon": false, "gang": true}, "label_months": 60}
{"id": "theft_16", "charge": "盗窃罪", "fact": "被告人吕某,男。此前无违法犯罪记录。经审理查明:被告人吕某伙同他人结伙翻窗入户进入被害人位于某小区的住宅内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币9880元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭对指控予以否认。", "gold": {"amount": 9880, "prior_record": false, "surrender": false, "restitution": true, "confession": false, "burglary": true, "carry_weapon": true, "gang": true}, "label_months": 39}
{"id": "theft_06", "charge": "盗窃罪", "fact": "被告人秦某,男。此前无违法犯罪记录。经审理查明:被告人秦某单独在某电动车棚内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币268560元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭对指控予以否认。", "gold": {"amount": 268560, "prior_record": false, "surrender": false, "restitution": true, "confession": false, "burglary": false, "carry_weapon": true, "gang": false}, "label_months": 45}
{"id": "theft_20", "charge": "盗窃罪", "fact": "被告人曹某,男。此前无违法犯罪记录。经审理查明:被告人曹某单独翻窗入户进入被害人位于某小区的住宅内窃取他人财物,经鉴定价值人民币261380元。案发后主动到公安机关投案自首,已退赔全部赃款并取得谅解。当庭对指控予以否认。", "gold": {"amount": 261380, "prior_record": false, "surrender": true, "restitution": true, "confession": false, "burglary": true, "carry_weapon": false, "gang": false}, "label_months": 40}
{"id": "fraud_15", "charge": "诈骗罪", "fact": "被告人姜某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人姜某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币229490元。后被公安机关抓获,已退赔全部赃款。当庭否认诈骗故意。", "gold": {"amount": 229490, "prior_record": true, "surrender": false, "restitution": true, "confession": false, "scam_type": "合同", "victim_count": 5, "gang": true}, "label_months": 68}
{"id": "fraud_18", "charge": "诈骗罪", "fact": "被告人蒋某,男。此前无犯罪记录。经审理查明:被告人蒋某伙同他人组成团伙,以帮忙办事为由,虚构事实骗取被害人钱财,骗取财物共计人民币159700元。后被公安机关抓获,赃款未追回。当庭认罪认罚。", "gold": {"amount": 159700, "prior_record": false, "surrender": false, "restitution": false, "confession": true, "scam_type": "普通", "victim_count": 3, "gang": true}, "label_months": 56}
{"id": "fraud_11", "charge": "诈骗罪", "fact": "被告人姜某,男。此前无犯罪记录。经审理查明:被告人姜某通过拨打电话、发送短信等电信网络手段,虚构投资项目骗取37名被害人钱财,骗取财物共计人民币681430元。案发后主动投案自首,已退赔全部赃款。当庭认罪认罚。", "gold": {"amount": 681430, "prior_record": false, "surrender": true, "restitution": true, "confession": true, "scam_type": "电信网络", "victim_count": 37, "gang": false}, "label_months": 58}
{"id": "theft_10", "charge": "盗窃罪", "fact": "被告人邹某,男。此前无违法犯罪记录。经审理查明:被告人邹某单独翻窗入户进入被害人位于某网吧的住宅内窃取他人财物,经鉴定价值人民币153580元。后被公安机关抓获归案,赃款已被挥霍,未退赔。当庭对指控予以否认。", "gold": {"amount": 153580, "prior_record": false, "surrender": false, "restitution": false, "confession": false, "burglary": true, "carry_weapon": false, "gang": false}, "label_months": 50}
{"id": "theft_04", "charge": "盗窃罪", "fact": "被告人蒋某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人蒋某伙同他人结伙在某电动车棚内窃取他人财物,经鉴定价值人民币184580元。后被公安机关抓获归案,已退赔全部赃款并取得谅解。当庭认罪认罚。", "gold": {"amount": 184580, "prior_record": true, "surrender": false, "restitution": true, "confession": true, "burglary": false, "carry_weapon": false, "gang": true}, "label_months": 49}
{"id": "assault_20", "charge": "故意伤害罪", "fact": "被告人魏某,男。曾因寻衅滋事被判刑,系累犯。经审理查明:被告人魏某因琐事发生口角后,持持械(砍刀)殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": true, "surrender": false, "restitution": true, "confession": true, "injury_level": "轻微伤", "armed": true, "premeditated": false, "gang": false}, "label_months": 7}
{"id": "assault_17", "charge": "故意伤害罪", "fact": "被告人杨某,男。平时表现尚可,无前科。经审理查明:被告人杨某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,未赔偿被害人损失。当庭辩称系正当防卫。", "gold": {"prior_record": false, "surrender": false, "restitution": false, "confession": false, "injury_level": "轻微伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 10}
{"id": "assault_13", "charge": "故意伤害罪", "fact": "被告人蒋某,男。平时表现尚可,无前科。经审理查明:被告人蒋某因琐事发生口角后,持持械(砍刀)殴打被害人,致其经鉴定为轻微伤。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭辩称系正当防卫。", "gold": {"prior_record": false, "surrender": false, "restitution": true, "confession": false, "injury_level": "轻微伤", "armed": true, "premeditated": false, "gang": false}, "label_months": 2}
{"id": "theft_05", "charge": "盗窃罪", "fact": "被告人施某,男。此前无违法犯罪记录。经审理查明:被告人施某单独在某商场内窃取他人财物,经鉴定价值人民币29220元。后被公安机关抓获归案,赃款已被挥霍,未退赔。当庭对指控予以否认。", "gold": {"amount": 29220, "prior_record": false, "surrender": false, "restitution": false, "confession": false, "burglary": false, "carry_weapon": false, "gang": false}, "label_months": 36}
{"id": "assault_08", "charge": "故意伤害罪", "fact": "被告人卫某,男。平时表现尚可,无前科。经审理查明:被告人卫某因积怨已久、事先预谋,持赤手空拳殴打被害人,致其经鉴定为重伤二级。作案后逃离现场,后被抓获,已赔偿被害人损失并取得谅解。当庭认罪认罚。", "gold": {"prior_record": false, "surrender": false, "restitution": true, "confession": true, "injury_level": "重伤", "armed": false, "premeditated": true, "gang": false}, "label_months": 36}
{"id": "fraud_06", "charge": "诈骗罪", "fact": "被告人严某,男。曾因诈骗被判刑,系累犯。经审理查明:被告人严某伙同他人组成团伙,在签订、履行合同过程中,以虚假身份和虚构履约能力骗取被害人钱财,骗取财物共计人民币151930元。后被公安机关抓获,已退赔全部赃款。当庭否认诈骗故意。", "gold": {"amount": 151930, "prior_record": true, "surrender": false, "restitution": true, "confession": false, "scam_type": "合同", "victim_count": 4, "gang": true}, "label_months": 65}
{"id": "theft_09", "charge": "盗窃罪", "fact": "被告人周某,男。曾因盗窃罪被判刑,刑满释放后再次作案,系累犯。经审理查明:被告人周某单独翻窗入户进入被害人位于某菜市场的住宅内,作案时随身携带匕首一把窃取他人财物,经鉴定价值人民币160660元。后被公安机关抓获归案,赃款已被挥霍,未退赔。当庭认罪认罚。", "gold": {"amount": 160660, "prior_record": true, "surrender": false, "restitution": false, "confession": true, "burglary": true, "carry_weapon": true, "gang": false}, "label_months": 65}
{"id": "theft_15", "charge": "盗窃罪", "fact": "被告人周某,男。此前无违法犯罪记录。经审理查明:被告人周某单独在某写字楼内窃取他人财物,经鉴定价值人民币371670元。后被公安机关抓获归案,赃款已被挥霍,未退赔。当庭认罪认罚。", "gold": {"amount": 371670, "prior_record": false, "surrender": false, "restitution": false, "confession": true, "burglary": false, "carry_weapon": false, "gang": false}, "label_months": 45}