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)
This commit is contained in:
99
apps/api/ww_api/services/digest_extraction.py
Normal file
99
apps/api/ww_api/services/digest_extraction.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""终稿 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
|
||||
Reference in New Issue
Block a user