
# 产品需求文档：题目解析与结构化服务 (QPES)
**Question Parsing & Extraction Service - Backend**

| 文档版本     | V1.0                   |
| :------- | :--------------------- |
| **依赖模块** | UDC-M (通用文档转换服务 V2.0)  |
| **最后更新** | 2025-12-09             |
| **状态**   | 待开发                    |
| **涉及端**  | 后端 API, LLM Agent, 数据库 |

---

## 1. 项目背景与目标
### 1.1 背景
模块一（UDC-M）已经将 PDF/Word/Excel 变成了带有 MinIO 图片链接的标准 Markdown 流。但此时的数据仍然是**非结构化**的“一坨文本”。
我们需要将这些文本中的每一道试题（题干、选项、答案、解析）精准切割出来，变成数据库中的一行行记录，以便后续进行知识点打标和组卷搜索。

### 1.2 目标
构建一个**智能分块与语义提取服务**。
*   **语义切分**：利用 LLM 识别题目边界，解决“跨页中断”、“题目粘连”问题。
*   **结构化输出**：将非结构化文本转化为标准 JSON 格式（`content`, `answer`, `analysis`）。
*   **多模态保留**：在切分过程中，**绝对保留** Markdown 中的图片链接（公式截图、几何图）。
*   **无缝衔接**：通过 `document_id` 与 UDC-M 的资产表深度关联。

---

## 2. 系统架构设计

### 2.1 核心流程
1.  **Input**: 接收 `document_id` -> 从 UDC-M 获取 Markdown 文本。
2.  **Chunking Strategy (切片引擎)**:
    *   由于 LLM 上下文限制（即使 DeepSeek 支持 128k，过长也会导致注意力分散），需采用 **“滑动窗口 (Sliding Window)”** 策略。
    *   *策略*: 设定窗口大小（如 3000 Tokens 或 5 页），重叠大小（如 500 Tokens 或 1 页）。
3.  **LLM Processor (提取引擎)**:
    *   构造 Prompt，要求 LLM 输出特定 Schema 的 JSON。
    *   处理重叠区域的**题目去重 (Deduplication)**。
4.  **Validation**: 使用 Pydantic 校验 LLM 返回的 JSON 格式，失败则重试。
5.  **Storage**: 写入 `questions` 表。

---

## 3. 功能需求详细说明

### 3.1 提取任务管理
*   **异步处理**: 提取过程耗时较长（LLM 生成），必须异步。
*   **状态机**: `queued` -> `processing` -> `deduplicating` (去重中) -> `success` / `failed`。

### 3.2 智能分块策略 (Sliding Window)
*   **场景**: 一道大题可能跨越第 5 页和第 6 页。如果硬切，题目会断裂。
*   **逻辑**:
    *   **Chunk N**: 读取第 1-1000 行。Prompt: "提取所有完整题目，如果末尾题目被截断，请**不要**提取，并在 JSON 中标记 `truncated: true`"。
    *   **Chunk N+1**: 读取第 800-1800 行（重叠 200 行）。Prompt: "提取所有完整题目。注意：如果是开头被截断的题目（即上一块的末尾），请尝试修复并提取"。
*   **去重逻辑**:
    *   对提取出的每一道题计算 `MD5(content_text)`。
    *   如果后一个 Chunk 提取出的题目 Hash 与前一个 Chunk 已存的题目 Hash 一致，则丢弃，防止重复。

### 3.3 Prompt Engineering (核心资产)
*   **输入**: Markdown 片段。
*   **要求**:
    1.  识别选择题、填空题、解答题。
    2.  **关键**: 保持文本中的 `![](...)` 图片链接不被 LLM 吞掉或篡改。
    3.  **关键**: 数学公式保持 LaTeX 格式（`$` 包裹）。
    4.  输出 list of objects。

### 3.4 结构化存储
*   将提取结果存入 `questions` 表。
*   **预留字段**: `knowledges` 和 `methods` 字段初始化为 `NULL`，等待模块三（知识增强）处理。

---

## 4. 数据库设计 (PostgreSQL)

### **4.1 Table: `questions` (题库核心表)**
 本模块负责该表的 **初始化 (INSERT)** 操作。
 *   **写入字段**: `id`, `document_id`, `question_seq`, `content_md`, `options_json`, `answer_md`, `analysis_md`, `content_hash`。
*   **默认字段**: `enrich_status` 默认为 `'pending'`，`knowledges`/`methods` 等字段保持为 `NULL`。
*   *(完整表结构定义请参考“统一数据库设计规范”文档)*

### 4.2 Table: `extraction_tasks` (提取任务记录)
用于记录每次 LLM 调用的消耗和状态。

| 字段名 | 类型 | 说明 |
| :--- | :--- | :--- |
| `id` | UUID (PK) | 任务 ID |
| `document_id` | UUID | 关联文档 |
| `model_name` | VARCHAR | 使用的模型 (e.g., `deepseek-v3`) |
| `token_usage` | INT | 本次任务消耗 Token 数 (用于成本核算) |
| `status` | VARCHAR | 状态 |
| `error_log` | TEXT | 错误日志 |

---

## 5. API 接口定义 (RESTful)

**Base URL**: `/api/v1/extractions`

### 5.1 发起提取任务
*   **Endpoint**: `POST /`
*   **Description**: 对指定文档进行题目提取。
*   **Request Body**:
    ```json
    {
        "document_id": "uuid-of-document",
        "chunk_size": 3000,  // 可选，Token数
        "overlap": 500       // 可选，重叠Token数
    }
    ```
*   **Logic**:

    1.  检查 `documents` 表中该文档是否存在且 `parse_result_url` (Markdown) 是否就绪。
    2. 检查 documents.markdown_status == 'success'.
    3. **Clean-up Strategy**: "If force_refresh=True or existing questions found, DELETE old questions for this doc_id before processing."
    4.  创建 `extraction_task`。
    5.  推送到 Redis 队列 `llm-extraction-queue`。
*   **Response**: `{"task_id": "...", "status": "queued"}`

### 5.2 查询提取状态
*   **Endpoint**: `GET /{task_id}`
*   **Response**:
    ```json
    {
        "status": "processing",
        "progress": "3/10 chunks",
        "extracted_count": 45
    }
    ```

### 5.3 获取提取结果 (核心)
*   **Endpoint**: `GET /documents/{document_id}/questions`
*   **Description**: 获取某文档下的所有题目（支持分页）。
*   **Response**:
    ```json
    {
        "total": 12,
        "items": [
            {
                "id": 1001,
                "question_seq": 1,
                "content_md": "已知函数 $f(x)=x^2$ ... ![](http://minio.../a.jpg)",
                "enrich_status": "pending"
            },
            ...
        ]
    }
    ```

### 5.4 人工修正接口 (可选)
*   **Endpoint**: `PUT /questions/{question_id}`
*   **Description**: 人工校对 OCR 错误或切分错误。

---

## 6. 技术选型 (Vibe Coding Stack)

除了沿用模块一的 **FastAPI + Celery + PostgreSQL** 外，本模块的核心在于 **LLM 交互**。

| 组件 | 选型 | 理由 |
| :--- | :--- | :--- |
| **LLM Model** | **DeepSeek-V3** | API 成本极低，Context 窗口大，逻辑推理能力强，适合处理长文档。 |
| **LLM SDK** | **OpenAI SDK** | DeepSeek 完美兼容 OpenAI 接口协议。不要引入 LangChain (太重)，直接用原生 SDK 配合 Pydantic 即可。 |
| **Schema Validation** | **Pydantic (Instructor)** | **强烈推荐**使用 `instructor` 库（基于 Pydantic），它可以强制 LLM 输出完全符合 Pydantic 定义的 JSON 结构，极大降低解析错误率。 |
| **Markdown Splitter** | **LangChain TextSplitter** | 虽然不用 LangChain 的 Chain，但它的 `MarkdownHeaderTextSplitter` 工具类非常好用，用于预处理 Chunk。 |

---

## 7. 开发难点与 Vibe Coding 提示

在开发此模块时，请注意以下 Prompt 技巧，通过 Cursor/Windsurf 编写代码时由于重要：

### 7.1 "Instructor" 模式示例
不要让 LLM 返回纯文本然后自己 `json.loads()`，这很容易崩。请使用 Schema 驱动模式：

```python
from pydantic import BaseModel, Field
from typing import List, Optional

# 1. 定义题目结构
class QuestionItem(BaseModel):
    content: str = Field(..., description="The main text of the question, including LaTeX and image links.")
    options: Optional[List[str]] = Field(None, description="List of options if it is a multiple choice question.")
    answer: Optional[str] = Field(None, description="The answer key.")
    type: str = Field(..., description="Type: choice, fill, or essay")

class ExtractionResult(BaseModel):
    questions: List[QuestionItem]

# 2. Vibe Coding 调用 (伪代码)
# client = instructor.patch(OpenAI(...))
# resp = client.chat.completions.create(
#     model="deepseek-chat",
#     response_model=ExtractionResult,  <-- 关键！强制返回对象
#     messages=[...]
# )
```

### 7.2 图片链接保护 Prompt
在 System Prompt 中必须加入：
> "You are a strictly structural parser. You must NOT simplify, summarize, or remove any content from the original text.
> **CRITICAL RULE**: Do NOT remove or modify any image links formatted as `![](...)`. They must be preserved exactly as they appear in the text."

### 7.3 去重逻辑实现
建议在 Python 代码中实现去重，而不是让 LLM 去重。
*   **方法**: 维护一个 `Set`，存储 `hash(content)`。
*   **流程**: Worker 处理完 Chunk 1，将题目入库并存 Hash 到 Set。处理 Chunk 2 时，先计算 Hash，如果在 Set 里，直接跳过。

---

## 8. 与后续模块的接口

该模块完成后，数据库中的 `questions` 表已经有了干净的题目数据。
*   **字段状态**: `knowledges` 为 NULL, `enrich_status` 为 `pending`。
*   **下一步**: 模块三（知识增强服务）只需要 `SELECT * FROM questions WHERE enrich_status = 'pending'`，然后逐条调用 Summary API 进行填空即可。

这套设计保证了模块的**高内聚、低耦合**。即使模块三挂了，模块二依然可以照常解析入库，不会阻塞业务。