Files
writer-work-flow/apps/api/ww_api/services/digest_extraction.py
Yaojia Wang 68f194a043 feat: M2 — 写→审(一致性)→裁决→验收(事务);未决冲突禁验收
- 续审 Agent 声明(AgentSpec) + 结构化输出契约(ContinuityReview/Conflict 五类)
- LangGraph 并行审子图(可扩四审) + collect 落 chapter_reviews 留痕 + review SSE(section/conflict)
- 验收-side Repository:章节 accepted 版本晋升 + digest append-only + 审稿留痕/裁决
- API:review(SSE) + reviews 历史 + accept(单原子事务:晋升 version + 终稿 digest + 裁决留痕)
- 冲突 gate:未决裁决拦截(CONFLICT_UNRESOLVED);digest 从终稿提炼(不变量#4)
- 前端:审稿报告页 + 冲突就地标注 + 裁决(采纳/忽略/手改) + 未决禁验收 + 「本次将更新」清单
- M2 E2E:真实 DB + 多档位 mock 网关零 token 走通 写→审→裁决→验收→摘要入库
- 多 agent 协同台账(PROGRESS.md) + 共享记忆(memory/contracts·decisions·gotchas)
2026-06-18 11:38:28 +02:00

100 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""终稿 digest 提炼(验收事务的 R2 步骤ARCH §5.5 / §6.1,不变量 #4
验收时用**终稿**(作者裁决/改稿后的最终文本)经网关跑一次轻量结构化提炼,得 digest
(结构化事实)。**在开原子事务之前**完成——别在持开事务里跨网络调 LLMR2
`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 时必非 NoneC1防御性兜底若 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