"""终稿 digest 提炼(验收事务的 R2 步骤;ARCH §5.5 / §6.1,不变量 #4)。 验收时用**终稿**(作者裁决/改稿后的最终文本)经网关跑一次轻量结构化提炼,得 digest (结构化事实)。**在开原子事务之前**完成——别在持开事务里跨网络调 LLM(R2)。 `ChapterDigestFacts` 是 digest 的结构化形:本章关键事实清单,供后续章节注入近况摘要 (assemble 的 `recent_digests`)+ 一致性比对。轻量档位(tier=light),只读终稿、产事实。 记账:digest 提炼这次网关调用产 usage,经 `SqlAlchemyLedgerSink` flush 进**同一请求 session**——由验收事务在末尾一次 commit 落 `usage_ledger`(见 ledger gotcha)。 """ from __future__ import annotations import uuid from typing import Any import structlog from pydantic import BaseModel, Field from ww_llm_gateway import Gateway from ww_llm_gateway.types import Block, LlmRequest, Scope log = structlog.get_logger(__name__) DIGEST_SYSTEM_PROMPT = """你是长篇连载小说的「章节摘要提炼」。读入本章**终稿**,抽取后续\ 章节一致性比对所需的结构化事实,产出结构化摘要。 只提炼**终稿明确写出**的事实,不臆造、不推断未写明的内容: - summary:本章一句话主线(≤60 字); - events:本章发生的关键事件(按时序); - characters:登场人物及其本章状态变化(姓名 + 状态/变化); - locations:出现的地点; - foreshadow:本章埋下或回收的伏笔线索。 纪律:只读终稿、只产事实,不评价、不改稿、不报冲突(冲突在审稿期产)。""" class CharacterStateFact(BaseModel): """单个人物的本章状态事实。""" name: str = Field(description="人物姓名") state: str = Field(description="本章该人物的状态/变化") class ChapterDigestFacts(BaseModel): """终稿提炼的结构化事实(落 `chapter_digests.facts`)。""" summary: str = Field(default="", description="本章一句话主线") events: list[str] = Field(default_factory=list, description="关键事件(时序)") characters: list[CharacterStateFact] = Field( default_factory=list, description="登场人物及其本章状态变化" ) locations: list[str] = Field(default_factory=list, description="出现的地点") foreshadow: list[str] = Field(default_factory=list, description="埋下/回收的伏笔线索") def build_digest_request( *, final_text: str, user_id: uuid.UUID, project_id: uuid.UUID ) -> LlmRequest: """据终稿构造 digest 提炼请求(纯函数)。 `system_prompt` 进缓存断点前块;终稿进 `input`(断点后)。tier=light(不变量 #2)。 `output_schema=ChapterDigestFacts` → 网关经 instructor 保证产结构化实例(C1)。 """ return LlmRequest( tier="light", system=[Block(text=DIGEST_SYSTEM_PROMPT, cache=True)], input=f"## 本章终稿\n{final_text}", output_schema=ChapterDigestFacts, scope=Scope(user_id=user_id, project_id=project_id), ) async def extract_digest_facts( gateway: Gateway, *, final_text: str, user_id: uuid.UUID, project_id: uuid.UUID, chapter_no: int, ) -> dict[str, Any]: """从终稿提炼结构化事实,返回可直接落 `facts` 列的 dict。 **在开原子事务之前**调用(R2:别在持开事务里跨网络调 LLM)。`gateway.run(req).parsed` 带 schema 时必非 None(C1);防御性兜底:若 parsed 缺失则落空事实(不崩验收)。 日志脱敏:只记终稿长度,不记正文。 """ req = build_digest_request(final_text=final_text, user_id=user_id, project_id=project_id) resp = await gateway.run(req) parsed = resp.parsed facts = parsed.model_dump() if parsed is not None else ChapterDigestFacts().model_dump() log.info( "digest_extracted", project_id=str(project_id), chapter_no=chapter_no, final_text_len=len(final_text), event_count=len(facts.get("events", [])), ) return facts