feat(b0): 注入透明可控版后端 — PUT override(pin/排除/recent_n) + 选择函数加参 + draft 同读
- selection.select_relevant_entities 加 pinned/excluded frozenset 入参;新理由 author_pin(末位) - assemble 加 override 关键字参(recent_n 覆盖回看章数 + pin/排除);GET/PUT/draft 同读同一覆盖(不变量 #6 作者兜底) - 新 domain/injection_repo.py(InjectionOverride/EntityRef/SqlInjectionOverrideRepo,upsert 只 flush) - 新表 chapter_injection(迁移 ad2c4c663daf,唯一 project_id+chapter_no;复用 outline 行已否决) - PUT/GET injection 端点 + InjectionOverrideRequest/回显 pinned/excluded;recent_n 1..20 校验 422 - 单测:selection pin/排除/exclude>pin + assemble override + 端点 PUT/404/422;regen TS 客户端 - 门禁绿:ruff/format · mypy 163 · alembic 无漂移 · pytest 476 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain.chapter_repo import ChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
||||
from ww_core.domain.project_repo import ProjectCreate, ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo
|
||||
@@ -41,7 +42,12 @@ from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.injection import InjectionEntity, InjectionResponse
|
||||
from ww_api.schemas.injection import (
|
||||
InjectionEntity,
|
||||
InjectionEntityRef,
|
||||
InjectionOverrideRequest,
|
||||
InjectionResponse,
|
||||
)
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
AcceptResponse,
|
||||
@@ -67,6 +73,7 @@ from ww_api.services.project_deps import (
|
||||
get_chapter_repo,
|
||||
get_digest_append_repo,
|
||||
get_digest_gateway,
|
||||
get_injection_repo,
|
||||
get_memory_repos,
|
||||
get_project_repo,
|
||||
get_review_gateway,
|
||||
@@ -85,6 +92,7 @@ GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
||||
DigestRepoDep = Annotated[DigestAppendRepo, Depends(get_digest_append_repo)]
|
||||
|
||||
@@ -126,36 +134,95 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
|
||||
return _to_response(view)
|
||||
|
||||
|
||||
async def _injection_response(
|
||||
repos: MemoryRepos,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
override: InjectionOverride | None,
|
||||
) -> InjectionResponse:
|
||||
"""组装注入响应:以作者覆盖跑确定性 assemble → selected + override 回显(GET/PUT 共用)。"""
|
||||
context = await assemble(repos, project_id, chapter_no, override=override)
|
||||
selected = [
|
||||
InjectionEntity(kind=e.kind, name=e.name, reasons=list(e.reasons))
|
||||
for e in context.selection.selected
|
||||
]
|
||||
effective_n = override.recent_n if (override and override.recent_n) else RECENT_DIGEST_COUNT
|
||||
return InjectionResponse(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
selected=selected,
|
||||
recent_n=effective_n,
|
||||
pinned=[
|
||||
InjectionEntityRef(kind=r.kind, name=r.name)
|
||||
for r in (override.pinned if override else [])
|
||||
],
|
||||
excluded=[
|
||||
InjectionEntityRef(kind=r.kind, name=r.name)
|
||||
for r in (override.excluded if override else [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/injection")
|
||||
async def get_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
repos: MemoryReposDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
injection_repo: InjectionRepoDep,
|
||||
) -> InjectionResponse:
|
||||
"""本章注入透明(B0,读端点):回放确定性 SelectionTrace。无 LLM、无 commit。
|
||||
"""本章注入透明(B0,读端点):回放确定性 SelectionTrace + 作者覆盖。无 LLM、无 commit。
|
||||
|
||||
项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
context = await assemble(repos, project_id, chapter_no)
|
||||
selected = [
|
||||
InjectionEntity(kind=e.kind, name=e.name, reasons=list(e.reasons))
|
||||
for e in context.selection.selected
|
||||
]
|
||||
override = await injection_repo.get(project_id, chapter_no)
|
||||
resp = await _injection_response(repos, project_id, chapter_no, override)
|
||||
log.info(
|
||||
"injection_read",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
selected_count=len(selected),
|
||||
selected_count=len(resp.selected),
|
||||
pinned_count=len(resp.pinned),
|
||||
excluded_count=len(resp.excluded),
|
||||
)
|
||||
return InjectionResponse(
|
||||
project_id=project_id,
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/injection")
|
||||
async def save_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
body: InjectionOverrideRequest,
|
||||
repos: MemoryReposDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
injection_repo: InjectionRepoDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> InjectionResponse:
|
||||
"""本章注入覆盖(B0 可控版,写端点):存 pin/排除/recent_n → 回放新的确定性 selected。
|
||||
|
||||
项目不存在 → 404。upsert 只 flush,端点末尾 commit。覆盖是确定性输入,draft 端点同读,
|
||||
故「看到的=写章用的」仍成立(不变量 #6 的作者兜底)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
override = InjectionOverride(
|
||||
pinned=[EntityRef(kind=r.kind, name=r.name) for r in body.pinned],
|
||||
excluded=[EntityRef(kind=r.kind, name=r.name) for r in body.excluded],
|
||||
recent_n=body.recent_n,
|
||||
)
|
||||
saved = await injection_repo.upsert(project_id, chapter_no, override)
|
||||
await session.commit()
|
||||
log.info(
|
||||
"injection_saved",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
selected=selected,
|
||||
recent_n=RECENT_DIGEST_COUNT,
|
||||
pinned_count=len(saved.pinned),
|
||||
excluded_count=len(saved.excluded),
|
||||
recent_n=saved.recent_n,
|
||||
)
|
||||
return await _injection_response(repos, project_id, chapter_no, saved)
|
||||
|
||||
|
||||
def _encode_sse(event: SseEvent) -> str:
|
||||
@@ -171,11 +238,13 @@ async def stream_draft(
|
||||
request: Request,
|
||||
repos: MemoryReposDep,
|
||||
gateway: GatewayDep,
|
||||
injection_repo: InjectionRepoDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> StreamingResponse:
|
||||
"""流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。"""
|
||||
"""流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一为 SSE 事件 → text/event-stream。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
context = await assemble(repos, project_id, chapter_no)
|
||||
override = await injection_repo.get(project_id, chapter_no)
|
||||
context = await assemble(repos, project_id, chapter_no, override=override)
|
||||
log.info(
|
||||
"draft_stream_start",
|
||||
project_id=str(project_id),
|
||||
|
||||
Reference in New Issue
Block a user