2643616413 пре 9 часа
родитељ
комит
0866270333

+ 35 - 4
algo/docs/API.md

@@ -72,6 +72,7 @@ RAG 索引使用进程内 **`InMemoryRagStore`**(按 `task_id` 隔离)。**
 | GET | `/debug/embedding` | 探测 Embedding 连通性 |
 | GET | `/debug/ocr` | 探测 OCR(需 query `image_url`) |
 | POST | `/v1/outline/l1` | 一级大纲 |
+| POST | `/v1/outline/l2/batch` | 二级大纲批量(按 L1 结果逐章聚合) |
 | POST | `/v1/outline/l2` | 二级 / 末级结构(单章) |
 | POST | `/v1/section` | 单知识单元段落生成 |
 | POST | `/v1/rag/ingest-files` | 上传文件 → 解析 → 分块 → 入库 |
@@ -214,7 +215,7 @@ RAG 索引使用进程内 **`InMemoryRagStore`**(按 `task_id` 隔离)。**
 | `chapter_id` | string | 须与输入候选一致 |
 | `chapter_name` | string | 须与输入候选一致 |
 | `presentation_enum` | string | `S1` 独立呈现 / `S2` 不呈现 |
-| `paragraph_count_enum` | string | `P0`~`P4`;**`S2` 必须为 `P1`;`S1` 不可为 `P0`**(服务端校验) |
+| `paragraph_count_enum` | string | `P0`~`P4`;**`S2` 必须为 `P0`;`S1` 不可为 `P0`**(服务端校验) |
 | `reason` | string | 判断理由 |
 
 **业务校验**(非空 `chapter_candidates` 时,见 `skills/outline_l1/outline_l1.py`):
@@ -274,15 +275,45 @@ RAG 索引使用进程内 **`InMemoryRagStore`**(按 `task_id` 隔离)。**
 | `source_candidate_name` | string / null | 候选名 |
 | `is_selected` | boolean | 默认 `true` |
 
-**`chapter_presentation_enum = S2` 时的服务端约束**:
+**`chapter_presentation_enum = S2` 时的服务端行为**:
 
-- 解析成功后若 `chapter_structure` **非空**,抛出 `ValueError` → **422**  
-- **Stub 模式**下对 `S2` 会直接返回 **空** `chapter_structure`
+- **非 stub**:**不调用 LLM**,直接返回 `chapter_structure: []`,避免模型偶发输出非空结构导致 422,并节省调用。  
+- **Stub 模式**:同样返回空 `chapter_structure`。  
+- **`S1`**:走模型 + JSON 校验;若返回的 `chapter_name` / `chapter_no` 与请求不一致等,仍可能 **422**。
 
 **`FINREP_STUB_SKILLS=true`**:不请求 LLM;`S1` 返回占位节点,`S2` 返回空结构。
 
 ---
 
+### `POST /v1/outline/l2/batch`
+
+**Content-Type**:`application/json`
+
+按 **`l1_response.chapter_results` 的顺序**逐章调用与单章 L2 相同的技能(`run_outline_l2`),将 **`l1_task_snapshot`** 作为各章的 `l1_task_snapshot`。`chapter_no` 自动为 `1`、`2`、`3`…(与 L1 结果行序一致)。
+
+**请求体**:`OutlineL2BatchRequest`
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `l1_task_snapshot` | object | 是 | 与调用 L1 时一致的 **`OutlineL1Request`** |
+| `l1_response` | object | 是 | L1 返回的 **`OutlineL1Response`**(含 `chapter_results`、`overall_logic`) |
+| `task_id` | string | 否 | 透传各章 `OutlineL2Request` |
+| `tenant_id` | string | 否 | 同上 |
+| `leaf_chapter_candidates_by_chapter_id` | object | 否 | 可选:键为 `chapter_id`,值为该章末级候选 `object[]`;未出现的 `chapter_id` 走模板内置末级分支 |
+
+**响应体**:`OutlineL2BatchResponse`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `overall_logic` | string | 与 `l1_response.overall_logic` 一致 |
+| `chapters` | array | 与 L1 结果 **同序**;每项含 `chapter_id`、`chapter_name`、`presentation_enum`、`paragraph_count_enum`、`reason` 及嵌套的 **`l2`**(`OutlineL2Response`) |
+
+**说明**:章数较多或非 stub 时,总耗时会接近 **N 次单章 L2** 之和;超时可在网关或客户端按需要调大。
+
+**422**:同单章 L2(主要为 **`S1` 章**模型输出无法解析或与请求不一致等;`S2` 章不再因「结构非空」失败)。
+
+---
+
 ## 8. 段落生成
 
 ### `POST /v1/section`

BIN
algo/src/finrep_algo_agent/api/routers/__pycache__/outline.cpython-312.pyc


+ 22 - 2
algo/src/finrep_algo_agent/api/routers/outline.py

@@ -3,8 +3,15 @@ from __future__ import annotations
 from fastapi import APIRouter, HTTPException
 
 from finrep_algo_agent.api.deps import LlmDep, SettingsDep
-from finrep_algo_agent.schemas.outline import OutlineL1Request, OutlineL1Response, OutlineL2Request, OutlineL2Response
-from finrep_algo_agent.skills import run_outline_l1, run_outline_l2
+from finrep_algo_agent.schemas.outline import (
+    OutlineL1Request,
+    OutlineL1Response,
+    OutlineL2BatchRequest,
+    OutlineL2BatchResponse,
+    OutlineL2Request,
+    OutlineL2Response,
+)
+from finrep_algo_agent.skills import run_outline_l1, run_outline_l2, run_outline_l2_batch
 
 router = APIRouter()
 
@@ -17,6 +24,19 @@ async def outline_l1(body: OutlineL1Request, settings: SettingsDep, llm: LlmDep)
         raise HTTPException(status_code=422, detail=str(e)) from e
 
 
+@router.post("/l2/batch", response_model=OutlineL2BatchResponse)
+async def outline_l2_batch(
+    body: OutlineL2BatchRequest,
+    settings: SettingsDep,
+    llm: LlmDep,
+) -> OutlineL2BatchResponse:
+    """按 L1 的 `chapter_results` 顺序逐章调用 L2,返回聚合后的完整二级大纲。"""
+    try:
+        return await run_outline_l2_batch(body, settings=settings, llm=llm)
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e)) from e
+
+
 @router.post("/l2", response_model=OutlineL2Response)
 async def outline_l2(body: OutlineL2Request, settings: SettingsDep, llm: LlmDep) -> OutlineL2Response:
     try:

+ 2 - 1
algo/src/finrep_algo_agent/prompts/builders.py → algo/src/finrep_algo_agent/prompts/builders/__init__.py

@@ -9,7 +9,8 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
 from finrep_algo_agent.schemas.outline import OutlineL1Request, OutlineL2Request
 from finrep_algo_agent.schemas.section import SectionRequest
 
-_TPL_DIR = Path(__file__).resolve().parent / "templates"
+# 模板在 prompts/templates/,本文件位于 prompts/builders/
+_TPL_DIR = Path(__file__).resolve().parent.parent / "templates"
 
 
 def _env() -> Environment:

BIN
algo/src/finrep_algo_agent/prompts/builders/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/schemas/__pycache__/outline.cpython-312.pyc


+ 31 - 0
algo/src/finrep_algo_agent/schemas/outline.py

@@ -134,3 +134,34 @@ class OutlineL2Response(BaseModel):
     chapter_no: str
     chapter_structure: list[ChapterStructureNode]
     structure_logic: str = ""
+
+
+class OutlineL2BatchRequest(BaseModel):
+    """汇总 L1 输出后一次性生成各章 L2;`chapter_no` 按 `l1_response.chapter_results` 顺序自 1 递增。"""
+
+    task_id: str | None = None
+    tenant_id: str | None = None
+    l1_task_snapshot: OutlineL1Request = Field(
+        description="与调用 /v1/outline/l1 时一致的请求体,作为各章 L2 的 l1_task_snapshot(报告背景等)",
+    )
+    l1_response: OutlineL1Response = Field(
+        description="L1 返回的 chapter_results 与 overall_logic",
+    )
+    leaf_chapter_candidates_by_chapter_id: dict[str, list[dict[str, Any]]] = Field(
+        default_factory=dict,
+        description="可选:按 chapter_id 注入该章末级候选覆盖;未配置的章走模板内置分支",
+    )
+
+
+class OutlineL2BatchChapterResult(BaseModel):
+    chapter_id: str
+    chapter_name: str
+    presentation_enum: str
+    paragraph_count_enum: str
+    reason: str = ""
+    l2: OutlineL2Response
+
+
+class OutlineL2BatchResponse(BaseModel):
+    overall_logic: str
+    chapters: list[OutlineL2BatchChapterResult]

+ 2 - 2
algo/src/finrep_algo_agent/skills/__init__.py

@@ -1,6 +1,6 @@
 from finrep_algo_agent.skills.outline_l1 import run_outline_l1
-from finrep_algo_agent.skills.outline_l2 import run_outline_l2
+from finrep_algo_agent.skills.outline_l2 import run_outline_l2, run_outline_l2_batch
 from finrep_algo_agent.skills.rag_retrieve import RagService
 from finrep_algo_agent.skills.section_gen import run_section
 
-__all__ = ["run_outline_l1", "run_outline_l2", "run_section", "RagService"]
+__all__ = ["run_outline_l1", "run_outline_l2", "run_outline_l2_batch", "run_section", "RagService"]

BIN
algo/src/finrep_algo_agent/skills/__pycache__/__init__.cpython-312.pyc


+ 2 - 1
algo/src/finrep_algo_agent/skills/outline_l2/__init__.py

@@ -1,3 +1,4 @@
+from finrep_algo_agent.skills.outline_l2.batch import run_outline_l2_batch
 from finrep_algo_agent.skills.outline_l2.outline_l2 import run_outline_l2
 
-__all__ = ["run_outline_l2"]
+__all__ = ["run_outline_l2", "run_outline_l2_batch"]

BIN
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/__init__.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/batch.cpython-312.pyc


BIN
algo/src/finrep_algo_agent/skills/outline_l2/__pycache__/outline_l2.cpython-312.pyc


+ 51 - 0
algo/src/finrep_algo_agent/skills/outline_l2/batch.py

@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.llm import LlmClient
+from finrep_algo_agent.schemas.outline import (
+    OutlineL2BatchChapterResult,
+    OutlineL2BatchRequest,
+    OutlineL2BatchResponse,
+    OutlineL2Request,
+)
+from finrep_algo_agent.skills.outline_l2.outline_l2 import run_outline_l2
+
+
+async def run_outline_l2_batch(
+    req: OutlineL2BatchRequest,
+    *,
+    settings: Settings,
+    llm: LlmClient,
+) -> OutlineL2BatchResponse:
+    overall = req.l1_response.overall_logic or ""
+    chapters: list[OutlineL2BatchChapterResult] = []
+    leaf_map = req.leaf_chapter_candidates_by_chapter_id
+
+    for idx, ch in enumerate(req.l1_response.chapter_results, start=1):
+        leaf = leaf_map.get(ch.chapter_id, [])
+        l2_req = OutlineL2Request(
+            task_id=req.task_id,
+            tenant_id=req.tenant_id,
+            chapter_name=ch.chapter_name,
+            chapter_no=str(idx),
+            l1_chapter_id=ch.chapter_id,
+            chapter_paragraph_count_enum=ch.paragraph_count_enum,
+            chapter_presentation_enum=ch.presentation_enum,
+            chapter_reason=ch.reason,
+            overall_logic=overall,
+            leaf_chapter_candidates=list(leaf),
+            l1_task_snapshot=req.l1_task_snapshot,
+        )
+        l2_resp = await run_outline_l2(l2_req, settings=settings, llm=llm)
+        chapters.append(
+            OutlineL2BatchChapterResult(
+                chapter_id=ch.chapter_id,
+                chapter_name=ch.chapter_name,
+                presentation_enum=ch.presentation_enum,
+                paragraph_count_enum=ch.paragraph_count_enum,
+                reason=ch.reason,
+                l2=l2_resp,
+            )
+        )
+
+    return OutlineL2BatchResponse(overall_logic=overall, chapters=chapters)

+ 10 - 0
algo/src/finrep_algo_agent/skills/outline_l2/outline_l2.py

@@ -54,6 +54,16 @@ async def run_outline_l2(
             )
         return out
 
+    # S2 不呈现:不调用模型,避免偶发 JSON 违反「结构必须为空」的约束,并省令牌
+    mode = (req.chapter_presentation_enum or "").strip().upper()
+    if mode == "S2":
+        return OutlineL2Response(
+            chapter_name=req.chapter_name,
+            chapter_no=req.chapter_no,
+            chapter_structure=[],
+            structure_logic="一级章节呈现方式为 S2(不呈现),不生成末级知识单元结构。",
+        )
+
     user_content = build_outline_l2_user_prompt(req)
     raw = await llm.chat_completion(
         [{"role": "user", "content": user_content}],

BIN
algo/tests/__pycache__/test_outline_l2_batch.cpython-312-pytest-9.0.2.pyc


BIN
algo/tests/__pycache__/test_outline_l2_s2_skip_llm.cpython-312-pytest-9.0.2.pyc


+ 67 - 0
algo/tests/test_outline_l2_batch.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from decimal import Decimal
+
+from fastapi.testclient import TestClient
+
+from finrep_algo_agent.config import Settings, get_settings
+from finrep_algo_agent.main import app
+from finrep_algo_agent.schemas.outline import (
+    ChapterCandidate,
+    ChapterL1Result,
+    OutlineL1Request,
+    OutlineL1Response,
+)
+
+
+def test_outline_l2_batch_stub_mixed_s1_s2() -> None:
+    def _stub_settings() -> Settings:
+        return Settings(stub_skills=True)
+
+    app.dependency_overrides[get_settings] = _stub_settings
+    try:
+        client = TestClient(app)
+        l1_req = OutlineL1Request(
+            report_type="项目融资",
+            agreement_amount=Decimal("40"),
+            chapter_candidates=[
+                ChapterCandidate(chapter_id="a1", chapter_name="章A"),
+                ChapterCandidate(chapter_id="a2", chapter_name="章B"),
+            ],
+        )
+        l1_resp = OutlineL1Response(
+            chapter_results=[
+                ChapterL1Result(
+                    chapter_id="a1",
+                    chapter_name="章A",
+                    presentation_enum="S2",
+                    paragraph_count_enum="P0",
+                    reason="不呈现",
+                ),
+                ChapterL1Result(
+                    chapter_id="a2",
+                    chapter_name="章B",
+                    presentation_enum="S1",
+                    paragraph_count_enum="P2",
+                    reason="呈现",
+                ),
+            ],
+            overall_logic="总逻辑占位",
+        )
+        r = client.post(
+            "/v1/outline/l2/batch",
+            json={
+                "l1_task_snapshot": l1_req.model_dump(mode="json"),
+                "l1_response": l1_resp.model_dump(mode="json"),
+            },
+        )
+        assert r.status_code == 200, r.text
+        body = r.json()
+        assert body["overall_logic"] == "总逻辑占位"
+        assert len(body["chapters"]) == 2
+        assert body["chapters"][0]["chapter_id"] == "a1"
+        assert body["chapters"][0]["l2"]["chapter_structure"] == []
+        assert body["chapters"][1]["chapter_id"] == "a2"
+        assert body["chapters"][1]["l2"]["chapter_structure"]
+    finally:
+        app.dependency_overrides.clear()

+ 27 - 0
algo/tests/test_outline_l2_s2_skip_llm.py

@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from finrep_algo_agent.config import Settings
+from finrep_algo_agent.schemas.outline import OutlineL1Request, OutlineL2Request
+from finrep_algo_agent.skills.outline_l2.outline_l2 import run_outline_l2
+
+
+@pytest.mark.asyncio
+async def test_outline_l2_s2_skips_llm() -> None:
+    settings = Settings(stub_skills=False, llm_api_key="k")
+    llm = AsyncMock()
+    llm.chat_completion = AsyncMock(return_value="{}")
+    req = OutlineL2Request(
+        chapter_name="测试章",
+        chapter_no="1",
+        chapter_presentation_enum="S2",
+        chapter_paragraph_count_enum="P0",
+        l1_task_snapshot=OutlineL1Request(report_type="项目融资"),
+    )
+    out = await run_outline_l2(req, settings=settings, llm=llm)
+    assert out.chapter_structure == []
+    assert out.chapter_name == "测试章"
+    llm.chat_completion.assert_not_called()