jevhttp 0.1.0__tar.gz

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.
jevhttp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 XUEHANG AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jevhttp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: jevhttp
3
+ Version: 0.1.0
4
+ Summary: Synchronous HTTP client and structured web decision toolkit for OpenAI-compatible models
5
+ Author-email: XUEHANGAI <xuehang.ai@outlook.com>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: openai>=3.16.2
10
+ Requires-Dist: pydantic>=2.7
11
+ Requires-Dist: requests>=2.34.2
12
+ Dynamic: license-file
13
+
14
+ # jevhttp
15
+
16
+ [English README](README.en.md)
17
+
18
+ `jevhttp` 是一个本地模型优先的同步HTTP与结构化决策工具包。
19
+
20
+ 它把网页请求、网页元数据抽取和OpenAI-compatible模型决策组合成一个轻量Python接口。
21
+
22
+ 主要用于本地模型推理,同时兼容在线OpenAI-compatible推理服务。
23
+
24
+ ## 为什么使用 jevhttp
25
+
26
+ 传统网页采集通常需要分别处理HTTP请求、HTML解析、链接发现和业务分类。
27
+
28
+ `jevhttp`将这些步骤组合起来,让网页分类、相关性判断和后续动作成为可校验的结构化结果,而不是自由文本。
29
+
30
+ ```text
31
+ HTTP请求 → Page网页对象 → 精简State → 结构化决策 → 业务结果
32
+ ```
33
+
34
+ 模型不会直接访问网页。程序负责获取和清洗网页,模型只处理用户明确提供的State。
35
+
36
+ ## 安装
37
+
38
+ 项目发布到PyPI后,可以直接安装:
39
+
40
+ ```bash
41
+ pip install jevhttp
42
+ ```
43
+
44
+ 从源码使用uv管理环境:
45
+
46
+ ```bash
47
+ uv sync
48
+ ```
49
+
50
+ ## 快速开始
51
+
52
+ ```python
53
+ from jevhttp import JevHttp
54
+
55
+ client = JevHttp(
56
+ base_url='http://127.0.0.1:8000/v1',
57
+ api_key='EMPTY',
58
+ model='openbmb/MiniCPM5-2B',
59
+ )
60
+
61
+ page = client.get('https://example.com')
62
+ state = [
63
+ {
64
+ '网址': page.final_url,
65
+ '标题': page.title,
66
+ '描述': page.description,
67
+ '状态码': page.status_code,
68
+ }
69
+ ]
70
+
71
+ result = client.system_one(
72
+ state=state,
73
+ decisions={
74
+ '页面类型': client.choice(['文章页', '列表页', '其他']),
75
+ '是否相关': client.boolean('这个网页与当前任务相关吗?'),
76
+ '相关性': client.score('请评估网页相关性', min=0, max=10),
77
+ },
78
+ )
79
+
80
+ print(result['页面类型'])
81
+ print(result['是否相关'])
82
+ print(result['相关性'])
83
+ print(result.decision_time)
84
+ ```
85
+
86
+ `base_url`只用于模型API,不影响`client.get()`访问的网页地址。
87
+
88
+ ## 主要特性
89
+
90
+ - 基于`requests.Session`的同步HTTP请求、连接复用、超时和有限重试
91
+ - `Page`和`Link`网页对象,提供标题、描述、标题列表、正文、链接和HTTP响应信息
92
+ - 自动把相对链接转换为绝对URL,并移除fragment和常见追踪参数
93
+ - `system_one()`一次请求执行一个或多个结构化决策
94
+ - 内置`Choice`、`Boolean`和`Score`决策类型
95
+ - 中文优先的模型提示词和分类结果校验
96
+ - 支持中文分类兜底项,避免小模型偶发同义词导致批处理终止
97
+ - 支持Pydantic结构化抽取:`client.extract(state, Model)`
98
+ - 支持带域名、深度、页数、robots.txt和内网地址保护的同步爬虫
99
+ - 每次决策提供原始JSON、模型名称、Token使用量和决策耗时
100
+ - 通过OpenAI-compatible接口兼容本地vLLM和远程模型服务
101
+
102
+ ## 适合做什么
103
+
104
+ `jevhttp`适合需要“先获取网页,再进行结构化判断”的任务,例如:
105
+
106
+ - 学校、机构、企业网站的页面类型识别
107
+ - 新闻、文章、目录和详情页分类
108
+ - 网页相关性筛选和采集入口发现
109
+ - 本地小模型驱动的大规模网页预处理和批量决策
110
+ - 将网页状态抽取为业务Pydantic模型
111
+ - 对特定域名进行受限、可恢复的同步采集
112
+
113
+ 它不是浏览器自动化框架,也不负责JavaScript渲染、验证码绕过、代理池、数据库、向量数据库或分布式任务调度。
114
+
115
+ ## 性能验证
116
+
117
+ 以下数据来自本地vLLM部署`openbmb/MiniCPM5-2B`的批量网页决策验证,使用8个并发工作线程。实际速度会受到GPU、网络和网页响应时间影响。
118
+
119
+ ### 分阶段耗时
120
+
121
+ | 阶段 | 任务 | 并发数 | 平均耗时 | 中位数 | P95 | 备注 |
122
+ | --- | --- | ---: | ---: | ---: | ---: | --- |
123
+ | HTTP | 请求网页并解析HTML | 8 | 2.875秒 | 2.752秒 | 4.071秒 | 包含网络耗时 |
124
+ | Choice | 单项页面类型分类 | 1 | 0.376秒 | - | - | 本地小模型单次测试 |
125
+ | Boolean | 单项布尔判断 | 1 | 0.362秒 | - | - | 本地小模型单次测试 |
126
+ | Score | 单项相关性评分 | 1 | 0.398秒 | - | - | 本地小模型单次测试 |
127
+ | Choice | 批量页面类型分类 | 8 | 0.125秒 | 0.108秒 | 0.238秒 | 9998次成功分类 |
128
+ | 全流程 | 请求、解析、决策和落盘 | 8 | 约0.463秒/页 | - | - | 10000页约77分钟 |
129
+
130
+ ### 页面处理结果
131
+
132
+ | 指标 | 数值 |
133
+ | --- | ---: |
134
+ | 处理页面数 | 10000 |
135
+ | 成功分类页面 | 9998 |
136
+ | 异常页面 | 2 |
137
+ | 成功率 | 99.98% |
138
+ | 唯一URL数量 | 10000 |
139
+ | 主要详情页分类 | 8983 |
140
+ | 主要列表页分类 | 1015 |
141
+
142
+ 测试代码位于`test/`目录,包含HTTP解析、Choice、Boolean、Score、多决策、性能基准和实时JSONL采集测试。
143
+
144
+ ## 版本范围
145
+
146
+ 当前版本优先保证同步调用和可预测行为,暂不包含:
147
+
148
+ - asyncio和异步API
149
+ - 浏览器渲染
150
+ - Playwright和Selenium
151
+ - 数据库和ORM
152
+ - 向量数据库和RAG
153
+ - 代理池、验证码绕过和分布式队列
154
+
155
+ ## 生态
156
+
157
+ - [awesome-jev](https://github.com/kraayenjon/awesome-jev)
158
+ - [Made with jev](https://madewithjev.com/)
159
+ - [xuehang.ai](https://xuehang.ai/)
160
+
161
+ ## 许可证
162
+
163
+ 本项目采用[MIT License](LICENSE)。
164
+
165
+ Copyright © 2026 [XUEHANG AI](https://xuehang.ai/)。
@@ -0,0 +1,152 @@
1
+ # jevhttp
2
+
3
+ [English README](README.en.md)
4
+
5
+ `jevhttp` 是一个本地模型优先的同步HTTP与结构化决策工具包。
6
+
7
+ 它把网页请求、网页元数据抽取和OpenAI-compatible模型决策组合成一个轻量Python接口。
8
+
9
+ 主要用于本地模型推理,同时兼容在线OpenAI-compatible推理服务。
10
+
11
+ ## 为什么使用 jevhttp
12
+
13
+ 传统网页采集通常需要分别处理HTTP请求、HTML解析、链接发现和业务分类。
14
+
15
+ `jevhttp`将这些步骤组合起来,让网页分类、相关性判断和后续动作成为可校验的结构化结果,而不是自由文本。
16
+
17
+ ```text
18
+ HTTP请求 → Page网页对象 → 精简State → 结构化决策 → 业务结果
19
+ ```
20
+
21
+ 模型不会直接访问网页。程序负责获取和清洗网页,模型只处理用户明确提供的State。
22
+
23
+ ## 安装
24
+
25
+ 项目发布到PyPI后,可以直接安装:
26
+
27
+ ```bash
28
+ pip install jevhttp
29
+ ```
30
+
31
+ 从源码使用uv管理环境:
32
+
33
+ ```bash
34
+ uv sync
35
+ ```
36
+
37
+ ## 快速开始
38
+
39
+ ```python
40
+ from jevhttp import JevHttp
41
+
42
+ client = JevHttp(
43
+ base_url='http://127.0.0.1:8000/v1',
44
+ api_key='EMPTY',
45
+ model='openbmb/MiniCPM5-2B',
46
+ )
47
+
48
+ page = client.get('https://example.com')
49
+ state = [
50
+ {
51
+ '网址': page.final_url,
52
+ '标题': page.title,
53
+ '描述': page.description,
54
+ '状态码': page.status_code,
55
+ }
56
+ ]
57
+
58
+ result = client.system_one(
59
+ state=state,
60
+ decisions={
61
+ '页面类型': client.choice(['文章页', '列表页', '其他']),
62
+ '是否相关': client.boolean('这个网页与当前任务相关吗?'),
63
+ '相关性': client.score('请评估网页相关性', min=0, max=10),
64
+ },
65
+ )
66
+
67
+ print(result['页面类型'])
68
+ print(result['是否相关'])
69
+ print(result['相关性'])
70
+ print(result.decision_time)
71
+ ```
72
+
73
+ `base_url`只用于模型API,不影响`client.get()`访问的网页地址。
74
+
75
+ ## 主要特性
76
+
77
+ - 基于`requests.Session`的同步HTTP请求、连接复用、超时和有限重试
78
+ - `Page`和`Link`网页对象,提供标题、描述、标题列表、正文、链接和HTTP响应信息
79
+ - 自动把相对链接转换为绝对URL,并移除fragment和常见追踪参数
80
+ - `system_one()`一次请求执行一个或多个结构化决策
81
+ - 内置`Choice`、`Boolean`和`Score`决策类型
82
+ - 中文优先的模型提示词和分类结果校验
83
+ - 支持中文分类兜底项,避免小模型偶发同义词导致批处理终止
84
+ - 支持Pydantic结构化抽取:`client.extract(state, Model)`
85
+ - 支持带域名、深度、页数、robots.txt和内网地址保护的同步爬虫
86
+ - 每次决策提供原始JSON、模型名称、Token使用量和决策耗时
87
+ - 通过OpenAI-compatible接口兼容本地vLLM和远程模型服务
88
+
89
+ ## 适合做什么
90
+
91
+ `jevhttp`适合需要“先获取网页,再进行结构化判断”的任务,例如:
92
+
93
+ - 学校、机构、企业网站的页面类型识别
94
+ - 新闻、文章、目录和详情页分类
95
+ - 网页相关性筛选和采集入口发现
96
+ - 本地小模型驱动的大规模网页预处理和批量决策
97
+ - 将网页状态抽取为业务Pydantic模型
98
+ - 对特定域名进行受限、可恢复的同步采集
99
+
100
+ 它不是浏览器自动化框架,也不负责JavaScript渲染、验证码绕过、代理池、数据库、向量数据库或分布式任务调度。
101
+
102
+ ## 性能验证
103
+
104
+ 以下数据来自本地vLLM部署`openbmb/MiniCPM5-2B`的批量网页决策验证,使用8个并发工作线程。实际速度会受到GPU、网络和网页响应时间影响。
105
+
106
+ ### 分阶段耗时
107
+
108
+ | 阶段 | 任务 | 并发数 | 平均耗时 | 中位数 | P95 | 备注 |
109
+ | --- | --- | ---: | ---: | ---: | ---: | --- |
110
+ | HTTP | 请求网页并解析HTML | 8 | 2.875秒 | 2.752秒 | 4.071秒 | 包含网络耗时 |
111
+ | Choice | 单项页面类型分类 | 1 | 0.376秒 | - | - | 本地小模型单次测试 |
112
+ | Boolean | 单项布尔判断 | 1 | 0.362秒 | - | - | 本地小模型单次测试 |
113
+ | Score | 单项相关性评分 | 1 | 0.398秒 | - | - | 本地小模型单次测试 |
114
+ | Choice | 批量页面类型分类 | 8 | 0.125秒 | 0.108秒 | 0.238秒 | 9998次成功分类 |
115
+ | 全流程 | 请求、解析、决策和落盘 | 8 | 约0.463秒/页 | - | - | 10000页约77分钟 |
116
+
117
+ ### 页面处理结果
118
+
119
+ | 指标 | 数值 |
120
+ | --- | ---: |
121
+ | 处理页面数 | 10000 |
122
+ | 成功分类页面 | 9998 |
123
+ | 异常页面 | 2 |
124
+ | 成功率 | 99.98% |
125
+ | 唯一URL数量 | 10000 |
126
+ | 主要详情页分类 | 8983 |
127
+ | 主要列表页分类 | 1015 |
128
+
129
+ 测试代码位于`test/`目录,包含HTTP解析、Choice、Boolean、Score、多决策、性能基准和实时JSONL采集测试。
130
+
131
+ ## 版本范围
132
+
133
+ 当前版本优先保证同步调用和可预测行为,暂不包含:
134
+
135
+ - asyncio和异步API
136
+ - 浏览器渲染
137
+ - Playwright和Selenium
138
+ - 数据库和ORM
139
+ - 向量数据库和RAG
140
+ - 代理池、验证码绕过和分布式队列
141
+
142
+ ## 生态
143
+
144
+ - [awesome-jev](https://github.com/kraayenjon/awesome-jev)
145
+ - [Made with jev](https://madewithjev.com/)
146
+ - [xuehang.ai](https://xuehang.ai/)
147
+
148
+ ## 许可证
149
+
150
+ 本项目采用[MIT License](LICENSE)。
151
+
152
+ Copyright © 2026 [XUEHANG AI](https://xuehang.ai/)。
@@ -0,0 +1,34 @@
1
+ """导出jevhttp公共接口"""
2
+
3
+ from .client import DecisionResult, JevHttp
4
+ from .crawler import CrawlConfig, CrawlItem, Crawler
5
+ from .decisions import Boolean, Choice, Decision, Score
6
+ from .errors import (
7
+ AIResponseError,
8
+ AITransportError,
9
+ DecisionParseError,
10
+ DecisionValueError,
11
+ HTTPRequestError,
12
+ JevHttpError,
13
+ )
14
+ from .page import Link, Page
15
+
16
+ __all__ = [
17
+ 'JevHttp',
18
+ 'Page',
19
+ 'Link',
20
+ 'Decision',
21
+ 'Choice',
22
+ 'Boolean',
23
+ 'Score',
24
+ 'DecisionResult',
25
+ 'Crawler',
26
+ 'CrawlConfig',
27
+ 'CrawlItem',
28
+ 'JevHttpError',
29
+ 'HTTPRequestError',
30
+ 'AITransportError',
31
+ 'AIResponseError',
32
+ 'DecisionParseError',
33
+ 'DecisionValueError',
34
+ ]
@@ -0,0 +1,314 @@
1
+ """实现网页请求、结构化决策和模型结构化抽取"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any, Mapping, Type
10
+
11
+ import requests
12
+ from openai import OpenAI
13
+
14
+ from .decisions import Boolean, Choice, Decision, Score
15
+ from .errors import (
16
+ AIResponseError,
17
+ AITransportError,
18
+ DecisionParseError,
19
+ DecisionValueError,
20
+ HTTPRequestError,
21
+ )
22
+ from .page import Page
23
+
24
+
25
+ @dataclass
26
+ class DecisionResult:
27
+ """保存结构化决策结果和模型调试信息"""
28
+
29
+ values: dict[str, Any]
30
+ raw: Any = None
31
+ model: str | None = None
32
+ usage: Any = None
33
+ decision_time: float | None = None
34
+ state_truncated: bool = False
35
+
36
+ @property
37
+ def latency(self) -> float | None:
38
+ """返回本次模型决策耗时,单位为秒"""
39
+ return self.decision_time
40
+
41
+ def __getattr__(self, name: str) -> Any:
42
+ """支持通过属性名读取决策结果"""
43
+ try:
44
+ return self.values[name]
45
+ except KeyError as exc:
46
+ raise AttributeError(name) from exc
47
+
48
+ def __getitem__(self, name: str) -> Any:
49
+ """支持通过键读取决策结果"""
50
+ return self.values[name]
51
+
52
+
53
+ class JevHttp:
54
+ """提供同步网页请求和OpenAI-compatible结构化决策"""
55
+
56
+ def __init__(
57
+ self,
58
+ base_url: str,
59
+ api_key: str,
60
+ model: str,
61
+ timeout: float = 30,
62
+ max_retries: int = 2,
63
+ temperature: float = 0,
64
+ max_tokens: int | None = None,
65
+ extra_body: dict | None = None,
66
+ headers: Mapping[str, str] | None = None,
67
+ ):
68
+ """初始化客户端及其网页、模型请求配置"""
69
+ self.base_url = base_url
70
+ self.api_key = api_key
71
+ self.model = model
72
+ self.timeout = timeout
73
+ self.max_retries = max_retries
74
+ self.temperature = temperature
75
+ self.max_tokens = max_tokens
76
+ self.extra_body = extra_body or {}
77
+ self.session = requests.Session()
78
+ self.session.headers.update({'User-Agent': 'jevhttp/0.1'})
79
+ if headers:
80
+ self.session.headers.update(headers)
81
+ self._ai = OpenAI(
82
+ base_url=base_url,
83
+ api_key=api_key,
84
+ timeout=timeout,
85
+ max_retries=0,
86
+ )
87
+
88
+ @classmethod
89
+ def from_env(cls, **kwargs) -> 'JevHttp':
90
+ """从环境变量创建客户端"""
91
+ return cls(
92
+ os.environ['JEVHTTP_BASE_URL'],
93
+ os.environ['JEVHTTP_API_KEY'],
94
+ os.environ['JEVHTTP_MODEL'],
95
+ **kwargs,
96
+ )
97
+
98
+ def choice(
99
+ self,
100
+ choices,
101
+ question: str = '',
102
+ fallback: str | None = None,
103
+ ) -> Choice:
104
+ """创建中文枚举决策"""
105
+ choices = tuple(str(choice) for choice in choices)
106
+ if not choices:
107
+ raise ValueError('choices不能为空')
108
+ if fallback is not None and fallback not in choices:
109
+ raise ValueError('fallback必须是choices中的一项')
110
+ return Choice('choice', question, choices, fallback)
111
+
112
+ def boolean(self, question: str = '') -> Boolean:
113
+ """创建布尔决策"""
114
+ return Boolean('boolean', question)
115
+
116
+ noul = boolean
117
+
118
+ def score(
119
+ self,
120
+ question: str = '',
121
+ min: float = 0,
122
+ max: float = 1,
123
+ ) -> Score:
124
+ """创建指定范围内的数值决策"""
125
+ if min > max:
126
+ raise ValueError('min不能大于max')
127
+ return Score('score', question, min, max)
128
+
129
+ def _request(self, method: str, url: str, **kwargs):
130
+ """发送网页请求并执行有限重试"""
131
+ last_error = None
132
+ timeout = kwargs.pop('timeout', self.timeout)
133
+ retry_post = kwargs.pop('retry_post', False)
134
+ for attempt in range(self.max_retries + 1):
135
+ try:
136
+ response = self.session.request(
137
+ method,
138
+ url,
139
+ timeout=timeout,
140
+ **kwargs,
141
+ )
142
+ if response.status_code == 429 or response.status_code >= 500:
143
+ response.raise_for_status()
144
+ return response
145
+ except requests.RequestException as exc:
146
+ last_error = exc
147
+ should_stop = attempt >= self.max_retries
148
+ should_stop = should_stop or (
149
+ method.upper() == 'POST' and not retry_post
150
+ )
151
+ if should_stop:
152
+ break
153
+ time.sleep(min(2**attempt * 0.25, 2))
154
+ raise HTTPRequestError(
155
+ f'HTTP请求失败: method={method}, url={url}, error={last_error}'
156
+ ) from last_error
157
+
158
+ def get(self, url: str, **kwargs) -> Page:
159
+ """获取网页并返回解析后的``Page``对象"""
160
+ return self._page_request('GET', url, **kwargs)
161
+
162
+ def post(self, url: str, data=None, **kwargs) -> Page:
163
+ """提交表单并返回解析后的``Page``对象"""
164
+ return self._page_request('POST', url, data=data, **kwargs)
165
+
166
+ def _page_request(self, method: str, url: str, depth: int = 0, **kwargs):
167
+ """发送请求并将响应转换为``Page``"""
168
+ response = self._request(method, url, **kwargs)
169
+ return Page.from_response(response, depth=depth)
170
+
171
+ @staticmethod
172
+ def _state_json(state, max_chars: int = 8000) -> tuple[str, bool]:
173
+ """序列化并限制送入模型的状态长度"""
174
+ value = state.to_state() if isinstance(state, Page) else state
175
+ encoded = json.dumps(
176
+ value,
177
+ ensure_ascii=False,
178
+ separators=(',', ':'),
179
+ default=str,
180
+ )
181
+ return encoded[:max_chars], len(encoded) > max_chars
182
+
183
+ def _chat_json(self, system: str, user: str, schema: dict):
184
+ """调用模型并优先请求JSON Schema输出"""
185
+ request_body = {
186
+ 'model': self.model,
187
+ 'temperature': self.temperature,
188
+ 'messages': [
189
+ {'role': 'system', 'content': system},
190
+ {'role': 'user', 'content': user},
191
+ ],
192
+ 'response_format': {
193
+ 'type': 'json_schema',
194
+ 'json_schema': {
195
+ 'name': 'jevhttp_result',
196
+ 'strict': True,
197
+ 'schema': schema,
198
+ },
199
+ },
200
+ }
201
+ if self.max_tokens is not None:
202
+ request_body['max_tokens'] = self.max_tokens
203
+ request_body.update(self.extra_body)
204
+ try:
205
+ response = self._ai.chat.completions.create(**request_body)
206
+ content = response.choices[0].message.content if response.choices else None
207
+ except Exception as first_error:
208
+ content = None
209
+ if not content:
210
+ request_body['response_format'] = {'type': 'json_object'}
211
+ try:
212
+ response = self._ai.chat.completions.create(**request_body)
213
+ except Exception as second_error:
214
+ raise AITransportError(
215
+ f'模型请求失败: {second_error}'
216
+ ) from locals().get('first_error')
217
+ if not response.choices:
218
+ raise AIResponseError('模型响应没有choices')
219
+ content = response.choices[0].message.content
220
+ if not content:
221
+ finish_reason = getattr(response.choices[0], 'finish_reason', None)
222
+ raise AIResponseError(
223
+ f'模型响应没有文本内容,finish_reason={finish_reason!r}'
224
+ )
225
+ return response, content
226
+
227
+ def system_one(
228
+ self,
229
+ state,
230
+ decisions: Mapping[str, Decision],
231
+ max_chars: int = 8000,
232
+ ) -> DecisionResult:
233
+ """使用一次中文提示词完成多个结构化决策"""
234
+ if not decisions:
235
+ raise ValueError('decisions不能为空')
236
+ state_text, state_truncated = self._state_json(state, max_chars)
237
+ schema = {
238
+ 'type': 'object',
239
+ 'properties': {
240
+ name: decision.schema()
241
+ for name, decision in decisions.items()
242
+ },
243
+ 'required': list(decisions),
244
+ 'additionalProperties': False,
245
+ }
246
+ system = (
247
+ '你是一个严谨的中文结构化信息判断模型。'
248
+ '请只返回符合JSON Schema的JSON对象。'
249
+ '所有Choice分类必须原样使用候选项中的中文文本,禁止输出英文、翻译结果或新分类。'
250
+ '只能依据提供的网页状态判断,不要补充网页中不存在的事实。'
251
+ )
252
+ decision_text = '\n'.join(
253
+ f'{name}: {decision.question or "请根据网页状态判断"}'
254
+ for name, decision in decisions.items()
255
+ )
256
+ user = (
257
+ f'网页状态(JSON):\n{state_text}\n\n'
258
+ f'待判断项目:\n{decision_text}'
259
+ )
260
+ started = time.perf_counter()
261
+ response, content = self._chat_json(system, user, schema)
262
+ try:
263
+ values = json.loads(content)
264
+ except json.JSONDecodeError as exc:
265
+ raise DecisionParseError(
266
+ f'模型返回内容不是有效JSON: {content[:300]!r}'
267
+ ) from exc
268
+ if not isinstance(values, dict):
269
+ raise DecisionParseError('模型结果必须是JSON对象')
270
+ validated = {}
271
+ for name, decision in decisions.items():
272
+ try:
273
+ validated[name] = decision.validate(values[name])
274
+ except (KeyError, TypeError, ValueError) as exc:
275
+ raise DecisionValueError(
276
+ f'决策{name!r}的结果未通过校验: {exc}'
277
+ ) from exc
278
+ return DecisionResult(
279
+ validated,
280
+ raw=content,
281
+ model=getattr(response, 'model', self.model),
282
+ usage=getattr(response, 'usage', None),
283
+ decision_time=time.perf_counter() - started,
284
+ state_truncated=state_truncated,
285
+ )
286
+
287
+ def extract(
288
+ self,
289
+ state,
290
+ model: Type,
291
+ max_chars: int = 8000,
292
+ instruction: str = '',
293
+ ):
294
+ """使用中文提示词将状态抽取为Pydantic模型"""
295
+ if not hasattr(model, 'model_json_schema'):
296
+ raise TypeError('model必须是Pydantic模型类')
297
+ state_text, _ = self._state_json(state, max_chars)
298
+ system = (
299
+ '你是一个严谨的中文结构化信息抽取模型。'
300
+ '请只返回符合JSON Schema的JSON对象,不要输出解释。'
301
+ '只能依据输入内容抽取,无法确认的字段按Schema允许的空值处理。'
302
+ )
303
+ user = f'网页状态(JSON):\n{state_text}\n\n补充要求:\n{instruction}'
304
+ _, content = self._chat_json(system, user, model.model_json_schema())
305
+ try:
306
+ return model.model_validate_json(content)
307
+ except Exception as exc:
308
+ raise DecisionValueError(f'抽取结果未通过Pydantic校验: {exc}') from exc
309
+
310
+ def crawl(self, seed_url: str, goal: str, **kwargs):
311
+ """创建同步广度优先网页爬虫"""
312
+ from .crawler import Crawler
313
+
314
+ return Crawler(self).crawl(seed_url, goal, **kwargs)