b-sort 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- b_sort/__init__.py +0 -0
- b_sort/blocks.py +22 -0
- b_sort/config.py +76 -0
- b_sort/fault_tolerance.py +71 -0
- b_sort/graphs/__init__.py +0 -0
- b_sort/graphs/images_sort.py +30 -0
- b_sort/graphs/paper_cluster.py +101 -0
- b_sort/graphs/paper_sort.py +176 -0
- b_sort/graphs/texts_sort.py +30 -0
- b_sort/llm.py +55 -0
- b_sort/nodes/__init__.py +0 -0
- b_sort/nodes/blocks_split.py +23 -0
- b_sort/nodes/data_resolve.py +28 -0
- b_sort/nodes/images_assign.py +264 -0
- b_sort/nodes/output_file_json.py +65 -0
- b_sort/nodes/result_clusters.py +48 -0
- b_sort/nodes/texts_cluster.py +69 -0
- b_sort/nodes/texts_cluster_bbox.py +69 -0
- b_sort/nodes/texts_cluster_non_bbox.py +60 -0
- b_sort/nodes/texts_cluster_resolve.py +26 -0
- b_sort/skills/images-assignment/SKILL.md +57 -0
- b_sort/skills/texts-clustering/SKILL.md +51 -0
- b_sort/skills/texts-clustering/SKILL_bbox.md +51 -0
- b_sort/skills/texts-clustering/SKILL_non_bbox.md +39 -0
- b_sort/skills.py +158 -0
- b_sort/states/__init__.py +0 -0
- b_sort/states/sort_state.py +164 -0
- b_sort-0.1.0.dist-info/METADATA +9 -0
- b_sort-0.1.0.dist-info/RECORD +30 -0
- b_sort-0.1.0.dist-info/WHEEL +4 -0
b_sort/__init__.py
ADDED
|
File without changes
|
b_sort/blocks.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
BLOCKS_FILENAME = "blocks.json"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_blocks(root: Path | None = None) -> list:
|
|
8
|
+
"""加载blocks"""
|
|
9
|
+
root = root or Path.cwd()
|
|
10
|
+
blocks_path = root / BLOCKS_FILENAME
|
|
11
|
+
if not blocks_path.exists():
|
|
12
|
+
return []
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
overrides = json.loads(blocks_path.read_text(encoding="utf-8"))
|
|
16
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
17
|
+
raise TypeError(f"{BLOCKS_FILENAME} 不是合法 JSON: {exc}") from exc
|
|
18
|
+
|
|
19
|
+
if not isinstance(overrides, list):
|
|
20
|
+
raise TypeError(f"{BLOCKS_FILENAME} 顶层必须是 LIST 对象")
|
|
21
|
+
|
|
22
|
+
return overrides
|
b_sort/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from b_sort.states.sort_state import PipelineConfig, SortState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_config(state: SortState) -> PipelineConfig:
|
|
10
|
+
"""从图状态中取出 PipelineConfig,自动适配三种来源:
|
|
11
|
+
|
|
12
|
+
1. CLI 入口(`src/graphs/main.py`)通过 ``ainvoke({"config": config})`` 传入真实实例;
|
|
13
|
+
2. LangGraph Studio 把 ``__start__`` 的 raw input 作为 ``dict`` 透传,``config`` 也是 dict;
|
|
14
|
+
3. ``__start__`` 未传 ``config`` 时字段缺失。
|
|
15
|
+
|
|
16
|
+
第 2/3 种情况下回退到 ``PipelineConfig()`` 默认值;若 ``config`` 是 dict,
|
|
17
|
+
按 ``PipelineConfig.model_fields`` 校验字段并实例化,非法字段让 Pydantic 报错
|
|
18
|
+
上抛 —— 与 ``src/config.py:load_config`` 的语义一致。
|
|
19
|
+
"""
|
|
20
|
+
value = state.get("config")
|
|
21
|
+
if isinstance(value, PipelineConfig):
|
|
22
|
+
return value
|
|
23
|
+
if value is None:
|
|
24
|
+
return PipelineConfig()
|
|
25
|
+
if isinstance(value, dict):
|
|
26
|
+
unknown = set(value) - set(PipelineConfig.model_fields)
|
|
27
|
+
if unknown:
|
|
28
|
+
raise ConfigError(f"{CONFIG_FILENAME} 包含未知字段: {sorted(unknown)}")
|
|
29
|
+
try:
|
|
30
|
+
return PipelineConfig(**value)
|
|
31
|
+
except ValidationError as exc:
|
|
32
|
+
raise ConfigError(f"{CONFIG_FILENAME} 字段非法: {exc}") from exc
|
|
33
|
+
raise ConfigError(
|
|
34
|
+
f"state['config'] 类型不受支持: {type(value).__name__},"
|
|
35
|
+
"应为 PipelineConfig 实例、dict 覆盖或 None"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
CONFIG_FILENAME = "config.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigError(Exception):
|
|
43
|
+
"""配置文件非法(非法 JSON / 字段类型错误 / 非法取值)。"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config(root: Path | None = None) -> PipelineConfig:
|
|
47
|
+
"""加载流水线配置。
|
|
48
|
+
|
|
49
|
+
- 根目录不存在 config.json 时,返回全默认配置;
|
|
50
|
+
- 存在时,仅覆盖文件中出现的字段;
|
|
51
|
+
- 文件非法时抛出 ConfigError,不静默回退默认值。
|
|
52
|
+
|
|
53
|
+
未知字段校验直接读 PipelineConfig.model_fields,故新增配置项(如 skills_dir /
|
|
54
|
+
cluster_skill / topics_skill)无需改动本函数即可被覆盖与校验。
|
|
55
|
+
"""
|
|
56
|
+
root = root or Path.cwd()
|
|
57
|
+
config_path = root / CONFIG_FILENAME
|
|
58
|
+
if not config_path.exists():
|
|
59
|
+
return PipelineConfig()
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
overrides = json.loads(config_path.read_text(encoding="utf-8"))
|
|
63
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
64
|
+
raise ConfigError(f"{CONFIG_FILENAME} 不是合法 JSON: {exc}") from exc
|
|
65
|
+
|
|
66
|
+
if not isinstance(overrides, dict):
|
|
67
|
+
raise ConfigError(f"{CONFIG_FILENAME} 顶层必须是 JSON 对象")
|
|
68
|
+
|
|
69
|
+
unknown = set(overrides) - set(PipelineConfig.model_fields)
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ConfigError(f"{CONFIG_FILENAME} 包含未知字段: {sorted(unknown)}")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return PipelineConfig(**overrides)
|
|
75
|
+
except ValidationError as exc:
|
|
76
|
+
raise ConfigError(f"{CONFIG_FILENAME} 字段非法: {exc}") from exc
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from langgraph.errors import NodeError
|
|
4
|
+
from langgraph.types import Command, RetryPolicy, TimeoutPolicy, default_retry_on
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from b_sort.config import ConfigError
|
|
8
|
+
from b_sort.skills import SkillError
|
|
9
|
+
|
|
10
|
+
# 黑名单优先:重试 N 次也必然失败的确定性错误,直接进入降级路径
|
|
11
|
+
_NON_RETRYABLE: tuple[type[Exception], ...] = (
|
|
12
|
+
ConfigError,
|
|
13
|
+
SkillError,
|
|
14
|
+
TypeError,
|
|
15
|
+
ImportError,
|
|
16
|
+
NameError,
|
|
17
|
+
SyntaxError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# 项目白名单:default_retry_on 默认排除这些类型,但在本项目中它们恰是
|
|
21
|
+
# 「LLM 输出不合格(ValidationError)/ 网络抖动(OSError)/ 结构化输出异常(RuntimeError)」
|
|
22
|
+
# 这三类最该重试的瞬时失败
|
|
23
|
+
_RETRYABLE: tuple[type[Exception], ...] = (
|
|
24
|
+
ValidationError,
|
|
25
|
+
RuntimeError,
|
|
26
|
+
OSError,
|
|
27
|
+
TimeoutError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pipeline_retry_on(exc: Exception) -> bool:
|
|
32
|
+
"""先黑名单、再白名单、最后回落 default_retry_on(NodeTimeoutError 默认可重试)。"""
|
|
33
|
+
if isinstance(exc, _NON_RETRYABLE):
|
|
34
|
+
return False
|
|
35
|
+
if isinstance(exc, _RETRYABLE):
|
|
36
|
+
return True
|
|
37
|
+
return default_retry_on(exc)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DEFAULT_RETRY = RetryPolicy(
|
|
41
|
+
max_attempts=2,
|
|
42
|
+
initial_interval=0.5,
|
|
43
|
+
backoff_factor=2.0,
|
|
44
|
+
max_interval=8.0,
|
|
45
|
+
jitter=True,
|
|
46
|
+
retry_on=pipeline_retry_on,
|
|
47
|
+
)
|
|
48
|
+
DEFAULT_TIMEOUT = TimeoutPolicy(run_timeout=30.0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
LLM_RETRY = RetryPolicy(
|
|
52
|
+
max_attempts=3,
|
|
53
|
+
initial_interval=2.0,
|
|
54
|
+
backoff_factor=2.0,
|
|
55
|
+
max_interval=30.0,
|
|
56
|
+
jitter=True,
|
|
57
|
+
retry_on=pipeline_retry_on,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
LLM_TIMEOUT = TimeoutPolicy(run_timeout=120.0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_degrade_handler(fallback: dict[str, Any], goto: str):
|
|
64
|
+
"""降级处理器工厂:重试耗尽后写告警 + 安全空值产出,并路由到 goto(design D7)。"""
|
|
65
|
+
|
|
66
|
+
def degrade(state: dict[str, Any], error: NodeError) -> Command:
|
|
67
|
+
warning = f"[{error.node}] 重试耗尽后降级: {error.error}"
|
|
68
|
+
print(f"[fault][warn] {warning}")
|
|
69
|
+
return Command(update={**fallback, "warnings": [warning]}, goto=goto)
|
|
70
|
+
|
|
71
|
+
return degrade
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from langgraph.graph import END, START, StateGraph
|
|
2
|
+
from langgraph.types import TimeoutPolicy
|
|
3
|
+
|
|
4
|
+
from b_sort.fault_tolerance import (
|
|
5
|
+
DEFAULT_RETRY,
|
|
6
|
+
LLM_RETRY,
|
|
7
|
+
make_degrade_handler,
|
|
8
|
+
)
|
|
9
|
+
from b_sort.nodes.images_assign import images_assign
|
|
10
|
+
from b_sort.states.sort_state import SortState
|
|
11
|
+
|
|
12
|
+
builder = StateGraph(SortState)
|
|
13
|
+
builder.set_node_defaults(retry_policy=DEFAULT_RETRY)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
builder.add_node(
|
|
17
|
+
"images_assign",
|
|
18
|
+
images_assign,
|
|
19
|
+
retry_policy=LLM_RETRY,
|
|
20
|
+
timeout=TimeoutPolicy(run_timeout=300.0),
|
|
21
|
+
error_handler=make_degrade_handler(
|
|
22
|
+
{"image_assigns": []}, goto=END
|
|
23
|
+
), # 错误handler后面考虑
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
builder.add_edge(START, "images_assign")
|
|
28
|
+
builder.add_edge("images_assign", END)
|
|
29
|
+
|
|
30
|
+
images_sort_graph = builder.compile()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from langgraph.graph import END, START, StateGraph
|
|
4
|
+
|
|
5
|
+
from b_sort.fault_tolerance import (
|
|
6
|
+
DEFAULT_RETRY,
|
|
7
|
+
DEFAULT_TIMEOUT,
|
|
8
|
+
make_degrade_handler,
|
|
9
|
+
)
|
|
10
|
+
from b_sort.graphs.images_sort import images_sort_graph
|
|
11
|
+
from b_sort.graphs.paper_sort import paper_sort
|
|
12
|
+
from b_sort.graphs.texts_sort import texts_sort_graph
|
|
13
|
+
from b_sort.nodes.blocks_split import blocks_split
|
|
14
|
+
from b_sort.nodes.data_resolve import data_resolve
|
|
15
|
+
from b_sort.nodes.output_file_json import output_file_json
|
|
16
|
+
from b_sort.nodes.result_clusters import result_clusters
|
|
17
|
+
from b_sort.nodes.texts_cluster_resolve import texts_cluster_resolve
|
|
18
|
+
from b_sort.states.sort_state import SortState
|
|
19
|
+
|
|
20
|
+
builder = StateGraph(SortState)
|
|
21
|
+
builder.set_node_defaults(retry_policy=DEFAULT_RETRY)
|
|
22
|
+
builder.add_node(
|
|
23
|
+
"data_resolve",
|
|
24
|
+
data_resolve,
|
|
25
|
+
retry_policy=DEFAULT_RETRY,
|
|
26
|
+
timeout=DEFAULT_TIMEOUT,
|
|
27
|
+
error_handler=make_degrade_handler(
|
|
28
|
+
{
|
|
29
|
+
"blocks": [],
|
|
30
|
+
},
|
|
31
|
+
goto=END,
|
|
32
|
+
), # 错误handler后面考虑策略
|
|
33
|
+
)
|
|
34
|
+
builder.add_node(
|
|
35
|
+
"blocks_split",
|
|
36
|
+
blocks_split,
|
|
37
|
+
retry_policy=DEFAULT_RETRY,
|
|
38
|
+
timeout=DEFAULT_TIMEOUT,
|
|
39
|
+
error_handler=make_degrade_handler(
|
|
40
|
+
{"texts": [], "images": []}, goto=END
|
|
41
|
+
), # 错误handler后面考虑策略
|
|
42
|
+
)
|
|
43
|
+
builder.add_node(
|
|
44
|
+
"texts_sort_graph",
|
|
45
|
+
texts_sort_graph,
|
|
46
|
+
error_handler=make_degrade_handler(
|
|
47
|
+
{"text_clusters": []}, goto=END
|
|
48
|
+
), # 错误handler后面考虑策略,也通过edge表现出来
|
|
49
|
+
)
|
|
50
|
+
builder.add_node(
|
|
51
|
+
"images_sort_graph",
|
|
52
|
+
images_sort_graph,
|
|
53
|
+
error_handler=make_degrade_handler({"image_assigns": []}, goto=END),
|
|
54
|
+
)
|
|
55
|
+
builder.add_node(
|
|
56
|
+
"texts_cluster_resolve",
|
|
57
|
+
texts_cluster_resolve,
|
|
58
|
+
retry_policy=DEFAULT_RETRY,
|
|
59
|
+
timeout=DEFAULT_TIMEOUT,
|
|
60
|
+
error_handler=make_degrade_handler({"text_clusters_block": []}, goto=END),
|
|
61
|
+
)
|
|
62
|
+
builder.add_node(
|
|
63
|
+
"result_clusters",
|
|
64
|
+
result_clusters,
|
|
65
|
+
retry_policy=DEFAULT_RETRY,
|
|
66
|
+
timeout=DEFAULT_TIMEOUT,
|
|
67
|
+
error_handler=make_degrade_handler({"result_clusters": []}, goto=END),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
builder.add_node(
|
|
71
|
+
"output_file_json",
|
|
72
|
+
output_file_json,
|
|
73
|
+
retry_policy=DEFAULT_RETRY,
|
|
74
|
+
timeout=DEFAULT_TIMEOUT,
|
|
75
|
+
error_handler=make_degrade_handler({}, goto=END),
|
|
76
|
+
)
|
|
77
|
+
builder.add_edge(START, "data_resolve")
|
|
78
|
+
builder.add_edge("data_resolve", "blocks_split")
|
|
79
|
+
builder.add_edge("blocks_split", "texts_sort_graph")
|
|
80
|
+
builder.add_edge("texts_sort_graph", "texts_cluster_resolve")
|
|
81
|
+
builder.add_edge("texts_cluster_resolve", "images_sort_graph")
|
|
82
|
+
builder.add_edge("images_sort_graph", "result_clusters")
|
|
83
|
+
builder.add_edge("result_clusters", END)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_agent(checkpointer):
|
|
87
|
+
"""CLI 场景用:注入检查点存储,支持断点续跑。"""
|
|
88
|
+
return builder.compile(checkpointer=checkpointer)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def paper_cluster(blocks: list | None = None) -> SortState | None:
|
|
92
|
+
result = await paper_sort(blocks=blocks, build_call=build_agent)
|
|
93
|
+
return result if result else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
from b_sort.blocks import load_blocks
|
|
98
|
+
|
|
99
|
+
blocks = load_blocks()
|
|
100
|
+
result = asyncio.run(paper_cluster(blocks=blocks))
|
|
101
|
+
print("[paper_cluster]", result)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from langgraph.graph import END, START, StateGraph
|
|
2
|
+
|
|
3
|
+
from b_sort.fault_tolerance import (
|
|
4
|
+
DEFAULT_RETRY,
|
|
5
|
+
DEFAULT_TIMEOUT,
|
|
6
|
+
make_degrade_handler,
|
|
7
|
+
)
|
|
8
|
+
from b_sort.graphs.images_sort import images_sort_graph
|
|
9
|
+
from b_sort.graphs.texts_sort import texts_sort_graph
|
|
10
|
+
from b_sort.nodes.blocks_split import blocks_split
|
|
11
|
+
from b_sort.nodes.data_resolve import data_resolve
|
|
12
|
+
from b_sort.nodes.output_file_json import output_file_json
|
|
13
|
+
from b_sort.nodes.result_clusters import result_clusters
|
|
14
|
+
from b_sort.nodes.texts_cluster_resolve import texts_cluster_resolve
|
|
15
|
+
from b_sort.states.sort_state import SortState
|
|
16
|
+
|
|
17
|
+
builder = StateGraph(SortState)
|
|
18
|
+
builder.set_node_defaults(retry_policy=DEFAULT_RETRY)
|
|
19
|
+
builder.add_node(
|
|
20
|
+
"data_resolve",
|
|
21
|
+
data_resolve,
|
|
22
|
+
retry_policy=DEFAULT_RETRY,
|
|
23
|
+
timeout=DEFAULT_TIMEOUT,
|
|
24
|
+
error_handler=make_degrade_handler(
|
|
25
|
+
{
|
|
26
|
+
"blocks": [],
|
|
27
|
+
},
|
|
28
|
+
goto=END,
|
|
29
|
+
), # 错误handler后面考虑策略
|
|
30
|
+
)
|
|
31
|
+
builder.add_node(
|
|
32
|
+
"blocks_split",
|
|
33
|
+
blocks_split,
|
|
34
|
+
retry_policy=DEFAULT_RETRY,
|
|
35
|
+
timeout=DEFAULT_TIMEOUT,
|
|
36
|
+
error_handler=make_degrade_handler(
|
|
37
|
+
{"texts": [], "images": []}, goto=END
|
|
38
|
+
), # 错误handler后面考虑策略
|
|
39
|
+
)
|
|
40
|
+
builder.add_node(
|
|
41
|
+
"texts_sort_graph",
|
|
42
|
+
texts_sort_graph,
|
|
43
|
+
error_handler=make_degrade_handler(
|
|
44
|
+
{"text_clusters": []}, goto=END
|
|
45
|
+
), # 错误handler后面考虑策略,也通过edge表现出来
|
|
46
|
+
)
|
|
47
|
+
builder.add_node(
|
|
48
|
+
"images_sort_graph",
|
|
49
|
+
images_sort_graph,
|
|
50
|
+
error_handler=make_degrade_handler({"image_assigns": []}, goto=END),
|
|
51
|
+
)
|
|
52
|
+
builder.add_node(
|
|
53
|
+
"texts_cluster_resolve",
|
|
54
|
+
texts_cluster_resolve,
|
|
55
|
+
retry_policy=DEFAULT_RETRY,
|
|
56
|
+
timeout=DEFAULT_TIMEOUT,
|
|
57
|
+
error_handler=make_degrade_handler({"text_clusters_block": []}, goto=END),
|
|
58
|
+
)
|
|
59
|
+
builder.add_node(
|
|
60
|
+
"result_clusters",
|
|
61
|
+
result_clusters,
|
|
62
|
+
retry_policy=DEFAULT_RETRY,
|
|
63
|
+
timeout=DEFAULT_TIMEOUT,
|
|
64
|
+
error_handler=make_degrade_handler({"result_clusters": []}, goto=END),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
builder.add_node(
|
|
68
|
+
"output_file_json",
|
|
69
|
+
output_file_json,
|
|
70
|
+
retry_policy=DEFAULT_RETRY,
|
|
71
|
+
timeout=DEFAULT_TIMEOUT,
|
|
72
|
+
error_handler=make_degrade_handler({}, goto=END),
|
|
73
|
+
)
|
|
74
|
+
builder.add_edge(START, "data_resolve")
|
|
75
|
+
builder.add_edge("data_resolve", "blocks_split")
|
|
76
|
+
builder.add_edge("blocks_split", "texts_sort_graph")
|
|
77
|
+
builder.add_edge("texts_sort_graph", "texts_cluster_resolve")
|
|
78
|
+
builder.add_edge("texts_cluster_resolve", "images_sort_graph")
|
|
79
|
+
builder.add_edge("images_sort_graph", "result_clusters")
|
|
80
|
+
builder.add_edge("result_clusters", "output_file_json")
|
|
81
|
+
builder.add_edge("output_file_json", END)
|
|
82
|
+
|
|
83
|
+
agent = builder.compile()
|
|
84
|
+
|
|
85
|
+
import asyncio
|
|
86
|
+
import os
|
|
87
|
+
import signal
|
|
88
|
+
from datetime import UTC, datetime
|
|
89
|
+
|
|
90
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
91
|
+
from langgraph.errors import GraphDrained
|
|
92
|
+
from langgraph.runtime import RunControl
|
|
93
|
+
|
|
94
|
+
from b_sort.blocks import load_blocks
|
|
95
|
+
from b_sort.config import load_config
|
|
96
|
+
from b_sort.skills import load_skill
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_thread_id(config) -> str:
|
|
100
|
+
"""优先 PipelineConfig.thread_id,其次环境变量 TOPIC_THREAD_ID,回落当日日期。"""
|
|
101
|
+
if config.thread_id:
|
|
102
|
+
return config.thread_id
|
|
103
|
+
# 使用本地时间,精确到秒
|
|
104
|
+
now = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d-%H-%M-%S")
|
|
105
|
+
return os.getenv("TOPIC_THREAD_ID") or f"paper-sort-{now}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_agent(checkpointer):
|
|
109
|
+
"""CLI 场景用:注入检查点存储,支持断点续跑。"""
|
|
110
|
+
return builder.compile(checkpointer=checkpointer)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
from collections.abc import Callable
|
|
114
|
+
|
|
115
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
116
|
+
|
|
117
|
+
from b_sort.states.sort_state import PipelineConfig
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def paper_sort(
|
|
121
|
+
blocks: list | None = None,
|
|
122
|
+
config: PipelineConfig | None = None,
|
|
123
|
+
build_call: Callable[[AsyncSqliteSaver], CompiledStateGraph] | None = None,
|
|
124
|
+
) -> SortState | None:
|
|
125
|
+
if config is None:
|
|
126
|
+
config = load_config()
|
|
127
|
+
if blocks is None:
|
|
128
|
+
blocks = load_blocks()
|
|
129
|
+
|
|
130
|
+
skill = load_skill(config.skills_dir, config.texts_cluster_skill)
|
|
131
|
+
print(f"[main] skill 就绪 {skill.name}({len(skill.prompt_block)} 字符)")
|
|
132
|
+
|
|
133
|
+
thread_id = _resolve_thread_id(config)
|
|
134
|
+
print(f"[main] thread_id={thread_id} 检查点库={config.checkpoint_db}")
|
|
135
|
+
|
|
136
|
+
control = RunControl()
|
|
137
|
+
|
|
138
|
+
def _on_signal(signum, frame):
|
|
139
|
+
control.request_drain(f"收到信号 {signal.Signals(signum).name}")
|
|
140
|
+
|
|
141
|
+
signal.signal(signal.SIGTERM, _on_signal)
|
|
142
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
143
|
+
|
|
144
|
+
async with AsyncSqliteSaver.from_conn_string(config.checkpoint_db) as saver:
|
|
145
|
+
if build_call is None:
|
|
146
|
+
graph = build_agent(saver)
|
|
147
|
+
else:
|
|
148
|
+
graph = build_call(saver)
|
|
149
|
+
run_config = {"configurable": {"thread_id": thread_id}}
|
|
150
|
+
snapshot = await graph.aget_state(run_config)
|
|
151
|
+
if snapshot.values:
|
|
152
|
+
# 命中已有检查点:传 None 续跑(已跑完则直接返回旧结果,design D9)
|
|
153
|
+
pending = ", ".join(snapshot.next) if snapshot.next else "无(已跑完)"
|
|
154
|
+
print(
|
|
155
|
+
f"[main] 命中已有检查点(待执行: {pending}),续跑该会话;"
|
|
156
|
+
"如需强制重跑请设置 TOPIC_THREAD_ID"
|
|
157
|
+
)
|
|
158
|
+
input_data = None
|
|
159
|
+
else:
|
|
160
|
+
input_data = {"config": config, "blocks": blocks}
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = await graph.ainvoke(input_data, config=run_config, control=control)
|
|
164
|
+
except GraphDrained:
|
|
165
|
+
print(f"[main] 优雅停机: {control.drain_reason}")
|
|
166
|
+
print(f"[main] 检查点已保存,保持 thread_id={thread_id} 重跑即可续跑")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if control.drain_requested:
|
|
170
|
+
print(f"[main] 停机请求({control.drain_reason})到达时流程恰好已完成")
|
|
171
|
+
print("[main] 完成")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
asyncio.run(paper_sort())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from langgraph.graph import END, START, StateGraph
|
|
2
|
+
from langgraph.types import TimeoutPolicy
|
|
3
|
+
|
|
4
|
+
from b_sort.fault_tolerance import (
|
|
5
|
+
DEFAULT_RETRY,
|
|
6
|
+
LLM_RETRY,
|
|
7
|
+
make_degrade_handler,
|
|
8
|
+
)
|
|
9
|
+
from b_sort.nodes.texts_cluster import texts_cluster
|
|
10
|
+
from b_sort.states.sort_state import SortState
|
|
11
|
+
|
|
12
|
+
builder = StateGraph(SortState)
|
|
13
|
+
builder.set_node_defaults(retry_policy=DEFAULT_RETRY)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
builder.add_node(
|
|
17
|
+
"texts_cluster",
|
|
18
|
+
texts_cluster,
|
|
19
|
+
retry_policy=LLM_RETRY,
|
|
20
|
+
timeout=TimeoutPolicy(run_timeout=300.0),
|
|
21
|
+
error_handler=make_degrade_handler(
|
|
22
|
+
{"text_clusters": []}, goto=END
|
|
23
|
+
), # 错误handler后面考虑
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
builder.add_edge(START, "texts_cluster")
|
|
28
|
+
builder.add_edge("texts_cluster", END)
|
|
29
|
+
|
|
30
|
+
texts_sort_graph = builder.compile()
|
b_sort/llm.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""LLM 模型工厂:ChatOpenAI 指向阿里云 DashScope 兼容模式。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from langchain_core.messages import BaseMessage
|
|
6
|
+
from langchain_openai import ChatOpenAI
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
10
|
+
|
|
11
|
+
# SDK 客户端超时取节点 run_timeout(120s)的约 80%,让 SDK 先于节点超时
|
|
12
|
+
# 抛出可归因的网络错误
|
|
13
|
+
# CLIENT_TIMEOUT_SECONDS = 96
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_chat_model(model: str) -> ChatOpenAI:
|
|
17
|
+
"""创建指向 DashScope 兼容模式的模型,API key 只读 BL_TOKEN 环境变量。
|
|
18
|
+
|
|
19
|
+
max_retries=0:重试职责单一归属图层 RetryPolicy,避免 SDK 重试叠乘(design D4)。
|
|
20
|
+
"""
|
|
21
|
+
api_key = os.getenv("BL_TOKEN", "").strip()
|
|
22
|
+
if not api_key:
|
|
23
|
+
raise RuntimeError("环境变量 BL_TOKEN 未设置,无法调用 LLM")
|
|
24
|
+
return ChatOpenAI(
|
|
25
|
+
api_key=api_key,
|
|
26
|
+
base_url=DASHSCOPE_BASE_URL,
|
|
27
|
+
model=model,
|
|
28
|
+
# temperature=0.3,
|
|
29
|
+
# timeout=CLIENT_TIMEOUT_SECONDS,
|
|
30
|
+
# max_retries=0,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Prompt = str | list[BaseMessage]
|
|
35
|
+
from typing import TypeVar
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T", bound=BaseModel)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def ainvoke_structured(model_name: str, schema: type[T], prompt: Prompt) -> T:
|
|
41
|
+
"""structured output 单次调用;失败直接抛异常,重试由图层 RetryPolicy 承担。"""
|
|
42
|
+
model = make_chat_model(model_name).with_structured_output(schema)
|
|
43
|
+
result = await model.ainvoke(prompt)
|
|
44
|
+
|
|
45
|
+
# model = make_chat_model(model_name)
|
|
46
|
+
|
|
47
|
+
# async for chunk in model.astream(prompt):
|
|
48
|
+
# print(chunk.content, end="", flush=True)
|
|
49
|
+
|
|
50
|
+
if not isinstance(result, schema):
|
|
51
|
+
# TRY004 不适用:TypeError 在重试黑名单内,此处需 RuntimeError 走图层重试
|
|
52
|
+
raise RuntimeError( # noqa: TRY004
|
|
53
|
+
f"{model_name} 结构化输出类型异常: {type(result)}"
|
|
54
|
+
)
|
|
55
|
+
return result
|
b_sort/nodes/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from b_sort.states.sort_state import BlockImage, BlockText, SortState
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
async def blocks_split(state: SortState) -> SortState:
|
|
5
|
+
blocks = state["blocks"]
|
|
6
|
+
|
|
7
|
+
texts: list[BlockText] = []
|
|
8
|
+
images: list[BlockImage] = []
|
|
9
|
+
|
|
10
|
+
print("[blocks_split] start")
|
|
11
|
+
for block in blocks:
|
|
12
|
+
if block.type == "text":
|
|
13
|
+
texts.append(block)
|
|
14
|
+
elif block.type == "image":
|
|
15
|
+
images.append(block)
|
|
16
|
+
else:
|
|
17
|
+
raise ValueError(f"Illegal block: {block}")
|
|
18
|
+
|
|
19
|
+
print("[blocks_split] end")
|
|
20
|
+
return {
|
|
21
|
+
"texts": texts,
|
|
22
|
+
"images": images,
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from b_sort.config import resolve_config
|
|
2
|
+
from b_sort.states.sort_state import BlockImage, BlockText, SortState
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def data_resolve(state: SortState) -> SortState:
|
|
6
|
+
blocks = state["blocks"]
|
|
7
|
+
|
|
8
|
+
loaded_blocks: list[BlockText | BlockImage] = []
|
|
9
|
+
|
|
10
|
+
print("[data_resolve] start")
|
|
11
|
+
for block in blocks:
|
|
12
|
+
if isinstance(block, dict):
|
|
13
|
+
if block["type"] == "text":
|
|
14
|
+
block = BlockText.model_validate(block)
|
|
15
|
+
elif block["type"] == "image":
|
|
16
|
+
block = BlockImage.model_validate(block)
|
|
17
|
+
else:
|
|
18
|
+
raise ValueError(f"Illegal block: {block}")
|
|
19
|
+
|
|
20
|
+
loaded_blocks.append(block)
|
|
21
|
+
|
|
22
|
+
config = resolve_config(state)
|
|
23
|
+
|
|
24
|
+
print("[data_resolve] end")
|
|
25
|
+
return {
|
|
26
|
+
"config": config,
|
|
27
|
+
"blocks": loaded_blocks,
|
|
28
|
+
}
|