# 政务公文知识图谱本体V1增强-实施计划

## Context

当前系统已有"治理图(phase_0) + 事项图(phase_1)"，共 9 个实体 + 14 条关系。产品希望扩展为"通用政务公文图"，覆盖政策、任务、项目、系统、数据、预算、产业等语义。PRD 提出了三层架构（核心层 / 办公扩展层 / 办事能力层），本计划在 PRD 基础上整合两轮评审意见后落地实施。

### 核心设计约束：三套集合必须一致

本次改造最重要的设计约束是区分并统一三套集合：

| 集合 | 定义 | 用途 |
|------|------|------|
| **运行时支持集合** | schema 中全部合法实体/关系类型（不受 active_phases 过滤） | 约束创建、写图合法性校验、管理端类型列表 |
| **场景抽取集合** | `active_phases + extraction_scenes.extra_phases(scene)` 对应的实体/关系 | 构造 prompt、归一化校验、抽取后合法性校验 |
| **查询暴露集合** | 默认等于运行时支持集合（不受 active_phases 限制） | 实体搜索、关联文档、概览、图谱浏览器标签白名单 |

**关键约束**：phase_3 实体被场景抽取写入后，必须能被通用查询接口发现，不能在归一化阶段被过滤、查询白名单被隐藏、搜索路径用不上。

### 后续迭代

[政务公文知识图谱本体V1.5增强-实施计划.md](政务公文知识图谱本体V1.5增强-实施计划.md)


---

## 实施总览

分 5 个 Sprint，每个可独立部署测试：

| Sprint | 内容 | 估时 |
|--------|------|------|
| 1 | PRD 修订 + Schema 口径对齐 + 定义扩充 | 1-2d |
| 2 | Schema Loader 增强 + Neo4j 支撑 | 1d |
| 3 | 抽取链路改造（场景化 Prompt + 归一化 + Builder） | 2-3d |
| 4 | 查询与 API 改造（白名单统一 + 全文搜索接入 + 卡片） | 2-3d |
| 5 | 前端适配 + 联调回归 | 1-2d |

---

## Sprint 1: PRD 修订 + Schema 口径对齐 + 定义扩充

### 1.1 修订 PRD 文档

**文件**: `prd/government-document-graph-ontology-v1-prd.md`

修订内容：

1. **Phase 编号对齐** — 新增 §13.5 映射表：
    - PRD Phase 0 核心公文图 → 扩充现有 `phase_0`
    - PRD Phase 1 办公扩展 → 新增 `phase_3`
    - PRD Phase 2 事项对齐 → 现有 `phase_1` 不变
    - PRD Phase 3 深化增强 → 新增 `phase_4`（未来）

2. **新增"运行时集合约定"节** — 明确运行时支持集合、场景抽取集合、查询暴露集合三者定义（见上方表格）

3. **scene_type 信号源优先级** — 明确判定优先级：
   ```
   1. document_scene_type  （最精确，由 LLM 元数据抽取产生）
   2. knowledge_category_code  （知识分类，覆盖面更广）
   3. ""  （空值兜底，仅使用 active_phases）
   ```

4. **补充实体消歧规则**（非文档优化项，是抽取质量关键）：
    - **Policy vs Document**：仅当文档包含多条独立政策措施时才抽取 Policy；文档整体即为一条政策时不额外抽取
    - **Task vs Project**：Task 是可分配的工作项（有责任方/截止时间），Project 是有独立建设周期和预算的具名项目
    - **Project vs System**：Project 是建设过程，System 是建成后的运行态 IT 系统/平台
    - **Indicator vs Standard**：Indicator 仅限可量化 KPI/目标值/统计指标；Standard 仅限规范性标准/标准文件/服务标准/技术标准。不允许两者同时承接"考核"与"规范"双重语义
    - **Event vs Document(纪要)**：Event 是活动事件本身，Document 是活动产生的文件载体

5. **Person 唯一键策略** — Person 不用 `name` 全局合并（公职人员重名风险太高）：
    - `key_property: person_id`
    - 初版 `person_id` 生成规则：`hash(name + serving_org + position + source_doc_id_prefix)`
    - 后续迭代可引入更精确的身份对齐

6. **新增"主键策略表"** — 明确所有实体的主键选择：

   | 实体 | key_property | 说明 |
      |------|-------------|------|
   | Document | doc_id | 元数据驱动 |
   | Person | person_id | 防重名合并，见第5点 |
   | 其他所有实体 | name | 默认按名称合并 |

   **实现约束**：所有非 `name` 主键实体必须通过统一关系键解析器参与关系装配。后续如有新增非 `name` 主键类型，必须同步更新关系键解析逻辑。

7. **补充归一化规则** — 在 §9 后新增 §9.1，为每个新实体定义 max_length、strip_punctuation 等

7. **同名关系查询策略** — 必须显式标签约束的关系清单：
    - `APPLIES_TO_REGION`（Document→Region / Policy→Region / Matter→Region）
    - `EVALUATES`（Indicator→Task / Indicator→Project / Standard→Task）
    - `LOCATED_IN`（Project→Region / Industry→Region）
    - `SUPPORTED_BY`（Project→Policy / Industry→Policy）
    - `MANAGED_BY` 系列（已拆分为前缀区分）
    - `USES_TECH`（Project→Technology / System→Technology）

8. **补充 prompt token 预算说明** — 扩充后 system prompt 约 2000-2500 tokens，在可控范围内

### 1.2 扩充 graph_schema.yaml — 核心层实体 (phase_0)

**文件**: `backend/app/config/graph_schema.yaml`

在现有 PolicyTheme 之后，新增 8 个实体定义：

```yaml
Policy:       color="#9254de", icon=audit,         extraction_mode=llm, max_length=80
Task:         color="#ff7a45", icon=carry-out,      extraction_mode=llm, max_length=80
Project:      color="#36cfc9", icon=project,        extraction_mode=llm, max_length=80
System:       color="#597ef7", icon=cloud-server,   extraction_mode=llm, max_length=60
DataResource: color="#73d13d", icon=database,       extraction_mode=llm, max_length=80
Indicator:    color="#ffc53d", icon=dashboard,      extraction_mode=llm, max_length=60
Budget:       color="#ff4d4f", icon=money-collect,   extraction_mode=llm, max_length=60
Industry:     color="#bae637", icon=shop,           extraction_mode=llm, max_length=30
```

每个实体包含：
- `name`, `description`（中文抽取规则说明，**不用于前端展示**）
- `zh_name`（短中文显示名，如"政策"/"任务"/"项目"，用于前端标签）
- `properties_schema`（含 `name`, `normalized_name`, `entity_subtype`, `subtype_code`, `aliases` + 各自专有属性，参照 PRD §10）
- `phase: phase_0`

> **重要**：`description` 将包含完整抽取判定规则（如"仅当文档包含多条独立政策措施时才抽取"），不适合作为前端短标签。前端中文名统一使用 `zh_name` 字段。

### 1.3 扩充 graph_schema.yaml — 办公扩展层实体 (phase_3)

新增 6 个实体定义：

```yaml
Person:         color="#faad14", icon=user,                key_property=person_id, max_length=20
Event:          color="#ff85c0", icon=calendar,            max_length=80
Mechanism:      color="#87e8de", icon=setting,             max_length=60
Standard:       color="#d3adf7", icon=safety-certificate,  max_length=80
Infrastructure: color="#95de64", icon=cluster,             max_length=60
Technology:     color="#69c0ff", icon=experiment,          max_length=30
```

> **注意**: Person 使用 `key_property: person_id` 而非 `name`，避免公职人员重名合并风险。

### 1.4 扩充 graph_schema.yaml — 核心层关系 (phase_0)

在现有 REFERENCES 之后新增：

| 关系 | 起点 | 终点 |
|------|------|------|
| CONTAINS_POLICY | Document | Policy |
| DEPLOYS_TASK | Document | Task |
| APPROVES_PROJECT | Document | Project |
| LEAD_BY | Task | Organization |
| ASSISTED_BY | Task | Organization |
| ASSIGNED_TO | Policy | Organization |
| IMPLEMENTED_BY | Project | Organization |
| OPERATED_BY | System | Organization |
| SUBORDINATE_TO | Organization | Organization |
| COOPERATES_WITH | Organization | Organization |
| RELATES_TO_THEME | Policy | PolicyTheme |
| IMPLEMENTS | Task | Policy |
| LOCATED_IN | Project | Region |
| SUPPORTED_BY | Project | Policy |
| FUNDS | Budget | Project, Task |
| MANAGES | System | DataResource |
| EVALUATES | Indicator | Task, Project |

### 1.5 扩充 graph_schema.yaml — 办公扩展层关系 (phase_3)

| 关系 | 起点 | 终点 |
|------|------|------|
| SERVES_IN | Person | Organization |
| LEADS | Person | Task |
| INSTRUCTS | Person | Document |
| CONTACTS_FOR | Person | Project |
| HELD_BY | Event | Organization |
| CHAIRED_BY | Event | Person |
| ATTENDED_BY | Event | Person, Organization |
| PRODUCES | Event | Document |
| DECIDES | Event | Task |
| HELD_IN | Event | Region |
| ESTABLISHED_BY | Mechanism | Document |
| MECHANISM_MANAGED_BY | Mechanism | Organization |
| APPLIES_TO | Mechanism | Task, Project, System |
| STIPULATED_BY | Standard | Document |
| CONFORMED_BY | Standard | System, DataResource |
| INFRA_MANAGED_BY | Infrastructure | Organization |
| HOSTS | Infrastructure | System |
| COVERS | Infrastructure | Region |
| USES_TECH | Project, System | Technology |
| INDUSTRY_SUPPORTED_BY | Industry | Policy |
| INDUSTRY_LOCATED_IN | Industry | Region |
| INDUSTRY_MANAGED_BY | Industry | Organization |

> **同名关系消歧策略**: 扩展层中 MANAGED_BY/SUPPORTED_BY/LOCATED_IN 用前缀区分（`MECHANISM_MANAGED_BY`、`INFRA_MANAGED_BY`、`INDUSTRY_MANAGED_BY` 等）。核心层中 LOCATED_IN(Project→Region) 和 SUPPORTED_BY(Project→Policy) 保持原名（无同 phase 同名冲突）。

### 1.6 扩充归一化规则

```yaml
Policy: { strip_punctuation: true, trim_whitespace: true, max_length: 80 }
Task: { strip_punctuation: true, trim_whitespace: true, max_length: 80 }
Project: { strip_punctuation: true, trim_whitespace: true, max_length: 80 }
System: { strip_punctuation: true, trim_whitespace: true, max_length: 60 }
DataResource: { strip_punctuation: true, trim_whitespace: true, max_length: 80 }
Indicator: { strip_punctuation: true, trim_whitespace: true, max_length: 60 }
Budget: { strip_punctuation: true, trim_whitespace: true, max_length: 60 }
Industry: { strip_punctuation: true, trim_whitespace: true, max_length: 30 }
Person: { strip_punctuation: true, trim_whitespace: true, max_length: 20 }
Event: { strip_punctuation: true, trim_whitespace: true, max_length: 80 }
Mechanism: { strip_punctuation: true, trim_whitespace: true, max_length: 60 }
Standard: { strip_punctuation: true, trim_whitespace: true, max_length: 80 }
Infrastructure: { strip_punctuation: true, trim_whitespace: true, max_length: 60 }
Technology: { strip_punctuation: true, trim_whitespace: true, max_length: 30 }
```

### 1.7 新增场景抽取配置

```yaml
extraction_scenes:
  service_guide:
    extra_phases: []
    entity_bias: [Matter, Condition, Material, TimeLimit, TargetGroup]
  leader_speech_national_provincial:
    extra_phases: [phase_3]
    entity_bias: [Person, Event]
  leader_speech_city:
    extra_phases: [phase_3]
    entity_bias: [Person, Event]
  plenary_report_planning:
    extra_phases: []
    entity_bias: [Task, Indicator, Industry]
  digital_gov_project:
    extra_phases: [phase_3]
    entity_bias: [Project, System, Technology, Budget, Infrastructure]
  policy_regulation:
    extra_phases: []
    entity_bias: [Policy, Task]
  government_work_report:
    extra_phases: []
    entity_bias: [Task, Indicator, Industry]
  official_document:
    extra_phases: []
  # entity_bias 本期仅作策略口径记录，不实现权重逻辑
  # 后续迭代可据此优化 prompt 或后处理
```

### 1.8 active_phases 保持不变

```yaml
active_phases:
  - phase_0
  - phase_1
  # - phase_2a
  # - phase_2b
  # - phase_3   # 办公扩展层，通过 extraction_scenes 按需启用
```

---

## Sprint 2: Schema Loader 增强 + Neo4j 支撑

### 2.1 扩展 GraphSchema 数据类

**文件**: `backend/app/core/graph_schema_loader.py`

**保持 `frozen=True`**（不改为可变对象），通过构造时一次性赋值新字段。

新增字段：
```python
extraction_scenes: dict[str, dict[str, Any]] = field(default_factory=dict)
_all_entity_types: tuple[dict[str, Any], ...] = field(default_factory=tuple)  # tuple 兼容 frozen
_all_relationship_types: tuple[dict[str, Any], ...] = field(default_factory=tuple)
```

新增完整方法集：
```python
# ── 全量集合（运行时支持集合）──
def all_entity_types_unfiltered(self) -> list[dict]: ...
def all_rel_types_unfiltered(self) -> list[dict]: ...
def all_entity_type_names_unfiltered(self) -> set[str]: ...
def all_rel_type_names_unfiltered(self) -> set[str]: ...
def all_node_labels_unfiltered(self) -> set[str]:
    """含 Document，供查询层白名单使用。"""
    return self.all_entity_type_names_unfiltered() | {"Document"}
def all_entity_type_map_unfiltered(self) -> dict[str, dict]:
    """全量 name -> full definition 映射，供 _key_prop() 等需要按标签查 key_property 的场景使用。"""
    return {et["name"]: et for et in self._all_entity_types}

# ── 场景化集合 ──
def get_scene_phases(self, scene_type: str) -> list[str]:
    """active_phases + extra_phases，保序去重。"""
def entity_types_for_scene(self, scene_type: str) -> list[dict]: ...
def rel_types_for_scene(self, scene_type: str) -> list[dict]: ...
def entity_type_names_for_scene(self, scene_type: str) -> set[str]: ...
```

### 2.2 修改 _load_and_validate()

1. 先对全量类型做标识符校验（所有 phase）
2. 再按 `active_phases` 过滤出默认活跃集合
3. 全量定义存入 `_all_entity_types` / `_all_relationship_types`
4. 读取 `extraction_scenes` 配置

```python
schema = GraphSchema(
    entity_types=entity_types,              # active_phases 过滤后
    relationship_types=rel_types,           # active_phases 过滤后
    _all_entity_types=tuple(all_entity_types),     # 全量
    _all_relationship_types=tuple(all_rel_types),  # 全量
    extraction_scenes=extraction_scenes,
    # ... 其余不变
)
```

### 2.3 更新 Neo4j 约束创建 + `_key_prop()` 全量映射

**文件**: `backend/app/infrastructure/neo4j_client.py`

- `init_schema()` 使用 `schema.all_entity_types_unfiltered()` 创建唯一约束（全量建，约束无数据不影响性能）
- 新增全文索引（为 Sprint 4 搜索接入做准备，本 Sprint 不宣称搜索已完成）：
  ```
  policy_fulltext:  Policy(name, summary)
  task_fulltext:    Task(name)
  project_fulltext: Project(name)
  system_fulltext:  System(name)
  ```
- `_VALID_NODE_LABELS` / `_VALID_REL_TYPES` 改为基于全量集合加载

**⚠️ 关键修复：`_key_prop()` 必须使用全量映射**

当前 `_key_prop()` (neo4j_client.py:680-696) 使用 `schema.entity_type_map()` 查询 key_property，而 `entity_type_map()` 基于 active_phases 过滤后的 entity_types。这意味着 phase_3 实体（如 Person 的 `key_property: person_id`）在 active_phases 不含 phase_3 时，`_key_prop("Person")` 会回退到默认值 `"name"`，破坏 Person 的主键策略。

修改：
```python
def _key_prop(label: str) -> str:
    if label == "Document":
        return "doc_id"
    schema = get_schema()
    # 改用全量映射，确保 phase_3 类型也能正确解析 key_property
    et_map = schema.all_entity_type_map_unfiltered()
    et = et_map.get(label)
    if et:
        return et.get("key_property", "name")
    return "name"
```

这样 `_batch_merge_nodes()` 和 `_batch_merge_rels()` 在处理 Person 节点时，会正确使用 `person_id` 作为 MERGE 键，而非 `name`。

### 2.4 改造 GraphAdminService（Sprint 2 主任务之一，非补充项）

**文件**: `backend/app/core/graph_admin_service.py`

**⚠️ 这是 Sprint 2 的三大主任务之一**（与 Loader 增强、Neo4j Client 改造并列），不是附加补充。如果只改 loader 和 neo4j client 而不改这里，任何一次类型缓存刷新都会把 phase_3 类型从管理端和校验链路中"藏回去"。

**问题根因**: `_load_default_entity_types()` / `_load_default_rel_types()` 使用 `schema.entity_types` / `schema.relationship_types`（即 active_phases 过滤后的集合）。`ensure_default_types()` 据此向 Neo4j 写入 `_EntityType` / `_RelationType` 元节点。`refresh_type_cache()` 又从 Neo4j 读取元节点后调用 `reload_valid_types()` 重置模块级白名单。结果：任何一次 cache refresh 都会把 phase_3 类型从白名单中"隐藏"回去。

**强制约束**：GraphAdminService 必须同步改造为全量类型缓存模型。任何一次 `ensure_default_types()` 或 `refresh_type_cache()` 后，不得将 phase_3 类型从模块级白名单中移除。

**修改清单**：

1. **`_load_default_entity_types()`** — 改为使用 `schema.all_entity_types_unfiltered()`，并包含 `zh_name` 和 `key_property`：
```python
def _load_default_entity_types() -> list[dict]:
    schema = get_schema()
    return [
        {"name": et["name"], "description": et.get("description", ""),
         "icon": et.get("icon", ""), "color": et.get("color", ""),
         "zh_name": et.get("zh_name", ""),
         "key_property": et.get("key_property", "name"),
         "phase": et.get("phase", "phase_0")}
        for et in schema.all_entity_types_unfiltered()
    ]
```

2. **`_load_default_rel_types()`** — 同理改为 `schema.all_rel_types_unfiltered()`，包含 `zh_name`

3. **`ensure_default_types()`** — 将全量类型（含 phase_3）写入 `_EntityType` / `_RelationType` 元节点，元节点属性包含 `zh_name`、`key_property`

4. **`refresh_type_cache()`** — 刷新后的模块级白名单必须包含全量类型，不能只同步 active 集合。具体做法：
    - `reload_valid_types()` 的输入应为全量缓存中的标签集合
    - 或者直接从 `schema.all_entity_type_names_unfiltered()` 刷新白名单
    - 缓存中的每条类型记录必须包含 `key_property` 字段（从元节点读取）

5. **`list_entity_types()`** — 返回结果必须包含 `key_property` 字段，供前端构造 `entity_key`

6. **⚠️ 本期禁用类型定义级 CRUD — schema 为唯一来源**

**核心原则**：运行时支持集合的唯一来源是 `graph_schema.yaml`。管理端不允许创建、删除、重命名实体类型或关系类型，避免产生 schema 与元节点缓存两套来源。

**禁用范围**：
- `create_entity_type()` / `delete_entity_type()` / `rename_entity_type()` — 一律返回 400，提示"类型定义仅通过 graph_schema.yaml 管理，修改后重启生效"
- `create_rel_type()` / `delete_rel_type()` / `rename_rel_type()` — 同理禁用
- 对应的后端路由（`POST /entity-types`、`DELETE /entity-types/{name}`、`PUT /entity-types/{name}`(rename) 等）保留但返回 400

**保留的能力**：
- `list_entity_types()` / `list_rel_types()` — 列表查看，供前端获取类型元数据（zh_name/color/icon/key_property）
- `update_entity_type()` — 仅允许修改 `zh_name`、`icon`、`color`。请求中包含 `new_name`、`description`、`properties_schema`、`key_property` 则返回 400（这些属于 schema 定义本身，不属于展示元数据）
- `update_rel_type()` — 仅允许修改 `zh_name`。请求中包含 `new_name`、`description`、`source_labels`、`target_labels` 则返回 400。关系类型本期不引入 icon/color 展示元数据字段
- `EntityTypeItem`（列表响应）保留 `key_property` 只读字段，前端可读取但不可写

除列表查看外，管理端类型定义页不再暴露新建、重命名、删除交互入口，前端对应页面同步隐藏相关按钮与弹窗（见 Sprint 5 §5.6）。

**元节点标记**：
- `_EntityType` / `_RelationType` 元节点增加 `is_default: bool` 属性，`ensure_default_types()` 写入时设为 `true`
- `key_property` 仅由 `graph_schema.yaml` 声明，通过 `_load_default_entity_types()` 写入元节点

**约束命名规则**（仅 `neo4j_client.init_schema()` 负责创建，管理端不再创建约束）：
- 所有实体唯一约束统一命名为 `{label}_{keyProperty}_unique`（如 `organization_name_unique`、`person_person_id_unique`）
- 与 `neo4j_client.py` 的 `init_schema()` 约束命名逻辑对齐

**前端管理端适配**：
- 前端管理页的"新建类型"/"删除类型"/"重命名类型"按钮本期隐藏或置灰
- `createEntityType()` / `createRelType()` 等前端 API 调用保留但不再使用，避免点击后报错
- 类型列表页和展示元数据编辑功能保留

> **注意**：`key_property` 仅适用于实体类型。关系类型无此字段，`RelTypeItem` 不应包含 `key_property`。

### 2.5 横向约束：key_property 读取路径统一切全量

当前系统中 key_property 的用法分散在约束创建、节点合并、关系合并三个层面（均在 neo4j_client.py 中）。**凡是按标签查询 key_property 的代码路径，一律使用 `all_entity_type_map_unfiltered()`，不允许继续依赖 active_phases 过滤后的 `entity_type_map()`。** 这样可以一次性堵住 phase_3 标签 key_property 回退到 `name` 的问题。

### 2.6 验证

- 运行 `tests/test_graph_schema_unit.py` 确认不破坏
- 新增测试：`entity_types_for_scene("leader_speech_city")` 返回含 Person/Event
- 新增测试：`all_entity_type_names_unfiltered()` 返回全部 23 个实体名
- 新增测试：`all_entity_type_map_unfiltered()["Person"]["key_property"]` == `"person_id"`
- 验证 `_key_prop("Person")` 返回 `"person_id"`（而非 `"name"`）
- 验证 `init_schema()` 为全部实体创建约束
- 验证 `ensure_default_types()` 写入的 `_EntityType` 元节点包含 phase_3 类型
- 验证 `refresh_type_cache()` 后模块级白名单包含 Person/Event 等 phase_3 标签
- 验证 `list_entity_types()` 返回的 Person 条目包含 `key_property: "person_id"`（key_property 元数据链路闭环）
- 验证前端调用 `listEntityTypes()` 后，`nodeTypeState.keyProperties["Person"]` === `"person_id"`
- 验证管理端 `create_entity_type()` 返回 400（本期禁用类型定义级创建）
- 验证管理端 `create_rel_type()` 返回 400（本期禁用关系类型创建）
- 验证管理端 `delete_entity_type("Person")` 返回 400（本期禁用类型删除）
- 验证管理端 `rename_entity_type("Organization", "Org")` 返回 400（本期禁用类型重命名）
- 验证管理端 `update_entity_type("Organization", icon="new-icon")` 正常执行（展示元数据维护保留）
- 验证管理端 `update_entity_type()` 请求中包含 `key_property` 时返回 400（只读字段不可修改）
- 验证 `update_rel_type("REFERENCES", zh_name="引用文件")` 可成功保存并在 `list_rel_types()` 中返回
- 验证 `update_rel_type()` 请求中包含 `source_labels` 或 `target_labels` 时返回 400（结构定义字段只读）
- 验证 `get_docs_by_entity_key()` 返回的文档集合与 `get_docs_by_entity()` 对同一实体的 1 跳直连结果一致（不扩大查询跳数）

---

## Sprint 3: 抽取链路改造

### 3.1 扩展 entity_extraction.py — 分层 prompt 结构

**文件**: `backend/app/prompts/entity_extraction.py`

Prompt 分三层构建，避免不同场景下 prompt 冗长失控：

1. **基础规则块**（始终包含）：通用规则 + phase_0 治理图 + phase_1 事项图
2. **核心扩展规则块**（phase_0 新增实体存在时注入）：Policy/Task/Project/System/DataResource/Budget/Indicator/Industry 的抽取规则
3. **场景增强规则块**（仅 phase_3 场景启用时注入）：Person/Event/Mechanism/Standard/Infrastructure/Technology 的抽取规则

**核心扩展规则块**内容：
```
## 核心公文图抽取规则
- **Policy vs Document**：仅当文档包含多条可独立引用的政策措施时才抽取 Policy
- **Task vs Project**：Task 是可分配工作项（有责任方/截止时间），Project 是有独立建设周期和预算的具名项目
- **Project vs System**：Project 是建设过程，System 是运行态 IT 系统/平台
- **Indicator vs Standard**：Indicator 仅限量化 KPI，Standard 仅限规范性标准文件
- **Event vs Document(纪要)**：Event 是活动事件本身，Document 是文件载体
- **Task**：必须是可分配的具体工作项，不是宏观目标
- **Budget**：必须包含可识别的资金金额或预算科目
- **Industry**：必须是具体产业/行业名称
```

同步更新静态 fallback prompt `ENTITY_EXTRACTION_SYSTEM`。

### 3.2 新增场景化 prompt 构建

**文件**: `backend/app/prompts/entity_extraction.py`

```python
def build_scene_system_prompt(scene_type: str) -> str:
    """根据文档场景类型构建抽取 prompt。"""
    schema = get_schema()
    if not scene_type:
        return build_system_prompt(schema.entity_types, schema.relationship_types)
    entity_types = schema.entity_types_for_scene(scene_type)
    rel_types = schema.rel_types_for_scene(scene_type)
    return build_system_prompt(entity_types, rel_types)
```

### 3.3 改造 GraphBuilder — 场景化归一化（关键修复点）

**文件**: `backend/app/core/graph_builder.py`

**3.3.1 `_normalise_entities()` 必须使用场景化实体集合**

这是本次改造最关键的修复点。当前 `_normalise_entities()` 使用 `_get_valid_entity_types()` 校验，该函数基于 `active_phases` 过滤。如果场景抽取产出 phase_3 实体（如 Person），会在归一化阶段被直接丢弃。

修改方案：
```python
def _normalise_entities(self, raw: list[dict], *, scene_type: str = "") -> list[dict]:
    schema = get_schema()
    if scene_type:
        valid_types = schema.entity_type_names_for_scene(scene_type)
    else:
        valid_types = schema.entity_type_names()
    # ... 后续逻辑不变
```

**3.3.2 `_write_to_neo4j()` 使用全量类型校验**

写入层的合法性校验必须基于全量类型（运行时支持集合），不受 active_phases 限制：
```python
def _get_all_valid_entity_types() -> set[str]:
    return get_schema().all_entity_type_names_unfiltered()

def _get_all_valid_rel_types() -> set[str]:
    return get_schema().all_rel_type_names_unfiltered()
```

**3.3.3 扩展 `_AUTO_ID_FIELDS`**

```python
_AUTO_ID_FIELDS = {
    # Phase 1（现有）
    "Matter": "matter_id", "Condition": "condition_id",
    "Material": "material_id", "TimeLimit": "time_limit_id", "TargetGroup": "target_group_id",
    # Phase 0 新增
    "Policy": "policy_id", "Task": "task_id", "Project": "project_id",
    "System": "system_id", "DataResource": "data_resource_id",
    "Indicator": "indicator_id", "Budget": "budget_id", "Industry": "industry_id",
    # Phase 3
    "Event": "event_id", "Mechanism": "mechanism_id", "Standard": "standard_id",
    "Infrastructure": "infrastructure_id", "Technology": "technology_id",
}
# Person 使用特殊 ID 生成逻辑（见 3.3.4），不在此映射中
```

**3.3.4 Person ID 特殊生成**

```python
def _generate_person_id(name: str, props: dict, doc_id: str) -> str:
    org = props.get("serving_org", "")
    position = props.get("position", "")
    seed = f"{name}|{org}|{position}|{doc_id[:8]}"
    h = hashlib.sha256(seed.encode()).hexdigest()[:12]
    return f"person_{h}"
```

**降级规则**：当 `serving_org` 或 `position` 缺失时，该字段以空字符串参与哈希。这意味着不同文档中提到的同名同机构同职位人员会合并，但同名不同机构人员不会误合并。如果 `serving_org` 和 `position` 均缺失，退化为 `hash(name + doc_id_prefix)`——即同文档内同名合并，跨文档不合并（保守策略）。

**3.3.5 ⚠️ 统一主键解析步骤（关键修复点，适用于所有非 name 主键实体）**

**强制约束**：关系装配必须引入统一主键解析步骤。所有非 `name` 主键实体在构造 `neo_rels` 时均使用 `key_property` 对应值作为 `from_key` 或 `to_key`，不允许继续默认使用实体 `name`。此规则不是 Person 专项修复，而是通用规则——后续如 Standard、Mechanism 等改为非 name 主键，也自动适用。

**问题根因**：当前 `_write_to_neo4j()` (graph_builder.py:327-373) 构造 `neo_rels` 时，`from_key` / `to_key` 直接使用实体的 `name`。而 `_batch_merge_rels()` (neo4j_client.py:753-826) 使用 `_key_prop()` 解析出的主键属性来 MATCH 节点。对于 Person（`key_property: person_id`），关系中传的 `to_key` 是 name，但 Neo4j MATCH 的是 `{person_id: item.to_key}`——不匹配，关系挂不上。

**修改方案**：新增统一辅助函数 + 在 `_write_to_neo4j()` 中使用：

```python
def _resolve_entity_key(label: str, name: str, props: dict) -> str:
    """按标签的 key_property 解析实体的实际连接键。
    对于 key_property == "name" 的类型直接返回 name；
    对于 key_property != "name" 的类型（如 Person）返回对应属性值。
    """
    if label == "Document":
        return props.get("doc_id", name)
    schema = get_schema()
    et_map = schema.all_entity_type_map_unfiltered()
    et_def = et_map.get(label, {})
    key_prop = et_def.get("key_property", "name")
    return props.get(key_prop, name)


async def _write_to_neo4j(self, doc_id, metadata, entities, relations, referenced_doc_numbers):
    # ... 现有实体构造逻辑 ...

    # ── 统一主键解析：建立 (label, name) -> key_value 映射 ──
    entity_key_map: dict[tuple[str, str], str] = {}
    for ent in neo_entities:
        label = ent["label"]
        props = ent["properties"]
        name = props.get("name", "")
        key_val = _resolve_entity_key(label, name, props)
        entity_key_map[(label, name)] = key_val

    # ── 构造 neo_rels 时，统一使用解析后的主键值 ──
    for rel in relations:
        src_type = ...
        src_name = ...
        # ... 现有清洗和 CURRENT_DOC 替换逻辑 ...

        # 统一解析关系连接键（适用于所有实体类型）
        from_key = entity_key_map.get((src_type, src_name), src_name)
        to_key = entity_key_map.get((tgt_type, tgt_name), tgt_name)

        # Document 类型 CURRENT_DOC 替换后已经是 doc_id
        if src_type == "Document":
            from_key = src_name
        if tgt_type == "Document":
            to_key = tgt_name

        neo_rels.append({
            "from_label": src_type,
            "from_key": from_key,
            "to_label": tgt_type,
            "to_key": to_key,
            "type": rtype,
            "properties": rel.get("properties") or {},
        })
```

这样所有非 name 主键实体的关系都能正确连接。当前只有 Person 使用 `person_id`，但此机制对未来扩展也自动适用。

**3.3.6 `build_graph()` 接受 scene_type**

```python
async def build_graph(self, doc_id, metadata, content, *, scene_type: str = "") -> dict:
```

`_build_dynamic_system_prompt()` 和 `_extract_via_llm()` 相应传递 scene_type。

### 3.4 上游传递 scene_type（带兜底优先级）

**文件**: 调用 `build_graph()` 的位置（`ingest_pipeline.py` 或类似文件）

```python
scene_type = (
    metadata.get("document_scene_type")
    or metadata.get("knowledge_category_code")
    or ""
)
await graph_builder.build_graph(doc_id, metadata, content, scene_type=scene_type)
```

### 3.5 验证

- 用一篇政策文档调用 `build_graph()`，验证 Policy/Task/Project 被正确抽取
- 用一篇办事指南调用，验证 Matter 仍正常抽取（回归）
- 用一篇讲话稿（scene_type=leader_speech_city）调用，验证 Person/Event 被抽取且**不被归一化丢弃**
- **⚠️ 验证非 name 主键实体关系闭环**：
    - 抽取含 Person 的讲话稿后，验证 `MATCH (p:Person)-[:SERVES_IN]->(o:Organization)` 有结果
    - 验证 Person→Task (LEADS) 和 Person→Document (INSTRUCTS) 也能挂上，证明双向连接键逻辑一致
    - 验证 `_resolve_entity_key()` 对 key_property=name 的类型（如 Task）返回 name（回归）
- 检查 system prompt token 数不超过 3000

---

## Sprint 4: 查询与 API 改造

### 4.1 查询层标签白名单统一改造（必做项）

**文件**: `backend/app/core/graph_query_service.py`

审查所有使用 `schema.entity_type_names()` 的位置，统一改为 `schema.all_entity_type_names_unfiltered()`（或 `all_node_labels_unfiltered()`），确保 phase_3 写入的数据能被通用查询接口发现。

需要改造的入口：
- `search_entities()` — 实体搜索的标签 OR 条件
- `get_entity()` / `get_entity_neighborhood()` — 实体详情
- `get_document_entities()` — 文档关联实体
- `get_docs_by_entity()` — 实体关联文档
- 其他 overview / neighborhood 相关方法

### 4.1.1 关联文档查询从 name 匹配改为主键定位（必做）

**文件**: `backend/app/core/graph_query_service.py`, `backend/app/api/v1/graph.py`

**问题根因**：当前 `get_docs_by_entity()` 和 `/related-docs` API 使用 `entity_name` + `entity_label` 做 `WHERE n.name = $name` 匹配。对于 Person（`key_property: person_id`），同名不同人的节点会互相串数据——张伟(市发改委)和张伟(市教育局)查出同一批关联文档。这与 Sprint 3 的非 name 主键策略直接矛盾。

**修改方案**：

1. **API 层**：保留现有 `POST /graph/related-docs` 接口和 `RelatedDocsRequest` 请求体不变，在请求体中新增可选字段 `entity_key`：
```python
class RelatedDocsRequest(BaseModel):
    entity_name: str          # 现有字段，保留
    entity_label: str         # 现有字段，保留
    entity_key: str | None = None  # 新增：标签对应 key_property 的业务主键值
    # ... 其余现有字段不变
```

路由处理逻辑：
```python
@router.post("/related-docs")      # 保持 POST，不改 GET
async def get_related_docs(req: RelatedDocsRequest, ...):
    if req.entity_key:
        docs = await svc.get_docs_by_entity_key(req.entity_key, req.entity_label, ...)
    else:
        docs = await svc.get_docs_by_entity(req.entity_name, req.entity_label, ...)
```

> **命名约定**：新参数命名为 `entity_key`（而非 `entity_id`），避免与现有 `/entity/{entity_id}`（Neo4j elementId 语义）冲突。系统中的 ID 语义区分：
> - `GraphNode.id` / `/entity/{entity_id}` = Neo4j elementId
> - `entity_key` = 标签对应 `key_property` 的业务主键值（如 Person 的 `person_id`、Matter 的 `name`）

2. **Service 层**：新增 `get_docs_by_entity_key()` 方法，通过 `schema.all_entity_type_map_unfiltered()` 解析标签对应 `key_property` 后按唯一键 MATCH：

**⚠️ 安全硬约束：标签白名单校验**：在格式化 Cypher 前，**必须**先用 `schema.all_node_labels_unfiltered()` 校验 `label` 合法性；非法 label 直接返回空结果，不进入查询。`key_property` 仅来自 `schema.all_entity_type_map_unfiltered()` 的查询结果，**不接受外部传入字段名**，杜绝 Cypher 注入风险。

```python
async def get_docs_by_entity_key(self, entity_key: str, label: str, ...) -> list[dict]:
    schema = get_schema()
    # ── 标签白名单校验（与现有 search_entities / get_docs_by_entity 一致）──
    if label not in schema.all_node_labels_unfiltered():
        return []
    et_map = schema.all_entity_type_map_unfiltered()
    et_def = et_map.get(label, {})
    key_prop = et_def.get("key_property", "name")  # 仅从 schema 获取，不接受外部传入
    query = f"MATCH (n:{label} {{{key_prop}: $entity_key}})-[r]-(d:Document) ..."
    # 保持 1 跳直连，与现有 get_docs_by_entity 语义一致
    # 不扩大跳数，避免改变"关联文档"的产品语义
    # ... ACL 过滤与现有 get_docs_by_entity 保持一致
```

> **注意**：不直接跨模块调用 `neo4j_client._key_prop()`（私有 helper）。查询层统一通过 `schema.all_entity_type_map_unfiltered()` 解析 key_property，符合"三套集合由 schema 驱动"的主线。如后续多处需要此逻辑，可上提为 `GraphSchema.key_prop_for(label)` 公共方法。

3. **前端调用适配**：`GraphExplorer.vue` 的 `hydrateSelectedNode()` 在请求关联文档时，优先传 `entity_key`（节点属性中的业务主键值）：
```typescript
// 使用现有前端类型系统中的字段名
const label = selectedNode._type || getPrimaryLabel(selectedNode)
// 从后端类型缓存 nodeTypeState 中获取 key_property 配置
const keyProp = nodeTypeState.keyProperties?.[label] || 'name'
const entityKey = selectedNode.properties?.[keyProp]
await getRelatedDocs({
  entity_name: selectedNode.properties?.name || '',
  entity_label: label,
  entity_key: entityKey,  // 新增字段
})
```

> **前端类型注意**：当前 `GraphNode` 只有 `id`（elementId）、`labels`、`properties`、`_type`，没有 `label` 或 `name` 顶级字段。计划中所有前端代码必须使用 `node._type` / `getPrimaryLabel(node)` 获取主标签，使用 `node.properties?.name` 获取名称。`getKeyProperty` 非现有函数，需在 Sprint 5 中新增（或内联从 `nodeTypeState` 读取）。

4. **向后兼容**：`entity_key` 为可选字段（默认 `None`），未传时走现有 `entity_name` 匹配逻辑。现有前端调用点和第三方集成不受影响。

### 4.1.2 精确名称实体解析方法（供 Planner 和卡片链路使用）

**文件**: `backend/app/core/graph_query_service.py`

当前 `search_entities()` 走模糊匹配（`name CONTAINS`），不适合作为卡片主键解析的可靠入口。新增专用精确解析方法：

**⚠️ 安全硬约束：标签白名单校验**：与 §4.1.1 相同，格式化 Cypher 前必须先校验 `label` 合法性；`key_prop` 仅从 schema 获取，不接受外部传入。

```python
async def resolve_entity_by_exact_name(
    self, name: str, label: str, *, acl_tokens=None
) -> str | None:
    """按标签 + 精确名称匹配解析实体业务主键。
    仅在 name 完全相等时返回，否则返回 None。
    """
    schema = get_schema()
    # ── 标签白名单校验 ──
    if label not in schema.all_node_labels_unfiltered():
        return None
    et_map = schema.all_entity_type_map_unfiltered()
    key_prop = et_map.get(label, {}).get("key_property", "name")  # 仅从 schema 获取
    query = f"MATCH (n:{label} {{name: $name}}) RETURN n.{key_prop} AS entity_key LIMIT 1"
    # ... ACL 过滤与 visible_doc_exists 保持一致
```

**设计要点**：
- 使用 `WHERE n.name = $name` 等值匹配，不走 CONTAINS 或全文索引
- 返回业务主键值（如 `policy_id`），不返回完整节点
- 未命中返回 `None`，调用方自行降级
- **非法 label 直接返回 `None`，不抛异常**
- Planner 和其他需要"名称 → 主键"转换的场景统一使用此方法

### 4.2 全文搜索入口接入（必做，否则 Sprint 2 索引无收益）

**文件**: `backend/app/core/graph_query_service.py`

**本期验收口径**：label 指定为 Policy/Task/Project/System 时优先走对应全文索引。未指定标签的通用搜索本期维持现有 `WHERE n.name CONTAINS` 逻辑不变。

改造 `search_entities()`：
```python
# 全文索引名称映射
_FULLTEXT_INDEXES = {
    "Policy": "policy_fulltext",
    "Task": "task_fulltext",
    "Project": "project_fulltext",
    "System": "system_fulltext",
    "Organization": "org_fulltext",   # 现有
    "Matter": "matter_fulltext",      # 现有
}

async def search_entities(self, name, label=None, limit=20, acl_tokens=None):
    if label and label in _FULLTEXT_INDEXES:
        # 走全文索引
        index_name = _FULLTEXT_INDEXES[label]
        query = f"CALL db.index.fulltext.queryNodes('{index_name}', $query) ..."
    else:
        # 现有通用搜索逻辑
        ...
```

> **后续规划**：如需通用搜索也吃全文索引，需设计跨标签合并检索，本期不做。

**⚠️ ACL 与可见性约束必须保留**

全文索引路径必须保持与现有 `search_entities` 相同的 ACL 与可见性约束。全文索引只负责召回候选节点，最终返回前必须再次应用 `visible_doc_exists` 和 `acl_tokens` 过滤，不得因索引接入扩大可见范围。否则会出现权限放大风险——某实体只挂在受限文档上，走全文索引却被无权限用户搜到。

验收增加一条：对受 ACL 控制的图数据，全文索引路径与原 CONTAINS 路径返回结果的权限范围一致，不出现权限放大。

### 4.3 新增实体卡片查询

**文件**: `backend/app/core/graph_query_service.py`

```python
async def get_policy_card(self, entity_key: str, acl_tokens=None) -> dict:
    """政策卡片：责任主体、部署任务、推动项目、关联主题、来源文件
    entity_key 为 Policy 节点的 key_property 值（当前 key_property=name，所以传名称）"""

async def get_task_card(self, entity_key: str, acl_tokens=None) -> dict:
    """任务卡片：牵头/配合单位、落实政策、考核指标、资金支持、依据文件"""

async def get_project_card(self, entity_key: str, acl_tokens=None) -> dict:
    """项目卡片：实施单位、落地区域、支撑政策、预算、技术栈、依据文件"""
```

> **参数约定**：三类卡片接口统一使用 `entity_key` 参数名，其值为标签对应 `key_property` 属性值。当前 Policy/Task/Project 的 `key_property` 均为 `name`，所以 `entity_key` 实际传的是实体名称。如后续这些类型升级为非 name 主键，接口签名无需变更，仅底层 MATCH 条件随 key_property 变化。

**⚠️ 硬约束：卡片查询内部必须动态解析 key_property**

`get_policy_card`、`get_task_card`、`get_project_card` 实现内部**一律**先通过 `schema.all_entity_type_map_unfiltered()` 解析当前标签的 `key_property`，再按该 `key_property` 构造 MATCH 条件。**禁止在 Cypher 中硬编码 `{name: $entity_key}`**——即使当前这三个类型的 `key_property` 恰好是 `name`，也必须走动态解析路径，确保后续任一类型升级为非 name 主键时无需改动卡片查询代码。

```python
async def get_policy_card(self, entity_key: str, acl_tokens=None) -> dict:
    schema = get_schema()
    et_map = schema.all_entity_type_map_unfiltered()
    key_prop = et_map.get("Policy", {}).get("key_property", "name")
    # key_prop 当前为 "name"，但必须动态获取
    query = f"MATCH (p:Policy {{{key_prop}: $entity_key}}) ..."
    # ... 后续 OPTIONAL MATCH 同理使用显式标签约束
```

示例 Cypher（当前 key_property=name 时的实际效果）：
```cypher
MATCH (t:Task {name: $entity_key})
OPTIONAL MATCH (t)<-[:DEPLOYS_TASK]-(d:Document)
OPTIONAL MATCH (t)-[:LEAD_BY]->(org:Organization)
```

### 4.4 GraphQueryPlanner 新增意图 + 消歧优先级 + 证据收集闭环

**文件**: `backend/app/core/graph_query_planner.py`, `backend/app/prompts/query_planning.py`

> **作用范围说明**：GraphQueryPlanner 的新增意图仅作用于 Research 模式下的规则路由与图谱证据收集，不替代图谱浏览器、实体搜索和节点卡片接口的显式查询实现。

**改造内容**：

1. **QueryIntent 枚举新增**：`POLICY_DETAIL`, `TASK_DETAIL`, `PROJECT_DETAIL`

2. **`_infer_entity_label()` 消歧优先级**（扩展正则）：
    1. 明确"项目/工程/建设/采购/试点" → 优先判 Project
    2. 明确"政策/措施/办法/规定/奖补" → 优先判 Policy
    3. 明确"任务/工作/推进/整改/督办/落实" → 优先判 Task
    4. 明确"平台/系统/应用/门户" → 判为 System，但本期 **不进入 DETAIL 意图**，仍落回 GENERAL（System 本期仅参与抽取、搜索和图浏览，不提供专用卡片）

3. **`plan()` 规则判别新增分支**：识别到上述标签后，分发到对应 `*_DETAIL` 意图

4. **`collect_evidence()` 新增 3 个分支（含名称→业务主键解析）**：

   Planner 从自然语言中获取的是实体名称（`entity_name`），而卡片服务需要业务主键（`policy_id` / `task_id` / `project_id`）。中间必须增加一步实体解析：

   主键解析使用 Sprint 4 新增的专用精确解析方法 `resolve_entity_by_exact_name()`（见 §4.1.2），不复用现有模糊搜索：

   ```python
   # collect_evidence 中 POLICY_DETAIL 分支示例
   # 沿用现有 collect_evidence(graph_service, ...) 签名
   entity_key = await graph_service.resolve_entity_by_exact_name(
       name=plan.entity_name, label="Policy", acl_tokens=acl_tokens
   )
   if entity_key:
       card = await graph_service.get_policy_card(entity_key, acl_tokens=acl_tokens)
   else:
       card = None  # 精确匹配未命中，返回空证据，不报错
   ```

    - `POLICY_DETAIL` → 解析 entity_key → 调用 `get_policy_card(entity_key)`
    - `TASK_DETAIL` → 解析 entity_key → 调用 `get_task_card(entity_key)`
    - `PROJECT_DETAIL` → 解析 entity_key → 调用 `get_project_card(entity_key)`

5. **证据模板**（`query_planning.py`）：新增 3 个卡片证据格式化模板，参照现有 `MATTER_DETAIL` 的证据格式，输出"标题 + 关联实体列表 + 来源文件"。

### 4.5 新增 API 端点

**文件**: `backend/app/api/v1/graph.py`

```python
@router.get("/policy/{entity_key}")   # entity_key = Policy 的 key_property 值（当前为 name）
@router.get("/task/{entity_key}")     # entity_key = Task 的 key_property 值（当前为 name）
@router.get("/project/{entity_key}")  # entity_key = Project 的 key_property 值（当前为 name）
```

### 4.6 新增响应 Schema

**文件**: `backend/app/api/schemas/`

```python
class PolicyCardResponse(BaseModel): ...
class TaskCardResponse(BaseModel): ...
class ProjectCardResponse(BaseModel): ...
```

> **规划项（本期不实施）**: 后续统一抽象卡片协议，避免 Person/Event/System 等再重复造接口模式。

### 4.7 验证

- 验证 `search_entities(name="xx", label=None)` 能搜到 phase_3 写入的 Person/Event 节点（白名单已改为全量）
- 验证 `search_entities(name="xx", label="Policy")` 走 fulltext 索引（可通过 EXPLAIN 确认）
- 验证 `search_entities(name="xx", label=None)` 结果正确且行为与改造前一致（不要求性能变化）
- 验证 `get_document_entities()` 能返回新类型实体
- 卡片 API 返回格式正确
- **⚠️ 验证关联文档主键定位**：对 Person 节点 POST `/related-docs` 带 `entity_key=person_xxx` + `entity_label=Person`，确认返回的文档与该 person_id 节点实际关联一致，不会串到同名其他 Person 的文档
- **向后兼容**：不传 `entity_key` 时，POST `/related-docs` 带 `entity_name=xx` + `entity_label=Organization` 仍走旧逻辑且结果正确
- **验证精确解析未命中降级**：`resolve_entity_by_exact_name` 传入不存在的实体名时返回 `None`，Planner 对应 detail intent 返回空证据（`card = None`），不抛异常，不影响其他检索链路正常返回
- **验证非法 label 安全边界**：`get_docs_by_entity_key(entity_key="xxx", label="INVALID_LABEL")` 返回空列表，不抛异常；`resolve_entity_by_exact_name(name="xxx", label="INVALID_LABEL")` 返回 `None`，不进入 Cypher 执行
- 回归：Matter 卡片和现有查询不破坏

> **前端配套说明**：当前前端图谱搜索调用 `searchEntities` 时未指定 label，因此本期全文索引的用户可感知收益不会自动覆盖图谱浏览器默认搜索。若要体现索引收益，需在图谱浏览器增加标签限定搜索入口（可作为后续优化项）。

---

## Sprint 5: 前端适配 + 联调回归

### 5.1 更新图谱标签配置

**文件**: `frontend/src/utils/graphLabels.ts`

新增 14 个实体的 NODE_TYPE_COLORS、NODE_TYPE_ZH，以及 20+ 新关系的 REL_TYPE_ZH。

**⚠️ 中文名来源切换**：当前 `loadTypesFromApi()` 把实体类型的 `description` 写入 `NODE_TYPE_ZH`。由于本次 `description` 改为包含完整抽取规则（长文本），必须改为优先使用 `zh_name` 字段：

```typescript
// zh_name 覆盖逻辑 — 已内嵌在 §5.4 的 loadTypesFromApi() 响应式实现中
// 此处仅说明覆盖优先级：zh_name > description > 本地 fallback
// 完整代码见 §5.4 响应式数据源设计
```

同步改动（zh_name 契约打通，缺一环前端都拿不到）：

**后端全链路（实体类型 + 关系类型双链路）**：
- `backend/app/config/graph_schema.yaml` — 每个实体定义和关系定义均新增 `zh_name` 字段
- `backend/app/core/graph_admin_service.py` — **完整链路改造（实体+关系）**：
    - `_load_default_entity_types()` / `_load_default_rel_types()` 返回值增加 `zh_name`
    - `ensure_default_types()` 将 `zh_name` 写入 `_EntityType` / `_RelationType` 元节点
    - `refresh_type_cache()` 从元节点读取 `zh_name` 并填入缓存
    - `list_entity_types()` / `list_rel_types()` 返回 `zh_name`
    - `update_entity_type()` 仅接受 `zh_name`、`icon`、`color`（create/delete/rename 本期禁用，见 §2.4.6）
    - `update_rel_type()` 仅接受 `zh_name`（关系类型本期不引入 icon/color；create/delete/rename 本期禁用）
- `backend/app/api/schemas/admin_graph.py`：
    - `EntityTypeItem` 增加 `zh_name: str | None` 和 `key_property: str = "name"`（只读，供前端构造 entity_key）
    - `EntityTypeUpdateRequest` 增加 `zh_name: str | None`（**不含** `key_property`，见 §2.4.6 只读策略）
    - `EntityTypeCreateRequest` — 本期禁用，可保留模型但路由返回 400
    - `RelTypeItem` 增加 `zh_name: str | None`（**不含** `key_property`，仅实体类型有此字段）
    - `RelTypeUpdateRequest` 增加 `zh_name: str | None`
    - `RelTypeCreateRequest` — 本期禁用
- `backend/app/api/v1/admin_graph.py`：
    - `list_entity_types` / `list_rel_types` 路由返回 `zh_name`，实体类型含 `key_property` 只读字段
    - `create_entity_type` / `delete_entity_type` / `rename_entity_type` 路由返回 400（本期禁用）
    - `create_rel_type` / `delete_rel_type` / `rename_rel_type` 路由返回 400（本期禁用）
    - `update_entity_type` 仅允许修改 `zh_name`、`icon`、`color`
    - `update_rel_type` 仅允许修改 `zh_name`
- `backend/app/api/v1/admin_graph.py` — 实体类型和关系类型列表接口均返回 `zh_name`

**前端契约**：
- `frontend/src/types/admin-graph.d.ts`：
    - 实体类型接口增加 `zh_name?: string` 和 `key_property?: string`（默认 `"name"`）
    - 关系类型接口增加 `zh_name?: string`

同名关系需区分展示文案：
- `EVALUATES` → 展示时根据起点标签区分"指标评价" vs "标准考核"
- 前缀关系如 `MECHANISM_MANAGED_BY` / `INFRA_MANAGED_BY` → 均展示为"管理单位"（前端可简化）

### 5.2 新增实体卡片组件 + 接入 GraphExplorer（首批仅 3 个）

**新建文件**:
- `frontend/src/components/PolicyCard.vue`
- `frontend/src/components/TaskCard.vue`
- `frontend/src/components/ProjectCard.vue`

参照 `MatterCard.vue` 模式。Person/Event/System 等卡片后续按使用频次补齐。

**⚠️ 必须接入 GraphExplorer，而非仅新建组件**

当前 `GraphExplorer.vue` 的节点点击逻辑只设置 `selectedNode` 并查询 `relatedDocs`，没有按标签分发卡片的分支。如果只新建组件不改 Explorer，卡片永远用不到。

**改造 GraphExplorer.vue**：
1. 在节点详情区增加"按标签分发"的渲染逻辑：
    - `Policy` → 调用 `getPolicyCard()` 并渲染 `PolicyCard`
    - `Task` → 调用 `getTaskCard()` 并渲染 `TaskCard`
    - `Project` → 调用 `getProjectCard()` 并渲染 `ProjectCard`
    - `Matter` → 继续使用现有 `MatterCard`
    - 其他类型 → 继续使用现有通用节点详情面板
2. 在节点点击逻辑中补充卡片数据加载流程（不只是 `getRelatedDocs`）

### 5.3 新增 API 封装和 TypeScript 类型

**文件**: `frontend/src/api/graph.ts`：
- 新增 `getPolicyCard(entityKey)` / `getTaskCard(entityKey)` / `getProjectCard(entityKey)`
- **改造 `getRelatedDocs()`**：当前为位置参数签名 `getRelatedDocs(entityName, entityLabel, limit)`，需改为请求体对象签名以支持 `entity_key` 可选字段：
  ```typescript
  // 改造前
  export function getRelatedDocs(entityName: string, entityLabel: string, limit?: number)
  // 改造后
  interface RelatedDocsParams {
    entity_name: string
    entity_label: string
    entity_key?: string  // 新增：非 name 主键实体的业务主键值
    limit?: number
  }
  export function getRelatedDocs(params: RelatedDocsParams)
  ```

**文件**: `frontend/src/types/graph.d.ts` — 新增对应 Response 接口

### 5.4 图谱浏览器适配 + 动态类型加载修复 + 旧标签清理

**⚠️ 动态类型加载当前不生效，必须修复**

现状：`graphLabels.ts` 定义了 `loadTypesFromApi()` 函数，但当前 **没有任何调用点**。`GraphExplorer.vue` 把 `NODE_TYPE_LIST` 在组件初始化时直接固化为常量快照。结果：新增类型不会自动出现在图谱浏览器的筛选区和图例中。

**修复方案（响应式数据源设计）**：

当前 `NODE_TYPE_ZH`、`NODE_TYPE_COLORS`、`REL_TYPE_ZH` 是 plain module objects，Vue 无法追踪其属性变化。仅用 `computed` 包裹不会解决问题——`computed` 依赖的底层数据源本身必须是响应式的。

**具体方案**：将 `graphLabels.ts` 的核心数据改为 `reactive()` 包裹的响应式对象：

```typescript
// graphLabels.ts
import { reactive, computed } from 'vue'

// 响应式数据源 — Vue 能追踪属性变化
export const nodeTypeState = reactive({
  colors: { ...DEFAULT_NODE_TYPE_COLORS } as Record<string, string>,
  zhNames: { ...DEFAULT_NODE_TYPE_ZH } as Record<string, string>,
  keyProperties: {} as Record<string, string>,  // label -> key_property 映射，供 entity_key 查询使用
})

export const relTypeState = reactive({
  zhNames: { ...DEFAULT_REL_TYPE_ZH } as Record<string, string>,
})

// 响应式派生列表 — 会随 nodeTypeState 自动更新
// ⚠️ 输出形状保持 { label, value, color }，与 GraphExplorer 现有消费一致
export const NODE_TYPE_LIST = computed(() =>
  Object.entries(nodeTypeState.zhNames).map(([value, label]) => ({
    label,    // 中文显示名
    value,    // 英文类型标识（如 "Policy"）
    color: nodeTypeState.colors[value] || '#999',
  }))
)

// loadTypesFromApi 写入响应式对象，触发依赖更新
// 使用现有 admin-graph.ts 的 listEntityTypes / listRelTypes
export async function loadTypesFromApi() {
  const [entityTypeResp, relTypeResp] = await Promise.all([
    listEntityTypes(),   // 来自 frontend/src/api/admin-graph.ts，返回 { items: [...] }
    listRelTypes(),      // 来自 frontend/src/api/admin-graph.ts，返回 { items: [...] }
  ])
  const entityTypes = entityTypeResp.items || []
  const relTypes = relTypeResp.items || []
  for (const t of entityTypes) {
    nodeTypeState.zhNames[t.name] = t.zh_name || t.description || nodeTypeState.zhNames[t.name]
    if (t.color) nodeTypeState.colors[t.name] = t.color
    if (t.key_property) nodeTypeState.keyProperties[t.name] = t.key_property
  }
  for (const r of relTypes) {
    relTypeState.zhNames[r.name] = r.zh_name || r.description || relTypeState.zhNames[r.name]
  }
}
```

**调用时机**：`GraphExplorer.vue` 的 `onMounted` 中调用 `loadTypesFromApi()`。由于数据源是 `reactive()`，后续任何使用 `NODE_TYPE_LIST` 或 `nodeTypeState` 的模板/computed 都会自动响应更新。

**GraphExplorer 脚本侧适配**：当前 GraphExplorer 将 `NODE_TYPE_LIST` 当普通数组消费（如可见类型初始化、筛选芯片、复选框）。改为 `computed` 后，以下依赖点需同步改造：
- 可见类型初始化（`visibleTypes` 等）改为依赖 `NODE_TYPE_LIST.value`
- 筛选芯片/复选框的 `v-for` 遍历改为消费 computed ref
- 任何脚本中直接按索引或 `.length` 访问 `NODE_TYPE_LIST` 的代码都要加 `.value`

**visibleTypes 同步策略**：当前 `visibleTypes` 在页面初始化时一次性从 `NODE_TYPE_LIST` 取值。即便 `NODE_TYPE_LIST` 改成 computed，`loadTypesFromApi` 动态补进的新类型不会自动进入 `visibleTypes`，图上仍会隐藏新类型。修复方案：
- 对 `NODE_TYPE_LIST` 建立 `watch`，增量同步新增类型到 `visibleTypes`
- 保留用户手动关闭过的类型状态（只添加之前不存在的 value，不重置已关闭的）
```typescript
watch(NODE_TYPE_LIST, (newList) => {
  const currentValues = new Set(visibleTypes.value)
  for (const item of newList) {
    if (!currentValues.has(item.value)) {
      visibleTypes.value.push(item.value)
    }
  }
})
```

**向后兼容**：原有直接读 `NODE_TYPE_ZH[label]` 的代码统一改为读 `nodeTypeState.zhNames[label]`（或保留原导出名作为 getter 别名）。

**统一选中节点流程**

当前 `onNodeClick` 和搜索结果点击走不同路径：前者触发关联文档加载，后者只设置 `selectedNode`。如果只在 `onNodeClick` 里补卡片数据请求，搜索结果点击不会出卡片。

**修复方案**：抽一个统一的 `hydrateSelectedNode(node)` 方法，节点点击、搜索结果点击、程序化选中都走同一条路径。内部按标签分发：
- Policy/Task/Project → 加载专用卡片数据
- Matter → **复用现有 `MatterCard.vue` 组件和 `/graph/matters/{matter_id}` 接口，但需在 GraphExplorer 中新增接线**（当前 Explorer 尚未接入 MatterCard，只有通用节点详情面板）
- 其他 → 通用节点详情 + 关联文档

**模板展示点改造**

当前搜索结果标签和详情面板类型标签直接显示英文 label（如 `Policy`）。需统一改为显示 `NODE_TYPE_ZH[label]` 映射后的中文标签。

**旧标签清理**：当前 `graphLabels.ts` 中存在 Subject、DocCategory、Keyword 等遗留映射，与当前 schema 定义不一致。本 Sprint 顺手清理，确保不出现"新旧两套语义同时存在"的展示噪声。

### 5.6 管理端类型页适配

**文件**: `frontend/src/views/admin/graph/TypeManageTab.vue`

当前该页面包含完整的类型 CRUD 交互（新建、重命名、删除按钮与弹窗），以及调用 `createEntityType`、`deleteEntityType`、`createRelType`、`deleteRelType` 等前端 API。

**改造内容**：

1. **移除定义级 CRUD 入口**：
    - 隐藏或移除"新建类型""重命名""删除"按钮与对应弹窗
    - `createEntityType`、`createRelType`、`deleteEntityType`、`deleteRelType` 在前端不再被引用（可保留 API 函数但移除所有调用点）

2. **新增展示元数据编辑入口**（当前管理页无此功能，需新建）：
    - 实体类型行新增"编辑展示信息"按钮，点击弹出编辑抽屉/弹窗，仅含 `zh_name`（中文显示名）、`icon`（图标）、`color`（颜色）三个可编辑字段
    - 关系类型行新增"编辑显示名称"按钮，弹窗仅含 `zh_name` 一个可编辑字段
    - 不复用原来的重命名弹窗（那是改 name 标识符的，语义不同）

3. **列表页只读信息展示**：
    - 实体类型列表展示 `name`、`zh_name`、`icon`、`color`、`key_property`、`instance_count`（均为只读）
    - 关系类型列表展示 `name`、`zh_name`、`source_labels`、`target_labels`、`instance_count`（均为只读）

### 5.7 验证

- **新类型可见**：不修改前端代码常量也能在筛选区和图例中看到新增实体类型（动态加载生效）
- **响应式验证**：后端新增一个实体类型后，刷新 GraphExplorer 页面，筛选区和图例自动出现新类型（无需修改前端代码）
- 图谱浏览器显示新实体类型，颜色/图标正确
- 旧实体映射不与新 schema 语义冲突
- **点击图上 Policy/Task/Project 节点后，详情区渲染专用卡片**
- **从搜索结果进入 Policy/Task/Project 实体时，也能正确显示专用卡片**（统一选中流程生效）
- 搜索结果和详情面板的类型标签显示短中文名，不显示英文 label 或长描述
- **phase_3 写入的数据可搜、可看、可跳转**
- **关联文档请求传 entity_key**：点击 Person 节点后，前端发出的 related-docs 请求体包含 `entity_key: "person_xxx"` 字段
- 现有 Matter 卡片和图谱功能不退化

> **边界说明**：首批仅适配 PolicyCard/TaskCard/ProjectCard。Person/Event/System 等卡片后续按使用频次补齐。

---

## 关键文件清单

| 文件 | 改动类型 |
|------|----------|
| `prd/government-document-graph-ontology-v1-prd.md` | 修订 |
| `backend/app/config/graph_schema.yaml` | 大幅扩充 |
| `backend/app/core/graph_schema_loader.py` | 增强（三套集合 + 场景化支持） |
| `backend/app/prompts/entity_extraction.py` | 增强（分层 prompt + 消歧规则 + 场景构建） |
| `backend/app/core/graph_builder.py` | 改造（场景化归一化 + 全量写入校验 + Person ID） |
| `backend/app/core/graph_admin_service.py` | 改造（全量类型缓存 + ensure_default_types） |
| `backend/app/infrastructure/neo4j_client.py` | 改造（全量约束 + 全文索引 + _key_prop 全量映射） |
| `backend/app/core/graph_query_service.py` | 改造（白名单统一 + 全文搜索接入 + 卡片查询） |
| `backend/app/core/graph_query_planner.py` | 新增意图 + 消歧优先级 |
| `backend/app/api/v1/graph.py` | 新增端点 |
| `backend/app/api/schemas/admin_graph.py` | 改造（EntityTypeItem 增加 zh_name + key_property） |
| `backend/app/api/v1/admin_graph.py` | 改造（类型列表返回 zh_name + key_property） |
| `frontend/src/utils/graphLabels.ts` | 扩充映射 + zh_name 优先 + 旧标签清理 |
| `frontend/src/views/GraphExplorer.vue` | 改造（按标签分发详情卡片） |
| `frontend/src/components/{Policy,Task,Project}Card.vue` | 新建 |
| `frontend/src/api/graph.ts` + `types/graph.d.ts` | 新增 |
| `frontend/src/views/admin/graph/TypeManageTab.vue` | 改造（禁用类型定义 CRUD，仅保留展示元数据编辑） |
| `frontend/src/types/admin-graph.d.ts` | 增加 zh_name + key_property 字段 |
| `backend/app/prompts/query_planning.py` | 新增卡片证据模板 |

