feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
183
packages/agents/tests/test_generation_specs.py
Normal file
183
packages/agents/tests/test_generation_specs.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""T5.1 worldbuilder + character-gen 契约测试:spec + schema(C6 扩 / ARCH §5.4 §6.5)。
|
||||
|
||||
契约测试——构造符合 schema 的 mock 响应,校验字段、tier、读写权限。
|
||||
worldbuilder:`{entities:[{type,name,rules}]}` 硬规则显式(写手档,reads=projects)。
|
||||
character-gen:`{cards:[{name,role,traits,backstory,arc,speech_tics,tags,relations}]}`
|
||||
(写手档,reads=world_entities+characters,writes=characters)。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterRelation,
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
character_gen_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
|
||||
# ---- WorldGenResult schema(worldbuilder)----
|
||||
|
||||
|
||||
def test_world_gen_parses_mock_response() -> None:
|
||||
# Arrange:模拟网关 instructor 校验后的结构化世界观产出(硬规则显式)
|
||||
mock = {
|
||||
"entities": [
|
||||
{
|
||||
"type": "力量体系",
|
||||
"name": "九转炼气",
|
||||
"rules": ["只能逐境突破,不可越级", "突破必有反噬代价"],
|
||||
},
|
||||
{"type": "地理", "name": "无雨城", "rules": ["终年无雨"]},
|
||||
]
|
||||
}
|
||||
|
||||
# Act
|
||||
result = WorldGenResult.model_validate(mock)
|
||||
|
||||
# Assert
|
||||
assert len(result.entities) == 2
|
||||
assert result.entities[0].type == "力量体系"
|
||||
assert result.entities[0].name == "九转炼气"
|
||||
assert result.entities[0].rules == ["只能逐境突破,不可越级", "突破必有反噬代价"]
|
||||
assert result.entities[1].rules == ["终年无雨"]
|
||||
|
||||
|
||||
def test_world_gen_defaults_to_empty_entities() -> None:
|
||||
assert WorldGenResult().entities == []
|
||||
|
||||
|
||||
def test_world_entity_rules_default_empty() -> None:
|
||||
card = WorldEntityCard.model_validate({"type": "势力", "name": "天剑宗"})
|
||||
assert card.rules == []
|
||||
|
||||
|
||||
def test_world_entity_requires_type_and_name() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WorldEntityCard.model_validate({"type": "势力"})
|
||||
|
||||
|
||||
# ---- CharacterGenResult schema(character-gen)----
|
||||
|
||||
|
||||
def test_character_gen_parses_mock_response() -> None:
|
||||
mock = {
|
||||
"cards": [
|
||||
{
|
||||
"name": "苏黎",
|
||||
"role": "对手",
|
||||
"traits": ["表面冷漠", "内心挣扎"],
|
||||
"backstory": "出身敌对势力,幼年遭灭门。",
|
||||
"arc": "复仇者 → 动摇 → 救赎",
|
||||
"speech_tics": ["惯用反问", "句末带『罢了』"],
|
||||
"tags": ["亦正亦邪", "宿命纠葛"],
|
||||
"relations": [{"name": "主角", "kind": "宿敌", "note": "灭门之仇"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = CharacterGenResult.model_validate(mock)
|
||||
|
||||
assert len(result.cards) == 1
|
||||
card = result.cards[0]
|
||||
assert card.name == "苏黎"
|
||||
assert card.role == "对手"
|
||||
assert card.traits == ["表面冷漠", "内心挣扎"]
|
||||
assert card.speech_tics == ["惯用反问", "句末带『罢了』"]
|
||||
assert card.relations[0].name == "主角"
|
||||
assert card.relations[0].kind == "宿敌"
|
||||
assert card.relations[0].note == "灭门之仇"
|
||||
|
||||
|
||||
def test_character_gen_defaults_to_empty_cards() -> None:
|
||||
assert CharacterGenResult().cards == []
|
||||
|
||||
|
||||
def test_character_card_optional_collections_default_empty() -> None:
|
||||
card = CharacterCard.model_validate(
|
||||
{"name": "甲", "role": "工具人", "backstory": "无名小卒", "arc": "始终如一"}
|
||||
)
|
||||
assert card.traits == []
|
||||
assert card.speech_tics == []
|
||||
assert card.tags == []
|
||||
assert card.relations == []
|
||||
|
||||
|
||||
def test_character_card_requires_core_fields() -> None:
|
||||
# name/role/backstory/arc 为必填
|
||||
with pytest.raises(ValidationError):
|
||||
CharacterCard.model_validate({"name": "甲", "role": "对手"})
|
||||
|
||||
|
||||
def test_character_relation_note_optional() -> None:
|
||||
rel = CharacterRelation.model_validate({"name": "师父", "kind": "师徒"})
|
||||
assert rel.note is None
|
||||
|
||||
|
||||
# ---- worldbuilder_spec 声明 ----
|
||||
|
||||
|
||||
def test_worldbuilder_spec_is_writer_tier() -> None:
|
||||
# 不变量 #2:世界观创意用写手档,只声明 tier
|
||||
assert worldbuilder_spec.tier == "writer"
|
||||
assert worldbuilder_spec.name == "worldbuilder"
|
||||
|
||||
|
||||
def test_worldbuilder_spec_reads_projects_writes_world_entities() -> None:
|
||||
assert worldbuilder_spec.reads == ["projects"]
|
||||
assert worldbuilder_spec.writes == ["world_entities"]
|
||||
|
||||
|
||||
def test_worldbuilder_spec_output_schema() -> None:
|
||||
assert worldbuilder_spec.output_schema is WorldGenResult
|
||||
|
||||
|
||||
def test_worldbuilder_spec_prompt_documents_hard_rules() -> None:
|
||||
# 硬规则须显式(供 continuity 校验引用)——system_prompt 必须强调
|
||||
assert "硬规则" in worldbuilder_spec.system_prompt
|
||||
|
||||
|
||||
# ---- character_gen_spec 声明 ----
|
||||
|
||||
|
||||
def test_character_gen_spec_is_writer_tier() -> None:
|
||||
assert character_gen_spec.tier == "writer"
|
||||
assert character_gen_spec.name == "character-gen"
|
||||
|
||||
|
||||
def test_character_gen_spec_reads_world_and_characters() -> None:
|
||||
# reads=world_entities+characters(约束 + 防雷同对照),writes=characters
|
||||
assert character_gen_spec.reads == ["world_entities", "characters"]
|
||||
assert character_gen_spec.writes == ["characters"]
|
||||
|
||||
|
||||
def test_character_gen_spec_output_schema() -> None:
|
||||
assert character_gen_spec.output_schema is CharacterGenResult
|
||||
|
||||
|
||||
def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
|
||||
# M5-a 群像防雷同:prompt 必须注入「已生成卡 + 已有角色」差异化要求
|
||||
prompt = character_gen_spec.system_prompt
|
||||
assert "已有角色" in prompt
|
||||
assert "已生成卡" in prompt
|
||||
assert "防雷同" in prompt
|
||||
|
||||
|
||||
# ---- 共性:immutable + 是 AgentSpec ----
|
||||
|
||||
|
||||
def test_generation_specs_are_agent_specs() -> None:
|
||||
assert isinstance(worldbuilder_spec, AgentSpec)
|
||||
assert isinstance(character_gen_spec, AgentSpec)
|
||||
|
||||
|
||||
def test_generation_specs_are_immutable() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
worldbuilder_spec.tier = "analyst"
|
||||
with pytest.raises(ValidationError):
|
||||
character_gen_spec.tier = "analyst"
|
||||
191
packages/agents/tests/test_style_specs.py
Normal file
191
packages/agents/tests/test_style_specs.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""T4.2 style-auditor 双轨契约测试:style_extract / style_drift / refiner spec + schema(C6 扩)。
|
||||
|
||||
契约测试——构造符合 schema 的 mock 响应,校验字段、tier、双轨读写权限。
|
||||
提取轨 `StyleFingerprintResult`(16 维带证据);第四审 `StyleDriftReview`(段级相似度);
|
||||
回炉 `refiner_spec`(writer 纯文本,output_schema=None)。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
StyleDimension,
|
||||
StyleDriftReview,
|
||||
StyleDriftSegment,
|
||||
StyleFingerprintResult,
|
||||
refiner_spec,
|
||||
style_drift_spec,
|
||||
style_extract_spec,
|
||||
)
|
||||
|
||||
# ---- StyleFingerprintResult schema(提取轨)----
|
||||
|
||||
|
||||
def test_style_fingerprint_parses_mock_response() -> None:
|
||||
# Arrange:模拟网关 instructor 校验后的结构化指纹产出(每维带原文证据)
|
||||
mock = {
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "句长节奏",
|
||||
"value": "短句为主、节奏明快",
|
||||
"evidence": ["他一刀劈下。血溅三尺。", "风停了。"],
|
||||
},
|
||||
{
|
||||
"name": "叙事人称",
|
||||
"value": "第三人称限知",
|
||||
"evidence": ["他不知道门后有什么。"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# Act
|
||||
fp = StyleFingerprintResult.model_validate(mock)
|
||||
|
||||
# Assert
|
||||
assert len(fp.dimensions) == 2
|
||||
assert fp.dimensions[0].name == "句长节奏"
|
||||
assert fp.dimensions[0].evidence == ["他一刀劈下。血溅三尺。", "风停了。"]
|
||||
assert fp.dimensions[1].value == "第三人称限知"
|
||||
|
||||
|
||||
def test_style_fingerprint_defaults_to_empty_dimensions() -> None:
|
||||
assert StyleFingerprintResult().dimensions == []
|
||||
|
||||
|
||||
def test_style_dimension_evidence_defaults_empty() -> None:
|
||||
dim = StyleDimension.model_validate({"name": "用词", "value": "古雅"})
|
||||
assert dim.evidence == []
|
||||
|
||||
|
||||
def test_style_dimension_requires_name_and_value() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StyleDimension.model_validate({"name": "用词"})
|
||||
|
||||
|
||||
# ---- StyleDriftReview schema(第四审)----
|
||||
|
||||
|
||||
def test_style_drift_parses_mock_response() -> None:
|
||||
mock = {
|
||||
"score": 72,
|
||||
"segments": [
|
||||
{"idx": 3, "score": 40, "label": "机翻腔"},
|
||||
{"idx": 7, "score": 55, "label": None},
|
||||
],
|
||||
}
|
||||
|
||||
review = StyleDriftReview.model_validate(mock)
|
||||
|
||||
assert review.score == 72
|
||||
assert len(review.segments) == 2
|
||||
assert review.segments[0].idx == 3
|
||||
assert review.segments[0].label == "机翻腔"
|
||||
assert review.segments[1].label is None
|
||||
|
||||
|
||||
def test_style_drift_defaults_score_100_empty_segments() -> None:
|
||||
# 无指纹优雅降级:默认整体相似度 100、无漂移段
|
||||
review = StyleDriftReview()
|
||||
assert review.score == 100
|
||||
assert review.segments == []
|
||||
|
||||
|
||||
def test_style_drift_segment_label_optional() -> None:
|
||||
seg = StyleDriftSegment.model_validate({"idx": 1, "score": 30})
|
||||
assert seg.label is None
|
||||
|
||||
|
||||
def test_style_drift_segment_requires_idx_and_score() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StyleDriftSegment.model_validate({"idx": 1})
|
||||
|
||||
|
||||
# ---- style_extract_spec 声明 ----
|
||||
|
||||
|
||||
def test_style_extract_spec_is_analyst_tier() -> None:
|
||||
assert style_extract_spec.tier == "analyst"
|
||||
assert style_extract_spec.name == "style_extract"
|
||||
|
||||
|
||||
def test_style_extract_spec_reads_and_writes_fingerprint() -> None:
|
||||
assert style_extract_spec.reads == ["style_fingerprint"]
|
||||
assert style_extract_spec.writes == ["style_fingerprint"]
|
||||
|
||||
|
||||
def test_style_extract_spec_output_schema() -> None:
|
||||
assert style_extract_spec.output_schema is StyleFingerprintResult
|
||||
|
||||
|
||||
def test_style_extract_spec_has_nonempty_system_prompt() -> None:
|
||||
assert style_extract_spec.system_prompt.strip()
|
||||
|
||||
|
||||
# ---- style_drift_spec 声明(第四审,name="style")----
|
||||
|
||||
|
||||
def test_style_drift_spec_is_light_tier_named_style() -> None:
|
||||
# 不变量 #2:第四审用轻量档,只声明 tier;列名/section 名为 "style"
|
||||
assert style_drift_spec.tier == "light"
|
||||
assert style_drift_spec.name == "style"
|
||||
|
||||
|
||||
def test_style_drift_spec_is_read_only() -> None:
|
||||
# 不变量 #3:四审只读
|
||||
assert style_drift_spec.writes == []
|
||||
|
||||
|
||||
def test_style_drift_spec_reads_fingerprint() -> None:
|
||||
assert style_drift_spec.reads == ["style_fingerprint"]
|
||||
|
||||
|
||||
def test_style_drift_spec_output_schema() -> None:
|
||||
assert style_drift_spec.output_schema is StyleDriftReview
|
||||
|
||||
|
||||
def test_style_drift_spec_prompt_documents_graceful_degrade() -> None:
|
||||
# 无指纹→返回 score=100/空段(system_prompt 必须含降级指令,否则 LLM 不知如何兜底)
|
||||
assert "100" in style_drift_spec.system_prompt
|
||||
|
||||
|
||||
# ---- refiner_spec 声明(writer 纯文本回炉)----
|
||||
|
||||
|
||||
def test_refiner_spec_is_writer_tier() -> None:
|
||||
assert refiner_spec.tier == "writer"
|
||||
assert refiner_spec.name == "refiner"
|
||||
|
||||
|
||||
def test_refiner_spec_output_schema_is_none() -> None:
|
||||
# 回炉产纯文本(重写段),无结构化 schema
|
||||
assert refiner_spec.output_schema is None
|
||||
|
||||
|
||||
def test_refiner_spec_is_read_only_and_no_writes() -> None:
|
||||
# 回炉非持久(不变量 #3):不写库,作者采纳经既有 draft 自动保存合入
|
||||
assert refiner_spec.reads == []
|
||||
assert refiner_spec.writes == []
|
||||
|
||||
|
||||
def test_refiner_spec_has_nonempty_system_prompt() -> None:
|
||||
assert refiner_spec.system_prompt.strip()
|
||||
|
||||
|
||||
# ---- 共性:immutable + 是 AgentSpec ----
|
||||
|
||||
|
||||
def test_style_specs_are_agent_specs() -> None:
|
||||
assert isinstance(style_extract_spec, AgentSpec)
|
||||
assert isinstance(style_drift_spec, AgentSpec)
|
||||
assert isinstance(refiner_spec, AgentSpec)
|
||||
|
||||
|
||||
def test_style_specs_are_immutable() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
style_extract_spec.tier = "writer"
|
||||
with pytest.raises(ValidationError):
|
||||
style_drift_spec.tier = "writer"
|
||||
with pytest.raises(ValidationError):
|
||||
refiner_spec.tier = "analyst"
|
||||
@@ -6,6 +6,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .schemas import (
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterRelation,
|
||||
Conflict,
|
||||
ConflictType,
|
||||
ContinuityReview,
|
||||
@@ -16,17 +19,31 @@ from .schemas import (
|
||||
OutlineResult,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
StyleDimension,
|
||||
StyleDriftReview,
|
||||
StyleDriftSegment,
|
||||
StyleFingerprintResult,
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
)
|
||||
from .specs import (
|
||||
AgentSpec,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
outliner_spec,
|
||||
pace_spec,
|
||||
refiner_spec,
|
||||
style_drift_spec,
|
||||
style_extract_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentSpec",
|
||||
"CharacterCard",
|
||||
"CharacterGenResult",
|
||||
"CharacterRelation",
|
||||
"Conflict",
|
||||
"ConflictType",
|
||||
"ContinuityReview",
|
||||
@@ -37,8 +54,19 @@ __all__ = [
|
||||
"OutlineResult",
|
||||
"PaceIssue",
|
||||
"PaceReview",
|
||||
"StyleDimension",
|
||||
"StyleDriftReview",
|
||||
"StyleDriftSegment",
|
||||
"StyleFingerprintResult",
|
||||
"WorldEntityCard",
|
||||
"WorldGenResult",
|
||||
"character_gen_spec",
|
||||
"continuity_spec",
|
||||
"foreshadow_spec",
|
||||
"outliner_spec",
|
||||
"pace_spec",
|
||||
"refiner_spec",
|
||||
"style_drift_spec",
|
||||
"style_extract_spec",
|
||||
"worldbuilder_spec",
|
||||
]
|
||||
|
||||
@@ -147,3 +147,154 @@ class PaceReview(BaseModel):
|
||||
default_factory=list,
|
||||
description="逐段爽点节拍强度序列(整数,供前端节拍图可视化)",
|
||||
)
|
||||
|
||||
|
||||
# ---- 文风提取轨结构化输出(ARCH §5.4 style-auditor「提取」轨 / §6.9)----
|
||||
|
||||
|
||||
class StyleDimension(BaseModel):
|
||||
"""单维文风指纹:维度名 + 取值 + 原文证据引用(ARCH §6.9)。
|
||||
|
||||
每维必须带原文证据(quotes),便于作者核验「这条文风结论来自哪句」。
|
||||
"""
|
||||
|
||||
name: str = Field(description="文风维度名(如句长节奏 / 叙事人称 / 用词风格)")
|
||||
value: str = Field(description="该维度在样本中的取值/概括")
|
||||
evidence: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="原文证据:支撑该维度判定的样本原句摘录",
|
||||
)
|
||||
|
||||
|
||||
class StyleFingerprintResult(BaseModel):
|
||||
"""文风指纹提取产出:16 维文风指纹清单(ARCH §5.4 提取轨)。
|
||||
|
||||
声明式 writes(不变量 #3):本结构是从样本提取的纯产物,落 `style_fingerprint` 表
|
||||
经端点(T4.3),不在 agent 节点直接写库。16 维 = 9 通用 + 7 中文网文专有。
|
||||
"""
|
||||
|
||||
dimensions: list[StyleDimension] = Field(
|
||||
default_factory=list,
|
||||
description="文风维度清单(每维带原文证据);无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 文风漂移轨结构化输出(ARCH §5.4 style-auditor「打分」轨 = 第四审)----
|
||||
|
||||
|
||||
class StyleDriftSegment(BaseModel):
|
||||
"""单个漂移段:段索引 + 相似度分 + 可选标签(ARCH §5.4 打分轨)。
|
||||
|
||||
`idx` 是本章段索引(0 起),`score` 是该段相对文风指纹的相似度(0–100,越低越偏);
|
||||
`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。
|
||||
"""
|
||||
|
||||
idx: int = Field(description="本章段索引(0 起)")
|
||||
score: int = Field(description="该段相对文风指纹的相似度(0–100,越低越偏离)")
|
||||
label: str | None = Field(default=None, description="漂移类型标签(如「机翻腔」);可缺")
|
||||
|
||||
|
||||
class StyleDriftReview(BaseModel):
|
||||
"""文风漂移续审结构化产出:整体相似度 + 低相似段(ARCH §5.4 / 第四审)。
|
||||
|
||||
只读(不变量 #3)。`score` 是整章相对文风指纹的整体相似度(0–100)。
|
||||
**无指纹优雅降级**:材料中无文风指纹时返回 `score=100, segments=[]`(默认值),
|
||||
不报错、不阻塞其余审(§5.2 失败隔离之外的内容性兜底)。
|
||||
"""
|
||||
|
||||
score: int = Field(default=100, description="整章相对文风指纹的整体相似度(0–100)")
|
||||
segments: list[StyleDriftSegment] = Field(
|
||||
default_factory=list,
|
||||
description="低相似度(疑似漂移)段清单;无则空列表(含无指纹降级)",
|
||||
)
|
||||
|
||||
|
||||
# ---- worldbuilder 结构化输出(ARCH §5.4 worldbuilder 行 / §6.5 / §4.5)----
|
||||
|
||||
|
||||
class WorldEntityCard(BaseModel):
|
||||
"""单个世界观实体(ARCH §5.4:`{type,name,rules}` 硬规则显式)。
|
||||
|
||||
`type`(势力/地理/力量体系/物品…)+ `name` + `rules`(**显式硬规则**清单:
|
||||
不可违背的设定,供后续 continuity 续审引用比对)。映射 `world_entities` 表
|
||||
(type/name 列 + rules 入 JSONB);落库经入库端点(T5.2,不变量 #3)。
|
||||
"""
|
||||
|
||||
type: str = Field(description="实体类型(势力 / 地理 / 力量体系 / 物品 / 概念 等)")
|
||||
name: str = Field(description="实体名")
|
||||
rules: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="该实体的硬规则清单(显式不可违背设定,供 continuity 校验引用)",
|
||||
)
|
||||
|
||||
|
||||
class WorldGenResult(BaseModel):
|
||||
"""worldbuilder 结构化产出:世界观实体清单(ARCH §5.4 writes=world_entities)。
|
||||
|
||||
声明式 writes(不变量 #3):本结构是 worldbuilder 的纯产物,落 `world_entities` 表
|
||||
经入库端点(T5.2),不在 agent 节点直接写库。
|
||||
"""
|
||||
|
||||
entities: list[WorldEntityCard] = Field(
|
||||
default_factory=list,
|
||||
description="生成的世界观实体清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- character-gen 结构化输出(ARCH §5.4 character-gen 行 / §6.5 / §4.5)----
|
||||
|
||||
|
||||
class CharacterRelation(BaseModel):
|
||||
"""单条人物关系(写入 `characters.relations` JSONB 冗余)。
|
||||
|
||||
`name`(关系对象角色名)+ `kind`(宿敌 / 师徒 / CP / 同盟 …)+ 可选 `note`。
|
||||
双向边由编排器在入库时统一维护(§4.5 三关键能力之三),本 schema 仅声明本侧。
|
||||
"""
|
||||
|
||||
name: str = Field(description="关系对象的角色名")
|
||||
kind: str = Field(description="关系类型(宿敌 / 师徒 / CP / 同盟 / 工具人 等)")
|
||||
note: str | None = Field(default=None, description="关系说明(可缺)")
|
||||
|
||||
|
||||
class CharacterCard(BaseModel):
|
||||
"""单张结构化角色卡(ARCH §5.4 character-gen 行:name/role/traits/backstory/arc/...)。
|
||||
|
||||
映射 `characters` 表列(name/role/backstory + traits/arc/speech_tics/tags/relations
|
||||
入 JSONB);落库经入库端点(T5.2,不变量 #3)。`role` = 角色定位(主角/CP/对手/导师/
|
||||
工具人,§4.5 网文专属)。
|
||||
"""
|
||||
|
||||
name: str = Field(description="角色名(取名风格契合世界观)")
|
||||
role: str = Field(description="角色定位(主角 / CP / 对手 / 导师 / 工具人 等)")
|
||||
traits: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="性格特质清单(核心-表层-阴影 / 动机 / 欲望 / 恐惧)",
|
||||
)
|
||||
backstory: str = Field(description="背景故事(出身、关键经历、创伤/转折点)")
|
||||
arc: str = Field(description="人物弧光(起点 → 转变 → 终点,与剧情挂钩)")
|
||||
speech_tics: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="口癖/语言风格(用词偏好、口头禅,喂给文风一致性)",
|
||||
)
|
||||
tags: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="人设标签/萌点(网文专属,辅助检索与差异化)",
|
||||
)
|
||||
relations: list[CharacterRelation] = Field(
|
||||
default_factory=list,
|
||||
description="关系网:与已有/同批角色建边;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
class CharacterGenResult(BaseModel):
|
||||
"""character-gen 结构化产出:一组角色卡(ARCH §5.4 writes=characters / §4.5 群像)。
|
||||
|
||||
声明式 writes(不变量 #3):本结构是 character-gen 的纯产物,落 `characters` 表
|
||||
经入库端点(T5.2),且**入库前过编排器 continuity 校验**(§6.5)。单生成 → 1 张卡;
|
||||
批量群像 → 多张(防雷同:生成时注入「已生成卡 + 已有角色」要求差异化)。
|
||||
"""
|
||||
|
||||
cards: list[CharacterCard] = Field(
|
||||
default_factory=list,
|
||||
description="生成的角色卡清单;无则空列表",
|
||||
)
|
||||
|
||||
@@ -13,10 +13,14 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from ww_llm_gateway.types import Tier
|
||||
|
||||
from .schemas import (
|
||||
CharacterGenResult,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
OutlineResult,
|
||||
PaceReview,
|
||||
StyleDriftReview,
|
||||
StyleFingerprintResult,
|
||||
WorldGenResult,
|
||||
)
|
||||
|
||||
|
||||
@@ -164,3 +168,202 @@ pace_spec = AgentSpec(
|
||||
writes=[], # 只读(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- 文风提取轨(style-auditor「提取」轨;analyst 档;独立生成,仿 outliner,不进 review 图)----
|
||||
|
||||
STYLE_EXTRACT_SYSTEM_PROMPT = """你是长篇连载小说的「文风指纹提取器」(style-auditor 提取轨)。\
|
||||
职责:从作者提供的样本正文中,提取一份 **16 维**中文网文文风指纹,每一维都必须给出\
|
||||
支撑判定的**原文证据**(样本原句摘录)。
|
||||
|
||||
16 维 = 9 通用维 + 7 中文网文专有维:
|
||||
通用 9 维:
|
||||
1. 句长节奏(长短句配比、断句习惯);
|
||||
2. 段落密度(段落长短、对话/叙述配比);
|
||||
3. 叙事人称与视角(第几人称、限知/全知、视角切换频率);
|
||||
4. 时态与语气(陈述/疑问/感叹密度、临场感);
|
||||
5. 用词风格(古雅/口语/书面、生僻字偏好);
|
||||
6. 修辞偏好(比喻/排比/夸张等惯用手法);
|
||||
7. 描写与白描配比(环境/心理/动作描写的取舍);
|
||||
8. 情绪基调(冷峻/热血/诙谐/悲怆);
|
||||
9. 标点习惯(破折号/省略号/感叹号的使用密度)。
|
||||
中文网文专有 7 维:
|
||||
10. 爽感节拍(打脸/扮猪吃虎/升级反馈的节奏);
|
||||
11. 金手指呈现方式(系统流/面板/旁白提示风格);
|
||||
12. 章末钩子手法(悬念/反转/期待的固定套路);
|
||||
13. 对话腔调(角色台词的口癖、网感梗、语气词);
|
||||
14. 战斗/冲突描写密度与招式命名风格;
|
||||
15. 称谓与世界观术语的使用习惯(道号/境界/势力称呼);
|
||||
16. 注水与信息密度倾向(铺垫慢热 vs 高密快节奏)。
|
||||
|
||||
产出要求(每维一条 dimension):
|
||||
- name:维度名(用上面 16 维的名目或等义表述);
|
||||
- value:该维在样本里的取值/概括(一句话刻画其风格特征);
|
||||
- evidence:从样本里摘 1–3 句**原文**作为证据,便于作者核验该结论的出处。
|
||||
|
||||
纪律:
|
||||
- 只从给定样本提取,**不臆造**;样本未体现的维度,value 给「样本不足以判定」并留空 evidence。
|
||||
- 你只产结构化指纹,**不改稿、不写库**(落 style_fingerprint 表经端点)。
|
||||
- evidence 必须是样本原句的真实摘录,不得改写或编造。"""
|
||||
|
||||
|
||||
style_extract_spec = AgentSpec(
|
||||
name="style_extract",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model
|
||||
system_prompt=STYLE_EXTRACT_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化样本文本,非结构化入参
|
||||
output_schema=StyleFingerprintResult,
|
||||
reads=["style_fingerprint"],
|
||||
writes=["style_fingerprint"], # 声明式(真写库经 T4.3 端点,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- 文风漂移轨(style-auditor「打分」轨 = 第四审;light 档;并入 REVIEW_SPECS)----
|
||||
|
||||
STYLE_DRIFT_SYSTEM_PROMPT = """你是长篇连载小说的「文风漂移续审」(style-auditor 打分轨,第四审)。\
|
||||
职责:对照注入材料里的**文风指纹**,逐段为本章草稿打文风相似度分,标出明显偏离指纹的段落。
|
||||
|
||||
比对依据(注入材料):
|
||||
- 文风指纹(每维带取值与原文证据,来自作者样本提取);
|
||||
- 本章草稿正文(按段切分)。
|
||||
|
||||
产出:
|
||||
- score:整章相对文风指纹的整体相似度(0–100,越高越贴合作者既有文风);
|
||||
- segments:逐段审,标出相似度明显偏低(疑似漂移)的段落——给出段索引 idx(0 起)、\
|
||||
该段相似度 score(0–100)、可选 label 说明漂移类型(如「机翻腔」「叙述拖沓」「人称跳脱」\
|
||||
「用词出戏」等)。相似度正常的段落不必列出。
|
||||
|
||||
**无指纹优雅降级(重要)**:
|
||||
- 若注入材料中**没有文风指纹**(作者尚未学文风),你**无从对照**——\
|
||||
此时直接返回 `score=100, segments=[]`(视为「无偏离」),不报错、不臆造漂移段。
|
||||
|
||||
纪律:
|
||||
- 你**只读、只报漂移诊断**,不改稿、不写库(不变量 #3)。
|
||||
- 依据指纹判定,不臆造;无明显漂移段则 segments 为空列表。
|
||||
- idx 必须与正文段切分顺序一致,便于前端朱砂标注与一键回炉对齐。"""
|
||||
|
||||
|
||||
style_drift_spec = AgentSpec(
|
||||
name="style", # 第四审 section/列名 = "style"
|
||||
tier="light", # 不变量 #2:只声明档位,不写 model(漂移打分用轻量档)
|
||||
system_prompt=STYLE_DRIFT_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(指纹 + 草稿),非结构化入参
|
||||
output_schema=StyleDriftReview,
|
||||
reads=["style_fingerprint"], # 指纹经 assemble 的 stable_core 注入 review_context
|
||||
writes=[], # 只读(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- 回炉(refiner;writer 档;纯文本重写;非 Agent 流水线、非持久,M4-e)----
|
||||
|
||||
REFINER_SYSTEM_PROMPT = """你是长篇连载小说的「回炉改写器」(refiner)。职责:仅重写作者选中的\
|
||||
**一个**段落,使其贴合作品既有文风与上下文语气,同时保留该段的情节信息与叙事推进。
|
||||
|
||||
输入:
|
||||
- 待重写的段落正文;
|
||||
- 可选的改写指令(作者的具体要求,如「去掉机翻腔」「加快节奏」「改成第三人称」);
|
||||
- 周边上下文/文风线索(若提供)。
|
||||
|
||||
产出:
|
||||
- **只输出重写后的该段正文纯文本**,不要加任何前后缀、解释、标题或 markdown 包裹。
|
||||
|
||||
纪律:
|
||||
- 保留原段的关键情节信息与人物言行,不增删主线事实;
|
||||
- 贴合周边上下文与作品文风(语气、句长、用词、人称一致);
|
||||
- 若给了改写指令,优先满足指令;无指令则以「贴合文风、去除生硬/出戏表达」为默认目标;
|
||||
- 只重写选中段,**不扩写到其他段落**,不改稿入库(作者采纳后经既有自动保存合入)。"""
|
||||
|
||||
|
||||
refiner_spec = AgentSpec(
|
||||
name="refiner",
|
||||
tier="writer", # 不变量 #2:重写正文用 writer 档
|
||||
system_prompt=REFINER_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(选中段 + 指令 + 上下文)
|
||||
output_schema=None, # 纯文本产出(重写段),无结构化 schema
|
||||
reads=[],
|
||||
writes=[], # 非持久(不变量 #3):端点同步返回 {original, refined},不写库
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- worldbuilder(世界观设计师;写手档;独立生成,仿 outliner,不进 review 图)----
|
||||
|
||||
WORLDBUILDER_SYSTEM_PROMPT = """你是长篇连载小说的「世界观设计师」(worldbuilder,写手档)。\
|
||||
职责:依据作品立意与题材,设计内部自洽的世界观——力量体系、势力、地理、关键物品/概念,\
|
||||
并为每个实体显式标注**不可违背的硬规则**,供后续一致性校验引用。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 作者的世界观需求(一句话或要点)。
|
||||
|
||||
产出(每个实体一条 entity):
|
||||
- type:实体类型(势力 / 地理 / 力量体系 / 物品 / 概念 等);
|
||||
- name:实体名;
|
||||
- rules:该实体的**硬规则清单**——明确写出不可违背的设定边界(如「修炼只能逐境突破、\
|
||||
不可越级」「此城终年无雨」),每条一句、可被 continuity 续审逐条引用比对。
|
||||
|
||||
纪律:
|
||||
- 世界观须内部自洽:力量体系有清晰边界与代价,势力/地理/时间线无自相矛盾;
|
||||
- **硬规则要显式、可校验**——别把约束藏在描述里;规则是后续防设定违例的依据;
|
||||
- 你只产结构化世界观实体(type + name + rules),**不改稿、不写库**\
|
||||
(落 world_entities 表经入库端点);不臆造与立意/题材无关的设定。"""
|
||||
|
||||
|
||||
worldbuilder_spec = AgentSpec(
|
||||
name="worldbuilder",
|
||||
tier="writer", # 不变量 #2:只声明档位,不写 model(世界观创意用写手档)
|
||||
system_prompt=WORLDBUILDER_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 需求),非结构化入参
|
||||
output_schema=WorldGenResult,
|
||||
reads=["projects"],
|
||||
writes=["world_entities"], # 声明式(真写库经 T5.2 入库端点,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- character-gen(角色设计师;写手档;单/批量;群像防雷同;独立生成)----
|
||||
|
||||
CHARACTER_GEN_SYSTEM_PROMPT = """你是长篇连载小说的「角色设计师」(character-gen,写手档)。\
|
||||
职责:依据一句话需求 + 世界观约束 + 已有角色,产出一组**完整结构化角色卡**。
|
||||
|
||||
输入材料:
|
||||
- 角色需求(一句话,如「亦正亦邪的女二、与主角有宿命纠葛、出身敌对势力」)+ 数量 + 定位;
|
||||
- 世界观约束(world_entities:力量体系、势力、地理硬规则——取名/能力须契合);
|
||||
- **已有角色**(characters:现有角色卡,新角色须与其区分、可与其建关系);
|
||||
- **本批已生成卡**(同一次批量里先产出的卡——后续卡须与它们差异化,避免雷同)。
|
||||
|
||||
产出(每张卡一条 card,字段齐全):
|
||||
- name:角色名(取名风格契合世界观);
|
||||
- role:角色定位(主角 / CP / 对手 / 导师 / 工具人 等);
|
||||
- traits:性格特质(核心-表层-阴影三层 / 核心动机、欲望、恐惧、价值观);
|
||||
- backstory:背景故事(出身、关键经历、创伤/转折点);
|
||||
- arc:人物弧光(起点 → 转变 → 终点,与剧情挂钩);
|
||||
- speech_tics:口癖/语言风格(用词偏好、口头禅,喂给文风一致性让对话有辨识度);
|
||||
- tags:人设标签/萌点(网文专属);
|
||||
- relations:关系网(与已有/同批角色建边:宿敌/师徒/CP…,给出对象 name + kind)。
|
||||
|
||||
**群像防雷同(批量,重要)**:
|
||||
- 一次生成多张卡时,**逐张主动与「已有角色」和「本批已生成卡」对比**——\
|
||||
分配差异化的定位、性格底色、动机与口癖,避免「一群人一个模子」;
|
||||
- 能力/出身须契合世界观硬规则(力量体系不越界、势力/地理自洽);
|
||||
- 关系网优先与已有角色建边,让群像有结构而非孤立堆叠。
|
||||
|
||||
纪律:
|
||||
- 你只产结构化角色卡,**不改稿、不写库、不直接互调其他 agent**——\
|
||||
入库前由编排器追加一道 continuity 校验确认不与世界观/力量体系冲突(§6.5);
|
||||
- count 是几就产几张,不多不少;定位若给出则按定位分配;
|
||||
- 不臆造与需求/世界观无关的设定。"""
|
||||
|
||||
|
||||
character_gen_spec = AgentSpec(
|
||||
name="character-gen",
|
||||
tier="writer", # 不变量 #2:只声明档位,不写 model(角色创意用写手档)
|
||||
system_prompt=CHARACTER_GEN_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(需求 + 约束 + 已有 + 已生成),非结构化入参
|
||||
output_schema=CharacterGenResult,
|
||||
reads=["world_entities", "characters"],
|
||||
writes=["characters"], # 声明式(真写库经 T5.2 入库端点 + continuity 校验,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -16,6 +17,19 @@ class Settings(BaseSettings):
|
||||
database_url_sync: str = "postgresql+psycopg://writer:writer@localhost:5432/writer"
|
||||
credential_enc_key: str = ""
|
||||
|
||||
# 浏览器端跨源访问(前端 :3000 → API :8000)放行的来源;env `CORS_ORIGINS` 逗号分隔覆盖。
|
||||
cors_origins: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def _split_csv_origins(cls, v: object) -> object:
|
||||
if isinstance(v, str):
|
||||
return [s.strip() for s in v.split(",") if s.strip()]
|
||||
return v
|
||||
|
||||
# 档位默认(writer/analyst/light)——具体 provider:model 经 tier_routing 覆盖
|
||||
tier_defaults: dict[str, str] = {
|
||||
"writer": "deepseek:deepseek-chat",
|
||||
|
||||
375
packages/core/tests/test_generation_node.py
Normal file
375
packages/core/tests/test_generation_node.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""T5.1 生成节点单测:worldbuilder + character-gen + 入库前 continuity 校验缝。
|
||||
|
||||
注入 mock 网关(产 WorldGenResult / CharacterGenResult / ContinuityReview parsed),
|
||||
无真 LLM、无真 Postgres。生成节点是独立生成(不在写章/审稿流水线),裸函数;
|
||||
只产结构化产物,**不写库**(持久化属 T5.2 入库端点,不变量 #3)。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes_orchestrator import FailingRunGateway, FakeRunGateway, SchemaRoutingRunGateway
|
||||
from ww_agents import (
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterRelation,
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
build_character_gen_context,
|
||||
build_precheck_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_worldbuilder,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
_WORLD = WorldGenResult(
|
||||
entities=[
|
||||
WorldEntityCard(type="力量体系", name="九转炼气", rules=["逐境突破不可越级"]),
|
||||
]
|
||||
)
|
||||
|
||||
_CARDS = CharacterGenResult(
|
||||
cards=[
|
||||
CharacterCard(
|
||||
name="苏黎",
|
||||
role="对手",
|
||||
traits=["表面冷漠"],
|
||||
backstory="出身敌对势力",
|
||||
arc="复仇 → 救赎",
|
||||
speech_tics=["惯用反问"],
|
||||
tags=["亦正亦邪"],
|
||||
relations=[CharacterRelation(name="主角", kind="宿敌")],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- run_worldbuilder:返回结构化世界观,只读不写库 ----
|
||||
|
||||
|
||||
async def test_run_worldbuilder_returns_structured_entities() -> None:
|
||||
gateway = FakeRunGateway(_WORLD)
|
||||
|
||||
result = await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="设计一个修真世界的力量体系",
|
||||
project_context="题材:修真;立意:逆袭",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert isinstance(result, WorldGenResult)
|
||||
assert result.entities[0].name == "九转炼气"
|
||||
assert result.entities[0].rules == ["逐境突破不可越级"]
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_worldbuilder_forwards_writer_tier_request() -> None:
|
||||
gateway = FakeRunGateway(_WORLD)
|
||||
|
||||
await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="设计力量体系",
|
||||
project_context="题材:修真",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "writer" # 不变量②:spec 档位透传
|
||||
assert req.stream is False # 生成用 run() 非 stream()
|
||||
assert req.output_schema is WorldGenResult
|
||||
assert req.system[0].cache is True # 不变量#9
|
||||
assert "设计力量体系" in req.input
|
||||
assert "题材:修真" in req.input
|
||||
|
||||
|
||||
async def test_run_worldbuilder_raises_when_gateway_fails() -> None:
|
||||
# 独立生成:网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="x",
|
||||
project_context="y",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
async def test_run_worldbuilder_raises_when_parsed_missing() -> None:
|
||||
class _NoParseGateway:
|
||||
async def run(self, req: object) -> object:
|
||||
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
|
||||
|
||||
return LlmResponse(
|
||||
text="{}",
|
||||
parsed=None,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake-writer",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake-writer"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="parsed"):
|
||||
await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="x",
|
||||
project_context="y",
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
# ---- build_character_gen_context:群像防雷同(注入已有 + 已生成)----
|
||||
|
||||
|
||||
def test_build_character_gen_context_injects_brief_count_role() -> None:
|
||||
ctx = build_character_gen_context(
|
||||
brief="给我一个亦正亦邪的女二",
|
||||
count=3,
|
||||
role="CP",
|
||||
world_context="力量体系:九转炼气(逐境突破)",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
)
|
||||
|
||||
assert "给我一个亦正亦邪的女二" in ctx
|
||||
assert "3" in ctx
|
||||
assert "CP" in ctx
|
||||
assert "九转炼气" in ctx
|
||||
|
||||
|
||||
def test_build_character_gen_context_injects_existing_and_generated() -> None:
|
||||
# M5-a 防雷同:已有角色 + 本批已生成卡都进上下文,要求差异化
|
||||
existing = [CharacterCard(name="李白", role="主角", backstory="b", arc="a")]
|
||||
generated = [CharacterCard(name="苏黎", role="对手", backstory="b2", arc="a2")]
|
||||
|
||||
ctx = build_character_gen_context(
|
||||
brief="再来一个配角",
|
||||
count=1,
|
||||
role=None,
|
||||
world_context="",
|
||||
existing_chars=existing,
|
||||
generated_so_far=generated,
|
||||
)
|
||||
|
||||
assert "李白" in ctx # 已有角色
|
||||
assert "苏黎" in ctx # 本批已生成卡
|
||||
|
||||
|
||||
def test_build_character_gen_context_handles_empty_anti_dup() -> None:
|
||||
# 首次单生成:无已有/已生成时仍产出可用上下文(不崩)
|
||||
ctx = build_character_gen_context(
|
||||
brief="主角",
|
||||
count=1,
|
||||
role="主角",
|
||||
world_context="",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
)
|
||||
assert "主角" in ctx
|
||||
|
||||
|
||||
# ---- run_character_gen:返回结构化群像,注入防雷同上下文 ----
|
||||
|
||||
|
||||
async def test_run_character_gen_returns_structured_cards() -> None:
|
||||
gateway = FakeRunGateway(_CARDS)
|
||||
|
||||
result = await run_character_gen(
|
||||
character_gen_spec,
|
||||
brief="亦正亦邪的女二",
|
||||
count=1,
|
||||
role="对手",
|
||||
world_context="力量体系:九转炼气",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert isinstance(result, CharacterGenResult)
|
||||
assert result.cards[0].name == "苏黎"
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_character_gen_injects_anti_dup_into_request() -> None:
|
||||
existing = [CharacterCard(name="李白", role="主角", backstory="b", arc="a")]
|
||||
gateway = FakeRunGateway(_CARDS)
|
||||
|
||||
await run_character_gen(
|
||||
character_gen_spec,
|
||||
brief="配角",
|
||||
count=2,
|
||||
role=None,
|
||||
world_context="九转炼气",
|
||||
existing_chars=existing,
|
||||
generated_so_far=[],
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "writer"
|
||||
assert req.output_schema is CharacterGenResult
|
||||
assert "李白" in req.input # 已有角色注入(防雷同)
|
||||
assert "九转炼气" in req.input # 世界观约束注入
|
||||
|
||||
|
||||
async def test_run_character_gen_raises_when_gateway_fails() -> None:
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_character_gen(
|
||||
character_gen_spec,
|
||||
brief="x",
|
||||
count=1,
|
||||
role=None,
|
||||
world_context="",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
# ---- precheck_generated_cards:入库前 continuity 校验缝(ARCH §6.5)----
|
||||
|
||||
|
||||
def test_build_precheck_context_includes_cards_and_truth() -> None:
|
||||
ctx = build_precheck_context(
|
||||
cards=_CARDS.cards,
|
||||
world_context="力量体系:九转炼气(逐境突破不可越级)",
|
||||
characters_context="主角:李白",
|
||||
)
|
||||
|
||||
assert "苏黎" in ctx # 待校验角色卡
|
||||
assert "九转炼气" in ctx # 世界观真相源
|
||||
assert "李白" in ctx # 已有角色真相源
|
||||
|
||||
|
||||
async def test_precheck_returns_conflicts_when_card_violates_world() -> None:
|
||||
# 编排器入库前追加 continuity 校验:角色能力越级 → 检出设定违例冲突
|
||||
review = ContinuityReview(
|
||||
conflicts=[
|
||||
Conflict(
|
||||
type="设定违例",
|
||||
where="苏黎背景:天生跨境界",
|
||||
refs=["九转炼气:逐境突破不可越级"],
|
||||
suggestion="改为逐境修炼",
|
||||
)
|
||||
]
|
||||
)
|
||||
gateway = FakeRunGateway(review)
|
||||
|
||||
conflicts = await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="力量体系:九转炼气(逐境突破不可越级)",
|
||||
characters_context="",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert len(conflicts) == 1
|
||||
assert conflicts[0].type == "设定违例"
|
||||
assert gateway.call_count == 1
|
||||
# 校验用 continuity 档位(analyst),且只读
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "analyst"
|
||||
assert req.output_schema is ContinuityReview
|
||||
|
||||
|
||||
async def test_precheck_returns_empty_when_no_conflict() -> None:
|
||||
gateway = FakeRunGateway(ContinuityReview(conflicts=[]))
|
||||
|
||||
conflicts = await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="力量体系:九转炼气",
|
||||
characters_context="",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert conflicts == []
|
||||
|
||||
|
||||
async def test_precheck_uses_continuity_schema_routing() -> None:
|
||||
# 校验缝复用 continuity_spec → 网关按 output_schema 路由产 ContinuityReview
|
||||
gateway = SchemaRoutingRunGateway({ContinuityReview: ContinuityReview(conflicts=[])})
|
||||
|
||||
conflicts = await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="w",
|
||||
characters_context="c",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert conflicts == []
|
||||
assert gateway.calls == [ContinuityReview]
|
||||
|
||||
|
||||
async def test_precheck_raises_when_parsed_missing() -> None:
|
||||
class _NoParseGateway:
|
||||
async def run(self, req: object) -> object:
|
||||
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
|
||||
|
||||
return LlmResponse(
|
||||
text="{}",
|
||||
parsed=None,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake-analyst",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake-analyst"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="parsed"):
|
||||
await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="w",
|
||||
characters_context="c",
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
31
packages/core/tests/test_generation_write_repos.py
Normal file
31
packages/core/tests/test_generation_write_repos.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""T5.2 写侧 repo 的 schema→DB 形变(纯函数 helper,无 DB)。
|
||||
|
||||
DB 行的实际落库由 apps/api 端点测试(fake repo)+ T5.7 E2E(真 pg)覆盖;
|
||||
这里只钉住 list→dict / str→dict 的转换契约(T5.1 gotcha),防回归。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_core.domain.character_repo import _arc_to_jsonb, _traits_to_jsonb
|
||||
from ww_core.domain.world_entity_repo import _rules_to_jsonb
|
||||
|
||||
|
||||
def test_traits_list_wraps_into_jsonb_dict() -> None:
|
||||
# Arrange
|
||||
traits = ["腹黑", "护短"]
|
||||
# Act
|
||||
out = _traits_to_jsonb(traits)
|
||||
# Assert:DB 列是 JSONB dict,list 包成 {"items":[...]}。
|
||||
assert out == {"items": ["腹黑", "护短"]}
|
||||
|
||||
|
||||
def test_traits_empty_list_still_dict_shape() -> None:
|
||||
assert _traits_to_jsonb([]) == {"items": []}
|
||||
|
||||
|
||||
def test_arc_str_wraps_into_jsonb_dict() -> None:
|
||||
assert _arc_to_jsonb("从孤儿到帝王") == {"text": "从孤儿到帝王"}
|
||||
|
||||
|
||||
def test_rules_list_wraps_into_jsonb_dict() -> None:
|
||||
assert _rules_to_jsonb(["禁止跨界传送", "灵力守恒"]) == {"rules": ["禁止跨界传送", "灵力守恒"]}
|
||||
243
packages/core/tests/test_job_repo.py
Normal file
243
packages/core/tests/test_job_repo.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""T4.1 长任务 `jobs` 写侧 repo 单测(ARCH §7.4)。
|
||||
|
||||
纯内存 fake(镜像 `SqlJobRepo` 语义),无 DB:断言状态流转 queued→running→done/failed、
|
||||
progress 夹取、result/error 落位、`reap_zombies` 把 running→failed、frozen View 不可变。
|
||||
真实 DB 路径(含 `reap_zombies` 自 commit)由 T4.5 E2E 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.job_repo import (
|
||||
PROGRESS_COMPLETE,
|
||||
STATUS_DONE,
|
||||
STATUS_FAILED,
|
||||
STATUS_QUEUED,
|
||||
STATUS_RUNNING,
|
||||
JobRepo,
|
||||
JobView,
|
||||
)
|
||||
|
||||
KIND = "style_learn"
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
id: uuid.UUID
|
||||
kind: str
|
||||
status: str
|
||||
progress: int
|
||||
result: dict[str, Any] | None
|
||||
error: str | None
|
||||
|
||||
|
||||
def _view(row: _Row) -> JobView:
|
||||
return JobView(
|
||||
id=row.id,
|
||||
kind=row.kind,
|
||||
status=row.status,
|
||||
progress=row.progress,
|
||||
result=row.result,
|
||||
error=row.error,
|
||||
)
|
||||
|
||||
|
||||
def _clamp(pct: int) -> int:
|
||||
return max(0, min(PROGRESS_COMPLETE, pct))
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeJobRepo:
|
||||
"""内存替身——镜像 SqlJobRepo 语义(状态流转 + 进度夹取 + 僵尸回收)。"""
|
||||
|
||||
rows: list[_Row] = field(default_factory=list)
|
||||
|
||||
def _require(self, job_id: uuid.UUID) -> _Row:
|
||||
row = next((r for r in self.rows if r.id == job_id), None)
|
||||
if row is None:
|
||||
raise LookupError(f"job not found: {job_id}")
|
||||
return row
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
row = _Row(
|
||||
id=uuid.uuid4(),
|
||||
kind=kind,
|
||||
status=STATUS_QUEUED,
|
||||
progress=0,
|
||||
result=None,
|
||||
error=None,
|
||||
)
|
||||
self.rows.append(row)
|
||||
return _view(row)
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.status = STATUS_RUNNING
|
||||
return _view(row)
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.progress = _clamp(pct)
|
||||
return _view(row)
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.status = STATUS_DONE
|
||||
row.progress = PROGRESS_COMPLETE
|
||||
row.result = dict(result)
|
||||
return _view(row)
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.status = STATUS_FAILED
|
||||
row.error = error
|
||||
return _view(row)
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
row = next((r for r in self.rows if r.id == job_id), None)
|
||||
return _view(row) if row is not None else None
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
changed = 0
|
||||
for r in self.rows:
|
||||
if r.status == STATUS_RUNNING:
|
||||
r.status = STATUS_FAILED
|
||||
r.error = "job interrupted (process restart)"
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def _repo() -> FakeJobRepo:
|
||||
return FakeJobRepo()
|
||||
|
||||
|
||||
# ---- create ----
|
||||
|
||||
|
||||
async def test_create_starts_queued() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
view = await repo.create(PROJECT, KIND)
|
||||
assert isinstance(view, JobView)
|
||||
assert view.kind == KIND
|
||||
assert view.status == STATUS_QUEUED
|
||||
assert view.progress == 0
|
||||
assert view.result is None
|
||||
assert view.error is None
|
||||
|
||||
|
||||
async def test_create_allows_null_project() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
view = await repo.create(None, KIND)
|
||||
assert view.status == STATUS_QUEUED
|
||||
|
||||
|
||||
# ---- state transitions ----
|
||||
|
||||
|
||||
async def test_set_running_marks_running() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
running = await repo.set_running(job.id)
|
||||
assert running.status == STATUS_RUNNING
|
||||
|
||||
|
||||
async def test_complete_sets_done_full_progress_and_result() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
await repo.set_running(job.id)
|
||||
done = await repo.complete(job.id, {"version": 2, "dims_count": 16})
|
||||
assert done.status == STATUS_DONE
|
||||
assert done.progress == PROGRESS_COMPLETE
|
||||
assert done.result == {"version": 2, "dims_count": 16}
|
||||
|
||||
|
||||
async def test_fail_sets_failed_and_error() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
await repo.set_running(job.id)
|
||||
failed = await repo.fail(job.id, "boom")
|
||||
assert failed.status == STATUS_FAILED
|
||||
assert failed.error == "boom"
|
||||
|
||||
|
||||
# ---- progress (clamped) ----
|
||||
|
||||
|
||||
async def test_set_progress_updates_pct() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
v = await repo.set_progress(job.id, 42)
|
||||
assert v.progress == 42
|
||||
|
||||
|
||||
async def test_set_progress_clamps_out_of_range() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
assert (await repo.set_progress(job.id, 250)).progress == PROGRESS_COMPLETE
|
||||
assert (await repo.set_progress(job.id, -5)).progress == 0
|
||||
|
||||
|
||||
# ---- get ----
|
||||
|
||||
|
||||
async def test_get_returns_none_when_absent() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
assert await repo.get(uuid.uuid4()) is None
|
||||
|
||||
|
||||
async def test_get_returns_current_state() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
await repo.complete(job.id, {"ok": True})
|
||||
fetched = await repo.get(job.id)
|
||||
assert fetched is not None
|
||||
assert fetched.status == STATUS_DONE
|
||||
|
||||
|
||||
async def test_missing_job_raises_on_write() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
with pytest.raises(LookupError):
|
||||
await repo.set_running(uuid.uuid4())
|
||||
|
||||
|
||||
# ---- reap_zombies ----
|
||||
|
||||
|
||||
async def test_reap_zombies_marks_running_failed() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
queued = await repo.create(PROJECT, KIND)
|
||||
running = await repo.create(PROJECT, KIND)
|
||||
await repo.set_running(running.id)
|
||||
done = await repo.create(PROJECT, KIND)
|
||||
await repo.complete(done.id, {})
|
||||
|
||||
count = await repo.reap_zombies()
|
||||
|
||||
assert count == 1
|
||||
reaped = await repo.get(running.id)
|
||||
assert reaped is not None and reaped.status == STATUS_FAILED
|
||||
# queued / done 不受影响
|
||||
assert (await repo.get(queued.id)).status == STATUS_QUEUED # type: ignore[union-attr]
|
||||
assert (await repo.get(done.id)).status == STATUS_DONE # type: ignore[union-attr]
|
||||
|
||||
|
||||
async def test_reap_zombies_noop_when_no_running() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
await repo.create(PROJECT, KIND)
|
||||
assert await repo.reap_zombies() == 0
|
||||
|
||||
|
||||
# ---- frozen View ----
|
||||
|
||||
|
||||
async def test_view_is_frozen() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
v = await repo.create(PROJECT, KIND)
|
||||
with pytest.raises(ValidationError):
|
||||
v.status = STATUS_DONE
|
||||
@@ -14,6 +14,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -41,6 +42,9 @@ class FakeOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get(chapter_no)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return [self.rows[k] for k in sorted(self.rows)]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCharacterRepo:
|
||||
@@ -91,6 +95,14 @@ class FakeRulesRepo:
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProjectSpecRepo:
|
||||
row: ProjectSpecView | None = None
|
||||
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
return self.row
|
||||
|
||||
|
||||
def build_repos(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
@@ -100,6 +112,7 @@ def build_repos(
|
||||
foreshadows: list[ForeshadowView] | None = None,
|
||||
style: StyleView | None = None,
|
||||
rules: list[RuleView] | None = None,
|
||||
spec: ProjectSpecView | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=FakeOutlineRepo(outline or {}),
|
||||
@@ -109,6 +122,7 @@ def build_repos(
|
||||
foreshadow=FakeForeshadowRepo(foreshadows or []),
|
||||
style=FakeStyleRepo(style),
|
||||
rules=FakeRulesRepo(rules or []),
|
||||
project=FakeProjectSpecRepo(spec),
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +258,26 @@ async def test_rule_merge_precedence() -> None:
|
||||
assert g < ge < s < p
|
||||
|
||||
|
||||
async def test_bare_project_premise_only_yields_nonempty_prompt() -> None:
|
||||
# 全新项目:只有书级蓝本(premise/logline/title),无世界观/角色/大纲/伏笔。
|
||||
spec = ProjectSpecView(title="无名之书", logline="一个开始", premise="末世孤舟漂流")
|
||||
repos = build_repos(spec=spec) # 其余全空
|
||||
ctx = await assemble(repos, PROJECT, 1)
|
||||
# stable_core 非空且含书级蓝本
|
||||
assert ctx.stable_core != ""
|
||||
assert "末世孤舟漂流" in ctx.stable_core
|
||||
assert "无名之书" in ctx.stable_core
|
||||
# volatile 非空且含本章写作指令 → writer 的 user message 永不为空
|
||||
assert ctx.volatile != ""
|
||||
assert "请创作第 1 章的正文。" in ctx.volatile
|
||||
|
||||
|
||||
async def test_chapter_directive_uses_chapter_no() -> None:
|
||||
repos = build_repos(spec=ProjectSpecView(title="书"))
|
||||
ctx = await assemble(repos, PROJECT, 7)
|
||||
assert "请创作第 7 章的正文。" in ctx.volatile
|
||||
|
||||
|
||||
# ---- helpers ----
|
||||
|
||||
|
||||
@@ -267,4 +301,5 @@ def _full_repos() -> MemoryRepos:
|
||||
foreshadows=[ForeshadowView(code="F1", title="神秘石符", status="OPEN", planted_at=1)],
|
||||
style=StyleView(dimensions={"句长": "短句为主"}),
|
||||
rules=[RuleView(level="global", content="全局规则")],
|
||||
spec=ProjectSpecView(title="大梦主", logline="少年逆天改命", premise="灵气复苏"),
|
||||
)
|
||||
|
||||
46
packages/core/tests/test_rules_repo.py
Normal file
46
packages/core/tests/test_rules_repo.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""T5.5 规则写侧 repo 单测(C3 扩 / PRODUCT_SPEC §7 POST /rules)。
|
||||
|
||||
`RuleWriteRepo.create` 插一行 `rules`(level + content,绑 project_id),只 flush 不 commit
|
||||
(提交交端点事务,与项目其它写侧 repo 一致)。`level` 合法性(global/genre/style/project)
|
||||
由端点/schema 校验;repo 只负责写。纯内存 fake,无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeRuleWriteRepo:
|
||||
rows: list[RuleWriteView] = field(default_factory=list)
|
||||
flushed: int = 0
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
view = RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
self.rows.append(view)
|
||||
self.flushed += 1
|
||||
return view
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_view() -> None:
|
||||
repo: RuleWriteRepo = _FakeRuleWriteRepo()
|
||||
|
||||
view = await repo.create(PROJECT, level="project", content="主角不许复活")
|
||||
|
||||
assert view.level == "project"
|
||||
assert view.content == "主角不许复活"
|
||||
assert view.project_id == PROJECT
|
||||
|
||||
|
||||
def test_rule_view_is_frozen() -> None:
|
||||
view = RuleWriteView(project_id=PROJECT, level="global", content="x")
|
||||
with pytest.raises(ValidationError):
|
||||
view.content = "y"
|
||||
137
packages/core/tests/test_style_extract_node.py
Normal file
137
packages/core/tests/test_style_extract_node.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""T4.2 文风提取节点单测——build_style_extract_request + run_style_extraction(C6 扩 / ARCH §5.4)。
|
||||
|
||||
注入 mock 网关(产 `StyleFingerprintResult` parsed),无真 LLM、无真 Postgres。
|
||||
提取节点是裸函数(独立生成、仿 outliner,不在写章/审稿流水线);只产结构化指纹,**不写库**
|
||||
(持久化属 T4.3 端点,不变量 #3)。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
|
||||
from ww_agents import (
|
||||
StyleDimension,
|
||||
StyleFingerprintResult,
|
||||
style_extract_spec,
|
||||
)
|
||||
from ww_core.orchestrator import build_style_extract_request, run_style_extraction
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
SAMPLES = "样本一:他一刀劈下,血溅三尺。\n样本二:风停了,只剩呼吸声。"
|
||||
|
||||
_FINGERPRINT = StyleFingerprintResult(
|
||||
dimensions=[
|
||||
StyleDimension(
|
||||
name="句长节奏",
|
||||
value="短句为主、节奏明快",
|
||||
evidence=["他一刀劈下,血溅三尺。"],
|
||||
),
|
||||
StyleDimension(name="叙事人称", value="第三人称限知", evidence=["风停了"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- build_style_extract_request:tier 不变量、缓存断点、output_schema、run 非 stream ----
|
||||
|
||||
|
||||
def test_build_style_extract_request_uses_spec_tier_and_cached_prompt() -> None:
|
||||
req = build_style_extract_request(
|
||||
style_extract_spec, samples_text=SAMPLES, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
assert req.tier == "analyst" # 不变量②:spec 档位透传,不传 model
|
||||
assert req.stream is False # 提取用 run() 非 stream()
|
||||
assert len(req.system) == 1
|
||||
assert req.system[0].text == style_extract_spec.system_prompt
|
||||
assert req.system[0].cache is True # 不变量#9
|
||||
assert req.input == SAMPLES
|
||||
assert req.output_schema is StyleFingerprintResult
|
||||
assert req.scope.user_id == USER
|
||||
assert req.scope.project_id == PROJECT
|
||||
|
||||
|
||||
# ---- run_style_extraction:返回结构化指纹含证据,只读不写库 ----
|
||||
|
||||
|
||||
async def test_run_style_extraction_returns_fingerprint() -> None:
|
||||
gateway = FakeRunGateway(_FINGERPRINT)
|
||||
|
||||
result = await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert isinstance(result, StyleFingerprintResult)
|
||||
assert len(result.dimensions) == 2
|
||||
assert result.dimensions[0].name == "句长节奏"
|
||||
assert result.dimensions[0].evidence == ["他一刀劈下,血溅三尺。"]
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_style_extraction_forwards_constructed_request() -> None:
|
||||
gateway = FakeRunGateway(_FINGERPRINT)
|
||||
|
||||
await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "analyst"
|
||||
assert req.output_schema is StyleFingerprintResult
|
||||
assert req.input == SAMPLES
|
||||
|
||||
|
||||
async def test_run_style_extraction_raises_when_gateway_fails() -> None:
|
||||
# 提取是独立生成(非流水线并行审);网关失败直接上抛,端点/T4.3 处理。
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
async def test_run_style_extraction_raises_when_parsed_missing() -> None:
|
||||
# 带 output_schema 时 parsed 必非 None(C1);若网关违约返回 None,明确报错而非静默。
|
||||
class _NoParseGateway:
|
||||
async def run(self, req: object) -> object:
|
||||
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
|
||||
|
||||
return LlmResponse(
|
||||
text="{}",
|
||||
parsed=None,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake-analyst",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake-analyst"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="parsed"):
|
||||
await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
249
packages/core/tests/test_style_review.py
Normal file
249
packages/core/tests/test_style_review.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""T4.2 第四审(style/drift)单测——并入 review 图 + collect style 列 + SSE style 事件(C4 扩)。
|
||||
|
||||
全部注入 mock 网关 / 内存 fake repo,无真 LLM、无真 Postgres(图用 MemorySaver)。
|
||||
asyncio_mode=auto。验四审齐(continuity/foreshadow/pace/style)、collect 落 `style` 列、
|
||||
SSE `section{name:"style"}` + `style{score,segments}`、无指纹优雅降级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import (
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
StyleDriftReview,
|
||||
StyleDriftSegment,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
CONTINUITY,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
EVENT_STYLE,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
REVIEW_INCOMPLETE,
|
||||
REVIEW_OK,
|
||||
REVIEW_SPECS,
|
||||
SECTION_DONE,
|
||||
STYLE,
|
||||
ChapterState,
|
||||
build_review_context,
|
||||
build_review_graph,
|
||||
collect_reviews,
|
||||
extract_style,
|
||||
normalize_review,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
STABLE = "## 世界观硬规则\n灵气可凝丹\n## 文风指纹\n句长节奏:短句明快"
|
||||
VOLATILE = "## 近况\n主角已破境"
|
||||
DRAFT = "他一拳轰碎山岳。"
|
||||
|
||||
_CONFLICT = Conflict(
|
||||
type="能力不符", where="第3段", refs=["人物卡:主角"], suggestion="改为震裂巨石"
|
||||
)
|
||||
_FORESHADOW = ForeshadowReview(
|
||||
planted=[ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族")]
|
||||
)
|
||||
_PACE = PaceReview(water=[PaceIssue(where="第4段", reason="注水")], hook=True, beat_map=[1, 3, 5])
|
||||
_STYLE = StyleDriftReview(
|
||||
score=72,
|
||||
segments=[
|
||||
StyleDriftSegment(idx=3, score=40, label="机翻腔"),
|
||||
StyleDriftSegment(idx=7, score=55),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
|
||||
state: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 7,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": DRAFT,
|
||||
"review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
|
||||
}
|
||||
if reviews is not None:
|
||||
state["reviews"] = reviews
|
||||
return state
|
||||
|
||||
|
||||
def _four_reviews() -> dict[str, Any]:
|
||||
return {
|
||||
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}},
|
||||
FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()},
|
||||
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
|
||||
STYLE: {"status": REVIEW_OK, "result": _STYLE.model_dump()},
|
||||
}
|
||||
|
||||
|
||||
# ---- REVIEW_SPECS 含第四审 ----
|
||||
|
||||
|
||||
def test_review_specs_include_four_audits() -> None:
|
||||
names = [s.name for s in REVIEW_SPECS]
|
||||
assert names == ["continuity", "foreshadow", "pace", "style"]
|
||||
|
||||
|
||||
# ---- collect:style 列映射 ----
|
||||
|
||||
|
||||
def test_extract_style_returns_whole_dict() -> None:
|
||||
style = extract_style(_four_reviews())
|
||||
|
||||
assert style is not None
|
||||
assert style["score"] == 72
|
||||
assert len(style["segments"]) == 2
|
||||
assert style["segments"][0]["label"] == "机翻腔"
|
||||
|
||||
|
||||
def test_extract_style_none_when_incomplete() -> None:
|
||||
reviews = {STYLE: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
assert extract_style(reviews) is None
|
||||
|
||||
|
||||
def test_extract_style_none_when_absent() -> None:
|
||||
assert extract_style({}) is None
|
||||
|
||||
|
||||
async def test_collect_records_style_to_style_column() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
|
||||
await collect_reviews(_state(_four_reviews()), review_repo=repo)
|
||||
|
||||
assert len(repo.records) == 1 # 一次 record 落齐四审
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"][0]["type"] == "能力不符"
|
||||
assert len(rec["foreshadow_sug"]) == 1
|
||||
assert rec["pace"]["hook"] is True
|
||||
assert rec["style"]["score"] == 72 # style→style 列
|
||||
assert rec["style"]["segments"][0]["label"] == "机翻腔"
|
||||
|
||||
|
||||
# ---- 四审并行图跑通(mock 网关按 schema 路由产四 parsed)----
|
||||
|
||||
|
||||
async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
StyleDriftReview: _STYLE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-1"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
|
||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||
assert final["reviews"][STYLE]["status"] == REVIEW_OK
|
||||
assert len(gateway.calls) == 4 # 四审各调一次网关
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
||||
assert rec["style"]["score"] == 72
|
||||
|
||||
|
||||
async def test_four_review_graph_isolates_failing_style_audit() -> None:
|
||||
# style schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-2"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][STYLE]["status"] == REVIEW_INCOMPLETE
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] # 其余三审照常落库
|
||||
assert rec["style"] is None # style 失败列留空,不阻塞其余
|
||||
|
||||
|
||||
# ---- SSE:normalize_review surface 四审(含 style 事件)----
|
||||
|
||||
|
||||
async def test_normalize_review_surfaces_style_event() -> None:
|
||||
events = [e async for e in normalize_review(_four_reviews())]
|
||||
kinds = [e.event for e in events]
|
||||
|
||||
# 字典序遍历:continuity < foreshadow < pace < style
|
||||
assert kinds == [
|
||||
EVENT_SECTION,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_SECTION,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_SECTION,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
EVENT_STYLE,
|
||||
EVENT_DONE,
|
||||
]
|
||||
style_evt = next(e for e in events if e.event == EVENT_STYLE)
|
||||
assert style_evt.data["score"] == 72
|
||||
segments = style_evt.data["segments"]
|
||||
assert segments == [
|
||||
{"idx": 3, "score": 40, "label": "机翻腔"},
|
||||
{"idx": 7, "score": 55, "label": None},
|
||||
]
|
||||
style_section = next(
|
||||
e for e in events if e.event == EVENT_SECTION and e.data.get("name") == STYLE
|
||||
)
|
||||
assert style_section.data["status"] == SECTION_DONE
|
||||
assert events[-1].data == {"length": 4} # 四审项
|
||||
|
||||
|
||||
# ---- 无指纹优雅降级:style 审返回 score=100/空段(默认 schema)落库且 SSE 不报错 ----
|
||||
|
||||
|
||||
async def test_style_graceful_degrade_when_no_fingerprint() -> None:
|
||||
# 材料无指纹 → 第四审产默认 StyleDriftReview(score=100, segments=[])(system_prompt 指示)
|
||||
degraded = StyleDriftReview() # score=100, segments=[]
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[]),
|
||||
ForeshadowReview: ForeshadowReview(),
|
||||
PaceReview: PaceReview(),
|
||||
StyleDriftReview: degraded,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-degrade"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][STYLE]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["style"] == {"score": 100, "segments": []} # 降级也落库(非 None)
|
||||
|
||||
events = [e async for e in normalize_review(final["reviews"])]
|
||||
style_evt = next(e for e in events if e.event == EVENT_STYLE)
|
||||
assert style_evt.data["score"] == 100
|
||||
assert style_evt.data["segments"] == []
|
||||
@@ -8,6 +8,11 @@ from ww_core.domain.chapter_repo import (
|
||||
ChapterView,
|
||||
SqlChapterRepo,
|
||||
)
|
||||
from ww_core.domain.character_repo import (
|
||||
CharacterWriteRepo,
|
||||
CharacterWriteView,
|
||||
SqlCharacterWriteRepo,
|
||||
)
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.foreshadow_repo import (
|
||||
ForeshadowLedgerRepo,
|
||||
@@ -25,6 +30,11 @@ from ww_core.domain.foreshadow_state import (
|
||||
is_overdue,
|
||||
transition,
|
||||
)
|
||||
from ww_core.domain.job_repo import (
|
||||
JobRepo,
|
||||
JobView,
|
||||
SqlJobRepo,
|
||||
)
|
||||
from ww_core.domain.outline_write_repo import (
|
||||
OutlineWriteRepo,
|
||||
OutlineWriteView,
|
||||
@@ -38,12 +48,29 @@ from ww_core.domain.project_repo import (
|
||||
)
|
||||
from ww_core.domain.repositories import DigestView, MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView, SqlRuleWriteRepo
|
||||
from ww_core.domain.style_repo import (
|
||||
SqlStyleFingerprintWriteRepo,
|
||||
StyleFingerprintView,
|
||||
StyleFingerprintWriteRepo,
|
||||
)
|
||||
from ww_core.domain.world_entity_repo import (
|
||||
SqlWorldEntityWriteRepo,
|
||||
WorldEntityWriteRepo,
|
||||
WorldEntityWriteView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChapterDraftView",
|
||||
"ChapterRepo",
|
||||
"ChapterView",
|
||||
"SqlChapterRepo",
|
||||
"CharacterWriteRepo",
|
||||
"CharacterWriteView",
|
||||
"SqlCharacterWriteRepo",
|
||||
"WorldEntityWriteRepo",
|
||||
"WorldEntityWriteView",
|
||||
"SqlWorldEntityWriteRepo",
|
||||
"DigestAppendRepo",
|
||||
"SqlDigestAppendRepo",
|
||||
"DigestView",
|
||||
@@ -52,6 +79,9 @@ __all__ = [
|
||||
"SqlForeshadowLedgerRepo",
|
||||
"ForeshadowStatus",
|
||||
"InvalidTransition",
|
||||
"JobRepo",
|
||||
"JobView",
|
||||
"SqlJobRepo",
|
||||
"OPEN",
|
||||
"PARTIAL",
|
||||
"CLOSED",
|
||||
@@ -70,4 +100,10 @@ __all__ = [
|
||||
"ReviewRepo",
|
||||
"ReviewView",
|
||||
"SqlReviewRepo",
|
||||
"RuleWriteView",
|
||||
"RuleWriteRepo",
|
||||
"SqlRuleWriteRepo",
|
||||
"StyleFingerprintWriteRepo",
|
||||
"StyleFingerprintView",
|
||||
"SqlStyleFingerprintWriteRepo",
|
||||
]
|
||||
|
||||
121
packages/core/ww_core/domain/character_repo.py
Normal file
121
packages/core/ww_core/domain/character_repo.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""角色**写侧** Repository(C3 扩 / ARCH §5.4 character-gen writes=characters / §6.5 入库)。
|
||||
|
||||
读侧(`list_for_project`,供 assemble 注入)已在
|
||||
`ww_core.memory.sql_repositories.SqlCharacterRepo` + `domain.repositories.CharacterRepo`
|
||||
提供(C5 稳定,不动)。本模块加**写**能力,命名加 `Write` 前缀避歧义(同 `OutlineWriteRepo` 先例)。
|
||||
|
||||
**schema → DB 列形变(T5.1 gotcha,关键)**:`ww_agents.CharacterCard` 贴生成产物
|
||||
(`traits`/`speech_tics` 是 `list[str]`、`arc` 是 `str`),但 `characters` 表的对应列是
|
||||
JSONB **dict**(`traits`/`arc`/`speech_tics`),`tags`/`relations` 才是 JSONB **list**。
|
||||
入库时本 repo 做转换层:
|
||||
- `traits: list[str]` → `{"items": [...]}`(JSONB dict)
|
||||
- `speech_tics: list[str]` → `{"items": [...]}`(JSONB dict)
|
||||
- `arc: str` → `{"text": "..."}`(JSONB dict)
|
||||
- `tags: list` / `relations: list[dict]` → 直落 JSONB list
|
||||
- `name` / `role` / `backstory` → Text 列直落
|
||||
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
|
||||
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Character
|
||||
|
||||
|
||||
class CharacterWriteView(BaseModel):
|
||||
"""角色写入后的只读快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
role: str | None = None
|
||||
|
||||
|
||||
def _traits_to_jsonb(items: list[str]) -> dict[str, Any]:
|
||||
"""`list[str]` → JSONB dict(DB 列形)。空列表也包成 `{"items": []}` 形稳定。"""
|
||||
return {"items": list(items)}
|
||||
|
||||
|
||||
def _arc_to_jsonb(arc: str) -> dict[str, Any]:
|
||||
"""弧光一句话 `str` → JSONB dict(DB 列形)。"""
|
||||
return {"text": arc}
|
||||
|
||||
|
||||
class CharacterWriteRepo(Protocol):
|
||||
"""角色写侧接口(按 project_id 隔离;只 flush 不 commit)。
|
||||
|
||||
入参贴 `ww_agents.CharacterCard`(生成产物形);实现负责 schema → DB 列形变。
|
||||
"""
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView: ...
|
||||
|
||||
|
||||
class SqlCharacterWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `characters`(schema→DB 形变;只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView:
|
||||
row = Character(
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
role=role,
|
||||
traits=_traits_to_jsonb(traits),
|
||||
backstory=backstory,
|
||||
arc=_arc_to_jsonb(arc),
|
||||
speech_tics=_traits_to_jsonb(speech_tics),
|
||||
tags=list(tags),
|
||||
relations=[dict(r) for r in relations],
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return CharacterWriteView(id=row.id, name=row.name, role=row.role)
|
||||
|
||||
|
||||
# ---- request-shaping helper(schema 字段名导出供测试/端点共用)----
|
||||
|
||||
|
||||
class CharacterWriteFields(BaseModel):
|
||||
"""从 `CharacterCard` 拆出的入库字段(端点把 schema 卡转成此 kwargs 形)。"""
|
||||
|
||||
name: str
|
||||
role: str
|
||||
traits: list[str] = Field(default_factory=list)
|
||||
backstory: str
|
||||
arc: str
|
||||
speech_tics: list[str] = Field(default_factory=list)
|
||||
tags: list[Any] = Field(default_factory=list)
|
||||
relations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
169
packages/core/ww_core/domain/job_repo.py
Normal file
169
packages/core/ww_core/domain/job_repo.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""长任务 `jobs` 表**写侧** Repository(创建/进度/完成/失败/僵尸回收;ARCH §7.4)。
|
||||
|
||||
`jobs` 表 + `GET /jobs/{id}` 读端点已在 T0.3 建齐;本模块补**写/进度/回收**层,
|
||||
供 T4.3「学文风走 jobs」(`POST /style` 写一行 → BackgroundTask 跑提取 → 置 done/failed)
|
||||
复用。命名沿用读侧无歧义(无读侧同名 repo,故不加前缀)。
|
||||
|
||||
**提交边界**(同 M2/M3 写侧 repo 与 memory/gotchas「写库副作用归调用方」纪律):
|
||||
所有状态写方法**只 `flush()`(+`refresh`)不 `commit()`**——提交交调用方:
|
||||
`run_job`(job_runner)/端点事务负责 commit。
|
||||
**唯一例外** `reap_zombies`:它在 lifespan 启动期**独立调用**(无外层事务/调用方),
|
||||
故自己 `commit()`。
|
||||
|
||||
`JobView`(frozen)镜像 `GET /jobs/{id}` 出参字段:id/kind/status/progress/result/error。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import CursorResult, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Job
|
||||
|
||||
# job 状态常量(与 DB server_default 'queued' 对齐;ARCH §7.4)。
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_DONE = "done"
|
||||
STATUS_FAILED = "failed"
|
||||
PROGRESS_COMPLETE = 100
|
||||
|
||||
|
||||
class JobView(BaseModel):
|
||||
"""长任务只读快照(snake_case,frozen)——镜像 GET /jobs/{id} 出参。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
kind: str
|
||||
status: str
|
||||
progress: int = 0
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class JobRepo(Protocol):
|
||||
"""长任务写侧接口(状态写方法只 flush 不 commit;`reap_zombies` 自 commit)。"""
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
"""创建一行 job(status=queued, progress=0)。"""
|
||||
...
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
"""置 status=running(任务开始)。"""
|
||||
...
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
"""更新进度百分比(0..100,越界夹取)。"""
|
||||
...
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
"""置 status=done, progress=100, result=<dict>。"""
|
||||
...
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
"""置 status=failed, error=<str>。"""
|
||||
...
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
"""按 id 取单条,无则 None。"""
|
||||
...
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
"""把所有 status=running 的僵尸行标 failed,返回被改条数(启动期自 commit)。"""
|
||||
...
|
||||
|
||||
|
||||
def _to_view(row: Job) -> JobView:
|
||||
return JobView(
|
||||
id=row.id,
|
||||
kind=row.kind,
|
||||
status=row.status,
|
||||
progress=row.progress,
|
||||
result=row.result,
|
||||
error=row.error,
|
||||
)
|
||||
|
||||
|
||||
def _clamp_pct(pct: int) -> int:
|
||||
return max(0, min(PROGRESS_COMPLETE, pct))
|
||||
|
||||
|
||||
class SqlJobRepo:
|
||||
"""SQLAlchemy 实现:创建 + 状态流转 + 进度 + 僵尸回收。
|
||||
|
||||
状态写方法只 flush(+refresh);`reap_zombies` 启动期独立调用故自 commit。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def _find(self, job_id: uuid.UUID) -> Job | None:
|
||||
return (await self._s.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
|
||||
async def _require(self, job_id: uuid.UUID) -> Job:
|
||||
row = await self._find(job_id)
|
||||
if row is None:
|
||||
raise LookupError(f"job not found: {job_id}")
|
||||
return row
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
row = Job(
|
||||
project_id=project_id,
|
||||
kind=kind,
|
||||
status=STATUS_QUEUED,
|
||||
progress=0,
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.status = STATUS_RUNNING
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.progress = _clamp_pct(pct)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.status = STATUS_DONE
|
||||
row.progress = PROGRESS_COMPLETE
|
||||
row.result = dict(result)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.status = STATUS_FAILED
|
||||
row.error = error
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
row = await self._find(job_id)
|
||||
return _to_view(row) if row is not None else None
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
"""启动期僵尸回收:进程重启会丢 BackgroundTask,残留 running 行标 failed
|
||||
(让用户看到失败可重试,而非进度条永转;§7.4 缓解)。独立调用故自 commit。"""
|
||||
result = await self._s.execute(
|
||||
update(Job)
|
||||
.where(Job.status == STATUS_RUNNING)
|
||||
.values(status=STATUS_FAILED, error="job interrupted (process restart)")
|
||||
)
|
||||
await self._s.commit()
|
||||
# bulk UPDATE → CursorResult.rowcount = 受影响行数(`Result` 基类无此属性)。
|
||||
return cast("CursorResult[Any]", result).rowcount
|
||||
@@ -86,12 +86,29 @@ class RuleView(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class ProjectSpecView(BaseModel):
|
||||
"""项目书级蓝本只读快照(premise/logline/theme/title)——稳定、定型,入缓存前缀。
|
||||
|
||||
这是「作品蓝本」:哪怕世界观/角色/大纲全空,它也保证写章 prompt 非空、有方向
|
||||
(修复空 prompt 导致的 400 "message must not be empty")。
|
||||
"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
title: str
|
||||
logline: str | None = None
|
||||
premise: str | None = None
|
||||
theme: str | None = None
|
||||
|
||||
|
||||
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
||||
|
||||
|
||||
class OutlineRepo(Protocol):
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: ...
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: ...
|
||||
|
||||
|
||||
class CharacterRepo(Protocol):
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: ...
|
||||
@@ -119,9 +136,13 @@ class RulesRepo(Protocol):
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]: ...
|
||||
|
||||
|
||||
class ProjectSpecRepo(Protocol):
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryRepos:
|
||||
"""记忆服务所需的 7 个 repo 的依赖捆绑(注入点)。"""
|
||||
"""记忆服务所需的 8 个 repo 的依赖捆绑(注入点)。"""
|
||||
|
||||
outline: OutlineRepo
|
||||
character: CharacterRepo
|
||||
@@ -130,3 +151,4 @@ class MemoryRepos:
|
||||
foreshadow: ForeshadowRepo
|
||||
style: StyleRepo
|
||||
rules: RulesRepo
|
||||
project: ProjectSpecRepo
|
||||
|
||||
49
packages/core/ww_core/domain/rule_repo.py
Normal file
49
packages/core/ww_core/domain/rule_repo.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""规则**写侧** Repository(C3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`)。
|
||||
|
||||
读侧(`all_for_project`,供 assemble 注入 + `merge_rules` 四级合并)已在
|
||||
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供(C5,不动)。本模块加**写**能力,
|
||||
命名加 `Write` 前缀避免歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
|
||||
|
||||
`level` ∈ global/genre/style/project(合法性由端点/schema 校验,repo 只写)。
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧
|
||||
repo 一致,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Rule
|
||||
|
||||
|
||||
class RuleWriteView(BaseModel):
|
||||
"""规则写入后的只读快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
project_id: uuid.UUID | None
|
||||
level: str
|
||||
content: str
|
||||
|
||||
|
||||
class RuleWriteRepo(Protocol):
|
||||
"""规则写侧接口(绑 project_id;只 flush 不 commit)。"""
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: ...
|
||||
|
||||
|
||||
class SqlRuleWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `rules`(只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
row = Rule(project_id=project_id, level=level, content=content)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return RuleWriteView(project_id=row.project_id, level=row.level, content=row.content)
|
||||
108
packages/core/ww_core/domain/style_repo.py
Normal file
108
packages/core/ww_core/domain/style_repo.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""文风指纹**写侧** Repository(版本化 append + 最新指纹读取;ARCH §5.4 / §6.9)。
|
||||
|
||||
读侧(`latest`,供 assemble 注入 stable_core 文风段)已在
|
||||
`ww_core.memory.sql_repositories.SqlStyleRepo` / `domain.repositories.StyleRepo` 提供,
|
||||
但其 `StyleView` **只含 `dimensions`**(C5 assemble 只需维度文本,不需证据/版本)。
|
||||
本模块加**写**能力 + 一个含证据/版本的完整读视图(供 T4.3 `GET /projects/:id/style`
|
||||
展示完整指纹,对齐 UX §6.9),命名加 `Write` 前缀避免与 C5 读侧 `SqlStyleRepo` 同名歧义
|
||||
(同 `DigestAppendRepo`/`SqlOutlineWriteRepo` 先例,见 memory/decisions),且**不动 C5 读侧**。
|
||||
|
||||
**提交边界**(同 M2/M3 写侧 repo 与 memory/gotchas「写库副作用归调用方」纪律):
|
||||
`append` **只 `flush()`(+`refresh`)不 `commit()`**——提交交 `run_job`(学文风后台任务)/端点。
|
||||
|
||||
版本化:`style_fingerprint` 每次提取 INSERT 新行(不覆盖历史),`version` = 当前
|
||||
project 的 `max(version) + 1`(首次 = 1)。提取产 `StyleFingerprintResult` 由调用方拆成
|
||||
`dimensions_json = {dim.name: dim.value}` + `evidence_json = {dim.name: dim.evidence}`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import StyleFingerprint
|
||||
|
||||
|
||||
class StyleFingerprintView(BaseModel):
|
||||
"""文风指纹完整只读快照(snake_case,frozen)——含维度 + 证据 + 版本。
|
||||
|
||||
区别于 C5 读侧最小 `StyleView`(只 `dimensions`):本视图供 `GET /style`
|
||||
展示完整指纹(每维度名 → 值 + 原文证据列表)。
|
||||
"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
dimensions: dict[str, Any] = Field(default_factory=dict)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
version: int
|
||||
|
||||
|
||||
class StyleFingerprintWriteRepo(Protocol):
|
||||
"""文风指纹写侧接口(按 project_id 隔离;`append` 只 flush 不 commit)。"""
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
dimensions_json: dict[str, Any],
|
||||
evidence_json: dict[str, Any],
|
||||
) -> int:
|
||||
"""追加一行文风指纹(不覆盖历史),返回新行 `version`(= 当前 max + 1,首次 = 1)。"""
|
||||
...
|
||||
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
|
||||
"""取最新版本指纹(version 最大)的完整视图(含证据),无则 None。"""
|
||||
...
|
||||
|
||||
|
||||
class SqlStyleFingerprintWriteRepo:
|
||||
"""SQLAlchemy 实现:版本化 INSERT(append-only)+ 最新指纹读取(只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
dimensions_json: dict[str, Any],
|
||||
evidence_json: dict[str, Any],
|
||||
) -> int:
|
||||
current_max = (
|
||||
await self._s.execute(
|
||||
select(func.max(StyleFingerprint.version)).where(
|
||||
StyleFingerprint.project_id == project_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
next_version = (current_max or 0) + 1
|
||||
row = StyleFingerprint(
|
||||
project_id=project_id,
|
||||
dimensions_json=dict(dimensions_json),
|
||||
evidence_json=dict(evidence_json),
|
||||
version=next_version,
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return row.version
|
||||
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
|
||||
row = (
|
||||
await self._s.execute(
|
||||
select(StyleFingerprint)
|
||||
.where(StyleFingerprint.project_id == project_id)
|
||||
.order_by(StyleFingerprint.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return StyleFingerprintView(
|
||||
dimensions=dict(row.dimensions_json),
|
||||
evidence=dict(row.evidence_json),
|
||||
version=row.version,
|
||||
)
|
||||
75
packages/core/ww_core/domain/world_entity_repo.py
Normal file
75
packages/core/ww_core/domain/world_entity_repo.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""世界观实体**写侧** Repository(C3 扩 / ARCH §5.4 worldbuilder writes=world_entities)。
|
||||
|
||||
读侧(`list_for_project`,供 assemble 注入)已在
|
||||
`ww_core.memory.sql_repositories.SqlWorldEntityRepo` + `domain.repositories.WorldEntityRepo`/
|
||||
`WorldEntityView` 提供(C5 稳定,不动)。本模块加**写**能力,命名加 `Write` 前缀避歧义。
|
||||
|
||||
**schema → DB 列形变(T5.1 gotcha)**:`ww_agents.WorldEntityCard.rules` 是 `list[str]`,
|
||||
而 `world_entities.rules` 列是 JSONB **dict**——入库时包成 `{"rules": [...]}`。
|
||||
`type`/`name` 是 Text 列直落。
|
||||
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import WorldEntity
|
||||
|
||||
|
||||
class WorldEntityWriteView(BaseModel):
|
||||
"""世界观实体写入后的只读快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
type: str
|
||||
name: str
|
||||
|
||||
|
||||
def _rules_to_jsonb(rules: list[str]) -> dict[str, Any]:
|
||||
"""`list[str]` → JSONB dict(DB 列形)。"""
|
||||
return {"rules": list(rules)}
|
||||
|
||||
|
||||
class WorldEntityWriteRepo(Protocol):
|
||||
"""世界观实体写侧接口(按 project_id 隔离;只 flush 不 commit)。"""
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
type: str,
|
||||
name: str,
|
||||
rules: list[str],
|
||||
) -> WorldEntityWriteView: ...
|
||||
|
||||
|
||||
class SqlWorldEntityWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `world_entities`(rules list→dict;只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
type: str,
|
||||
name: str,
|
||||
rules: list[str],
|
||||
) -> WorldEntityWriteView:
|
||||
row = WorldEntity(
|
||||
project_id=project_id,
|
||||
type=type,
|
||||
name=name,
|
||||
rules=_rules_to_jsonb(rules),
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return WorldEntityWriteView(id=row.id, type=row.type, name=row.name)
|
||||
@@ -17,6 +17,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -43,7 +44,25 @@ def _section(title: str, body: str) -> str | None:
|
||||
return f"## {title}\n{body}" if body else None
|
||||
|
||||
|
||||
def _build_spec_section(spec: ProjectSpecView | None) -> str | None:
|
||||
"""作品书级蓝本(标题/一句话梗概/前提/主题)——稳定、定型 → 缓存前缀。
|
||||
|
||||
保证哪怕世界观/角色/大纲全空,stable_core 也非空、有方向(修空 prompt 400)。
|
||||
"""
|
||||
if spec is None:
|
||||
return None
|
||||
lines = [f"【作品】{spec.title}"]
|
||||
if spec.logline:
|
||||
lines.append(f"【一句话梗概】{spec.logline}")
|
||||
if spec.premise:
|
||||
lines.append(f"【前提】{spec.premise}")
|
||||
if spec.theme:
|
||||
lines.append(f"【主题】{spec.theme}")
|
||||
return _section("作品蓝本", "\n".join(lines))
|
||||
|
||||
|
||||
def _build_stable(
|
||||
spec: ProjectSpecView | None,
|
||||
world_entities: list[WorldEntityView],
|
||||
main_characters: list[CharacterView],
|
||||
style: StyleView | None,
|
||||
@@ -64,6 +83,7 @@ def _build_stable(
|
||||
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
|
||||
|
||||
sections = [
|
||||
_build_spec_section(spec),
|
||||
_section("世界观硬规则", "\n".join(world_lines)),
|
||||
_section("定型主角", "\n".join(char_lines)),
|
||||
_section("文风指纹", _ser(style.dimensions) if style else ""),
|
||||
@@ -73,6 +93,7 @@ def _build_stable(
|
||||
|
||||
|
||||
def _build_volatile(
|
||||
chapter_no: int,
|
||||
cards: str,
|
||||
foreshadows: list[ForeshadowView],
|
||||
digests: list[DigestView],
|
||||
@@ -86,6 +107,8 @@ def _build_volatile(
|
||||
f"第{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
|
||||
]
|
||||
sections = [
|
||||
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
||||
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
||||
_section("本章注入卡片", cards),
|
||||
_section("相关伏笔窗口", "\n".join(fore_lines)),
|
||||
_section("近况摘要", "\n".join(digest_lines)),
|
||||
@@ -119,11 +142,12 @@ async def assemble(
|
||||
foreshadows = await repos.foreshadow.list_for_codes(project_id, codes)
|
||||
style = await repos.style.latest(project_id)
|
||||
rules = await repos.rules.all_for_project(project_id)
|
||||
spec = await repos.project.spec(project_id)
|
||||
|
||||
main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES]
|
||||
|
||||
stable_core = _build_stable(world_entities, main_characters, style, rules)
|
||||
stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
|
||||
cards = render_cards(selection, characters, world_entities)
|
||||
volatile = _build_volatile(cards, foreshadows, recent_digests, outline.beats)
|
||||
volatile = _build_volatile(chapter_no, cards, foreshadows, recent_digests, outline.beats)
|
||||
|
||||
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)
|
||||
|
||||
@@ -15,6 +15,7 @@ from ww_db.models import (
|
||||
Character,
|
||||
Foreshadow,
|
||||
Outline,
|
||||
Project,
|
||||
Rule,
|
||||
StyleFingerprint,
|
||||
WorldEntity,
|
||||
@@ -26,6 +27,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -53,6 +55,22 @@ class SqlOutlineRepo:
|
||||
foreshadow_windows=list(row.foreshadow_windows or []),
|
||||
)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
rows = (
|
||||
await self._s.execute(
|
||||
select(Outline).where(Outline.project_id == project_id).order_by(Outline.chapter_no)
|
||||
)
|
||||
).scalars()
|
||||
return [
|
||||
OutlineView(
|
||||
volume=r.volume,
|
||||
chapter_no=r.chapter_no,
|
||||
beats=r.beats or {},
|
||||
foreshadow_windows=list(r.foreshadow_windows or []),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class SqlCharacterRepo:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
@@ -177,8 +195,31 @@ class SqlRulesRepo:
|
||||
return [RuleView(level=r.level, content=r.content) for r in rows]
|
||||
|
||||
|
||||
class SqlProjectSpecRepo:
|
||||
"""读项目书级蓝本(premise/logline/theme/title)供 assemble 注入缓存前缀。
|
||||
|
||||
按 project_id 读(不带 owner_id:assemble 已在 project 维度过滤,owner 隔离归路由层)。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
row = (
|
||||
await self._s.execute(select(Project).where(Project.id == project_id))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return ProjectSpecView(
|
||||
title=row.title,
|
||||
logline=row.logline,
|
||||
premise=row.premise,
|
||||
theme=row.theme,
|
||||
)
|
||||
|
||||
|
||||
def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
"""用一个 AsyncSession 装配全部 7 个 SQLAlchemy repo(T1.4 注入点)。"""
|
||||
"""用一个 AsyncSession 装配全部 8 个 SQLAlchemy repo(T1.4 注入点)。"""
|
||||
return MemoryRepos(
|
||||
outline=SqlOutlineRepo(session),
|
||||
character=SqlCharacterRepo(session),
|
||||
@@ -187,4 +228,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
foreshadow=SqlForeshadowRepo(session),
|
||||
style=SqlStyleRepo(session),
|
||||
rules=SqlRulesRepo(session),
|
||||
project=SqlProjectSpecRepo(session),
|
||||
)
|
||||
|
||||
@@ -12,11 +12,21 @@ from .collect import (
|
||||
CONTINUITY,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
STYLE,
|
||||
ReviewRecorder,
|
||||
collect_reviews,
|
||||
extract_conflicts,
|
||||
extract_foreshadow_sug,
|
||||
extract_pace,
|
||||
extract_style,
|
||||
)
|
||||
from .generation_node import (
|
||||
build_character_gen_context,
|
||||
build_precheck_context,
|
||||
build_worldbuilder_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_worldbuilder,
|
||||
)
|
||||
from .graph import (
|
||||
COLLECT_NODE,
|
||||
@@ -44,6 +54,7 @@ from .sse import (
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
EVENT_STYLE,
|
||||
EVENT_TOKEN,
|
||||
SECTION_DONE,
|
||||
SECTION_INCOMPLETE,
|
||||
@@ -57,9 +68,11 @@ from .sse import (
|
||||
normalize_review,
|
||||
pace_event,
|
||||
section_event,
|
||||
style_event,
|
||||
token_event,
|
||||
)
|
||||
from .state import ChapterState, merge_reviews
|
||||
from .style_extract_node import build_style_extract_request, run_style_extraction
|
||||
from .write_node import GatewayStream, build_write_request, stream_chapter_draft, write_node
|
||||
|
||||
__all__ = [
|
||||
@@ -71,6 +84,7 @@ __all__ = [
|
||||
"EVENT_FORESHADOW",
|
||||
"EVENT_PACE",
|
||||
"EVENT_SECTION",
|
||||
"EVENT_STYLE",
|
||||
"EVENT_TOKEN",
|
||||
"FORESHADOW",
|
||||
"PACE",
|
||||
@@ -80,6 +94,7 @@ __all__ = [
|
||||
"SECTION_DONE",
|
||||
"SECTION_INCOMPLETE",
|
||||
"SECTION_STARTED",
|
||||
"STYLE",
|
||||
"WRITE_NODE",
|
||||
"BoundReviewNode",
|
||||
"ChapterState",
|
||||
@@ -87,10 +102,14 @@ __all__ = [
|
||||
"GatewayStream",
|
||||
"ReviewRecorder",
|
||||
"SseEvent",
|
||||
"build_character_gen_context",
|
||||
"build_outline_request",
|
||||
"build_precheck_context",
|
||||
"build_review_context",
|
||||
"build_review_graph",
|
||||
"build_review_request",
|
||||
"build_style_extract_request",
|
||||
"build_worldbuilder_context",
|
||||
"build_write_graph",
|
||||
"build_write_request",
|
||||
"collect_reviews",
|
||||
@@ -100,17 +119,23 @@ __all__ = [
|
||||
"extract_conflicts",
|
||||
"extract_foreshadow_sug",
|
||||
"extract_pace",
|
||||
"extract_style",
|
||||
"foreshadow_event",
|
||||
"make_review_node",
|
||||
"merge_reviews",
|
||||
"normalize_deltas",
|
||||
"normalize_review",
|
||||
"pace_event",
|
||||
"precheck_generated_cards",
|
||||
"run_character_gen",
|
||||
"run_outline",
|
||||
"run_review",
|
||||
"run_style_extraction",
|
||||
"run_worldbuilder",
|
||||
"section_event",
|
||||
"setup_checkpointer",
|
||||
"stream_chapter_draft",
|
||||
"style_event",
|
||||
"token_event",
|
||||
"write_node",
|
||||
]
|
||||
|
||||
@@ -11,11 +11,12 @@ AI 产出真正入库经验收事务的裁决)。
|
||||
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
|
||||
本节点绝不 commit。
|
||||
|
||||
M3 三审齐 → 列映射(一次 record 落齐本章三审):
|
||||
M4 四审齐 → 列映射(一次 record 落齐本章四审):
|
||||
- continuity → `conflicts`(冲突清单 list);
|
||||
- foreshadow → `foreshadow_sug`(planted/resolved 扁平为建议 list,每条带 `kind` 标记);
|
||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map)。
|
||||
style 第四审留 M4。任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map);
|
||||
- style → `style`(文风漂移诊断 dict:score/segments)。
|
||||
任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +34,7 @@ log = structlog.get_logger(__name__)
|
||||
CONTINUITY = "continuity"
|
||||
FORESHADOW = "foreshadow"
|
||||
PACE = "pace"
|
||||
STYLE = "style"
|
||||
|
||||
# foreshadow 建议扁平化时的来源标记(planted=新埋 / resolved=回收)。
|
||||
FORESHADOW_KIND_PLANTED = "planted"
|
||||
@@ -108,6 +110,19 @@ def extract_pace(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return dict(result)
|
||||
|
||||
|
||||
def extract_style(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""从 style(第四审)分项取文风漂移诊断 dict(纯函数)。
|
||||
|
||||
`StyleDriftReview{score,segments}` 整体入 `style` 列(JSONB dict)。
|
||||
未完成/缺席 → None(列留空,§5.2 不阻塞其余)。无指纹降级(score=100/空段)
|
||||
仍是 `ok` 结果,照常落库(区别于 incomplete)。
|
||||
"""
|
||||
result = _ok_result(reviews, STYLE)
|
||||
if result is None:
|
||||
return None
|
||||
return dict(result)
|
||||
|
||||
|
||||
async def collect_reviews(
|
||||
state: ChapterState,
|
||||
*,
|
||||
@@ -124,12 +139,14 @@ async def collect_reviews(
|
||||
conflicts = extract_conflicts(reviews)
|
||||
foreshadow_sug = extract_foreshadow_sug(reviews)
|
||||
pace = extract_pace(reviews)
|
||||
style = extract_style(reviews)
|
||||
await review_repo.record(
|
||||
state["project_id"],
|
||||
state["chapter_no"],
|
||||
chapter_version=None,
|
||||
conflicts=conflicts,
|
||||
foreshadow_sug=foreshadow_sug,
|
||||
style=style,
|
||||
pace=pace,
|
||||
)
|
||||
log.info(
|
||||
@@ -139,6 +156,7 @@ async def collect_reviews(
|
||||
conflict_count=len(conflicts),
|
||||
foreshadow_sug_count=len(foreshadow_sug),
|
||||
has_pace=pace is not None,
|
||||
has_style=style is not None,
|
||||
reviews=sorted(reviews.keys()),
|
||||
)
|
||||
# 留痕已在表(真相源);不在节点 commit(端点/事务层负责,见模块 docstring)。
|
||||
|
||||
262
packages/core/ww_core/orchestrator/generation_node.py
Normal file
262
packages/core/ww_core/orchestrator/generation_node.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""生成节点(C6 扩 / ARCH §5.4 worldbuilder·character-gen 行 / §6.5 / §4.5)。
|
||||
|
||||
worldbuilder 与 character-gen 都是**独立生成**——不在写章/审稿流水线(不接进 review 图),
|
||||
仿 `outline_node` / `style_extract_node`。入库端点(T5.2)直接调本模块拿结构化产物,
|
||||
再持久化到 `world_entities` / `characters` 表。
|
||||
|
||||
三件事:
|
||||
1. `run_worldbuilder` — 据需求 + 作品设定产 `WorldGenResult`(硬规则显式)。
|
||||
2. `run_character_gen` — 据需求 + 世界观约束 + **已有角色 + 本批已生成卡**产 `CharacterGenResult`
|
||||
(群像防雷同:差异化上下文经 `build_character_gen_context` 注入,M5-a)。
|
||||
3. `precheck_generated_cards` — **入库前 continuity 校验缝**(ARCH §6.5):编排器在角色入库前
|
||||
追加一道 continuity 检查(**非 character-gen 直接互调**,守 §5.4 数据流),把生成卡 vs
|
||||
世界观/已有角色真相源比对、返回冲突清单。T5.2 入库端点调它做 gate。
|
||||
|
||||
确定性边界(CLAUDE.md「LangGraph」纪律):节点逻辑无 LLM 非确定性——不确定性藏在网关后,
|
||||
节点只做 `AgentSpec + 上下文 → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
|
||||
注入 mock 网关(产 `parsed`)即可单测,无需图运行时、无需真 Postgres。
|
||||
|
||||
不变量 #2:agent 只声明 `tier`,绝不传具体 model(spec.tier 透传)。
|
||||
不变量 #3:节点**只读、不写库**——结构化产物返回调用方,落库经入库端点(T5.2)。
|
||||
不变量 #9:`spec.system_prompt` 进缓存断点前块(cache=True);注入材料进 `input`(断点后)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
WorldGenResult,
|
||||
)
|
||||
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class GatewayRun(Protocol):
|
||||
"""生成节点对网关的最小依赖——只需 `run`(注入真网关或 mock)。
|
||||
|
||||
模块内部用(每模块各自声明,不跨模块复用、不在 orchestrator `__init__` 重复导出,
|
||||
见 gotchas 2026-06-18)。
|
||||
"""
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse: ...
|
||||
|
||||
|
||||
def _build_request(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
context: str,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> LlmRequest:
|
||||
"""据 spec + 注入材料构造生成请求(纯函数,worldbuilder/character-gen 共用)。
|
||||
|
||||
`spec.system_prompt` → `system` 缓存断点前块(cache=True,不变量 #9);
|
||||
`context`(序列化的需求/约束/已有/已生成文本)→ `input`(断点后)。
|
||||
生成用 `run()` 非 `stream()`(要结构化产物,非流式正文)。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier=spec.tier, # 不变量 #2:只传档位,不传 model
|
||||
system=[Block(text=spec.system_prompt, cache=True)],
|
||||
input=context,
|
||||
output_schema=spec.output_schema,
|
||||
scope=Scope(user_id=user_id, project_id=project_id),
|
||||
)
|
||||
|
||||
|
||||
# ---- worldbuilder ----
|
||||
|
||||
|
||||
def build_worldbuilder_context(*, brief: str, project_context: str) -> str:
|
||||
"""拼装 worldbuilder 注入文本(确定性,无时间戳)。"""
|
||||
return f"## 作品设定\n{project_context}\n\n## 世界观需求\n{brief}"
|
||||
|
||||
|
||||
async def run_worldbuilder(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
brief: str,
|
||||
project_context: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> WorldGenResult:
|
||||
"""跑世界观生成:构请求 → `gateway.run` → 返回结构化 `WorldGenResult`。
|
||||
|
||||
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
**只产结构化世界观、不写库**(持久化属 T5.2,不变量 #3)。
|
||||
"""
|
||||
context = build_worldbuilder_context(brief=brief, project_context=project_context)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, WorldGenResult):
|
||||
log.error(
|
||||
"worldbuilder_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed WorldGenResult for worldbuilder request")
|
||||
return parsed
|
||||
|
||||
|
||||
# ---- character-gen(群像防雷同)----
|
||||
|
||||
|
||||
def _render_cards_for_context(cards: Sequence[CharacterCard]) -> str:
|
||||
"""把角色卡序列化为简表(name / role / traits)供防雷同对照与校验。
|
||||
|
||||
确定性(保入参顺序)、无时间戳/UUID。只取差异化判定相关的关键字段。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for card in cards:
|
||||
traits = "、".join(card.traits) if card.traits else "(未列)"
|
||||
lines.append(f"- {card.name}({card.role}):{traits}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_character_gen_context(
|
||||
*,
|
||||
brief: str,
|
||||
count: int,
|
||||
role: str | None,
|
||||
world_context: str,
|
||||
existing_chars: Sequence[CharacterCard],
|
||||
generated_so_far: Sequence[CharacterCard],
|
||||
) -> str:
|
||||
"""拼装 character-gen 注入文本(含群像防雷同上下文,M5-a)。
|
||||
|
||||
注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。
|
||||
确定性拼接、无时间戳——同输入同输出,便于单测与缓存。
|
||||
"""
|
||||
role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)"
|
||||
existing_block = (
|
||||
_render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)"
|
||||
)
|
||||
generated_block = (
|
||||
_render_cards_for_context(generated_so_far) if generated_so_far else "(本批尚无已生成卡)"
|
||||
)
|
||||
return (
|
||||
"## 世界观约束(取名/能力须契合的硬规则)\n"
|
||||
f"{world_context or '(暂无世界观设定)'}\n\n"
|
||||
"## 已有角色(新角色须与之区分;可与之建关系)\n"
|
||||
f"{existing_block}\n\n"
|
||||
"## 本批已生成卡(后续卡须与之差异化,防雷同)\n"
|
||||
f"{generated_block}\n\n"
|
||||
"## 本次生成需求\n"
|
||||
f"需求:{brief}\n"
|
||||
f"数量:{count}\n"
|
||||
f"{role_line}"
|
||||
)
|
||||
|
||||
|
||||
async def run_character_gen(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
brief: str,
|
||||
count: int,
|
||||
role: str | None,
|
||||
world_context: str,
|
||||
existing_chars: Sequence[CharacterCard],
|
||||
generated_so_far: Sequence[CharacterCard],
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> CharacterGenResult:
|
||||
"""跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。
|
||||
|
||||
群像防雷同(M5-a):注入「已有角色 + 本批已生成卡」要求差异化(§4.5 / §6.5)。
|
||||
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
**只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经
|
||||
入库端点持久化(持久化属 T5.2,不变量 #3)。
|
||||
"""
|
||||
context = build_character_gen_context(
|
||||
brief=brief,
|
||||
count=count,
|
||||
role=role,
|
||||
world_context=world_context,
|
||||
existing_chars=existing_chars,
|
||||
generated_so_far=generated_so_far,
|
||||
)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, CharacterGenResult):
|
||||
log.error(
|
||||
"character_gen_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed CharacterGenResult for character-gen request")
|
||||
return parsed
|
||||
|
||||
|
||||
# ---- 入库前 continuity 校验缝(ARCH §6.5 / §4.5 三关键能力之一)----
|
||||
|
||||
|
||||
def build_precheck_context(
|
||||
*,
|
||||
cards: Sequence[CharacterCard],
|
||||
world_context: str,
|
||||
characters_context: str,
|
||||
) -> str:
|
||||
"""拼装「入库前 continuity 校验」注入文本(确定性,无时间戳)。
|
||||
|
||||
校验材料 = 待入库的**生成角色卡** + 世界观硬规则 + 已有角色真相源——
|
||||
让 continuity 续审逐项比对,找出与既有设定/力量体系/已有角色的冲突。
|
||||
"""
|
||||
return (
|
||||
"## 待校验:本次生成的角色卡(尚未入库)\n"
|
||||
f"{_render_cards_for_context(cards)}\n\n"
|
||||
"## 真相源:世界观硬规则\n"
|
||||
f"{world_context or '(暂无世界观设定)'}\n\n"
|
||||
"## 真相源:已有角色\n"
|
||||
f"{characters_context or '(暂无已有角色)'}"
|
||||
)
|
||||
|
||||
|
||||
async def precheck_generated_cards(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
cards: Sequence[CharacterCard],
|
||||
world_context: str,
|
||||
characters_context: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> list[Conflict]:
|
||||
"""入库前 continuity 校验(ARCH §6.5):跑 `continuity_spec` 比对生成卡 vs 真相源。
|
||||
|
||||
**编排器追加的一道检查**(非 character-gen 直接互调,守 §5.4 数据流/不变量 #1)。
|
||||
复用 `continuity_spec`(analyst 档,只读);返回冲突清单供 T5.2 入库端点做 gate
|
||||
(有冲突 → 提示作者裁决/调整,不静默入库)。
|
||||
|
||||
裸函数(显式 gateway 关键字),注入 mock 网关即可单测。校验失败语义同独立生成:
|
||||
网关失败直接上抛(端点处理);带 `output_schema` 时 parsed 必非 None(C1),违约报错。
|
||||
本缝**只读、不写库**(不变量 #3)。
|
||||
"""
|
||||
context = build_precheck_context(
|
||||
cards=cards,
|
||||
world_context=world_context,
|
||||
characters_context=characters_context,
|
||||
)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, ContinuityReview):
|
||||
log.error(
|
||||
"precheck_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed ContinuityReview for precheck request")
|
||||
return list(parsed.conflicts)
|
||||
@@ -20,6 +20,7 @@ from ww_agents import (
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
style_drift_spec,
|
||||
)
|
||||
|
||||
from .collect import ReviewRecorder, collect_reviews
|
||||
@@ -56,8 +57,13 @@ def build_write_graph(
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
|
||||
|
||||
# M3:三审齐(continuity + foreshadow + pace)。文风第四审属 M4/T4.2。
|
||||
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec, foreshadow_spec, pace_spec)
|
||||
# M4:四审齐(continuity + foreshadow + pace + style)。第四审 = 文风漂移打分轨。
|
||||
REVIEW_SPECS: tuple[AgentSpec, ...] = (
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
style_drift_spec,
|
||||
)
|
||||
|
||||
|
||||
def build_review_graph(
|
||||
|
||||
@@ -18,7 +18,7 @@ from pydantic import BaseModel
|
||||
from ww_llm_gateway.types import Delta
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from .collect import CONTINUITY, FORESHADOW, PACE
|
||||
from .collect import CONTINUITY, FORESHADOW, PACE, STYLE
|
||||
from .review_node import REVIEW_OK
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
@@ -29,6 +29,7 @@ EVENT_SECTION = "section" # 四审分项开始/完成(审稿 review)
|
||||
EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review)
|
||||
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review)
|
||||
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review)
|
||||
EVENT_STYLE = "style" # style 文风漂移诊断(整体相似度 + 漂移段,审稿 review)
|
||||
EVENT_DONE = "done"
|
||||
EVENT_ERROR = "error"
|
||||
|
||||
@@ -104,6 +105,17 @@ def pace_event(*, water: list[dict[str, object]], hook: bool, beat_map: list[int
|
||||
)
|
||||
|
||||
|
||||
def style_event(*, score: int, segments: list[dict[str, object]]) -> SseEvent:
|
||||
"""文风漂移事件(审稿 review)——前端 ◔ 整体相似度 + 漂移段列表(一键回炉)。
|
||||
|
||||
一次审产一条:整体相似度 score + 逐段漂移 segments(每段 idx/score/label)。
|
||||
"""
|
||||
return SseEvent(
|
||||
event=EVENT_STYLE,
|
||||
data={"score": score, "segments": segments},
|
||||
)
|
||||
|
||||
|
||||
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
|
||||
"""错误事件,形对齐错误信封 §7.1(`code`/`message`),附 `request_id` 便于贯通排查。"""
|
||||
return SseEvent(
|
||||
@@ -190,6 +202,20 @@ def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]:
|
||||
beat_map=[int(b) for b in result.get("beat_map") or []],
|
||||
)
|
||||
)
|
||||
elif name == STYLE:
|
||||
events.append(
|
||||
style_event(
|
||||
score=int(result.get("score", 100)),
|
||||
segments=[
|
||||
{
|
||||
"idx": int(seg.get("idx", 0)),
|
||||
"score": int(seg.get("score", 0)),
|
||||
"label": seg.get("label"),
|
||||
}
|
||||
for seg in result.get("segments") or []
|
||||
],
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@@ -205,7 +231,8 @@ async def normalize_review(
|
||||
surface 结构化结果:
|
||||
- continuity → 每冲突一条 `conflict`(形对齐 C6 `Conflict`);
|
||||
- foreshadow → 每条建议一条 `foreshadow`(planted/resolved,前端看板联动);
|
||||
- pace → 一条 `pace`(注水/钩子/节拍图,前端节奏报告/▁▃▅)。
|
||||
- pace → 一条 `pace`(注水/钩子/节拍图,前端节奏报告/▁▃▅);
|
||||
- style → 一条 `style`(整体相似度 + 漂移段,前端 ◔ + 一键回炉)。
|
||||
末尾 `done{length=审项数}`。HTTP event-stream 编码归端点,不在此处。
|
||||
|
||||
错误处理纪律(同 `normalize_deltas`):任何意外 → 一条 `error` 事件后正常收尾,不上抛。
|
||||
|
||||
88
packages/core/ww_core/orchestrator/style_extract_node.py
Normal file
88
packages/core/ww_core/orchestrator/style_extract_node.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""文风提取节点(C6 扩 / ARCH §5.4 style-auditor 提取轨 / §6.9)。
|
||||
|
||||
文风提取是**独立生成**——不在写章/审稿流水线(不接进 review 图),仿 `outline_node`。
|
||||
端点(T4.3)经 BackgroundTask 调 `run_style_extraction` 拿结构化 `StyleFingerprintResult`,
|
||||
再持久化到 `style_fingerprint` 表(version 化)。
|
||||
|
||||
确定性边界(CLAUDE.md「LangGraph」纪律):
|
||||
- 节点逻辑里**无 LLM 非确定性**——不确定性藏在网关后;节点只做
|
||||
`AgentSpec + samples → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
|
||||
- 因此注入 mock 网关(产 `parsed`)即可单测,无需图运行时、无需真 Postgres。
|
||||
|
||||
不变量 #2:agent 只声明 `tier`,绝不传具体 model(spec.tier 透传)。
|
||||
不变量 #3:节点**只读、不写库**——结构化指纹返回调用方,落库经端点(T4.3)。
|
||||
不变量 #9:`spec.system_prompt` 进缓存断点前块(cache=True);样本进 `input`(断点后)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
from ww_agents import AgentSpec, StyleFingerprintResult
|
||||
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class GatewayRun(Protocol):
|
||||
"""文风提取节点对网关的最小依赖——只需 `run`(注入真网关或 mock)。
|
||||
|
||||
模块内部用(每模块各自声明,不跨模块复用、不在 orchestrator `__init__` 重复导出,
|
||||
见 gotchas 2026-06-18)。
|
||||
"""
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse: ...
|
||||
|
||||
|
||||
def build_style_extract_request(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
samples_text: str,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> LlmRequest:
|
||||
"""据 spec + 样本正文构造文风提取请求(纯函数)。
|
||||
|
||||
`spec.system_prompt` → `system` 缓存断点前块(cache=True,不变量 #9);
|
||||
`samples_text`(拼接的样本正文)→ `input`(断点后)。
|
||||
提取用 `run()` 非 `stream()`(要结构化指纹,非流式正文)。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier=spec.tier, # 不变量 #2:只传档位,不传 model
|
||||
system=[Block(text=spec.system_prompt, cache=True)],
|
||||
input=samples_text,
|
||||
output_schema=spec.output_schema,
|
||||
scope=Scope(user_id=user_id, project_id=project_id),
|
||||
)
|
||||
|
||||
|
||||
async def run_style_extraction(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
samples_text: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> StyleFingerprintResult:
|
||||
"""跑文风提取:构请求 → `gateway.run` → 返回结构化 `StyleFingerprintResult`。
|
||||
|
||||
裸函数(显式 gateway 关键字),注入 mock 网关即可单测,无需图运行时。
|
||||
提取是独立生成(非并行审):网关失败直接上抛(端点/T4.3 处理),不静默吞。
|
||||
**只产结构化指纹、不写库**(持久化属 T4.3,不变量 #3)。
|
||||
"""
|
||||
req = build_style_extract_request(
|
||||
spec, samples_text=samples_text, user_id=user_id, project_id=project_id
|
||||
)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, StyleFingerprintResult):
|
||||
# 带 output_schema 时 parsed 必非 None(C1);违约则明确报错而非返回空指纹。
|
||||
log.error(
|
||||
"style_extract_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed StyleFingerprintResult for style request")
|
||||
return parsed
|
||||
@@ -0,0 +1,47 @@
|
||||
"""provider_credentials oauth columns
|
||||
|
||||
Revision ID: 1f011c42bd4d
|
||||
Revises: 220ca2e3d53f
|
||||
Create Date: 2026-06-19 15:25:05.499145
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "1f011c42bd4d"
|
||||
down_revision: str | None = "220ca2e3d53f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"provider_credentials",
|
||||
sa.Column("auth_type", sa.Text(), server_default=sa.text("'api_key'"), nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"provider_credentials",
|
||||
sa.Column("oauth_enc", sa.LargeBinary(), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"provider_credentials",
|
||||
"api_key_enc",
|
||||
existing_type=postgresql.BYTEA(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"provider_credentials",
|
||||
"api_key_enc",
|
||||
existing_type=postgresql.BYTEA(),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("provider_credentials", "oauth_enc")
|
||||
op.drop_column("provider_credentials", "auth_type")
|
||||
@@ -180,7 +180,9 @@ class ProviderCredential(UuidPk, CreatedAt, Base):
|
||||
ForeignKey("projects.id", ondelete="CASCADE")
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
api_key_enc: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
auth_type: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'api_key'"))
|
||||
api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
oauth_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
|
||||
class TierRouting(UuidPk, Base):
|
||||
|
||||
@@ -4,8 +4,11 @@ version = "0.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"openai>=1.40",
|
||||
"anthropic>=0.34",
|
||||
"google-genai>=0.3",
|
||||
"instructor>=1.5",
|
||||
"pydantic>=2.7",
|
||||
"tenacity>=8.2",
|
||||
"ww-shared",
|
||||
"ww-config",
|
||||
"ww-db",
|
||||
|
||||
119
packages/llm_gateway/tests/fakes_resilience.py
Normal file
119
packages/llm_gateway/tests/fakes_resilience.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""T5.4 韧性测试替身(多 provider 适配器 + 失败模拟 + 记账嗅探)——绝不联网。
|
||||
|
||||
放独立模块(非 conftest),测试走绝对导入 `from fakes_resilience import ...`。
|
||||
本目录无 __init__.py(见 fakes.py 注释)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.errors import TransientProviderError
|
||||
from ww_llm_gateway.routing import Route
|
||||
from ww_llm_gateway.types import LlmRequest, Scope, Tier, Usage
|
||||
|
||||
|
||||
class ScriptedAdapter:
|
||||
"""可编排成功/失败序列的假适配器。
|
||||
|
||||
`failures` 为开头要抛的异常列表(每次 complete/stream 消费一个);耗尽后正常返回。
|
||||
`capabilities_` 控制能力矩阵(测降级)。记录调用次数以断言回退/重试行为。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
*,
|
||||
text: str = "ok",
|
||||
failures: list[Exception] | None = None,
|
||||
capabilities_: Capabilities | None = None,
|
||||
input_tokens: int = 100,
|
||||
output_tokens: int = 50,
|
||||
structured_text: str | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.text = text
|
||||
self._failures = list(failures or [])
|
||||
self._caps = capabilities_ or Capabilities(structured_output=True, prefix_cache=True)
|
||||
self.input_tokens = input_tokens
|
||||
self.output_tokens = output_tokens
|
||||
self.structured_text = structured_text
|
||||
self.complete_calls = 0
|
||||
self.stream_calls = 0
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return self._caps
|
||||
|
||||
def _maybe_fail(self) -> None:
|
||||
if self._failures:
|
||||
raise self._failures.pop(0)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
self.complete_calls += 1
|
||||
self._maybe_fail()
|
||||
if req.output_schema is not None:
|
||||
# 模拟原生结构化输出:仅当能力声明支持时才会被网关派到这里。
|
||||
parsed = req.output_schema.model_validate({} if not _has_fields(req) else _stub(req))
|
||||
return ProviderResult(
|
||||
text=parsed.model_dump_json(),
|
||||
usage=ProviderUsage(
|
||||
input_tokens=self.input_tokens, output_tokens=self.output_tokens
|
||||
),
|
||||
parsed=parsed,
|
||||
)
|
||||
return ProviderResult(
|
||||
text=self.structured_text or self.text,
|
||||
usage=ProviderUsage(input_tokens=self.input_tokens, output_tokens=self.output_tokens),
|
||||
)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
self.stream_calls += 1
|
||||
self._maybe_fail()
|
||||
for ch in self.text:
|
||||
yield StreamChunk(text=ch)
|
||||
yield StreamChunk(
|
||||
usage=ProviderUsage(input_tokens=self.input_tokens, output_tokens=self.output_tokens)
|
||||
)
|
||||
|
||||
|
||||
def _has_fields(req: LlmRequest) -> bool:
|
||||
return bool(req.output_schema and req.output_schema.model_fields)
|
||||
|
||||
|
||||
def _stub(req: LlmRequest) -> dict[str, object]:
|
||||
# 用各字段默认/最小填充——测试 schema 仅需可构造。
|
||||
assert req.output_schema is not None
|
||||
out: dict[str, object] = {}
|
||||
for name, field in req.output_schema.model_fields.items():
|
||||
if field.is_required():
|
||||
out[name] = "x"
|
||||
return out
|
||||
|
||||
|
||||
class FakeLedger:
|
||||
def __init__(self) -> None:
|
||||
self.records: list[Usage] = []
|
||||
|
||||
async def record(self, scope: Scope, usage: Usage) -> None:
|
||||
self.records.append(usage)
|
||||
|
||||
|
||||
def transient(msg: str = "boom") -> TransientProviderError:
|
||||
return TransientProviderError(msg)
|
||||
|
||||
|
||||
def chain(*routes: tuple[str, str]) -> list[Route]:
|
||||
return [Route(provider=p, model=m) for p, m in routes]
|
||||
|
||||
|
||||
def chain_resolver(routes: list[Route]) -> Callable[[Tier], list[Route]]:
|
||||
def _resolve(tier: Tier) -> list[Route]:
|
||||
return routes
|
||||
|
||||
return _resolve
|
||||
59
packages/llm_gateway/tests/test_build_adapter_factory.py
Normal file
59
packages/llm_gateway/tests/test_build_adapter_factory.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""`build_adapter` provider→适配器分派(C1扩,T5.4 follow-up #2)。
|
||||
|
||||
只断「按 provider 名选对适配器类 + provider 字段透传」,不联网(客户端构造无网络 IO,
|
||||
api_key 仅持有不校验)。Anthropic/Gemini 分支懒 import 真 SDK(已 `uv sync`)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_llm_gateway import build_adapter
|
||||
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
|
||||
from ww_llm_gateway.adapters.gemini import GeminiAdapter
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
|
||||
_FAKE_KEY = "sk-test-not-a-real-key"
|
||||
|
||||
|
||||
def test_dispatches_anthropic_provider_to_anthropic_adapter() -> None:
|
||||
adapter = build_adapter("anthropic", api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, AnthropicAdapter)
|
||||
assert adapter.provider == "anthropic"
|
||||
|
||||
|
||||
def test_dispatches_gemini_provider_to_gemini_adapter() -> None:
|
||||
adapter = build_adapter("gemini", api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, GeminiAdapter)
|
||||
assert adapter.provider == "gemini"
|
||||
|
||||
|
||||
def test_dispatches_google_alias_to_gemini_adapter() -> None:
|
||||
adapter = build_adapter("google", api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, GeminiAdapter)
|
||||
assert adapter.provider == "google"
|
||||
|
||||
|
||||
def test_dispatches_deepseek_to_openai_compat_adapter() -> None:
|
||||
adapter = build_adapter("deepseek", api_key=_FAKE_KEY, base_url="https://api.deepseek.com")
|
||||
|
||||
assert isinstance(adapter, OpenAICompatAdapter)
|
||||
assert adapter.provider == "deepseek"
|
||||
|
||||
|
||||
def test_dispatches_unknown_provider_to_openai_compat_adapter() -> None:
|
||||
# kimi/qwen/glm/openai 等一律走 OpenAI 兼容默认分支。
|
||||
for provider in ("openai", "kimi", "qwen", "glm"):
|
||||
adapter = build_adapter(provider, api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, OpenAICompatAdapter)
|
||||
assert adapter.provider == provider
|
||||
|
||||
|
||||
def test_anthropic_adapter_capabilities_reflect_native_structured_output() -> None:
|
||||
adapter = build_adapter("anthropic", api_key=_FAKE_KEY)
|
||||
caps = adapter.capabilities()
|
||||
|
||||
assert caps.structured_output is True
|
||||
assert caps.prefix_cache is True
|
||||
109
packages/llm_gateway/tests/test_capability_degradation.py
Normal file
109
packages/llm_gateway/tests/test_capability_degradation.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""T5.4 能力协商 / 降级(ARCH §4.4)。
|
||||
|
||||
结构化输出:原生不支持时降级(instructor JSON-提示路径仍由适配器自处理;网关
|
||||
层负责在链内**优先选支持结构化输出的 provider**,无则降级到首个可用、对上层透明)。
|
||||
降级时 `served_by.degraded=True` 标注,记账/日志可见,正确性不受影响。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
from ww_llm_gateway.gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
|
||||
|
||||
class Tiny(BaseModel):
|
||||
x: str
|
||||
|
||||
|
||||
def _structured_req() -> LlmRequest:
|
||||
return LlmRequest(
|
||||
tier="analyst",
|
||||
input="给我结构化",
|
||||
output_schema=Tiny,
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
|
||||
async def test_prefers_structured_capable_provider_in_chain() -> None:
|
||||
# 主 provider 不支持结构化输出,回退 provider 支持 → 网关优先选支持者服务结构化请求。
|
||||
no_struct = ScriptedAdapter(
|
||||
"weakprov",
|
||||
capabilities_=Capabilities(structured_output=False, prefix_cache=False),
|
||||
)
|
||||
struct = ScriptedAdapter(
|
||||
"strongprov",
|
||||
capabilities_=Capabilities(structured_output=True, prefix_cache=True),
|
||||
)
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"weakprov": no_struct, "strongprov": struct},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"), ("strongprov", "s"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(_structured_req())
|
||||
|
||||
assert resp.served_by.provider == "strongprov"
|
||||
assert resp.parsed is not None
|
||||
assert no_struct.complete_calls == 0
|
||||
|
||||
|
||||
async def test_degrades_when_no_structured_capable_provider() -> None:
|
||||
# 链上无 provider 支持结构化输出 → 降级用首个可用(适配器自走 instructor JSON 提示)
|
||||
# 并标 served_by.degraded=True,不硬失败。
|
||||
weak = ScriptedAdapter(
|
||||
"weakprov",
|
||||
capabilities_=Capabilities(structured_output=False),
|
||||
)
|
||||
gw = Gateway(
|
||||
{"weakprov": weak},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(_structured_req())
|
||||
|
||||
assert resp.served_by.provider == "weakprov"
|
||||
assert resp.served_by.degraded is True
|
||||
assert weak.complete_calls == 1
|
||||
|
||||
|
||||
async def test_no_degradation_flag_for_plain_text() -> None:
|
||||
# 纯文本请求对任何 provider 都不算降级。
|
||||
weak = ScriptedAdapter("weakprov", capabilities_=Capabilities(structured_output=False))
|
||||
gw = Gateway(
|
||||
{"weakprov": weak},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
|
||||
)
|
||||
req = LlmRequest(tier="writer", input="正文", scope=Scope(user_id=uuid.UUID(int=1)))
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.degraded is False
|
||||
|
||||
|
||||
async def test_cache_blocks_passed_through_regardless_of_capability() -> None:
|
||||
# 前缀缓存不支持时只是跳过,不改正确性、不算降级。
|
||||
weak = ScriptedAdapter("weakprov", capabilities_=Capabilities(prefix_cache=False))
|
||||
gw = Gateway(
|
||||
{"weakprov": weak},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
|
||||
)
|
||||
req = LlmRequest(
|
||||
tier="writer",
|
||||
input="正文",
|
||||
system=[Block(text="世界观硬规则", cache=True)],
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text
|
||||
assert resp.served_by.degraded is False
|
||||
174
packages/llm_gateway/tests/test_fallback_chain.py
Normal file
174
packages/llm_gateway/tests/test_fallback_chain.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""T5.4 回退链 + 重试 + 熔断(ARCH §4.5)。
|
||||
|
||||
主模型 transient 失败 → 退避重试 → 仍失败切回退链下一个;回退服务时标
|
||||
`served_by.fell_back=True`;记账落实际服务方;链耗尽抛 LLM_UNAVAILABLE。
|
||||
熔断:某 provider 连续失败超阈值后短时熔断、直接跳过走回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
|
||||
from ww_llm_gateway.gateway import CircuitBreaker, Gateway
|
||||
from ww_llm_gateway.types import LlmRequest
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
async def test_primary_success_no_fallback(req: LlmRequest) -> None:
|
||||
primary = ScriptedAdapter("deepseek", text="主模型")
|
||||
backup = ScriptedAdapter("openai", text="备用")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "主模型"
|
||||
assert resp.served_by.provider == "deepseek"
|
||||
assert resp.served_by.fell_back is False
|
||||
assert backup.complete_calls == 0
|
||||
assert ledger.records[0].provider == "deepseek"
|
||||
|
||||
|
||||
async def test_falls_through_to_next_provider_on_transient(req: LlmRequest) -> None:
|
||||
# 主模型每次 complete 都 transient 失败(足够耗尽重试) → 切回退。
|
||||
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="备用结果")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=2,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "备用结果"
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
# 记账记实际服务方 openai,不是失败的 deepseek。
|
||||
assert len(ledger.records) == 1
|
||||
assert ledger.records[0].provider == "openai"
|
||||
|
||||
|
||||
async def test_retries_then_succeeds_on_same_provider(req: LlmRequest) -> None:
|
||||
# 前两次 transient,第三次成功 → 不应切回退(max_retries=2 即最多 3 次尝试)。
|
||||
primary = ScriptedAdapter("deepseek", text="重试后成功", failures=[transient(), transient()])
|
||||
backup = ScriptedAdapter("openai", text="备用")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=2,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "重试后成功"
|
||||
assert resp.served_by.provider == "deepseek"
|
||||
assert resp.served_by.fell_back is False
|
||||
assert backup.complete_calls == 0
|
||||
|
||||
|
||||
async def test_rate_limited_triggers_fallback(req: LlmRequest) -> None:
|
||||
rate_limited = AppError(ErrorCode.RATE_LIMITED, "429")
|
||||
primary = ScriptedAdapter("deepseek", failures=[rate_limited for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="降级备用")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
|
||||
|
||||
async def test_chain_exhausted_raises_llm_unavailable(req: LlmRequest) -> None:
|
||||
p1 = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
p2 = ScriptedAdapter("openai", failures=[transient() for _ in range(10)])
|
||||
gw = Gateway(
|
||||
{"deepseek": p1, "openai": p2},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await gw.run(req)
|
||||
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_missing_adapter_in_chain_skipped(req: LlmRequest) -> None:
|
||||
# 链上首个 provider 没注册适配器 → 跳过、走下一个(不硬失败)。
|
||||
backup = ScriptedAdapter("openai", text="可用")
|
||||
gw = Gateway(
|
||||
{"openai": backup},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("missing", "m"), ("openai", "gpt-4o"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
|
||||
|
||||
# ---- 熔断器 ----
|
||||
|
||||
|
||||
def test_circuit_breaker_trips_after_threshold() -> None:
|
||||
cb = CircuitBreaker(threshold=3, reset_seconds=60.0)
|
||||
assert cb.is_open("deepseek") is False
|
||||
cb.record_failure("deepseek")
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is False # 未到阈值
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is True # 第 3 次 → 熔断
|
||||
|
||||
|
||||
def test_circuit_breaker_success_resets() -> None:
|
||||
cb = CircuitBreaker(threshold=2, reset_seconds=60.0)
|
||||
cb.record_failure("deepseek")
|
||||
cb.record_success("deepseek")
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is False # 成功清零计数
|
||||
|
||||
|
||||
def test_circuit_breaker_reopens_after_cooldown() -> None:
|
||||
now = [1000.0]
|
||||
cb = CircuitBreaker(threshold=1, reset_seconds=30.0, clock=lambda: now[0])
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is True
|
||||
now[0] += 31.0 # 冷却窗口过 → 半开(放行试探)
|
||||
assert cb.is_open("deepseek") is False
|
||||
|
||||
|
||||
async def test_open_circuit_skips_provider(req: LlmRequest) -> None:
|
||||
# 熔断已打开的主 provider 被直接跳过,连 complete 都不调,直接走回退。
|
||||
primary = ScriptedAdapter("deepseek", text="不该被调")
|
||||
backup = ScriptedAdapter("openai", text="回退服务")
|
||||
cb = CircuitBreaker(threshold=1, reset_seconds=60.0)
|
||||
cb.record_failure("deepseek") # 预先熔断
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
breaker=cb,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
assert primary.complete_calls == 0
|
||||
139
packages/llm_gateway/tests/test_kimi_code_adapter.py
Normal file
139
packages/llm_gateway/tests/test_kimi_code_adapter.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Kimi Code 适配器单测(K1.2):断完整 7 头伪造头集 + base_url + bearer(不联网)。
|
||||
|
||||
验证点:构造出的 `AsyncOpenAI` 客户端带正确 base_url + default_headers(opencode 规范的
|
||||
完整 7 头:UA `KimiCLI/1.37.0` + 6 个 `X-Msh-*`,真源 `ooojustin/opencode-kimi`),
|
||||
access_token 作为 bearer,且适配器 provider 字段为 `kimi-code`。device-id 经环境变量
|
||||
`KIMI_DEVICE_ID` 注入以保证 CI 确定性(不触碰真实文件/HOME)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from ww_llm_gateway.adapters.kimi_code import (
|
||||
KIMI_CLI_VERSION,
|
||||
KIMI_CODE_BASE_URL,
|
||||
KIMI_CODE_PLATFORM,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
KimiCodeAdapter,
|
||||
build_kimi_code_client,
|
||||
kimi_code_headers,
|
||||
kimi_device_id,
|
||||
)
|
||||
|
||||
_ACCESS_TOKEN = "kimi-oauth-access-token-not-real"
|
||||
_TEST_DEVICE_ID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
#: opencode 发送的全部 7 个头键。
|
||||
_EXPECTED_HEADER_KEYS = {
|
||||
"User-Agent",
|
||||
"X-Msh-Platform",
|
||||
"X-Msh-Version",
|
||||
"X-Msh-Device-Name",
|
||||
"X-Msh-Device-Model",
|
||||
"X-Msh-Device-Id",
|
||||
"X-Msh-Os-Version",
|
||||
}
|
||||
|
||||
_HEX32_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
_ASCII_RE = re.compile(r"^[\x20-\x7e]+$")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_device_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""注入固定 device-id,使测试确定且不依赖真实 ~/.kimi 文件。"""
|
||||
monkeypatch.setenv("KIMI_DEVICE_ID", _TEST_DEVICE_ID)
|
||||
|
||||
|
||||
def test_user_agent_matches_kimi_cli_pattern() -> None:
|
||||
assert KIMI_CODE_USER_AGENT == "KimiCLI/1.37.0"
|
||||
assert KIMI_CODE_USER_AGENT == f"KimiCLI/{KIMI_CLI_VERSION}"
|
||||
assert KIMI_CODE_USER_AGENT.startswith("KimiCLI/")
|
||||
|
||||
|
||||
def test_headers_contain_all_seven_keys() -> None:
|
||||
headers = kimi_code_headers()
|
||||
|
||||
assert set(headers) == _EXPECTED_HEADER_KEYS
|
||||
|
||||
|
||||
def test_headers_fixed_literal_values() -> None:
|
||||
headers = kimi_code_headers()
|
||||
|
||||
assert headers["User-Agent"] == "KimiCLI/1.37.0"
|
||||
assert headers["X-Msh-Platform"] == KIMI_CODE_PLATFORM == "kimi_cli"
|
||||
assert headers["X-Msh-Version"] == "1.37.0"
|
||||
# UA 版本必须与 X-Msh-Version 一致。
|
||||
assert headers["X-Msh-Version"] == headers["User-Agent"].removeprefix("KimiCLI/")
|
||||
|
||||
|
||||
def test_device_id_is_stable_32_char_lowercase_hex() -> None:
|
||||
headers_a = kimi_code_headers()
|
||||
headers_b = kimi_code_headers()
|
||||
|
||||
device_id = headers_a["X-Msh-Device-Id"]
|
||||
assert _HEX32_RE.match(device_id) # 32 位小写 hex,无连字符
|
||||
assert "-" not in device_id
|
||||
# 跨两次调用稳定。
|
||||
assert headers_a["X-Msh-Device-Id"] == headers_b["X-Msh-Device-Id"]
|
||||
assert device_id == _TEST_DEVICE_ID
|
||||
|
||||
|
||||
def test_kimi_device_id_helper_stable_and_matches_env() -> None:
|
||||
assert kimi_device_id() == _TEST_DEVICE_ID
|
||||
assert kimi_device_id() == kimi_device_id()
|
||||
|
||||
|
||||
def test_host_derived_headers_present_nonempty_ascii() -> None:
|
||||
headers = kimi_code_headers()
|
||||
|
||||
for key in ("X-Msh-Device-Name", "X-Msh-Device-Model", "X-Msh-Os-Version"):
|
||||
value = headers[key]
|
||||
assert value, f"{key} must be non-empty"
|
||||
assert _ASCII_RE.match(value), f"{key} must be printable ASCII"
|
||||
|
||||
|
||||
def test_build_client_sets_base_url_and_full_headers() -> None:
|
||||
client = build_kimi_code_client(_ACCESS_TOKEN)
|
||||
|
||||
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
|
||||
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
|
||||
assert _EXPECTED_HEADER_KEYS.issubset(set(client.default_headers))
|
||||
|
||||
|
||||
def test_build_client_uses_access_token_as_bearer() -> None:
|
||||
client = build_kimi_code_client(_ACCESS_TOKEN)
|
||||
|
||||
# access token 作为 OpenAI client 的 api_key → SDK 自动发 Authorization: Bearer。
|
||||
assert client.api_key == _ACCESS_TOKEN
|
||||
|
||||
|
||||
def test_build_client_honors_explicit_base_url_override() -> None:
|
||||
custom = "https://example.test/coding/v1"
|
||||
client = build_kimi_code_client(_ACCESS_TOKEN, base_url=custom)
|
||||
|
||||
assert str(client.base_url).rstrip("/") == custom.rstrip("/")
|
||||
|
||||
|
||||
def test_adapter_provider_is_kimi_code() -> None:
|
||||
adapter = KimiCodeAdapter(build_kimi_code_client(_ACCESS_TOKEN))
|
||||
|
||||
assert adapter.provider == "kimi-code"
|
||||
# 复用 OpenAI 兼容能力(结构化输出 + 前缀缓存)。
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
|
||||
|
||||
def test_structured_client_uses_instructor_json_mode() -> None:
|
||||
"""kimi-for-coding 开启 thinking,与强制 tool_choice 互斥(live 400)。
|
||||
|
||||
因此 KimiCodeAdapter 的结构化客户端必须走 instructor JSON 模式(发
|
||||
`response_format`,**不**发 `tool_choice`),而非默认的 TOOLS 模式。
|
||||
"""
|
||||
import instructor
|
||||
|
||||
adapter = KimiCodeAdapter(build_kimi_code_client(_ACCESS_TOKEN))
|
||||
|
||||
structured = adapter._structured()
|
||||
assert getattr(structured, "mode", None) is instructor.Mode.JSON
|
||||
62
packages/llm_gateway/tests/test_kimi_code_factory.py
Normal file
62
packages/llm_gateway/tests/test_kimi_code_factory.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""工厂分派 `kimi-code` 单测(K1.2):access_token 当 api_key、完整 7 伪造头、coding base。
|
||||
|
||||
不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_llm_gateway import build_adapter
|
||||
from ww_llm_gateway.adapters.kimi_code import (
|
||||
KIMI_CODE_BASE_URL,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
KimiCodeAdapter,
|
||||
)
|
||||
|
||||
_ACCESS_TOKEN = "kimi-oauth-access-token-not-real"
|
||||
_TEST_DEVICE_ID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
_EXPECTED_HEADER_KEYS = {
|
||||
"User-Agent",
|
||||
"X-Msh-Platform",
|
||||
"X-Msh-Version",
|
||||
"X-Msh-Device-Name",
|
||||
"X-Msh-Device-Model",
|
||||
"X-Msh-Device-Id",
|
||||
"X-Msh-Os-Version",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_device_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""注入固定 device-id,使测试确定且不依赖真实 ~/.kimi 文件。"""
|
||||
monkeypatch.setenv("KIMI_DEVICE_ID", _TEST_DEVICE_ID)
|
||||
|
||||
|
||||
def test_factory_dispatches_kimi_code_to_kimi_code_adapter() -> None:
|
||||
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN)
|
||||
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
assert adapter.provider == "kimi-code"
|
||||
|
||||
|
||||
def test_factory_kimi_code_defaults_base_url_and_attaches_full_headers() -> None:
|
||||
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN)
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
client = adapter._client # noqa: SLF001 — 断言注入客户端配置
|
||||
|
||||
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
|
||||
assert client.api_key == _ACCESS_TOKEN
|
||||
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
|
||||
assert client.default_headers["X-Msh-Platform"] == "kimi_cli"
|
||||
assert client.default_headers["X-Msh-Version"] == "1.37.0"
|
||||
assert client.default_headers["X-Msh-Device-Id"] == _TEST_DEVICE_ID
|
||||
assert _EXPECTED_HEADER_KEYS.issubset(set(client.default_headers))
|
||||
|
||||
|
||||
def test_factory_kimi_code_honors_explicit_base_url() -> None:
|
||||
custom = "https://example.test/coding/v1"
|
||||
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN, base_url=custom)
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
|
||||
assert str(adapter._client.base_url).rstrip("/") == custom.rstrip("/") # noqa: SLF001
|
||||
69
packages/llm_gateway/tests/test_kimi_code_key_factory.py
Normal file
69
packages/llm_gateway/tests/test_kimi_code_key_factory.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""工厂分派 `kimi-code-key` 单测:静态 Console Key 的 ToS 合规变体(不联网)。
|
||||
|
||||
`kimi-code-key` = Kimi Code 订阅 plan 的 **Console 静态 API Key** 路径——与 OAuth 的
|
||||
`kimi-code` 命中同一 coding 端点 + 同一 model `kimi-for-coding` + 同样 thinking 开启
|
||||
(故结构化输出走 JSON 模式)。**实测纠正**:coding 端点据 `User-Agent` 做 allow-list 门禁,
|
||||
缺伪造头 → `403 access_terminated_error`,**无论 key 还是 OAuth**。故静态 key 路径也必须发
|
||||
与 OAuth 相同的 `KimiCLI/1.37.0` + `X-Msh-*` 头(区别仅凭据来源是静态 key)。两条订阅路径
|
||||
都需 UA 伪造 = 同样 ToS 风险;真正合规的只有 moonshot 平台 key(provider `kimi`)。
|
||||
|
||||
断言点:
|
||||
- 命中 coding base URL;key 作为 bearer(OpenAI client 的 `api_key`);
|
||||
- default_headers **含** 伪造 UA(`KimiCLI`)+ `X-Msh-*` 头(过 coding 端点门禁);
|
||||
- 结构化客户端走 instructor JSON 模式(与 thinking 兼容)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import instructor
|
||||
from ww_llm_gateway import build_adapter
|
||||
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_BASE_URL
|
||||
from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter
|
||||
|
||||
_API_KEY = "kimi-console-static-key-not-real"
|
||||
|
||||
|
||||
def test_factory_dispatches_kimi_code_key_to_kimi_code_key_adapter() -> None:
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
assert adapter.provider == "kimi-code-key"
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_defaults_coding_base_url_and_bearer() -> None:
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
client = adapter._client # noqa: SLF001 — 断言注入客户端配置
|
||||
|
||||
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
|
||||
assert client.api_key == _API_KEY
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_sends_spoofed_headers() -> None:
|
||||
"""实测纠正:coding 端点据 UA 做 allow-list 门禁(缺伪造头→403),故静态 key 也必须发
|
||||
与 OAuth 相同的 `KimiCLI/1.37.0` + `X-Msh-*` 头。"""
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
headers = adapter._client.default_headers # noqa: SLF001
|
||||
|
||||
# 伪造官方客户端 UA(过 coding 端点 access_terminated_error 门禁)。
|
||||
assert "KimiCLI" in str(headers.get("User-Agent", ""))
|
||||
# 带 OAuth 路径同款 X-Msh-* 头集。
|
||||
assert any(str(k).lower().startswith("x-msh-") for k in headers)
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_structured_client_uses_json_mode() -> None:
|
||||
"""coding 端点 thinking 开启 → 结构化必须走 instructor JSON 模式(非 TOOLS)。"""
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
|
||||
structured = adapter._structured() # noqa: SLF001
|
||||
assert getattr(structured, "mode", None) is instructor.Mode.JSON
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_honors_explicit_base_url() -> None:
|
||||
custom = "https://example.test/coding/v1"
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY, base_url=custom)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
|
||||
assert str(adapter._client.base_url).rstrip("/") == custom.rstrip("/") # noqa: SLF001
|
||||
51
packages/llm_gateway/tests/test_multi_provider_accounting.py
Normal file
51
packages/llm_gateway/tests/test_multi_provider_accounting.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""T5.4 多 provider 记账维度(ARCH §4.8)。
|
||||
|
||||
记账行须带**实际服务方** provider/model(回退后不能记成主模型),让 usage_ledger
|
||||
的多 provider 维度可查询/聚合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
|
||||
from ww_llm_gateway.gateway import Gateway
|
||||
from ww_llm_gateway.types import LlmRequest
|
||||
|
||||
|
||||
async def test_ledger_records_actual_serving_provider_after_fallback(req: LlmRequest) -> None:
|
||||
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="备用", input_tokens=222, output_tokens=33)
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
await gw.run(req)
|
||||
|
||||
assert len(ledger.records) == 1
|
||||
rec = ledger.records[0]
|
||||
assert rec.provider == "openai"
|
||||
assert rec.model == "gpt-4o"
|
||||
assert rec.input_tokens == 222
|
||||
assert rec.output_tokens == 33
|
||||
|
||||
|
||||
async def test_stream_ledger_records_actual_provider_after_fallback(req: LlmRequest) -> None:
|
||||
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="流式备用", input_tokens=10, output_tokens=4)
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
collected = [d.text async for d in gw.stream(req)]
|
||||
|
||||
assert "".join(collected) == "流式备用"
|
||||
assert len(ledger.records) == 1
|
||||
assert ledger.records[0].provider == "openai"
|
||||
assert ledger.records[0].model == "gpt-4o"
|
||||
198
packages/llm_gateway/tests/test_new_adapters.py
Normal file
198
packages/llm_gateway/tests/test_new_adapters.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""T5.4 新适配器:Anthropic + Gemini(ARCH §4.2/§4.4)。
|
||||
|
||||
网络客户端经注入的 Protocol 替身——绝不联网、绝不依赖真实 SDK。
|
||||
断言:能力矩阵声明正确、complete/stream 归一化、结构化输出、usage 提取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
|
||||
from ww_llm_gateway.adapters.gemini import GeminiAdapter
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
|
||||
|
||||
class Out(BaseModel):
|
||||
score: int
|
||||
|
||||
|
||||
# ---- Anthropic ----
|
||||
|
||||
|
||||
class _FakeAnthropicMessages:
|
||||
def __init__(self, text: str, in_tok: int, out_tok: int, cache_read: int) -> None:
|
||||
self._text = text
|
||||
self._in = in_tok
|
||||
self._out = out_tok
|
||||
self._cache_read = cache_read
|
||||
self.last_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def create(self, **kwargs: Any) -> Any:
|
||||
self.last_kwargs = kwargs
|
||||
|
||||
class _Block:
|
||||
type = "text"
|
||||
text = self._text
|
||||
|
||||
class _Usage:
|
||||
input_tokens = self._in
|
||||
output_tokens = self._out
|
||||
cache_read_input_tokens = self._cache_read
|
||||
|
||||
class _Resp:
|
||||
content = [_Block()]
|
||||
usage = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeAnthropicClient:
|
||||
def __init__(self, msgs: _FakeAnthropicMessages) -> None:
|
||||
self.messages = msgs
|
||||
|
||||
|
||||
def _req(text: str = "写第一章", *, cache: bool = False) -> LlmRequest:
|
||||
sys = [Block(text="世界观", cache=cache)] if cache else []
|
||||
return LlmRequest(tier="writer", input=text, system=sys, scope=Scope(user_id=uuid.UUID(int=1)))
|
||||
|
||||
|
||||
def test_anthropic_capabilities() -> None:
|
||||
client = _FakeAnthropicClient(_FakeAnthropicMessages("", 0, 0, 0))
|
||||
adapter = AnthropicAdapter("anthropic", client)
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
assert caps.prefix_cache is True
|
||||
assert caps.thinking is True
|
||||
|
||||
|
||||
async def test_anthropic_complete_text_and_usage() -> None:
|
||||
msgs = _FakeAnthropicMessages("生成正文", in_tok=120, out_tok=80, cache_read=30)
|
||||
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
|
||||
|
||||
result = await adapter.complete(_req(), "claude-3-5-sonnet")
|
||||
|
||||
assert result.text == "生成正文"
|
||||
assert result.usage.input_tokens == 120
|
||||
assert result.usage.output_tokens == 80
|
||||
assert result.usage.cache_read_tokens == 30
|
||||
|
||||
|
||||
async def test_anthropic_marks_cache_breakpoint() -> None:
|
||||
msgs = _FakeAnthropicMessages("x", 1, 1, 0)
|
||||
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
|
||||
|
||||
await adapter.complete(_req(cache=True), "claude-3-5-sonnet")
|
||||
|
||||
# 稳定块应带 cache_control 断点(Claude 显式缓存,§4.6)。
|
||||
system = msgs.last_kwargs["system"]
|
||||
assert any(blk.get("cache_control") for blk in system)
|
||||
|
||||
|
||||
# ---- Gemini ----
|
||||
|
||||
|
||||
class _FakeGeminiModels:
|
||||
def __init__(self, text: str, in_tok: int, out_tok: int) -> None:
|
||||
self._text = text
|
||||
self._in = in_tok
|
||||
self._out = out_tok
|
||||
self.last_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def generate_content(self, **kwargs: Any) -> Any:
|
||||
self.last_kwargs = kwargs
|
||||
|
||||
class _Usage:
|
||||
prompt_token_count = self._in
|
||||
candidates_token_count = self._out
|
||||
cached_content_token_count = 0
|
||||
|
||||
class _Resp:
|
||||
text = self._text
|
||||
usage_metadata = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeGeminiAio:
|
||||
def __init__(self, models: _FakeGeminiModels) -> None:
|
||||
self.models = models
|
||||
|
||||
|
||||
class _FakeGeminiClient:
|
||||
def __init__(self, models: _FakeGeminiModels) -> None:
|
||||
self.aio = _FakeGeminiAio(models)
|
||||
|
||||
|
||||
def test_gemini_capabilities() -> None:
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(_FakeGeminiModels("", 0, 0))) # type: ignore[arg-type]
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
assert caps.thinking is True
|
||||
|
||||
|
||||
async def test_gemini_complete_text_and_usage() -> None:
|
||||
models = _FakeGeminiModels("双子座正文", in_tok=200, out_tok=90)
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
|
||||
|
||||
result = await adapter.complete(_req(), "gemini-2.0-flash")
|
||||
|
||||
assert result.text == "双子座正文"
|
||||
assert result.usage.input_tokens == 200
|
||||
assert result.usage.output_tokens == 90
|
||||
|
||||
|
||||
async def test_gemini_structured_output() -> None:
|
||||
models = _FakeGeminiModels('{"score": 7}', in_tok=10, out_tok=5)
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
|
||||
req = LlmRequest(
|
||||
tier="analyst",
|
||||
input="打分",
|
||||
output_schema=Out,
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
result = await adapter.complete(req, "gemini-2.0-flash")
|
||||
|
||||
assert result.parsed is not None
|
||||
assert isinstance(result.parsed, Out)
|
||||
assert result.parsed.score == 7
|
||||
# 结构化请求应带 response schema 配置。
|
||||
assert "config" in models.last_kwargs
|
||||
|
||||
|
||||
async def test_anthropic_stream_yields_text_then_usage() -> None:
|
||||
class _StreamEvent:
|
||||
def __init__(self, **kw: Any) -> None:
|
||||
self.__dict__.update(kw)
|
||||
|
||||
class _StreamMessages:
|
||||
def stream(self, **kwargs: Any) -> Any:
|
||||
class _Ctx:
|
||||
async def __aenter__(self_inner) -> AsyncIterator[Any]:
|
||||
async def _gen() -> AsyncIterator[Any]:
|
||||
yield _StreamEvent(
|
||||
type="content_block_delta", delta=_StreamEvent(text="hel")
|
||||
)
|
||||
yield _StreamEvent(
|
||||
type="content_block_delta", delta=_StreamEvent(text="lo")
|
||||
)
|
||||
|
||||
return _gen()
|
||||
|
||||
async def __aexit__(self_inner, *a: Any) -> None:
|
||||
return None
|
||||
|
||||
return _Ctx()
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.messages = _StreamMessages()
|
||||
|
||||
adapter = AnthropicAdapter("anthropic", _Client())
|
||||
chunks = [c async for c in adapter.stream(_req(), "claude-3-5-sonnet")]
|
||||
text = "".join(c.text for c in chunks if c.text)
|
||||
assert text == "hello"
|
||||
@@ -1,7 +1,11 @@
|
||||
"""LLM 网关(C1 / ARCH §4):薄自建,tier→provider+model,屏蔽厂商差异。"""
|
||||
"""LLM 网关(C1 / ARCH §4):薄自建,tier→provider+model,屏蔽厂商差异。
|
||||
|
||||
T5.4 扩:多适配器(Anthropic/Gemini/OpenAI 兼容)+ 回退链 + 熔断 + 能力协商/降级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .adapters.anthropic import AnthropicAdapter
|
||||
from .adapters.base import (
|
||||
Capabilities,
|
||||
ProviderAdapter,
|
||||
@@ -9,10 +13,30 @@ from .adapters.base import (
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from .adapters.gemini import GeminiAdapter
|
||||
from .adapters.kimi_code import (
|
||||
KIMI_CLI_VERSION,
|
||||
KIMI_CODE_BASE_URL,
|
||||
KIMI_CODE_PLATFORM,
|
||||
KIMI_CODE_PROVIDER,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
KimiCodeAdapter,
|
||||
build_kimi_code_client,
|
||||
kimi_code_headers,
|
||||
kimi_device_id,
|
||||
kimi_device_model,
|
||||
)
|
||||
from .adapters.kimi_code_key import (
|
||||
KIMI_CODE_KEY_PROVIDER,
|
||||
KimiCodeKeyAdapter,
|
||||
build_kimi_code_key_client,
|
||||
)
|
||||
from .adapters.openai_compat import OpenAICompatAdapter
|
||||
from .gateway import Gateway
|
||||
from .errors import TransientProviderError
|
||||
from .factory import build_adapter
|
||||
from .gateway import CircuitBreaker, Gateway
|
||||
from .ledger import LedgerSink, SqlAlchemyLedgerSink
|
||||
from .routing import Route, resolve_route
|
||||
from .routing import ChainResolver, Route, chain_from_routing, resolve_chain, resolve_route
|
||||
from .types import (
|
||||
Block,
|
||||
Delta,
|
||||
@@ -25,10 +49,22 @@ from .types import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnthropicAdapter",
|
||||
"Block",
|
||||
"Capabilities",
|
||||
"ChainResolver",
|
||||
"CircuitBreaker",
|
||||
"Delta",
|
||||
"Gateway",
|
||||
"GeminiAdapter",
|
||||
"KIMI_CLI_VERSION",
|
||||
"KIMI_CODE_BASE_URL",
|
||||
"KIMI_CODE_KEY_PROVIDER",
|
||||
"KIMI_CODE_PLATFORM",
|
||||
"KIMI_CODE_PROVIDER",
|
||||
"KIMI_CODE_USER_AGENT",
|
||||
"KimiCodeAdapter",
|
||||
"KimiCodeKeyAdapter",
|
||||
"LedgerSink",
|
||||
"LlmRequest",
|
||||
"LlmResponse",
|
||||
@@ -42,6 +78,15 @@ __all__ = [
|
||||
"SqlAlchemyLedgerSink",
|
||||
"StreamChunk",
|
||||
"Tier",
|
||||
"TransientProviderError",
|
||||
"Usage",
|
||||
"build_adapter",
|
||||
"build_kimi_code_client",
|
||||
"build_kimi_code_key_client",
|
||||
"chain_from_routing",
|
||||
"kimi_code_headers",
|
||||
"kimi_device_id",
|
||||
"kimi_device_model",
|
||||
"resolve_chain",
|
||||
"resolve_route",
|
||||
]
|
||||
|
||||
184
packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py
Normal file
184
packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Anthropic(Claude)适配器(ARCH §4.2/§4.4/§4.6)。
|
||||
|
||||
- 能力:原生结构化输出(经 instructor)、显式前缀缓存(`cache_control` 断点)、思考。
|
||||
- 网络客户端注入(`AnthropicClient` Protocol,`AsyncAnthropic` 即满足)——测试注入替身,
|
||||
绝不联网;真实 SDK 仅在构造客户端时(apps/api)需要,本模块不硬 import `anthropic`。
|
||||
- 瞬时故障(429/超时/5xx/连接错误)翻译为 `TransientProviderError`,交网关退避/回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
import instructor
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..errors import TransientProviderError
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
# 瞬时错误类名(按名匹配,避免硬依赖 anthropic SDK 类型)。
|
||||
_TRANSIENT_NAMES = frozenset(
|
||||
{
|
||||
"RateLimitError",
|
||||
"APITimeoutError",
|
||||
"APIConnectionError",
|
||||
"InternalServerError",
|
||||
"APIStatusError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AnthropicClient(Protocol):
|
||||
"""`AsyncAnthropic` 满足此 Protocol(仅用到 `messages`)。"""
|
||||
|
||||
messages: Any
|
||||
|
||||
|
||||
class StructuredAnthropic(Protocol):
|
||||
async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel: ...
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
name = type(exc).__name__
|
||||
if name in _TRANSIENT_NAMES:
|
||||
return True
|
||||
status = getattr(exc, "status_code", None)
|
||||
return isinstance(status, int) and (status == 429 or status >= 500)
|
||||
|
||||
|
||||
def _system_blocks(req: LlmRequest) -> list[dict[str, Any]]:
|
||||
"""system 块;缓存断点前的稳定块带 `cache_control`(§4.6)。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for b in req.system:
|
||||
block: dict[str, Any] = {"type": "text", "text": b.text}
|
||||
if b.cache:
|
||||
block["cache_control"] = {"type": "ephemeral"}
|
||||
out.append(block)
|
||||
return out
|
||||
|
||||
|
||||
def _input_text(req: LlmRequest) -> str:
|
||||
if isinstance(req.input, str):
|
||||
return req.input
|
||||
return "\n\n".join(b.text for b in req.input)
|
||||
|
||||
|
||||
def _user_messages(req: LlmRequest) -> list[dict[str, Any]]:
|
||||
return [{"role": "user", "content": _input_text(req)}]
|
||||
|
||||
|
||||
def _usage_from(usage: Any) -> ProviderUsage:
|
||||
if usage is None:
|
||||
return ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderUsage(
|
||||
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
|
||||
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
|
||||
cache_read_tokens=int(getattr(usage, "cache_read_input_tokens", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
def _text_from(resp: Any) -> str:
|
||||
parts: list[str] = []
|
||||
for block in getattr(resp, "content", []) or []:
|
||||
if getattr(block, "type", None) == "text":
|
||||
parts.append(getattr(block, "text", "") or "")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_DEFAULT_MAX_TOKENS = 4096
|
||||
|
||||
|
||||
class AnthropicAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
client: AnthropicClient,
|
||||
*,
|
||||
structured_client: StructuredAnthropic | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self._client = client
|
||||
self._structured_client = structured_client
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True, prefix_cache=True, thinking=True)
|
||||
|
||||
def _structured(self) -> StructuredAnthropic:
|
||||
if self._structured_client is None:
|
||||
# 注入客户端是 `AnthropicClient` Protocol(保留测试替身缝),
|
||||
# 但 instructor.from_anthropic 只重载真实 SDK 的具体类型;
|
||||
# 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载,
|
||||
# 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic。
|
||||
async_client = cast("AsyncAnthropic", self._client)
|
||||
self._structured_client = cast(
|
||||
"StructuredAnthropic", instructor.from_anthropic(async_client)
|
||||
)
|
||||
return self._structured_client
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
try:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
|
||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
|
||||
"messages": _user_messages(req),
|
||||
}
|
||||
system = _system_blocks(req)
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
resp = await self._client.messages.create(**kwargs)
|
||||
usage = _usage_from(getattr(resp, "usage", None))
|
||||
return ProviderResult(text=_text_from(resp), usage=usage)
|
||||
|
||||
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
assert req.output_schema is not None
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
|
||||
"messages": _user_messages(req),
|
||||
}
|
||||
system = _system_blocks(req)
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
parsed = await self._structured().create(response_model=req.output_schema, **kwargs)
|
||||
# instructor.from_anthropic 默认不回 raw usage(按名隐藏);usage 经流/text 路径覆盖。
|
||||
usage = ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
|
||||
"messages": _user_messages(req),
|
||||
}
|
||||
system = _system_blocks(req)
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
try:
|
||||
async with self._client.messages.stream(**kwargs) as stream:
|
||||
async for event in stream:
|
||||
if getattr(event, "type", None) == "content_block_delta":
|
||||
delta = getattr(event, "delta", None)
|
||||
text = getattr(delta, "text", "") if delta is not None else ""
|
||||
if text:
|
||||
yield StreamChunk(text=text)
|
||||
usage = getattr(event, "usage", None)
|
||||
if usage is not None:
|
||||
yield StreamChunk(usage=_usage_from(usage))
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
127
packages/llm_gateway/ww_llm_gateway/adapters/gemini.py
Normal file
127
packages/llm_gateway/ww_llm_gateway/adapters/gemini.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Google Gemini 适配器(ARCH §4.2/§4.4)。
|
||||
|
||||
- 能力:原生结构化输出(`response_mime_type=application/json` + `response_schema`)、思考。
|
||||
前缀缓存走 Gemini 隐式/显式缓存,机制差异大,原型保守标 `prefix_cache=False`。
|
||||
- 客户端注入(`GeminiClient` Protocol,`google.genai.Client().aio` 满足)——测试注入替身,
|
||||
绝不联网;真实 SDK(`google-genai`)仅在 apps/api 构造客户端时需要,本模块不硬 import。
|
||||
- 瞬时故障翻译为 `TransientProviderError`,交网关退避/回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..errors import TransientProviderError
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
_TRANSIENT_NAMES = frozenset(
|
||||
{
|
||||
"ResourceExhausted",
|
||||
"ServiceUnavailable",
|
||||
"DeadlineExceeded",
|
||||
"InternalServerError",
|
||||
"ServerError",
|
||||
"APIError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GeminiModels(Protocol):
|
||||
async def generate_content(self, **kwargs: Any) -> Any: ...
|
||||
|
||||
def generate_content_stream(self, **kwargs: Any) -> Any: ...
|
||||
|
||||
|
||||
class GeminiAio(Protocol):
|
||||
models: GeminiModels
|
||||
|
||||
|
||||
class GeminiClient(Protocol):
|
||||
"""`google.genai.Client()` 满足此 Protocol(用其 `.aio.models`)。"""
|
||||
|
||||
aio: GeminiAio
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
name = type(exc).__name__
|
||||
if name in _TRANSIENT_NAMES:
|
||||
return True
|
||||
status = getattr(exc, "code", None) or getattr(exc, "status_code", None)
|
||||
return isinstance(status, int) and (status == 429 or status >= 500)
|
||||
|
||||
|
||||
def _contents(req: LlmRequest) -> str:
|
||||
if isinstance(req.input, str):
|
||||
return req.input
|
||||
return "\n\n".join(b.text for b in req.input)
|
||||
|
||||
|
||||
def _system_text(req: LlmRequest) -> str:
|
||||
return "\n\n".join(b.text for b in req.system)
|
||||
|
||||
|
||||
def _usage_from(meta: Any) -> ProviderUsage:
|
||||
if meta is None:
|
||||
return ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderUsage(
|
||||
input_tokens=int(getattr(meta, "prompt_token_count", 0) or 0),
|
||||
output_tokens=int(getattr(meta, "candidates_token_count", 0) or 0),
|
||||
cache_read_tokens=int(getattr(meta, "cached_content_token_count", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
def _config(req: LlmRequest) -> dict[str, Any]:
|
||||
cfg: dict[str, Any] = {}
|
||||
system = _system_text(req)
|
||||
if system:
|
||||
cfg["system_instruction"] = system
|
||||
if req.max_tokens is not None:
|
||||
cfg["max_output_tokens"] = req.max_tokens
|
||||
if req.output_schema is not None:
|
||||
cfg["response_mime_type"] = "application/json"
|
||||
cfg["response_schema"] = req.output_schema
|
||||
return cfg
|
||||
|
||||
|
||||
class GeminiAdapter:
|
||||
def __init__(self, provider: str, client: GeminiClient) -> None:
|
||||
self.provider = provider
|
||||
self._client = client
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True, prefix_cache=False, thinking=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
try:
|
||||
resp = await self._client.aio.models.generate_content(
|
||||
model=model, contents=_contents(req), config=_config(req)
|
||||
)
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
text = getattr(resp, "text", "") or ""
|
||||
usage = _usage_from(getattr(resp, "usage_metadata", None))
|
||||
if req.output_schema is not None:
|
||||
parsed = req.output_schema.model_validate_json(text)
|
||||
return ProviderResult(text=text, usage=usage, parsed=parsed)
|
||||
return ProviderResult(text=text, usage=usage)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
try:
|
||||
stream = await self._client.aio.models.generate_content_stream(
|
||||
model=model, contents=_contents(req), config=_config(req)
|
||||
)
|
||||
async for chunk in stream:
|
||||
text = getattr(chunk, "text", "") or ""
|
||||
if text:
|
||||
yield StreamChunk(text=text)
|
||||
meta = getattr(chunk, "usage_metadata", None)
|
||||
if meta is not None:
|
||||
yield StreamChunk(usage=_usage_from(meta))
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
190
packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py
Normal file
190
packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Kimi Code 订阅 plan 适配器(K1.2 / PROGRESS K1)。
|
||||
|
||||
Kimi 的 coding 端点是 **OpenAI 兼容** 的,因此本适配器复用 `OpenAICompatAdapter`——
|
||||
区别仅在:① base_url 指向 coding 端点;② 必须携带伪造的官方客户端头集;③ access_token
|
||||
作为 bearer(OpenAI SDK 的 `api_key` 自动发 `Authorization: Bearer <token>`)。
|
||||
|
||||
**伪造头集的真源 = `github.com/ooojustin/opencode-kimi`(`src/headers.ts` + `src/constants.ts`,
|
||||
1:1 镜像 kimi-cli v1.37.0)。** coding API 会校验这 7 个头;任何偏差都会让 Moonshot 后端
|
||||
返回 `access_terminated_error: only available for Coding Agents`(403)。本实现据此发送完整
|
||||
7 头:UA `KimiCLI/1.37.0` + 6 个 `X-Msh-*`。
|
||||
|
||||
注意:早先曾参照 `picassio/pi-kimi-coder`(`extensions/index.ts`,**仅**发 UA、不带 X-Msh-*)
|
||||
把头集裁成 UA-only——那是分歧/错误的参考实现,导致了误修。现已回退到 opencode 规范的完整
|
||||
7 头(见 `memory/gotchas.md`)。
|
||||
|
||||
`access_token` 由 @backend(K1.3 OAuth device 服务)按需刷新后传入;本适配器只接收当前
|
||||
access token 作为 `api_key`,不负责刷新/获取 token。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import instructor
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .openai_compat import OpenAICompatAdapter, StructuredClient
|
||||
|
||||
#: Kimi 订阅 plan 的 coding 推理端点(OpenAI 兼容)。
|
||||
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"
|
||||
|
||||
#: CLI 版本(opencode `constants.ts` KIMI_CLI_VERSION,镜像 kimi-cli v1.37.0)。
|
||||
KIMI_CLI_VERSION = "1.37.0"
|
||||
|
||||
#: 伪造的官方客户端 UA(opencode `USER_AGENT = f"KimiCLI/{KIMI_CLI_VERSION}"`)。
|
||||
#: UA 前缀必须精确为 `KimiCLI/<version>`,且 version 必须与 X-Msh-Version 一致。
|
||||
KIMI_CODE_USER_AGENT = f"KimiCLI/{KIMI_CLI_VERSION}"
|
||||
|
||||
#: `X-Msh-Platform` 是字面常量字符串(**不是** OS 名)。
|
||||
KIMI_CODE_PLATFORM = "kimi_cli"
|
||||
|
||||
#: provider 名(与既有 API-key provider `kimi` 分离,便于档位切换)。
|
||||
KIMI_CODE_PROVIDER = "kimi-code"
|
||||
|
||||
#: device-id 覆盖用环境变量(优先级高于持久化文件,便于测试/CI 注入)。
|
||||
KIMI_DEVICE_ID_ENV = "KIMI_DEVICE_ID"
|
||||
|
||||
#: device-id 持久化路径(与 kimi-cli / opencode 共享 `~/.kimi/device_id`,保证单一稳定指纹)。
|
||||
KIMI_DEVICE_ID_DIR = Path.home() / ".kimi"
|
||||
KIMI_DEVICE_ID_PATH = KIMI_DEVICE_ID_DIR / "device_id"
|
||||
|
||||
#: HTTP 头值若含非 ASCII 会被底层 fetch/httpx 拒绝;opencode `asciiHeaderValue` 同样裁剪。
|
||||
_ASCII_FALLBACK = "unknown"
|
||||
|
||||
|
||||
def _ascii_header_value(value: str, fallback: str = _ASCII_FALLBACK) -> str:
|
||||
"""裁掉非可打印 ASCII(`\\x20-\\x7e` 之外)+ 去空白;空则回退。
|
||||
|
||||
镜像 opencode `asciiHeaderValue`(含非 ASCII 的头值会被底层 fetch/httpx 拒绝)。
|
||||
"""
|
||||
sanitized = "".join(ch for ch in value if "\x20" <= ch <= "\x7e").strip()
|
||||
return sanitized or fallback
|
||||
|
||||
|
||||
def kimi_device_id() -> str:
|
||||
"""返回稳定的 device-id(32 位无连字符 UUID4 hex)。
|
||||
|
||||
优先级:① 环境变量 `KIMI_DEVICE_ID`;② 持久化文件 `~/.kimi/device_id`(不存在则生成一次
|
||||
`uuid.uuid4().hex` 写入、之后复用)。跨调用/进程重启保持稳定,**绝不**每次随机。
|
||||
"""
|
||||
env_value = os.environ.get(KIMI_DEVICE_ID_ENV, "").strip()
|
||||
if env_value:
|
||||
return env_value
|
||||
|
||||
if KIMI_DEVICE_ID_PATH.exists():
|
||||
existing = KIMI_DEVICE_ID_PATH.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
KIMI_DEVICE_ID_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
device_id = uuid.uuid4().hex
|
||||
KIMI_DEVICE_ID_PATH.write_text(device_id, encoding="utf-8")
|
||||
KIMI_DEVICE_ID_PATH.chmod(0o600)
|
||||
return device_id
|
||||
|
||||
|
||||
def kimi_device_model() -> str:
|
||||
"""`X-Msh-Device-Model`:镜像 opencode `kimiDeviceModel()` 的 OS 分支格式。
|
||||
|
||||
- macOS:`f"macOS {mac_ver} {machine}"`(如 `"macOS 14.5 arm64"`);
|
||||
- Windows:`f"Windows {release} {machine}"`;
|
||||
- 其它:`f"{system} {release} {machine}"`。
|
||||
`platform.machine()` 返回 `arm64`/`x86_64`(**不**做归一化)。
|
||||
"""
|
||||
system = platform.system()
|
||||
release = platform.release()
|
||||
machine = platform.machine()
|
||||
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0] or release
|
||||
if version and machine:
|
||||
return f"macOS {version} {machine}"
|
||||
if version:
|
||||
return f"macOS {version}"
|
||||
return f"macOS {machine}".strip()
|
||||
|
||||
if system == "Windows":
|
||||
if release and machine:
|
||||
return f"Windows {release} {machine}"
|
||||
if release:
|
||||
return f"Windows {release}"
|
||||
return f"Windows {machine}".strip()
|
||||
|
||||
if system:
|
||||
if release and machine:
|
||||
return f"{system} {release} {machine}"
|
||||
if release:
|
||||
return f"{system} {release}"
|
||||
return f"{system} {machine}".strip()
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _device_name() -> str:
|
||||
"""`X-Msh-Device-Name`:主机名(ASCII 化)。`socket.gethostname()` ≈ Node `os.hostname()`。"""
|
||||
return _ascii_header_value(socket.gethostname() or platform.node())
|
||||
|
||||
|
||||
def _os_version() -> str:
|
||||
"""`X-Msh-Os-Version`:OS 内核版本串。`platform.version()` ≈ Node `os.version()`。"""
|
||||
fallback = f"{platform.system()} {platform.release()}"
|
||||
return _ascii_header_value(platform.version() or fallback)
|
||||
|
||||
|
||||
def kimi_code_headers() -> dict[str, str]:
|
||||
"""组装 Kimi coding API 必需的伪造客户端头集(opencode 规范的完整 7 头)。
|
||||
|
||||
真源:`ooojustin/opencode-kimi` `src/headers.ts` `kimiHeaders()`。
|
||||
"""
|
||||
return {
|
||||
"User-Agent": KIMI_CODE_USER_AGENT,
|
||||
"X-Msh-Platform": KIMI_CODE_PLATFORM,
|
||||
"X-Msh-Version": KIMI_CLI_VERSION,
|
||||
"X-Msh-Device-Name": _device_name(),
|
||||
"X-Msh-Device-Model": _ascii_header_value(kimi_device_model()),
|
||||
"X-Msh-Device-Id": kimi_device_id(),
|
||||
"X-Msh-Os-Version": _os_version(),
|
||||
}
|
||||
|
||||
|
||||
def build_kimi_code_client(access_token: str, *, base_url: str | None = None) -> AsyncOpenAI:
|
||||
"""构造带伪造头的 `AsyncOpenAI` 客户端(access_token → bearer,coding base)。"""
|
||||
return AsyncOpenAI(
|
||||
api_key=access_token,
|
||||
base_url=base_url or KIMI_CODE_BASE_URL,
|
||||
default_headers=kimi_code_headers(),
|
||||
)
|
||||
|
||||
|
||||
def build_kimi_code_structured_client(client: AsyncOpenAI) -> StructuredClient:
|
||||
"""Kimi 结构化输出走 instructor **JSON 模式**(`response_format`),**不**用 TOOLS 模式。
|
||||
|
||||
缘由:`kimi-for-coding` 默认开启 thinking,而 Moonshot 后端对「thinking 开启 + 强制
|
||||
`tool_choice`」组合返回 `400 tool_choice 'specified' is incompatible with thinking enabled`。
|
||||
instructor 默认 `Mode.TOOLS` 会发强制 `tool_choice` → 触发该 400。改用 `Mode.JSON`
|
||||
(`response_format={"type":"json_object"}` + schema 注入 prompt)即避开 tool_choice,
|
||||
与 thinking 兼容。仅对 `kimi-code` 生效,其它 provider 维持默认模式。
|
||||
"""
|
||||
return instructor.from_openai(client, mode=instructor.Mode.JSON)
|
||||
|
||||
|
||||
class KimiCodeAdapter(OpenAICompatAdapter):
|
||||
"""Kimi Code 适配器:复用 OpenAI 兼容行为,固定 provider 名 `kimi-code`。
|
||||
|
||||
model(如 `kimi-for-coding`)经 `.complete(req, model)` 传入(路由/档位关注点),
|
||||
**不**在此硬编码。结构化输出强制走 JSON 模式(见 `build_kimi_code_structured_client`)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, client: AsyncOpenAI, *, structured_client: StructuredClient | None = None
|
||||
) -> None:
|
||||
super().__init__(
|
||||
KIMI_CODE_PROVIDER,
|
||||
client,
|
||||
structured_client=structured_client or build_kimi_code_structured_client(client),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Kimi Code 订阅 plan — 静态 Console API Key 适配器。
|
||||
|
||||
Kimi Code Console(`kimi.com/code/console`)签发的 API Key 走**订阅额度**,命中与 OAuth
|
||||
`kimi-code` **相同**的 coding 端点(`https://api.kimi.com/coding/v1`,OpenAI 兼容)+ 相同
|
||||
model `kimi-for-coding`。
|
||||
|
||||
**实测纠正(2026-06-20)**:coding 端点会校验 `User-Agent`,对非 allow-list 客户端返回
|
||||
`403 access_terminated_error: only available for Coding Agents such as Kimi CLI, Claude Code,
|
||||
Roo Code, ...`——**无论用静态 key 还是 OAuth token**。即静态 key 仅解决鉴权(无 401),但
|
||||
UA 门禁(403)依旧。故本适配器**也必须发**与 OAuth 相同的伪造客户端头集
|
||||
(`kimi_code_headers()`:`KimiCLI/1.37.0` + `X-Msh-*`),否则 403。早先「纯 key 无需伪造头、
|
||||
ToS 合规」的判断被实测推翻——**两条订阅路径都需 UA 伪造 = 同样的 ToS 违规/封号风险**。本路径
|
||||
相对 OAuth 的唯一优势是用静态 Console key(无 token 刷新机制),风险等同。真正合规的只有
|
||||
按量付费的 Moonshot 平台 key(provider `kimi`)。
|
||||
|
||||
唯一与普通 OpenAI 兼容 provider 的另一区别:coding 端点跑 `kimi-for-coding` 且 **thinking 开启**,
|
||||
Moonshot 后端对「thinking + 强制 `tool_choice`」返回 `400`。因此结构化输出走 instructor
|
||||
**JSON 模式**(复用 `build_kimi_code_structured_client`),而非默认 TOOLS 模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .kimi_code import (
|
||||
KIMI_CODE_BASE_URL,
|
||||
build_kimi_code_structured_client,
|
||||
kimi_code_headers,
|
||||
)
|
||||
from .openai_compat import OpenAICompatAdapter, StructuredClient
|
||||
|
||||
#: provider 名(与 OAuth 的 `kimi-code`、moonshot 的 `kimi` 均分离,便于档位切换)。
|
||||
KIMI_CODE_KEY_PROVIDER = "kimi-code-key"
|
||||
|
||||
|
||||
def build_kimi_code_key_client(api_key: str, *, base_url: str | None = None) -> AsyncOpenAI:
|
||||
"""构造 coding 端点客户端(key → bearer + **伪造客户端头**,coding base)。
|
||||
|
||||
实测:coding 端点据 `User-Agent` 做 allow-list 门禁,缺伪造头 → 403。故与 OAuth 的
|
||||
`build_kimi_code_client` 一样必须带 `default_headers=kimi_code_headers()`
|
||||
(`KimiCLI/1.37.0` + `X-Msh-*`);唯一区别是凭据来源是静态 Console key 而非 OAuth token。
|
||||
"""
|
||||
return AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url or KIMI_CODE_BASE_URL,
|
||||
default_headers=kimi_code_headers(),
|
||||
)
|
||||
|
||||
|
||||
class KimiCodeKeyAdapter(OpenAICompatAdapter):
|
||||
"""Kimi Code 静态 Key 适配器:OpenAI 兼容 + 伪造客户端头 + JSON 模式结构化。
|
||||
|
||||
model(`kimi-for-coding`)经 `.complete(req, model)` 由路由/档位传入,**不**在此硬编码。
|
||||
结构化输出强制走 JSON 模式(thinking 兼容);发与 OAuth 相同的伪造头以过 coding 端点 UA 门禁。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, client: AsyncOpenAI, *, structured_client: StructuredClient | None = None
|
||||
) -> None:
|
||||
super().__init__(
|
||||
KIMI_CODE_KEY_PROVIDER,
|
||||
client,
|
||||
structured_client=structured_client or build_kimi_code_structured_client(client),
|
||||
)
|
||||
@@ -13,9 +13,29 @@ from openai import AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..errors import TransientProviderError
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
# OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK)。
|
||||
_TRANSIENT_NAMES = frozenset(
|
||||
{
|
||||
"RateLimitError",
|
||||
"APITimeoutError",
|
||||
"APIConnectionError",
|
||||
"InternalServerError",
|
||||
"APIStatusError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
name = type(exc).__name__
|
||||
if name in _TRANSIENT_NAMES:
|
||||
return True
|
||||
status = getattr(exc, "status_code", None)
|
||||
return isinstance(status, int) and (status == 429 or status >= 500)
|
||||
|
||||
|
||||
class StructuredClient(Protocol):
|
||||
"""instructor 风格的结构化客户端缝(`AsyncInstructor` 即满足此协议)。
|
||||
@@ -87,9 +107,14 @@ class OpenAICompatAdapter:
|
||||
return self._structured_client
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
try:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
|
||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
resp = await self._client.chat.completions.create(
|
||||
@@ -113,17 +138,22 @@ class OpenAICompatAdapter:
|
||||
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
stream = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_messages(req),
|
||||
max_tokens=req.max_tokens,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
async for chunk in stream:
|
||||
if chunk.choices:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta and delta.content:
|
||||
yield StreamChunk(text=delta.content)
|
||||
if getattr(chunk, "usage", None):
|
||||
yield StreamChunk(usage=_usage_from(chunk.usage))
|
||||
try:
|
||||
stream = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_messages(req),
|
||||
max_tokens=req.max_tokens,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
async for chunk in stream:
|
||||
if chunk.choices:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta and delta.content:
|
||||
yield StreamChunk(text=delta.content)
|
||||
if getattr(chunk, "usage", None):
|
||||
yield StreamChunk(usage=_usage_from(chunk.usage))
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
|
||||
22
packages/llm_gateway/ww_llm_gateway/errors.py
Normal file
22
packages/llm_gateway/ww_llm_gateway/errors.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""网关内部异常类型(ARCH §4.5)。
|
||||
|
||||
适配器把厂商的可重试故障(限流/超时/5xx)翻译为 `TransientProviderError`,
|
||||
网关据此决定退避重试 / 切回退链。非瞬时错误(内容策略拒绝等)由适配器抛
|
||||
`AppError` 或原样上抛,网关不盲目重试。
|
||||
|
||||
`ww_shared.AppError(ErrorCode.RATE_LIMITED)` 也被网关视作可重试/可回退(限流即瞬时)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TransientProviderError(Exception):
|
||||
"""瞬时、可重试的 provider 故障(429/超时/5xx/连接错误)。
|
||||
|
||||
适配器在 `except` 厂商异常时包装抛出,携带原始 provider 名便于日志/熔断归因。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, provider: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.provider = provider
|
||||
77
packages/llm_gateway/ww_llm_gateway/factory.py
Normal file
77
packages/llm_gateway/ww_llm_gateway/factory.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Provider→适配器工厂(ARCH §4.2/§4.3)。
|
||||
|
||||
`build_gateway_for_tier`(apps/api)据 DB `tier_routing` 的 primary + fallback 逐 provider
|
||||
建适配器进 `adapters` dict——本工厂按 provider 名分派到对应适配器类,构造其真实客户端。
|
||||
|
||||
- `anthropic` → `AnthropicAdapter`(懒构建 `AsyncAnthropic`,不在模块顶层硬 import SDK)。
|
||||
- `gemini`/`google` → `GeminiAdapter`(懒构建 `google.genai.Client`)。
|
||||
- `kimi-code` → `KimiCodeAdapter`(OAuth access token 当 api_key + coding base + 伪造头)。
|
||||
- `kimi-code-key` → `KimiCodeKeyAdapter`(静态 Console Key + coding base,**无伪造头**)。
|
||||
- 其余(deepseek/kimi/qwen/glm/openai)→ `OpenAICompatAdapter(provider, AsyncOpenAI(...))`。
|
||||
|
||||
适配器本身仍接注入客户端(测试零联网);本工厂从 api_key 构造**真实**客户端,
|
||||
故单测只断分派逻辑(按 provider 名选哪个适配器类),不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .adapters.anthropic import AnthropicAdapter, AnthropicClient
|
||||
from .adapters.base import ProviderAdapter
|
||||
from .adapters.gemini import GeminiAdapter, GeminiClient
|
||||
from .adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter, build_kimi_code_client
|
||||
from .adapters.kimi_code_key import (
|
||||
KIMI_CODE_KEY_PROVIDER,
|
||||
KimiCodeKeyAdapter,
|
||||
build_kimi_code_key_client,
|
||||
)
|
||||
from .adapters.openai_compat import OpenAICompatAdapter
|
||||
|
||||
# 走专用 SDK 适配器的 provider 名(其余一律走 OpenAI 兼容)。
|
||||
_ANTHROPIC_PROVIDERS = frozenset({"anthropic"})
|
||||
_GEMINI_PROVIDERS = frozenset({"gemini", "google"})
|
||||
|
||||
|
||||
def _build_anthropic(provider: str, *, api_key: str, base_url: str | None) -> ProviderAdapter:
|
||||
# 懒 import:SDK 仅在真实接线 Anthropic 时才需要(单测不触发此分支)。
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
kwargs: dict[str, object] = {"api_key": api_key}
|
||||
if base_url is not None:
|
||||
kwargs["base_url"] = base_url
|
||||
client = AsyncAnthropic(**kwargs) # type: ignore[arg-type]
|
||||
# `AsyncAnthropic` 满足 `AnthropicClient` Protocol(read-only `messages` 不影响鸭子用法);
|
||||
# cast 跨过 Protocol settable-attr 静态约束(适配器仅读取该属性)。
|
||||
return AnthropicAdapter(provider, cast("AnthropicClient", client))
|
||||
|
||||
|
||||
def _build_gemini(provider: str, *, api_key: str, base_url: str | None) -> ProviderAdapter:
|
||||
# 懒 import:`google-genai` 仅在真实接线 Gemini 时才需要。
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
# `genai.Client` 满足 `GeminiClient` Protocol(用其 `.aio.models`);cast 跨过
|
||||
# Protocol settable-attr 静态约束(适配器仅读取 `.aio`)。
|
||||
return GeminiAdapter(provider, cast("GeminiClient", client))
|
||||
|
||||
|
||||
def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter:
|
||||
"""按 provider 名构造对应适配器,从 api_key(+可选 base_url)建真实客户端。
|
||||
|
||||
`build_gateway_for_tier`(apps/api)逐 provider 调用本工厂,组装回退链的 adapters。
|
||||
"""
|
||||
if provider == KIMI_CODE_PROVIDER:
|
||||
# Kimi 订阅 plan(OAuth):api_key = OAuth access token;coding base + 伪造头(缺头 403)。
|
||||
return KimiCodeAdapter(build_kimi_code_client(api_key, base_url=base_url))
|
||||
if provider == KIMI_CODE_KEY_PROVIDER:
|
||||
# Kimi 订阅 plan(静态 Console Key,ToS 合规):coding base + 纯 bearer,**无伪造头**;
|
||||
# 结构化走 JSON 模式(thinking 兼容)。
|
||||
return KimiCodeKeyAdapter(build_kimi_code_key_client(api_key, base_url=base_url))
|
||||
if provider in _ANTHROPIC_PROVIDERS:
|
||||
return _build_anthropic(provider, api_key=api_key, base_url=base_url)
|
||||
if provider in _GEMINI_PROVIDERS:
|
||||
return _build_gemini(provider, api_key=api_key, base_url=base_url)
|
||||
return OpenAICompatAdapter(provider, AsyncOpenAI(api_key=api_key, base_url=base_url))
|
||||
@@ -1,24 +1,56 @@
|
||||
"""网关核心:路由 → 调用适配器 → 记账 → 返回(ARCH §4.1–4.3/§4.8)。
|
||||
"""网关核心:路由 → 回退链 → 重试/熔断 → 调用适配器 → 记账 → 返回。
|
||||
|
||||
ARCH §4.3(路由)/§4.4(能力协商降级)/§4.5(回退/重试/熔断)/§4.8(记账)。
|
||||
|
||||
T5.4 多 provider 韧性:
|
||||
- **回退链**:`chain_resolver(tier)` 返 [(provider, model), ...];主失败→退避重试→
|
||||
仍失败切下一个;首个成功者服务并标 `served_by.fell_back`(非链首即 True)。
|
||||
- **熔断**:`CircuitBreaker` 按 provider 连续失败计数,超阈值短时熔断、直接跳过。
|
||||
- **能力降级**:结构化输出请求优先选声明支持的 provider;链上无支持者则降级用
|
||||
首个可用(适配器自走 instructor JSON 提示),标 `served_by.degraded`。
|
||||
- **重试归网关**(不变量):瞬时错误(TransientProviderError / RATE_LIMITED)经
|
||||
tenacity 指数退避重试 R 次;节点只感知干净的最终失败,绝不自循环。
|
||||
|
||||
M1 单 provider,无回退/熔断(那是 M5/T5.4)。流式经 `stream()` 归一为 `Delta`。
|
||||
日志脱敏:只记长度,绝不记原文/api key(不变量、§9.3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
import structlog
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from .adapters.base import ProviderAdapter, ProviderUsage
|
||||
from .adapters.base import ProviderAdapter, ProviderResult, ProviderUsage
|
||||
from .errors import TransientProviderError
|
||||
from .ledger import LedgerSink
|
||||
from .pricing import cost_minor
|
||||
from .routing import Route, resolve_route
|
||||
from .routing import Route, resolve_chain
|
||||
from .types import Delta, LlmRequest, LlmResponse, ServedBy, Tier, Usage
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 退避:min 0.05s、max 2s(确定性测试通过 max_retries 控制次数,退避不影响断言)。
|
||||
_RETRY_MIN_SECONDS = 0.05
|
||||
_RETRY_MAX_SECONDS = 2.0
|
||||
_DEFAULT_MAX_RETRIES = 2
|
||||
_DEFAULT_BREAKER_THRESHOLD = 5
|
||||
_DEFAULT_BREAKER_RESET_SECONDS = 30.0
|
||||
|
||||
|
||||
def _is_retryable(exc: BaseException) -> bool:
|
||||
"""瞬时、可退避重试 + 可回退的错误:TransientProviderError 或 RATE_LIMITED。"""
|
||||
if isinstance(exc, TransientProviderError):
|
||||
return True
|
||||
return isinstance(exc, AppError) and exc.code == ErrorCode.RATE_LIMITED
|
||||
|
||||
|
||||
def _input_len(req: LlmRequest) -> int:
|
||||
if isinstance(req.input, str):
|
||||
@@ -26,22 +58,92 @@ def _input_len(req: LlmRequest) -> int:
|
||||
return sum(len(b.text) for b in req.input)
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""每 provider 连续失败计数 → 超阈值短时熔断;成功清零;冷却后半开放行。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
threshold: int = _DEFAULT_BREAKER_THRESHOLD,
|
||||
reset_seconds: float = _DEFAULT_BREAKER_RESET_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._reset_seconds = reset_seconds
|
||||
self._clock = clock
|
||||
self._failures: dict[str, int] = {}
|
||||
self._opened_at: dict[str, float] = {}
|
||||
|
||||
def is_open(self, provider: str) -> bool:
|
||||
opened = self._opened_at.get(provider)
|
||||
if opened is None:
|
||||
return False
|
||||
if self._clock() - opened >= self._reset_seconds:
|
||||
# 冷却窗口过 → 半开:清状态、放行一次试探。
|
||||
self._opened_at.pop(provider, None)
|
||||
self._failures.pop(provider, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def record_failure(self, provider: str) -> None:
|
||||
count = self._failures.get(provider, 0) + 1
|
||||
self._failures[provider] = count
|
||||
if count >= self._threshold:
|
||||
self._opened_at[provider] = self._clock()
|
||||
|
||||
def record_success(self, provider: str) -> None:
|
||||
self._failures.pop(provider, None)
|
||||
self._opened_at.pop(provider, None)
|
||||
|
||||
|
||||
class Gateway:
|
||||
def __init__(
|
||||
self,
|
||||
adapters: dict[str, ProviderAdapter],
|
||||
ledger: LedgerSink,
|
||||
resolver: Callable[[Tier], Route] = resolve_route,
|
||||
*,
|
||||
chain_resolver: Callable[[Tier], list[Route]] | None = None,
|
||||
resolver: Callable[[Tier], Route] | None = None,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
breaker: CircuitBreaker | None = None,
|
||||
) -> None:
|
||||
self._adapters = adapters
|
||||
self._ledger = ledger
|
||||
self._resolve = resolver
|
||||
# `resolver`(单路由,C1/M1 兼容)会被包成单元素链;优先用 `chain_resolver`(回退链)。
|
||||
if chain_resolver is not None:
|
||||
self._resolve_chain = chain_resolver
|
||||
elif resolver is not None:
|
||||
single = resolver
|
||||
|
||||
def _adapter_for(self, provider: str) -> ProviderAdapter:
|
||||
adapter = self._adapters.get(provider)
|
||||
if adapter is None:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, f"no adapter for provider {provider!r}")
|
||||
return adapter
|
||||
def _as_chain(tier: Tier) -> list[Route]:
|
||||
return [single(tier)]
|
||||
|
||||
self._resolve_chain = _as_chain
|
||||
else:
|
||||
self._resolve_chain = resolve_chain
|
||||
self._max_retries = max_retries
|
||||
self._breaker = breaker or CircuitBreaker()
|
||||
|
||||
# ---- 路由 / 链选择 ----
|
||||
|
||||
def _ordered_chain(self, req: LlmRequest) -> list[Route]:
|
||||
"""据能力协商重排回退链:结构化输出请求优先把声明支持的 provider 提前。
|
||||
|
||||
仅在请求带 `output_schema` 时重排——把支持结构化输出的(且已注册适配器、未熔断)
|
||||
provider 提到前面,降低降级概率(§4.4)。保持相对顺序稳定。
|
||||
"""
|
||||
chain = self._resolve_chain(req.tier)
|
||||
if req.output_schema is None:
|
||||
return chain
|
||||
capable: list[Route] = []
|
||||
rest: list[Route] = []
|
||||
for route in chain:
|
||||
adapter = self._adapters.get(route.provider)
|
||||
if adapter is not None and adapter.capabilities().structured_output:
|
||||
capable.append(route)
|
||||
else:
|
||||
rest.append(route)
|
||||
return capable + rest
|
||||
|
||||
def _usage(self, route: Route, pu: ProviderUsage) -> Usage:
|
||||
cost, currency = cost_minor(route.provider, route.model, pu.input_tokens, pu.output_tokens)
|
||||
@@ -55,7 +157,19 @@ class Gateway:
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
def _log_call(self, req: LlmRequest, usage: Usage, *, stream: bool) -> None:
|
||||
def _served_by(
|
||||
self, route: Route, req: LlmRequest, adapter: ProviderAdapter, *, fell_back: bool
|
||||
) -> ServedBy:
|
||||
degraded = bool(
|
||||
req.output_schema is not None and not adapter.capabilities().structured_output
|
||||
)
|
||||
return ServedBy(
|
||||
provider=route.provider, model=route.model, fell_back=fell_back, degraded=degraded
|
||||
)
|
||||
|
||||
def _log_call(
|
||||
self, req: LlmRequest, usage: Usage, served_by: ServedBy, *, stream: bool
|
||||
) -> None:
|
||||
log.info(
|
||||
"llm_call",
|
||||
provider=usage.provider,
|
||||
@@ -67,33 +181,116 @@ class Gateway:
|
||||
cost_minor=usage.cost_minor,
|
||||
currency=usage.currency,
|
||||
stream=stream,
|
||||
fell_back=served_by.fell_back,
|
||||
degraded=served_by.degraded,
|
||||
input_chars=_input_len(req),
|
||||
project_id=str(req.scope.project_id) if req.scope.project_id else None,
|
||||
)
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
route = self._resolve(req.tier)
|
||||
adapter = self._adapter_for(route.provider)
|
||||
result = await adapter.complete(req, route.model)
|
||||
usage = self._usage(route, result.usage)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, stream=False)
|
||||
return LlmResponse(
|
||||
text=result.text,
|
||||
parsed=result.parsed,
|
||||
usage=usage,
|
||||
served_by=ServedBy(provider=route.provider, model=route.model),
|
||||
async def _retrying(self) -> AsyncRetrying:
|
||||
return AsyncRetrying(
|
||||
stop=stop_after_attempt(self._max_retries + 1),
|
||||
wait=wait_exponential(min=_RETRY_MIN_SECONDS, max=_RETRY_MAX_SECONDS),
|
||||
retry=retry_if_exception(_is_retryable),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
# ---- run(非流式)----
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
chain = self._ordered_chain(req)
|
||||
last_error: Exception | None = None
|
||||
for idx, route in enumerate(chain):
|
||||
adapter = self._adapters.get(route.provider)
|
||||
if adapter is None:
|
||||
log.warning("llm_route_no_adapter", provider=route.provider, tier=req.tier)
|
||||
continue
|
||||
if self._breaker.is_open(route.provider):
|
||||
log.warning("llm_circuit_open_skip", provider=route.provider, tier=req.tier)
|
||||
continue
|
||||
try:
|
||||
result = await self._complete_with_retry(adapter, req, route.model)
|
||||
except Exception as exc: # noqa: BLE001 — 链内逐 provider 兜底,最终统一上抛
|
||||
if not _is_retryable(exc):
|
||||
raise
|
||||
self._breaker.record_failure(route.provider)
|
||||
last_error = exc
|
||||
log.warning(
|
||||
"llm_provider_failed",
|
||||
provider=route.provider,
|
||||
tier=req.tier,
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
continue
|
||||
self._breaker.record_success(route.provider)
|
||||
fell_back = idx > 0
|
||||
served_by = self._served_by(route, req, adapter, fell_back=fell_back)
|
||||
usage = self._usage(route, result.usage)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, served_by, stream=False)
|
||||
return LlmResponse(
|
||||
text=result.text, parsed=result.parsed, usage=usage, served_by=served_by
|
||||
)
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"所有 provider 回退链耗尽,请稍后重试或更换档位",
|
||||
{"tier": req.tier, "last_error": type(last_error).__name__ if last_error else None},
|
||||
)
|
||||
|
||||
async def _complete_with_retry(
|
||||
self, adapter: ProviderAdapter, req: LlmRequest, model: str
|
||||
) -> ProviderResult:
|
||||
retrying = await self._retrying()
|
||||
async for attempt in retrying:
|
||||
with attempt:
|
||||
return await adapter.complete(req, model)
|
||||
raise AssertionError("unreachable: reraise=True")
|
||||
|
||||
# ---- stream(流式)----
|
||||
|
||||
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
|
||||
route = self._resolve(req.tier)
|
||||
adapter = self._adapter_for(route.provider)
|
||||
final = ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
async for chunk in adapter.stream(req, route.model):
|
||||
if chunk.text:
|
||||
yield Delta(text=chunk.text)
|
||||
if chunk.usage is not None:
|
||||
final = chunk.usage
|
||||
usage = self._usage(route, final)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, stream=True)
|
||||
chain = self._ordered_chain(req)
|
||||
last_error: Exception | None = None
|
||||
for idx, route in enumerate(chain):
|
||||
adapter = self._adapters.get(route.provider)
|
||||
if adapter is None:
|
||||
continue
|
||||
if self._breaker.is_open(route.provider):
|
||||
continue
|
||||
# 流式回退:仅在「尚未吐出任何 token」前可切。首块产出后失败属中途失败,
|
||||
# 不静默重连(§4.5:已存部分留 draft,节点报错停在 write 前 checkpoint)。
|
||||
try:
|
||||
stream_iter = adapter.stream(req, route.model)
|
||||
final = ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
started = False
|
||||
async for chunk in stream_iter:
|
||||
if chunk.text:
|
||||
started = True
|
||||
yield Delta(text=chunk.text)
|
||||
if chunk.usage is not None:
|
||||
final = chunk.usage
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if started or not _is_retryable(exc):
|
||||
raise
|
||||
self._breaker.record_failure(route.provider)
|
||||
last_error = exc
|
||||
log.warning(
|
||||
"llm_provider_failed",
|
||||
provider=route.provider,
|
||||
tier=req.tier,
|
||||
error=type(exc).__name__,
|
||||
stream=True,
|
||||
)
|
||||
continue
|
||||
self._breaker.record_success(route.provider)
|
||||
fell_back = idx > 0
|
||||
served_by = self._served_by(route, req, adapter, fell_back=fell_back)
|
||||
usage = self._usage(route, final)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, served_by, stream=True)
|
||||
return
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"所有 provider 回退链耗尽,请稍后重试或更换档位",
|
||||
{"tier": req.tier, "last_error": type(last_error).__name__ if last_error else None},
|
||||
)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""档位路由:tier -> (provider, model)(ARCH §4.3)。
|
||||
"""档位路由:tier -> 回退链 [(provider, model), ...](ARCH §4.3/§4.5)。
|
||||
|
||||
M1 只读全局默认(config.tier_defaults,形如 "deepseek:deepseek-chat");
|
||||
作品级 / Skill 级覆盖留待后续(§4.3 三级解析)。
|
||||
- `resolve_route(tier)`:单路由(M1 兼容;返回链首)。
|
||||
- `resolve_chain(tier)`:回退链。默认实现只读全局 `config.tier_defaults`(单元素链)。
|
||||
真正的多 provider 回退链由 apps/api 按 DB `tier_routing.fallback` 构造一个
|
||||
`ChainResolver` 注入网关(§4.3 三级解析 + §4.5 回退链)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ww_config import get_settings
|
||||
@@ -19,11 +22,42 @@ class Route:
|
||||
model: str
|
||||
|
||||
|
||||
def resolve_route(tier: Tier) -> Route:
|
||||
spec = get_settings().tier_defaults.get(tier)
|
||||
if not spec:
|
||||
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
|
||||
# 解析器缝:tier -> 回退链。apps/api 注入据 DB tier_routing 构造的实现。
|
||||
ChainResolver = Callable[[Tier], "list[Route]"]
|
||||
|
||||
|
||||
def _parse_spec(spec: str) -> Route:
|
||||
provider, sep, model = spec.partition(":")
|
||||
if not sep or not provider or not model:
|
||||
raise ValueError(f"invalid tier route {spec!r}; expected 'provider:model'")
|
||||
return Route(provider=provider, model=model)
|
||||
|
||||
|
||||
def resolve_route(tier: Tier) -> Route:
|
||||
spec = get_settings().tier_defaults.get(tier)
|
||||
if not spec:
|
||||
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
|
||||
return _parse_spec(spec)
|
||||
|
||||
|
||||
def resolve_chain(tier: Tier) -> list[Route]:
|
||||
"""默认链解析:仅全局默认(单元素)。多 provider 回退由注入的 `ChainResolver` 提供。"""
|
||||
return [resolve_route(tier)]
|
||||
|
||||
|
||||
def chain_from_routing(tier: Tier, primary: str, fallback: list[str]) -> list[Route]:
|
||||
"""据 DB `tier_routing` 行(primary `provider:model` + fallback 列表)构造回退链。
|
||||
|
||||
供 apps/api 包成 `ChainResolver` 注入网关;放这里以便单测覆盖解析逻辑。
|
||||
去重保序:同一 (provider, model) 只保留首次出现。
|
||||
"""
|
||||
routes: list[Route] = [_parse_spec(primary)]
|
||||
routes.extend(_parse_spec(s) for s in fallback)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique: list[Route] = []
|
||||
for r in routes:
|
||||
key = (r.provider, r.model)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
@@ -56,11 +56,17 @@ class Usage(BaseModel):
|
||||
|
||||
|
||||
class ServedBy(BaseModel):
|
||||
"""实际服务方;`fell_back` 标记是否走了回退链(M5 才有回退,M1 恒 False)。"""
|
||||
"""实际服务方(C1 扩,T5.4)。
|
||||
|
||||
- `fell_back`:是否走了回退链(主模型失败/熔断后由链上后续 provider 服务)。
|
||||
- `degraded`:是否发生能力降级(如所选 provider 不支持原生结构化输出,
|
||||
改走 instructor JSON-提示路径)。两者仅观测/记账标注,对上层正确性透明。
|
||||
"""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
fell_back: bool = False
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
class LlmResponse(BaseModel):
|
||||
|
||||
25
packages/skills/pyproject.toml
Normal file
25
packages/skills/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "ww-skills"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.7",
|
||||
"sqlalchemy>=2.0",
|
||||
"ww-shared",
|
||||
"ww-db",
|
||||
"ww-agents",
|
||||
"ww-llm-gateway",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["ww_skills"]
|
||||
|
||||
[tool.uv.sources]
|
||||
ww-shared = { workspace = true }
|
||||
ww-db = { workspace = true }
|
||||
ww-agents = { workspace = true }
|
||||
ww-llm-gateway = { workspace = true }
|
||||
130
packages/skills/tests/test_skill_permissions.py
Normal file
130
packages/skills/tests/test_skill_permissions.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""T5.5 表权限沙箱单测(ARCH §5.6:声明式权限 + apply 层白名单,非进程沙箱)。
|
||||
|
||||
纯函数、无 IO:
|
||||
- `filter_reads`:注入时只把 skill 声明 `reads` 的表数据喂给它(越权读 → 丢弃,不喂)。
|
||||
- `partition_writes`:写库时只应用 skill 声明 `writes` 的产出字段(越权写 → 丢弃 + 审计),
|
||||
返回 `(allowed, rejected)` 两份,调用方据此落库 allowed、log rejected。
|
||||
- `validate_declaration`:加载/注册 skill 时校验其声明的 `reads/writes` 全在已知表白名单内
|
||||
(杜绝声明指向不存在的表);越界 → AppError(VALIDATION)。
|
||||
不变量 #3:自定义 skill 产出仍过验收 gate——本层只做白名单裁剪,不开写后门。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_agents import AgentSpec
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import (
|
||||
KNOWN_TABLES,
|
||||
filter_reads,
|
||||
partition_writes,
|
||||
validate_declaration,
|
||||
)
|
||||
|
||||
|
||||
def _spec(*, reads: list[str], writes: list[str], scope: str = "custom") -> AgentSpec:
|
||||
return AgentSpec(
|
||||
name="custom_skill",
|
||||
tier="writer",
|
||||
system_prompt="x",
|
||||
reads=reads,
|
||||
writes=writes,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
# ---- filter_reads ----
|
||||
|
||||
|
||||
def test_filter_reads_keeps_only_declared_tables() -> None:
|
||||
spec = _spec(reads=["world_entities"], writes=[])
|
||||
available = {
|
||||
"world_entities": [{"name": "灵根"}],
|
||||
"characters": [{"name": "主角"}], # 未声明 → 必须丢弃
|
||||
}
|
||||
|
||||
fed = filter_reads(spec, available)
|
||||
|
||||
assert fed == {"world_entities": [{"name": "灵根"}]}
|
||||
|
||||
|
||||
def test_filter_reads_ignores_declared_table_absent_from_available() -> None:
|
||||
spec = _spec(reads=["world_entities", "characters"], writes=[])
|
||||
available = {"world_entities": [{"name": "灵根"}]}
|
||||
|
||||
fed = filter_reads(spec, available)
|
||||
|
||||
assert fed == {"world_entities": [{"name": "灵根"}]}
|
||||
|
||||
|
||||
def test_filter_reads_empty_declaration_feeds_nothing() -> None:
|
||||
spec = _spec(reads=[], writes=[])
|
||||
available = {"world_entities": [{"name": "灵根"}]}
|
||||
|
||||
assert filter_reads(spec, available) == {}
|
||||
|
||||
|
||||
# ---- partition_writes ----
|
||||
|
||||
|
||||
def test_partition_writes_drops_over_permission_fields() -> None:
|
||||
spec = _spec(reads=[], writes=["world_entities"])
|
||||
produced = {
|
||||
"world_entities": [{"name": "新势力"}],
|
||||
"characters": [{"name": "越权角色"}], # 越权 → 丢弃 + 审计
|
||||
}
|
||||
|
||||
allowed, rejected = partition_writes(spec, produced)
|
||||
|
||||
assert allowed == {"world_entities": [{"name": "新势力"}]}
|
||||
assert rejected == ["characters"]
|
||||
|
||||
|
||||
def test_partition_writes_all_allowed_yields_empty_rejected() -> None:
|
||||
spec = _spec(reads=[], writes=["world_entities", "characters"])
|
||||
produced: dict[str, object] = {"world_entities": [], "characters": []}
|
||||
|
||||
allowed, rejected = partition_writes(spec, produced)
|
||||
|
||||
assert set(allowed) == {"world_entities", "characters"}
|
||||
assert rejected == []
|
||||
|
||||
|
||||
def test_partition_writes_no_declared_writes_rejects_all() -> None:
|
||||
spec = _spec(reads=[], writes=[])
|
||||
produced = {"world_entities": [{"name": "x"}]}
|
||||
|
||||
allowed, rejected = partition_writes(spec, produced)
|
||||
|
||||
assert allowed == {}
|
||||
assert rejected == ["world_entities"]
|
||||
|
||||
|
||||
# ---- validate_declaration ----
|
||||
|
||||
|
||||
def test_validate_declaration_passes_for_known_tables() -> None:
|
||||
spec = _spec(reads=["world_entities"], writes=["world_entities"])
|
||||
# 不抛即通过
|
||||
validate_declaration(spec)
|
||||
|
||||
|
||||
def test_validate_declaration_rejects_unknown_read_table() -> None:
|
||||
spec = _spec(reads=["secret_table"], writes=[])
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
validate_declaration(spec)
|
||||
|
||||
assert exc.value.code is ErrorCode.VALIDATION
|
||||
assert "secret_table" in str(exc.value.details)
|
||||
|
||||
|
||||
def test_validate_declaration_rejects_unknown_write_table() -> None:
|
||||
# 系统表 users 不在创作表白名单 → 越权写声明被拒。
|
||||
assert "users" not in KNOWN_TABLES
|
||||
spec = _spec(reads=[], writes=["users"])
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
validate_declaration(spec)
|
||||
|
||||
assert exc.value.code is ErrorCode.VALIDATION
|
||||
103
packages/skills/tests/test_skill_registry.py
Normal file
103
packages/skills/tests/test_skill_registry.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""T5.5 Skill registry loader 单测(ARCH §5.6 加载段)。
|
||||
|
||||
`SkillRegistry` 从 `SkillRepo`(DB 行抽象)加载声明式 `AgentSpec`(builtin/custom/community),
|
||||
提供 `get(name)` / `list_scope(scope)` / `names()`。加载时跑 `validate_declaration`——
|
||||
越权声明(reads/writes 指向未知表)的 skill 直接拒绝(不入册),守 §5.6 表权限契约。
|
||||
|
||||
纯内存 fake `SkillRepo`,无 DB:注册行只是声明数据,registry 转成 frozen AgentSpec。
|
||||
不变量 #2:行里只带 tier,registry 不解析具体 model(model 解析在网关)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_agents import AgentSpec
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SkillRecord, SkillRegistry
|
||||
|
||||
|
||||
def _record(
|
||||
name: str,
|
||||
*,
|
||||
scope: str = "custom",
|
||||
reads: list[str] | None = None,
|
||||
writes: list[str] | None = None,
|
||||
) -> SkillRecord:
|
||||
return SkillRecord(
|
||||
name=name,
|
||||
scope=scope,
|
||||
tier="writer",
|
||||
system_prompt=f"prompt for {name}",
|
||||
reads=reads or [],
|
||||
writes=writes or [],
|
||||
genre=None,
|
||||
)
|
||||
|
||||
|
||||
class _FakeSkillRepo:
|
||||
def __init__(self, records: list[SkillRecord]) -> None:
|
||||
self._records = records
|
||||
|
||||
async def list_all(self) -> list[SkillRecord]:
|
||||
return list(self._records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_builds_specs_keyed_by_name() -> None:
|
||||
repo = _FakeSkillRepo(
|
||||
[
|
||||
_record(
|
||||
"worldgen",
|
||||
scope="builtin",
|
||||
reads=["world_entities"],
|
||||
writes=["world_entities"],
|
||||
),
|
||||
_record("cpgen", scope="custom", reads=["characters"]),
|
||||
]
|
||||
)
|
||||
|
||||
registry = await SkillRegistry.load(repo)
|
||||
|
||||
assert set(registry.names()) == {"worldgen", "cpgen"}
|
||||
spec = registry.get("worldgen")
|
||||
assert isinstance(spec, AgentSpec)
|
||||
assert spec.tier == "writer"
|
||||
assert spec.reads == ["world_entities"]
|
||||
assert spec.scope == "builtin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_skill_raises_not_found() -> None:
|
||||
registry = await SkillRegistry.load(_FakeSkillRepo([_record("a")]))
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
registry.get("missing")
|
||||
|
||||
assert exc.value.code is ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_scope_filters_by_scope() -> None:
|
||||
repo = _FakeSkillRepo(
|
||||
[
|
||||
_record("b1", scope="builtin"),
|
||||
_record("c1", scope="custom"),
|
||||
_record("c2", scope="custom"),
|
||||
]
|
||||
)
|
||||
|
||||
registry = await SkillRegistry.load(repo)
|
||||
|
||||
assert {s.name for s in registry.list_scope("custom")} == {"c1", "c2"}
|
||||
assert {s.name for s in registry.list_scope("builtin")} == {"b1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_rejects_over_permission_skill() -> None:
|
||||
# 越权声明(reads 指向未知表)→ 加载即拒绝(守 §5.6),不静默入册。
|
||||
repo = _FakeSkillRepo([_record("evil", reads=["secret_table"])])
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await SkillRegistry.load(repo)
|
||||
|
||||
assert exc.value.code is ErrorCode.VALIDATION
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Skill 运行时(T5.5 / ARCH §5.6)——声明式权限 + apply 层白名单。
|
||||
|
||||
Skill registry loader(`skill_registry`)+ 表权限沙箱(`skill_permissions`)的**实现**就在
|
||||
本包(`packages/skills` 是 uv workspace member)。消费方(apps/api 编排/写库 apply 层)
|
||||
直接 `from ww_skills import ...`。
|
||||
|
||||
注:§5.6 的强制点在编排/写库层;本包提供声明式 skill 的加载 + 白名单裁剪纯函数,
|
||||
调用方(生成入库端点)落 `partition_writes` 的 allowed、仍须经验收 gate 才真入库(不变量 #3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_skills.skill_permissions import (
|
||||
KNOWN_TABLES,
|
||||
filter_reads,
|
||||
partition_writes,
|
||||
validate_declaration,
|
||||
)
|
||||
from ww_skills.skill_registry import (
|
||||
SkillRecord,
|
||||
SkillRegistry,
|
||||
SkillRepo,
|
||||
SqlSkillRepo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"KNOWN_TABLES",
|
||||
"filter_reads",
|
||||
"partition_writes",
|
||||
"validate_declaration",
|
||||
"SkillRecord",
|
||||
"SkillRegistry",
|
||||
"SkillRepo",
|
||||
"SqlSkillRepo",
|
||||
]
|
||||
|
||||
71
packages/skills/ww_skills/skill_permissions.py
Normal file
71
packages/skills/ww_skills/skill_permissions.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Skill 表权限沙箱(ARCH §5.6 / PRODUCT_SPEC §5.5)——纯函数,无 IO。
|
||||
|
||||
这**不是进程级沙箱**:声明式 Skill 不执行代码。强制点在编排/写库层("声明式权限 +
|
||||
apply 层白名单"):
|
||||
- `filter_reads`:注入时只把 skill 声明 `reads` 的表数据喂给它(未声明的表丢弃,不喂)。
|
||||
- `partition_writes`:写库时只应用 skill 声明 `writes` 的产出(越权字段丢弃 + 审计),
|
||||
返回 `(allowed, rejected)`——调用方落 `allowed`、log `rejected`。不变量 #3:
|
||||
allowed 仍须经验收 gate 才真入库,本层只裁剪白名单,不开写后门。
|
||||
- `validate_declaration`:注册/加载 skill 时校验 `reads/writes` 全在已知创作表白名单内
|
||||
(杜绝声明指向不存在/系统表);越界 → AppError(VALIDATION)。
|
||||
|
||||
不可变:所有函数返回新 dict/list,从不原地改入参(全局 immutability 约定)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ww_agents import AgentSpec
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
# 声明式 Skill 允许声明读/写的创作表白名单(ARCH §3.1 创作表 + rules;
|
||||
# 系统/运营表 users/jobs/usage_ledger/provider_credentials/tier_routing/skills 不在此列)。
|
||||
KNOWN_TABLES: frozenset[str] = frozenset(
|
||||
{
|
||||
"projects",
|
||||
"characters",
|
||||
"world_entities",
|
||||
"outline",
|
||||
"chapter_digests",
|
||||
"foreshadow",
|
||||
"style_fingerprint",
|
||||
"rules",
|
||||
"chapters",
|
||||
"chapter_reviews",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def filter_reads(spec: AgentSpec, available: dict[str, Any]) -> dict[str, Any]:
|
||||
"""注入白名单:只保留 skill 声明 `reads` 且 `available` 里确有的表(新 dict)。"""
|
||||
declared = set(spec.reads)
|
||||
return {table: data for table, data in available.items() if table in declared}
|
||||
|
||||
|
||||
def partition_writes(spec: AgentSpec, produced: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""写库白名单:把产出分成 (allowed, rejected)。
|
||||
|
||||
`allowed`:产出表 ∈ skill 声明 `writes`(新 dict,调用方落库)。
|
||||
`rejected`:越权产出表名(排序,调用方审计 log + 丢弃)。
|
||||
"""
|
||||
declared = set(spec.writes)
|
||||
allowed: dict[str, Any] = {}
|
||||
rejected: list[str] = []
|
||||
for table, data in produced.items():
|
||||
if table in declared:
|
||||
allowed[table] = data
|
||||
else:
|
||||
rejected.append(table)
|
||||
return allowed, sorted(rejected)
|
||||
|
||||
|
||||
def validate_declaration(spec: AgentSpec) -> None:
|
||||
"""校验 skill 的 `reads/writes` 全在 `KNOWN_TABLES`;越界 → AppError(VALIDATION)。"""
|
||||
unknown = sorted((set(spec.reads) | set(spec.writes)) - KNOWN_TABLES)
|
||||
if unknown:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"Skill「{spec.name}」声明了未知表:{unknown}",
|
||||
{"skill": spec.name, "unknown_tables": unknown},
|
||||
)
|
||||
131
packages/skills/ww_skills/skill_registry.py
Normal file
131
packages/skills/ww_skills/skill_registry.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Skill registry loader(ARCH §5.6 加载段 / PRODUCT_SPEC §5.5)。
|
||||
|
||||
从 `SkillRepo`(`skills` 表的读侧抽象)加载声明式 skill 行 → 转 frozen `AgentSpec`
|
||||
(内置 builtin / 用户 custom / 社区 community 同构)。加载后按 `tier` 经 LLM 网关执行
|
||||
(复用 §5.1 的 agent 机制,本模块**不**重实现网关)。
|
||||
|
||||
加载时对每条声明跑 `validate_declaration`:越权(reads/writes 指向未知表)的 skill
|
||||
直接拒绝(不入册),守 §5.6 表权限契约(不可信用户输入的第一道闸)。
|
||||
|
||||
不变量 #2:行里只带 `tier`(writer/analyst/light),registry 不解析具体 model
|
||||
(model 解析在网关)。registry 加载后不可变(specs 字典只读)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, cast, get_args
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import AgentSpec
|
||||
from ww_db.models import Skill
|
||||
from ww_llm_gateway.types import Tier
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_skills.skill_permissions import validate_declaration
|
||||
|
||||
_VALID_TIERS: frozenset[str] = frozenset(get_args(Tier))
|
||||
|
||||
|
||||
class SkillRecord(BaseModel):
|
||||
"""`skills` 表一行的声明式快照(frozen;snake_case)。
|
||||
|
||||
`input_schema`/`output_schema` 在 DB 里是 JSON Schema dict(用户自定义无 Python 类型),
|
||||
registry 暂以纯声明(prompt + 表权限 + tier)执行;结构化 schema 的运行期绑定属后续,
|
||||
故这里不携带 type[BaseModel](内置 8 agent 仍用各自硬编码的 spec,见 ww_agents)。
|
||||
"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
name: str
|
||||
scope: str # builtin / custom / community
|
||||
tier: Tier
|
||||
system_prompt: str
|
||||
reads: list[str] = []
|
||||
writes: list[str] = []
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
def _to_spec(record: SkillRecord) -> AgentSpec:
|
||||
return AgentSpec(
|
||||
name=record.name,
|
||||
tier=record.tier,
|
||||
system_prompt=record.system_prompt,
|
||||
input_schema=None,
|
||||
output_schema=None,
|
||||
reads=list(record.reads),
|
||||
writes=list(record.writes),
|
||||
genre=record.genre,
|
||||
scope=record.scope,
|
||||
)
|
||||
|
||||
|
||||
class SkillRepo(Protocol):
|
||||
"""`skills` 表读侧接口(registry 加载源;测试注内存 fake)。"""
|
||||
|
||||
async def list_all(self) -> list[SkillRecord]: ...
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
"""加载后的只读 skill 注册表(name → AgentSpec)。"""
|
||||
|
||||
def __init__(self, specs: dict[str, AgentSpec]) -> None:
|
||||
self._specs = dict(specs)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, repo: SkillRepo) -> SkillRegistry:
|
||||
"""从 repo 拉全部声明 → 校验表权限 → 建 frozen AgentSpec 字典。
|
||||
|
||||
越权声明(reads/writes 指向未知表)→ AppError(VALIDATION),加载中断(不入册)。
|
||||
"""
|
||||
specs: dict[str, AgentSpec] = {}
|
||||
for record in await repo.list_all():
|
||||
spec = _to_spec(record)
|
||||
validate_declaration(spec) # 越权 → 抛 VALIDATION
|
||||
specs[spec.name] = spec
|
||||
return cls(specs)
|
||||
|
||||
def get(self, name: str) -> AgentSpec:
|
||||
"""按名取 spec;不存在 → AppError(NOT_FOUND)。"""
|
||||
spec = self._specs.get(name)
|
||||
if spec is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"skill not found: {name}")
|
||||
return spec
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return sorted(self._specs)
|
||||
|
||||
def list_scope(self, scope: str) -> list[AgentSpec]:
|
||||
"""按 scope(builtin/custom/community)过滤,按 name 排序。"""
|
||||
return [s for s in sorted(self._specs.values(), key=lambda s: s.name) if s.scope == scope]
|
||||
|
||||
|
||||
def _row_to_record(row: Skill) -> SkillRecord:
|
||||
# DB `tier` 是裸 str,非法档位 → VALIDATION(不可信用户输入第一道闸)。
|
||||
if row.tier not in _VALID_TIERS:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"Skill「{row.name}」声明了非法档位:{row.tier}",
|
||||
{"skill": row.name, "tier": row.tier},
|
||||
)
|
||||
return SkillRecord(
|
||||
name=row.name,
|
||||
scope=row.scope,
|
||||
tier=cast(Tier, row.tier),
|
||||
system_prompt=row.system_prompt,
|
||||
reads=[str(t) for t in (row.reads or [])],
|
||||
writes=[str(t) for t in (row.writes or [])],
|
||||
genre=row.genre,
|
||||
)
|
||||
|
||||
|
||||
class SqlSkillRepo:
|
||||
"""`skills` 表读侧 SQLAlchemy 实现(registry 加载源;只读)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def list_all(self) -> list[SkillRecord]:
|
||||
rows = (await self._s.execute(select(Skill))).scalars().all()
|
||||
return [_row_to_record(r) for r in rows]
|
||||
Reference in New Issue
Block a user